Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Fix] no-unknown-property: properly recognise unknown HTML/DOM attributes #3377

Merged
merged 4 commits into from Sep 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Expand Up @@ -8,10 +8,15 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange
### Fixed
* [`jsx-key`]: avoid a crash with optional chaining ([#3371][] @ljharb)
* [`jsx-sort-props`]: avoid a crash with spread props ([#3376][] @ljharb)
* [`no-unknown-property`]: properly recognize valid data- and aria- attributes ([#3377][] @sjarva)
* [`no-unknown-property`]: properly recognize unknown HTML/DOM attributes ([#3377][] @sjarva)

### Changed
* [Docs] [`jsx-sort-propts`]: replace ref string with ref variable ([#3375][] @Luccasoli)
* [Docs] [`jsx-sort-props`]: replace ref string with ref variable ([#3375][] @Luccasoli)
* [Refactor] [`no-unknown-property`]: improve jsdoc; extract logic to separate functions ([#3377][] @sjarva)
* [Refactor] [`no-unknown-property`]: update DOM properties to include also one word properties ([#3377][] @sjarva)

[#3377]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3377
[#3376]: https://github.com/jsx-eslint/eslint-plugin-react/issues/3376
[#3375]: https://github.com/jsx-eslint/eslint-plugin-react/issues/3375
[#3371]: https://github.com/jsx-eslint/eslint-plugin-react/issues/3371
Expand Down
24 changes: 23 additions & 1 deletion docs/rules/no-unknown-property.md
Expand Up @@ -4,7 +4,8 @@

🔧 This rule is automatically fixable using the `--fix` [flag](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix) on the command line.

In JSX all DOM properties and attributes should be camelCased to be consistent with standard JavaScript style. This can be a possible source of error if you are used to writing plain HTML.
In JSX most DOM properties and attributes should be camelCased to be consistent with standard JavaScript style. This can be a possible source of error if you are used to writing plain HTML.
Only `data-*` and `aria-*` attributes are usings hyphens and lowercase letters in JSX.

## Rule Details

Expand All @@ -14,6 +15,10 @@ Examples of **incorrect** code for this rule:
var React = require('react');

var Hello = <div class="hello">Hello World</div>;
var Alphabet = <div abc="something">Alphabet</div>;

// Invalid aria-* attribute
var IconButton = <div aria-foo="bar" />;
```

Examples of **correct** code for this rule:
Expand All @@ -22,6 +27,23 @@ Examples of **correct** code for this rule:
var React = require('react');

var Hello = <div className="hello">Hello World</div>;
var Button = <button disabled>Cannot click me</button>;
var Img = <img src={catImage} alt="A cat sleeping on a keyboard" />;

// aria-* attributes
var IconButton = <button aria-label="Close" onClick={this.close}>{closeIcon}</button>;

// data-* attributes
var Data = <div data-index={12}>Some data</div>;

// React components are ignored
var MyComponent = <App class="foo-bar"/>;
var AnotherComponent = <Foo.bar for="bar" />;

// Custom web components are ignored
var MyElem = <div class="foo" is="my-elem"></div>;
var AtomPanel = <atom-panel class="foo"></atom-panel>;

```

## Rule Options
Expand Down
221 changes: 173 additions & 48 deletions lib/rules/no-unknown-property.js
Expand Up @@ -116,55 +116,150 @@ const SVGDOM_ATTRIBUTE_NAMES = {
'xml:space': 'xmlSpace',
};

const DOM_PROPERTY_NAMES = [
// Standard
'acceptCharset', 'accessKey', 'allowFullScreen', 'autoComplete', 'autoFocus', 'autoPlay',
'cellPadding', 'cellSpacing', 'classID', 'className', 'colSpan', 'contentEditable', 'contextMenu',
'dateTime', 'encType', 'formAction', 'formEncType', 'formMethod', 'formNoValidate', 'formTarget',
'frameBorder', 'hrefLang', 'htmlFor', 'httpEquiv', 'inputMode', 'keyParams', 'keyType', 'marginHeight', 'marginWidth',
const DOM_PROPERTY_NAMES_ONE_WORD = [
// Global attributes - can be used on any HTML/DOM element
// See https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes
'dir', 'draggable', 'hidden', 'id', 'lang', 'nonce', 'part', 'slot', 'style', 'title', 'translate',
// Element specific attributes
// See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes (includes global attributes too)
// To be considered if these should be added also to ATTRIBUTE_TAGS_MAP
'accept', 'action', 'allow', 'alt', 'async', 'buffered', 'capture', 'challenge', 'cite', 'code', 'cols',
'content', 'coords', 'csp', 'data', 'decoding', 'default', 'defer', 'disabled', 'form',
'headers', 'height', 'high', 'href', 'icon', 'importance', 'integrity', 'kind', 'label',
'language', 'loading', 'list', 'loop', 'low', 'max', 'media', 'method', 'min', 'multiple', 'muted',
'name', 'open', 'optimum', 'pattern', 'ping', 'placeholder', 'poster', 'preload', 'profile',
'rel', 'required', 'reversed', 'role', 'rows', 'sandbox', 'scope', 'selected', 'shape', 'size', 'sizes',
'span', 'src', 'start', 'step', 'target', 'type', 'value', 'width', 'wrap',
// React specific attributes
'ref',
];

const DOM_PROPERTY_NAMES_TWO_WORDS = [
// Global attributes - can be used on any HTML/DOM element
// See https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes
'accessKey', 'autoCapitalize', 'autoFocus', 'contentEditable', 'enterKeyHint', 'exportParts',
'inputMode', 'itemID', 'itemRef', 'itemProp', 'itemScope', 'itemType', 'spellCheck', 'tabIndex',
// Element specific attributes
// See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes (includes global attributes too)
// To be considered if these should be added also to ATTRIBUTE_TAGS_MAP
'acceptCharset', 'allowFullScreen', 'autoComplete', 'autoPlay', 'cellPadding', 'cellSpacing', 'classID', 'codeBase',
'colSpan', 'contextMenu', 'dateTime', 'encType', 'formAction', 'formEncType', 'formMethod', 'formNoValidate', 'formTarget',
'frameBorder', 'hrefLang', 'httpEquiv', 'isMap', 'keyParams', 'keyType', 'marginHeight', 'marginWidth',
'maxLength', 'mediaGroup', 'minLength', 'noValidate', 'onAnimationEnd', 'onAnimationIteration', 'onAnimationStart',
'onBlur', 'onChange', 'onClick', 'onContextMenu', 'onCopy', 'onCompositionEnd', 'onCompositionStart',
'onCompositionUpdate', 'onCut', 'onDoubleClick', 'onDrag', 'onDragEnd', 'onDragEnter', 'onDragExit', 'onDragLeave',
'onError', 'onFocus', 'onInput', 'onKeyDown', 'onKeyPress', 'onKeyUp', 'onLoad', 'onWheel', 'onDragOver',
'onDragStart', 'onDrop', 'onMouseDown', 'onMouseEnter', 'onMouseLeave', 'onMouseMove', 'onMouseOut', 'onMouseOver',
'onMouseUp', 'onPaste', 'onScroll', 'onSelect', 'onSubmit', 'onTransitionEnd', 'radioGroup', 'readOnly', 'rowSpan',
'spellCheck', 'srcDoc', 'srcLang', 'srcSet', 'tabIndex', 'useMap',
// Non standard
'autoCapitalize', 'autoCorrect',
'autoSave',
'itemProp', 'itemScope', 'itemType', 'itemRef', 'itemID',
'onMouseUp', 'onPaste', 'onScroll', 'onSelect', 'onSubmit', 'onTransitionEnd', 'radioGroup', 'readOnly', 'referrerPolicy',
'rowSpan', 'srcDoc', 'srcLang', 'srcSet', 'useMap',
// Safari/Apple specific, no listing available
'autoCorrect', // https://stackoverflow.com/questions/47985384/html-autocorrect-for-text-input-is-not-working
'autoSave', // https://stackoverflow.com/questions/25456396/what-is-autosave-attribute-supposed-to-do-how-do-i-use-it
// React specific attributes https://reactjs.org/docs/dom-elements.html#differences-in-attributes
'className', 'dangerouslySetInnerHTML', 'defaultValue', 'htmlFor', 'onChange', 'suppressContentEditableWarning', 'suppressHydrationWarning',
];

const DOM_PROPERTIES_IGNORE_CASE = ['charset'];

const ARIA_PROPERTIES = [
// See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes
// Global attributes
'aria-atomic', 'aria-braillelabel', 'aria-brailleroledescription', 'aria-busy', 'aria-controls', 'aria-current',
'aria-describedby', 'aria-description', 'aria-details',
'aria-disabled', 'aria-dropeffect', 'aria-errormessage', 'aria-flowto', 'aria-grabbed', 'aria-haspopup',
'aria-hidden', 'aria-invalid', 'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-live',
'aria-owns', 'aria-relevant', 'aria-roledescription',
// Widget attributes
'aria-autocomplete', 'aria-checked', 'aria-expanded', 'aria-level', 'aria-modal', 'aria-multiline', 'aria-multiselectable',
'aria-orientation', 'aria-placeholder', 'aria-pressed', 'aria-readonly', 'aria-required', 'aria-selected',
'aria-sort', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow', 'aria-valuetext',
// Relationship attributes
'aria-activedescendant', 'aria-colcount', 'aria-colindex', 'aria-colindextext', 'aria-colspan',
'aria-posinset', 'aria-rowcount', 'aria-rowindex', 'aria-rowindextext', 'aria-rowspan', 'aria-setsize',
];

function getDOMPropertyNames(context) {
const ALL_DOM_PROPERTY_NAMES = DOM_PROPERTY_NAMES_TWO_WORDS.concat(DOM_PROPERTY_NAMES_ONE_WORD);
// this was removed in React v16.1+, see https://github.com/facebook/react/pull/10823
if (!testReactVersion(context, '>= 16.1.0')) {
return ['allowTransparency'].concat(DOM_PROPERTY_NAMES);
return ['allowTransparency'].concat(ALL_DOM_PROPERTY_NAMES);
}
return DOM_PROPERTY_NAMES;
return ALL_DOM_PROPERTY_NAMES;
}

// ------------------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------------------

/**
* Checks if a node matches the JSX tag convention. This also checks if a node
* is extended as a webcomponent using the attribute "is".
* @param {Object} node - JSX element being tested.
* Checks if a node's parent is a JSX tag that is written with lowercase letters,
* and is not a custom web component. Custom web components have a hyphen in tag name,
* or have an `is="some-elem"` attribute.
*
* Note: does not check if a tag's parent against a list of standard HTML/DOM tags. For example,
* a `<fake>`'s child would return `true` because "fake" is written only with lowercase letters
* without a hyphen and does not have a `is="some-elem"` attribute.
*
* @param {Object} childNode - JSX element being tested.
* @returns {boolean} Whether or not the node name match the JSX tag convention.
*/
const tagConvention = /^[a-z][^-]*$/;
function isTagName(node) {
if (tagConvention.test(node.parent.name.name)) {
// https://www.w3.org/TR/custom-elements/#type-extension-semantics
return !node.parent.attributes.some((attrNode) => (
function isValidHTMLTagInJSX(childNode) {
const tagConvention = /^[a-z][^-]*$/;
if (tagConvention.test(childNode.parent.name.name)) {
return !childNode.parent.attributes.some((attrNode) => (
attrNode.type === 'JSXAttribute'
&& attrNode.name.type === 'JSXIdentifier'
&& attrNode.name.name === 'is'
// To learn more about custom web components and `is` attribute,
// see https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements-customized-builtin-example

));
}
return false;
}

/**
* Checks if an attribute name is a valid `data-*` attribute:
* if the name starts with "data-" and has some lowcase (a to z) words, separated but hyphens (-)
* (which is also called "kebab case" or "dash case"), then the attribute is valid data attribute.
*
* @param {String} name - Attribute name to be tested
* @returns {boolean} Result
*/
function isValidDataAttribute(name) {
const dataAttrConvention = /^data(-[a-z]*)*$/;
return !!dataAttrConvention.test(name);
}

/**
* Checks if an attribute name is a standard aria attribute by compering it to a list
* of standard aria property names
*
* @param {String} name - Attribute name to be tested
* @returns {Boolean} Result
*/

function isValidAriaAttribute(name) {
return ARIA_PROPERTIES.some((element) => element === name);
}

/**
* Checks if the attribute name is included in the attributes that are excluded
* from the camel casing.
*
* // returns true
* @example isCaseIgnoredAttribute('charSet')
*
* Note - these exclusions are not made by React core team, but `eslint-plugin-react` community.
*
* @param {String} name - Attribute name to be tested
* @returns {Boolean} Result
*/

function isCaseIgnoredAttribute(name) {
return DOM_PROPERTIES_IGNORE_CASE.some((element) => element === name.toLowerCase());
}

/**
* Extracts the tag name for the JSXAttribute
* @param {JSXAttribute} node - JSXAttribute being tested.
Expand Down Expand Up @@ -215,7 +310,8 @@ function getStandardName(name, context) {

const messages = {
invalidPropOnTag: 'Invalid property \'{{name}}\' found on tag \'{{tagName}}\', but it is only allowed on: {{allowedTags}}',
unknownProp: 'Unknown property \'{{name}}\' found, use \'{{standardName}}\' instead',
unknownPropWithStandardName: 'Unknown property \'{{name}}\' found, use \'{{standardName}}\' instead',
unknownProp: 'Unknown property \'{{name}}\' found',
};

module.exports = {
Expand Down Expand Up @@ -262,39 +358,68 @@ module.exports = {
return;
}

if (isValidDataAttribute(name)) { return; }

if (isValidAriaAttribute(name)) { return; }

if (isCaseIgnoredAttribute(name)) { return; }

const tagName = getTagName(node);

// 1. Some attributes are allowed on some tags only.
const allowedTags = has(ATTRIBUTE_TAGS_MAP, name) ? ATTRIBUTE_TAGS_MAP[name] : null;
if (tagName && allowedTags && /[^A-Z]/.test(tagName.charAt(0)) && allowedTags.indexOf(tagName) === -1) {
report(context, messages.invalidPropOnTag, 'invalidPropOnTag', {
// Let's dive deeper into tags that are HTML/DOM elements (`<button>`), and not React components (`<Button />`)
if (isValidHTMLTagInJSX(node)) {
// Some attributes are allowed on some tags only
const allowedTags = has(ATTRIBUTE_TAGS_MAP, name) ? ATTRIBUTE_TAGS_MAP[name] : null;
if (tagName && allowedTags) {
// Scenario 1A: Allowed attribute found where not supposed to, report it
if (allowedTags.indexOf(tagName) === -1) {
report(context, messages.invalidPropOnTag, 'invalidPropOnTag', {
node,
data: {
name,
tagName,
allowedTags: allowedTags.join(', '),
},
});
}
// Scenario 1B: There are allowed attributes on allowed tags, no need to report it
return;
}

// Let's see if the attribute is a close version to some standard property name
const standardName = getStandardName(name, context);

const hasStandardNameButIsNotUsed = standardName && standardName !== name;
const usesStandardName = standardName && standardName === name;

if (usesStandardName) {
// Scenario 2A: The attribute name is the standard name, no need to report it
return;
}

if (hasStandardNameButIsNotUsed) {
// Scenario 2B: The name of the attribute is close to a standard one, report it with the standard name
report(context, messages.unknownPropWithStandardName, 'unknownPropWithStandardName', {
node,
data: {
name,
standardName,
},
fix(fixer) {
return fixer.replaceText(node.name, standardName);
},
});
return;
}

// Scenario 3: We have an attribute that is unknown, report it
report(context, messages.unknownProp, 'unknownProp', {
node,
data: {
name,
tagName,
allowedTags: allowedTags.join(', '),
},
});
}

// 2. Otherwise, we'll try to find if the attribute is a close version
// of what we should normally have with React. If yes, we'll report an
// error. We don't want to report if the input attribute name is the
// standard name though!
const standardName = getStandardName(name, context);
if (!isTagName(node) || !standardName || standardName === name) {
return;
}
report(context, messages.unknownProp, 'unknownProp', {
node,
data: {
name,
standardName,
},
fix(fixer) {
return fixer.replaceText(node.name, standardName);
},
});
},
};
},
Expand Down