Skip to content

Commit cfa18ba

Browse files
aleclarsonljharb
authored andcommitted
[New] jsx-no-leaked-render: add ignoreAttributes option
When true, validation of JSX attribute values is skipped.
1 parent ed049b0 commit cfa18ba

4 files changed

Lines changed: 136 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange
99
### Added
1010
* [`jsx-props-no-multi-spaces`]: improve autofix for multi-line ([#3930][] @justisb)
1111
* [`jsx-handler-names`]: support namespaced component names ([#3943][] @takuji)
12+
* [`jsx-no-leaked-render`]: add `ignoreAttributes` option ([#3441][] @aleclarson)
1213

1314
### Fixed
1415
* [`no-unknown-property`]: allow `onLoad` on `body` ([#3923][] @DerekStapleton)
@@ -26,6 +27,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange
2627
[#3942]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3942
2728
[#3930]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3930
2829
[#3923]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3923
30+
[#3441]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3441
2931

3032
## [7.37.5] - 2025.04.03
3133

docs/rules/jsx-no-leaked-render.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,60 @@ const Component = ({ elements }) => {
153153

154154
The supported options are:
155155

156+
### `ignoreAttributes`
157+
158+
Boolean. When set to `true`, this option ignores all attributes except for `children` during validation, preventing false positives in scenarios where these attributes are used safely or validated internally. Default is `false`.
159+
160+
It can be set like:
161+
162+
```jsonc
163+
{
164+
// ...
165+
"react/jsx-no-leaked-render": [<enabled>, { "ignoreAttributes": true }]
166+
// ...
167+
}
168+
```
169+
170+
Assuming the following options: `{ "ignoreAttributes": true }`
171+
172+
Examples of **incorrect** code for this rule, with the above configuration:
173+
174+
```jsx
175+
function MyComponent({ value }) {
176+
return (
177+
<MyChildComponent nonChildrenProp={value && 'default'}>
178+
{value && <MyInnerChildComponent />}
179+
</MyChildComponent>
180+
);
181+
}
182+
```
183+
184+
Even with `ignoreAttributes: true`, `children` expressions are still checked. In the example above, `{value && <MyInnerChildComponent />}` will still be flagged.
185+
186+
Nested JSX children within attributes are also still checked:
187+
188+
```jsx
189+
const Component = ({ enabled }) => {
190+
return (
191+
<Foo bar={
192+
<Something>{enabled && <MuchWow />}</Something>
193+
} />
194+
)
195+
}
196+
```
197+
198+
Here, even though `<Something>…</Something>` is inside an attribute of `<Foo>`, the `{enabled && <MuchWow />}` expression is children of `<Something>`, so it is still flagged.
199+
200+
Examples of **correct** code for this rule, with the above configuration:
201+
202+
```jsx
203+
const Component = ({ enabled, checked }) => {
204+
return <CheckBox checked={enabled && checked} />
205+
}
206+
```
207+
208+
With `ignoreAttributes: true`, logical expressions in non-children attributes like `checked` are not flagged.
209+
156210
### `validStrategies`
157211

158212
An array containing `"coerce"`, `"ternary"`, or both (default: `["ternary", "coerce"]`) - Decide which strategies are considered valid to prevent leaked renders (at least 1 is required). The "coerce" option will transform the conditional of the JSX expression to a boolean. The "ternary" option transforms the binary expression into a ternary expression returning `null` for falsy values. The first option from the array will be the strategy used when autofixing, so the order of the values matters.

lib/rules/jsx-no-leaked-render.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,21 @@ function extractExpressionBetweenLogicalAnds(node) {
5555
);
5656
}
5757

58+
const stopTypes = {
59+
__proto__: null,
60+
JSXElement: true,
61+
JSXFragment: true,
62+
};
63+
64+
function isWithinAttribute(node) {
65+
let parent = node.parent;
66+
while (!stopTypes[parent.type]) {
67+
if (parent.type === 'JSXAttribute') return true;
68+
parent = parent.parent;
69+
}
70+
return false;
71+
}
72+
5873
function ruleFixer(context, fixStrategy, fixer, reportedNode, leftNode, rightNode) {
5974
const rightSideText = getText(context, rightNode);
6075

@@ -137,6 +152,10 @@ module.exports = {
137152
uniqueItems: true,
138153
default: DEFAULT_VALID_STRATEGIES,
139154
},
155+
ignoreAttributes: {
156+
type: 'boolean',
157+
default: false,
158+
},
140159
},
141160
additionalProperties: false,
142161
},
@@ -150,6 +169,9 @@ module.exports = {
150169

151170
return {
152171
'JSXExpressionContainer > LogicalExpression[operator="&&"]'(node) {
172+
if (config.ignoreAttributes && isWithinAttribute(node)) {
173+
return;
174+
}
153175
const leftSide = node.left;
154176

155177
const isCoerceValidLeftSide = COERCE_VALID_LEFT_SIDE_EXPRESSIONS
@@ -185,6 +207,9 @@ module.exports = {
185207
if (validStrategies.has(TERNARY_STRATEGY)) {
186208
return;
187209
}
210+
if (config.ignoreAttributes && isWithinAttribute(node)) {
211+
return;
212+
}
188213

189214
const isValidTernaryAlternate = TERNARY_INVALID_ALTERNATE_VALUES.indexOf(node.alternate.value) === -1;
190215
const isJSXElementAlternate = node.alternate.type === 'JSXElement';

tests/lib/rules/jsx-no-leaked-render.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,16 @@ ruleTester.run('jsx-no-leaked-render', rule, {
205205
`,
206206
options: [{ validStrategies: ['coerce'] }],
207207
},
208+
209+
// See #3292
210+
{
211+
code: `
212+
const Component = ({ enabled, checked }) => {
213+
return <CheckBox checked={enabled && checked} />
214+
}
215+
`,
216+
options: [{ ignoreAttributes: true }],
217+
},
208218
]) || [],
209219

210220
invalid: parsers.all([].concat(
@@ -877,6 +887,25 @@ ruleTester.run('jsx-no-leaked-render', rule, {
877887
column: 24,
878888
}],
879889
},
890+
891+
// See #3292
892+
{
893+
code: `
894+
const Component = ({ enabled, checked }) => {
895+
return <CheckBox checked={enabled && checked} />
896+
}
897+
`,
898+
output: `
899+
const Component = ({ enabled, checked }) => {
900+
return <CheckBox checked={enabled ? checked : null} />
901+
}
902+
`,
903+
errors: [{
904+
message: 'Potential leaked value that might cause unintentionally rendered values or rendering crashes',
905+
line: 3,
906+
column: 37,
907+
}],
908+
},
880909
{
881910
code: `
882911
const MyComponent = () => {
@@ -1002,6 +1031,32 @@ ruleTester.run('jsx-no-leaked-render', rule, {
10021031
line: 4,
10031032
column: 33,
10041033
}],
1034+
},
1035+
{
1036+
code: `
1037+
const Component = ({ enabled }) => {
1038+
return (
1039+
<Foo bar={
1040+
<Something>{enabled && <MuchWow />}</Something>
1041+
} />
1042+
)
1043+
}
1044+
`,
1045+
output: `
1046+
const Component = ({ enabled }) => {
1047+
return (
1048+
<Foo bar={
1049+
<Something>{enabled ? <MuchWow /> : null}</Something>
1050+
} />
1051+
)
1052+
}
1053+
`,
1054+
options: [{ ignoreAttributes: true }],
1055+
errors: [{
1056+
message: 'Potential leaked value that might cause unintentionally rendered values or rendering crashes',
1057+
line: 5,
1058+
column: 27,
1059+
}],
10051060
}
10061061
)),
10071062
});

0 commit comments

Comments
 (0)