Skip to content

Commit

Permalink
feat(eslint-plugin): adds rule ban-instanceof-html-element (#27178)
Browse files Browse the repository at this point in the history
  • Loading branch information
bsunderhus authored Mar 16, 2023
1 parent a97f513 commit a3463ec
Show file tree
Hide file tree
Showing 11 changed files with 217 additions and 8 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: rule to ban usage of instanceof HTMLElement",
"packageName": "@fluentui/eslint-plugin",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "chore: exports isHTMLElement as public",
"packageName": "@fluentui/react-utilities",
"email": "[email protected]",
"dependentChangeType": "patch"
}
24 changes: 24 additions & 0 deletions packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,27 @@ const context = React.createContext({ someValue: undefined });
import * as React from 'react';
const context = React.createContext(undefined);
```

### `ban-instanceof-html-element`

Bans usage of `instanceof HTMLElement` binary expressions as they might cause problems on [multiple realms](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/instanceof#instanceof_and_multiple_realms) environments.

The alternative is to use `isHTMLElement` helper method provided by `@fluentui/react-utilities` packages, since that method does the proper verifications to ensure proper instance comparison.

**❌ Don't**

```ts
event.target instanceof HTMLElement;

event.target instanceof HTMLInputElement;
```

**✅ Do**

```ts
import { isHTMLElement } from '@fluentui/react-components';

isHTMLElement(event.target);

isHTMLElement(event.target, { constructorName: 'HTMLInputElement' });
```
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/react.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const v9PackageDeps = Object.keys(
module.exports = {
extends: [path.join(__dirname, 'base'), path.join(__dirname, 'react-config')],
rules: {
'@fluentui/ban-instanceof-html-element': ['error'],
'@fluentui/no-context-default-value': [
'error',
{
Expand Down
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ module.exports = {
rules: {
'ban-imports': require('./rules/ban-imports'),
'ban-context-export': require('./rules/ban-context-export'),
'ban-instanceof-html-element': require('./rules/ban-instanceof-html-element'),
'deprecated-keyboard-event-props': require('./rules/deprecated-keyboard-event-props'),
'max-len': require('./rules/max-len'),
'no-global-react': require('./rules/no-global-react'),
Expand Down
115 changes: 115 additions & 0 deletions packages/eslint-plugin/src/rules/ban-instanceof-html-element/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// @ts-check
const { AST_NODE_TYPES } = require('@typescript-eslint/experimental-utils');
const createRule = require('../../utils/createRule');

/**
* @typedef {import("@typescript-eslint/types/dist/ts-estree").BinaryExpression} BinaryExpression
* @typedef {import("@fluentui/react-utilities/src/utils/isHTMLElement").HTMLElementConstructorName} HTMLElementConstructorName
*
*/

module.exports = createRule({
name: 'ban-instanceof-html-element',
meta: {
type: 'problem',
docs: {
description: 'Ban usage of instanceof HTMLElement comparison',
category: 'Possible Errors',
recommended: 'error',
},
messages: {
invalidBinaryExpression: 'instanceof {{right}} should be avoided, use isHTMLElement instead.',
},
fixable: 'code',
schema: [],
},
defaultOptions: [],
create: context => ({
/**
* @param {BinaryExpression} binaryExpression
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
BinaryExpression(binaryExpression) {
if (
binaryExpression.operator === 'instanceof' &&
binaryExpression.right.type === AST_NODE_TYPES.Identifier &&
constructorNamesSet.has(binaryExpression.right.name)
) {
context.report({
node: binaryExpression,
messageId: 'invalidBinaryExpression',
data: {
right: binaryExpression.right.name,
},
});
}
},
}),
});

/** @type {HTMLElementConstructorName[]} */
const constructorNames = [
'HTMLElement',
'HTMLAnchorElement',
'HTMLAreaElement',
'HTMLAudioElement',
'HTMLBaseElement',
'HTMLBodyElement',
'HTMLBRElement',
'HTMLButtonElement',
'HTMLCanvasElement',
'HTMLDataElement',
'HTMLDataListElement',
'HTMLDetailsElement',
'HTMLDialogElement',
'HTMLDivElement',
'HTMLDListElement',
'HTMLEmbedElement',
'HTMLFieldSetElement',
'HTMLFormElement',
'HTMLHeadingElement',
'HTMLHeadElement',
'HTMLHRElement',
'HTMLHtmlElement',
'HTMLIFrameElement',
'HTMLImageElement',
'HTMLInputElement',
'HTMLModElement',
'HTMLLabelElement',
'HTMLLegendElement',
'HTMLLIElement',
'HTMLLinkElement',
'HTMLMapElement',
'HTMLMetaElement',
'HTMLMeterElement',
'HTMLObjectElement',
'HTMLOListElement',
'HTMLOptGroupElement',
'HTMLOptionElement',
'HTMLOutputElement',
'HTMLParagraphElement',
'HTMLParamElement',
'HTMLPreElement',
'HTMLProgressElement',
'HTMLQuoteElement',
'HTMLSlotElement',
'HTMLScriptElement',
'HTMLSelectElement',
'HTMLSourceElement',
'HTMLSpanElement',
'HTMLStyleElement',
'HTMLTableElement',
'HTMLTableColElement',
'HTMLTableRowElement',
'HTMLTableSectionElement',
'HTMLTemplateElement',
'HTMLTextAreaElement',
'HTMLTimeElement',
'HTMLTitleElement',
'HTMLTrackElement',
'HTMLUListElement',
'HTMLVideoElement',
];

/** @type {Set<string>} */
const constructorNamesSet = new Set(constructorNames);
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// @ts-nocheck
const { ESLintUtils } = require('@typescript-eslint/experimental-utils');
const rule = require('./index');

const ruleTester = new ESLintUtils.RuleTester({
parser: '@typescript-eslint/parser',
});

ruleTester.run('ban-instanceof-htmlelement', rule, {
valid: [
{
code: `
event.currentTarget instanceof Object
`,
},
{
code: `
isHTMLElement(event.currentTarget)
`,
},
],
invalid: [
{
code: `
event.currentTarget instanceof HTMLElement
event.currentTarget instanceof HTMLInputElement
`,
errors: [{ messageId: 'invalidBinaryExpression' }, { messageId: 'invalidBinaryExpression' }],
},
{
code: `
if (event.currentTarget instanceof HTMLElement) {}
if (event.currentTarget instanceof HTMLInputElement) {}
`,
errors: [{ messageId: 'invalidBinaryExpression' }, { messageId: 'invalidBinaryExpression' }],
},
],
});
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ export const ImperativeAnchorTarget = () => {
setOpen(true);
}, []);

const onMouseLeave = React.useCallback((e: React.MouseEvent) => {
if (e.relatedTarget instanceof HTMLElement && e.relatedTarget.getAttribute('role') === 'tooltip') {
const onMouseLeave = React.useCallback((event: React.MouseEvent) => {
const target = event.relatedTarget as HTMLElement;
if (target && target.getAttribute('role') === 'tooltip') {
return;
}
setOpen(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export const IdPrefixProvider: React_2.Provider<string | undefined>;
// @internal
export function isFluentTrigger(element: React_2.ReactElement): element is React_2.ReactElement<TriggerProps>;

// @internal
// @public
export function isHTMLElement<ConstructorName extends HTMLElementConstructorName = 'HTMLElement'>(element?: unknown, options?: {
constructorName?: ConstructorName;
}): element is InstanceType<(typeof globalThis)[ConstructorName]>;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @fluentui/ban-instanceof-html-element */
import { isHTMLElement } from './isHTMLElement';

class CustomHTMLElement {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
/**
* @internal
* Verifies if a given node is an HTMLElement,
* this method works seamlessly with frames and elements from different documents
*
* This is required as simply using `instanceof`
* might be problematic while operating with [multiple realms](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/instanceof#instanceof_and_multiple_realms)
* This is preferred over simply using `instanceof`.
* Since `instanceof` might be problematic while operating with [multiple realms](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/instanceof#instanceof_and_multiple_realms)
*
* @example
* ```ts
* isHTMLElement(event.target) && event.target.focus()
* isHTMLElement(event.target, {constructorName: 'HTMLInputElement'}) && event.target.value // some value
* ```
*
*/
export function isHTMLElement<ConstructorName extends HTMLElementConstructorName = 'HTMLElement'>(
element?: unknown,
options?: { constructorName?: ConstructorName },
options?: {
/**
* Can be used to provide a custom constructor instead of `HTMLElement`,
* Like `HTMLInputElement` for example.
*/
constructorName?: ConstructorName;
},
): element is InstanceType<(typeof globalThis)[ConstructorName]> {
const typedElement = element as Node | null | undefined;
return Boolean(
Expand All @@ -18,7 +29,10 @@ export function isHTMLElement<ConstructorName extends HTMLElementConstructorName
);
}

type HTMLElementConstructorName =
/**
* @internal
*/
export type HTMLElementConstructorName =
| 'HTMLElement'
| 'HTMLAnchorElement'
| 'HTMLAreaElement'
Expand Down

0 comments on commit a3463ec

Please sign in to comment.