diff --git a/CHANGELOG.md b/CHANGELOG.md
index 002a1ed942..9696eca775 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,15 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange
## Unreleased
+### Added
+* [`jsx-props-no-multi-spaces`]: improve autofix for multi-line ([#3930][] @justisb)
+
+### Fixed
+* [`no-unknown-property`]: allow `onLoad` on `body` ([#3923][] @DerekStapleton)
+
+[#3930]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3930
+[#3923]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3923
+
## [7.37.5] - 2025.04.03
### Fixed
diff --git a/lib/rules/jsx-props-no-multi-spaces.js b/lib/rules/jsx-props-no-multi-spaces.js
index 8402c6d4ff..5786506d11 100644
--- a/lib/rules/jsx-props-no-multi-spaces.js
+++ b/lib/rules/jsx-props-no-multi-spaces.js
@@ -80,6 +80,22 @@ module.exports = {
prop1: getPropName(prev),
prop2: getPropName(node),
},
+ fix(fixer) {
+ const comments = sourceCode.getCommentsBefore ? sourceCode.getCommentsBefore(node) : [];
+ const nodes = [].concat(prev, comments, node);
+ const fixes = [];
+
+ for (let i = 1; i < nodes.length; i += 1) {
+ const prevNode = nodes[i - 1];
+ const currNode = nodes[i];
+ if (currNode.loc.start.line - prevNode.loc.end.line >= 2) {
+ const indent = ' '.repeat(currNode.loc.start.column);
+ fixes.push(fixer.replaceTextRange([prevNode.range[1], currNode.range[0]], `\n${indent}`));
+ }
+ }
+
+ return fixes;
+ },
});
}
diff --git a/lib/rules/no-unknown-property.js b/lib/rules/no-unknown-property.js
index d702cc4303..ff87d2f0ff 100644
--- a/lib/rules/no-unknown-property.js
+++ b/lib/rules/no-unknown-property.js
@@ -85,7 +85,7 @@ const ATTRIBUTE_TAGS_MAP = {
onEncrypted: ['audio', 'video'],
onEnded: ['audio', 'video'],
onError: ['audio', 'video', 'img', 'link', 'source', 'script', 'picture', 'iframe'],
- onLoad: ['script', 'img', 'link', 'picture', 'iframe', 'object', 'source'],
+ onLoad: ['script', 'img', 'link', 'picture', 'iframe', 'object', 'source', 'body'],
onLoadedData: ['audio', 'video'],
onLoadedMetadata: ['audio', 'video'],
onLoadStart: ['audio', 'video'],
diff --git a/lib/util/ast.js b/lib/util/ast.js
index 452b1a1ef2..4cc140254c 100644
--- a/lib/util/ast.js
+++ b/lib/util/ast.js
@@ -448,6 +448,24 @@ function isTSTypeParameterInstantiation(node) {
return node.type === 'TSTypeParameterInstantiation';
}
+function isMemberExpression(node) {
+ if (!node) { return false; }
+
+ return node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression';
+}
+
+function isObjectPattern(node) {
+ if (!node) { return false; }
+
+ return node.type === 'ObjectPattern';
+}
+
+function isVariableDeclarator(node) {
+ if (!node) { return false; }
+
+ return node.type === 'VariableDeclarator';
+}
+
module.exports = {
findReturnStatement,
getComponentProperties,
@@ -462,7 +480,9 @@ module.exports = {
isFunction,
isFunctionLike,
isFunctionLikeExpression,
+ isMemberExpression,
isNodeFirstInLine,
+ isObjectPattern,
isParenthesized,
isTSAsExpression,
isTSFunctionType,
@@ -477,6 +497,7 @@ module.exports = {
isTSTypeParameterInstantiation,
isTSTypeQuery,
isTSTypeReference,
+ isVariableDeclarator,
traverse,
traverseReturns,
unwrapTSAsExpression,
diff --git a/lib/util/usedPropTypes.js b/lib/util/usedPropTypes.js
index 41eb307d6b..f0dae34ca4 100644
--- a/lib/util/usedPropTypes.js
+++ b/lib/util/usedPropTypes.js
@@ -9,7 +9,6 @@ const values = require('object.values');
const astUtil = require('./ast');
const componentUtil = require('./componentUtil');
const testReactVersion = require('./version').testReactVersion;
-const ast = require('./ast');
const eslintUtil = require('./eslint');
const getScope = eslintUtil.getScope;
@@ -164,7 +163,7 @@ function isInLifeCycleMethod(node, checkAsyncSafeLifeCycles) {
*/
function isSetStateUpdater(node) {
const unwrappedParentCalleeNode = astUtil.isCallExpression(node.parent)
- && ast.unwrapTSAsExpression(node.parent.callee);
+ && astUtil.unwrapTSAsExpression(node.parent.callee);
return unwrappedParentCalleeNode
&& unwrappedParentCalleeNode.property
@@ -181,7 +180,7 @@ function isPropArgumentInSetStateUpdater(context, node, name) {
while (scope) {
const unwrappedParentCalleeNode = scope.block
&& astUtil.isCallExpression(scope.block.parent)
- && ast.unwrapTSAsExpression(scope.block.parent.callee);
+ && astUtil.unwrapTSAsExpression(scope.block.parent.callee);
if (
unwrappedParentCalleeNode
&& unwrappedParentCalleeNode.property
@@ -215,7 +214,7 @@ function isInClassComponent(context, node) {
function isThisDotProps(node) {
return !!node
&& node.type === 'MemberExpression'
- && ast.unwrapTSAsExpression(node.object).type === 'ThisExpression'
+ && astUtil.unwrapTSAsExpression(node.object).type === 'ThisExpression'
&& node.property.name === 'props';
}
@@ -239,7 +238,7 @@ function hasSpreadOperator(context, node) {
* @returns {boolean}
*/
function isPropTypesUsageByMemberExpression(context, node, utils, checkAsyncSafeLifeCycles) {
- const unwrappedObjectNode = ast.unwrapTSAsExpression(node.object);
+ const unwrappedObjectNode = astUtil.unwrapTSAsExpression(node.object);
if (isInClassComponent(context, node)) {
// this.props.*
@@ -260,7 +259,7 @@ function isPropTypesUsageByMemberExpression(context, node, utils, checkAsyncSafe
return false;
}
// props.* in function component
- return unwrappedObjectNode.name === 'props' && !ast.isAssignmentLHS(node);
+ return unwrappedObjectNode.name === 'props' && !astUtil.isAssignmentLHS(node);
}
/**
@@ -321,109 +320,91 @@ module.exports = function usedPropTypesInstructions(context, components, utils)
let name;
let allNames;
let properties;
- switch (node.type) {
- case 'OptionalMemberExpression':
- case 'MemberExpression':
- name = getPropertyName(context, node, utils, checkAsyncSafeLifeCycles);
- if (name) {
- allNames = parentNames.concat(name);
- if (
- // Match props.foo.bar, don't match bar[props.foo]
- node.parent.type === 'MemberExpression'
- && node.parent.object === node
- ) {
- markPropTypesAsUsed(node.parent, allNames);
- }
- // Handle the destructuring part of `const {foo} = props.a.b`
- if (
- node.parent.type === 'VariableDeclarator'
- && node.parent.id.type === 'ObjectPattern'
- ) {
- node.parent.id.parent = node.parent; // patch for bug in eslint@4 in which ObjectPattern has no parent
- markPropTypesAsUsed(node.parent.id, allNames);
- }
-
- // const a = props.a
- if (
- node.parent.type === 'VariableDeclarator'
- && node.parent.id.type === 'Identifier'
- ) {
- propVariables.set(node.parent.id.name, allNames);
- }
- // Do not mark computed props as used.
- type = name !== '__COMPUTED_PROP__' ? 'direct' : null;
+ if (astUtil.isMemberExpression(node)) {
+ name = getPropertyName(context, node, utils, checkAsyncSafeLifeCycles);
+ if (name) {
+ allNames = parentNames.concat(name);
+ const parent = node.parent;
+ if (
+ // Match props.foo.bar, don't match bar[props.foo]
+ parent.type === 'MemberExpression'
+ && 'object' in parent
+ && parent.object === node
+ ) {
+ markPropTypesAsUsed(parent, allNames);
}
- break;
- case 'ArrowFunctionExpression':
- case 'FunctionDeclaration':
- case 'FunctionExpression': {
- if (node.params.length === 0) {
- break;
+ // Handle the destructuring part of `const {foo} = props.a.b`
+ if (
+ astUtil.isVariableDeclarator(parent)
+ && parent.id.type === 'ObjectPattern'
+ ) {
+ parent.id.parent = parent; // patch for bug in eslint@4 in which ObjectPattern has no parent
+ markPropTypesAsUsed(parent.id, allNames);
+ }
+
+ // const a = props.a
+ if (
+ astUtil.isVariableDeclarator(parent)
+ && parent.id.type === 'Identifier'
+ ) {
+ propVariables.set(parent.id.name, allNames);
}
+ // Do not mark computed props as used.
+ type = name !== '__COMPUTED_PROP__' ? 'direct' : null;
+ }
+ } else if (astUtil.isFunctionLike(node)) {
+ if (node.params.length > 0) {
type = 'destructuring';
const propParam = isSetStateUpdater(node) ? node.params[1] : node.params[0];
properties = propParam.type === 'AssignmentPattern'
? propParam.left.properties
: propParam.properties;
- break;
}
- case 'ObjectPattern':
- type = 'destructuring';
- properties = node.properties;
- break;
- case 'TSEmptyBodyFunctionExpression':
- break;
- default:
- throw new Error(`${node.type} ASTNodes are not handled by markPropTypesAsUsed`);
+ } else if (astUtil.isObjectPattern(node)) {
+ type = 'destructuring';
+ properties = node.properties;
+ } else if (node.type !== 'TSEmptyBodyFunctionExpression') {
+ throw new Error(`${node.type} ASTNodes are not handled by markPropTypesAsUsed`);
}
const component = components.get(utils.getParentComponent(node));
const usedPropTypes = (component && component.usedPropTypes) || [];
let ignoreUnusedPropTypesValidation = (component && component.ignoreUnusedPropTypesValidation) || false;
- switch (type) {
- case 'direct': {
- // Ignore Object methods
- if (name in Object.prototype) {
- break;
- }
-
+ if (type === 'direct') {
+ // Ignore Object methods
+ if (!(name in Object.prototype)) {
const reportedNode = node.property;
usedPropTypes.push({
name,
allNames,
node: reportedNode,
});
- break;
}
- case 'destructuring': {
- for (let k = 0, l = (properties || []).length; k < l; k++) {
- if (hasSpreadOperator(context, properties[k]) || properties[k].computed) {
- ignoreUnusedPropTypesValidation = true;
- break;
- }
- const propName = ast.getKeyValue(context, properties[k]);
+ } else if (type === 'destructuring') {
+ for (let k = 0, l = (properties || []).length; k < l; k++) {
+ if (hasSpreadOperator(context, properties[k]) || properties[k].computed) {
+ ignoreUnusedPropTypesValidation = true;
+ break;
+ }
+ const propName = astUtil.getKeyValue(context, properties[k]);
- if (!propName || properties[k].type !== 'Property') {
- break;
- }
+ if (!propName || properties[k].type !== 'Property') {
+ break;
+ }
- usedPropTypes.push({
- allNames: parentNames.concat([propName]),
- name: propName,
- node: properties[k],
- });
+ usedPropTypes.push({
+ allNames: parentNames.concat([propName]),
+ name: propName,
+ node: properties[k],
+ });
- if (properties[k].value.type === 'ObjectPattern') {
- markPropTypesAsUsed(properties[k].value, parentNames.concat([propName]));
- } else if (properties[k].value.type === 'Identifier') {
- propVariables.set(properties[k].value.name, parentNames.concat(propName));
- }
+ if (properties[k].value.type === 'ObjectPattern') {
+ markPropTypesAsUsed(properties[k].value, parentNames.concat([propName]));
+ } else if (properties[k].value.type === 'Identifier') {
+ propVariables.set(properties[k].value.name, parentNames.concat(propName));
}
- break;
}
- default:
- break;
}
components.set(component ? component.node : node, {
@@ -484,7 +465,7 @@ module.exports = function usedPropTypesInstructions(context, components, utils)
return {
VariableDeclarator(node) {
- const unwrappedInitNode = ast.unwrapTSAsExpression(node.init);
+ const unwrappedInitNode = astUtil.unwrapTSAsExpression(node.init);
// let props = this.props
if (isThisDotProps(unwrappedInitNode) && isInClassComponent(context, node) && node.id.type === 'Identifier') {
@@ -559,7 +540,7 @@ module.exports = function usedPropTypesInstructions(context, components, utils)
return;
}
- const propVariable = propVariables.get(ast.unwrapTSAsExpression(node.object).name);
+ const propVariable = propVariables.get(astUtil.unwrapTSAsExpression(node.object).name);
if (propVariable) {
markPropTypesAsUsed(node, propVariable);
}
diff --git a/package.json b/package.json
index 56a2abf018..5c91dd7c08 100644
--- a/package.json
+++ b/package.json
@@ -56,12 +56,12 @@
"string.prototype.repeat": "^1.0.0"
},
"devDependencies": {
- "@babel/core": "^7.26.10",
- "@babel/eslint-parser": "^7.27.0",
- "@babel/plugin-syntax-decorators": "^7.25.9",
- "@babel/plugin-syntax-do-expressions": "^7.25.9",
- "@babel/plugin-syntax-function-bind": "^7.25.9",
- "@babel/preset-react": "^7.26.3",
+ "@babel/core": "^7.27.1",
+ "@babel/eslint-parser": "^7.27.1",
+ "@babel/plugin-syntax-decorators": "^7.27.1",
+ "@babel/plugin-syntax-do-expressions": "^7.27.1",
+ "@babel/plugin-syntax-function-bind": "^7.27.1",
+ "@babel/preset-react": "^7.27.1",
"@types/eslint": "=7.2.10",
"@types/estree": "0.0.52",
"@types/node": "^4.9.5",
diff --git a/tests/lib/rules/jsx-props-no-multi-spaces.js b/tests/lib/rules/jsx-props-no-multi-spaces.js
index d85573702f..8b8d15a9b4 100644
--- a/tests/lib/rules/jsx-props-no-multi-spaces.js
+++ b/tests/lib/rules/jsx-props-no-multi-spaces.js
@@ -84,7 +84,7 @@ ruleTester.run('jsx-props-no-multi-spaces', rule, {
{
code: `
`,
@@ -92,7 +92,7 @@ ruleTester.run('jsx-props-no-multi-spaces', rule, {
{
code: `