Skip to content

Commit 6b7b191

Browse files
Belco90ljharb
authored andcommitted
[New] add jsx-no-leaked-render
1 parent fdfbc60 commit 6b7b191

File tree

6 files changed

+1030
-0
lines changed

6 files changed

+1030
-0
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange
1010
* [`no-unknown-property`]: Allow crossOrigin on image tag (SVG) ([#3251][] @zpao)
1111
* [`jsx-tag-spacing`]: Add `multiline-always` option ([#3260][] @Nokel81)
1212
* [`function-component-definition`]: replace `var` by `const` in certain situations ([#3248][] @JohnBerd @SimeonC)
13+
* add [`jsx-no-leaked-render`] ([#3203][] @Belco90)
1314

1415
### Fixed
1516
* [`hook-use-state`]: Allow UPPERCASE setState setter prefixes ([#3244][] @duncanbeevers)
@@ -3685,6 +3686,7 @@ If you're still not using React 15 you can keep the old behavior by setting the
36853686
[`jsx-no-comment-textnodes`]: docs/rules/jsx-no-comment-textnodes.md
36863687
[`jsx-no-constructed-context-values`]: docs/rules/jsx-no-constructed-context-values.md
36873688
[`jsx-no-duplicate-props`]: docs/rules/jsx-no-duplicate-props.md
3689+
[`jsx-no-leaked-render`]: docs/rules/jsx-no-leaked-render.md
36883690
[`jsx-no-literals`]: docs/rules/jsx-no-literals.md
36893691
[`jsx-no-script-url`]: docs/rules/jsx-no-script-url.md
36903692
[`jsx-no-target-blank`]: docs/rules/jsx-no-target-blank.md

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ Enable the rules that you would like to use.
202202
|| | [react/jsx-no-comment-textnodes](docs/rules/jsx-no-comment-textnodes.md) | Comments inside children section of tag should be placed inside braces |
203203
| | | [react/jsx-no-constructed-context-values](docs/rules/jsx-no-constructed-context-values.md) | Prevents JSX context provider values from taking values that will cause needless rerenders. |
204204
|| | [react/jsx-no-duplicate-props](docs/rules/jsx-no-duplicate-props.md) | Enforce no duplicate props |
205+
| | 🔧 | [react/jsx-no-leaked-render](docs/rules/jsx-no-leaked-render.md) | Prevent problematic leaked values from being rendered |
205206
| | | [react/jsx-no-literals](docs/rules/jsx-no-literals.md) | Prevent using string literals in React component definition |
206207
| | | [react/jsx-no-script-url](docs/rules/jsx-no-script-url.md) | Forbid `javascript:` URLs |
207208
|| 🔧 | [react/jsx-no-target-blank](docs/rules/jsx-no-target-blank.md) | Forbid `target="_blank"` attribute without `rel="noreferrer"` |

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

+208
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# Prevent problematic leaked values from being rendered (react/jsx-no-leaked-render)
2+
3+
Using the `&&` operator to render some element conditionally in JSX can cause unexpected values being rendered, or even crashing the rendering.
4+
5+
6+
## Rule Details
7+
8+
This rule aims to prevent dangerous leaked values from being rendered since they can cause unexpected values reaching the final DOM or even crashing your render method.
9+
10+
In React, you might end up rendering unexpected values like `0` or `NaN`. In React Native, your render method will crash if you render `0`, `''`, or `NaN`:
11+
12+
```jsx
13+
const Example = () => {
14+
return (
15+
<>
16+
{0 && <Something/>}
17+
{/* React: renders undesired 0 */}
18+
{/* React Native: crashes 💥 */}
19+
20+
{'' && <Something/>}
21+
{/* React: renders nothing */}
22+
{/* React Native: crashes 💥 */}
23+
24+
{NaN && <Something/>}
25+
{/* React: renders undesired NaN */}
26+
{/* React Native: crashes 💥 */}
27+
</>
28+
)
29+
}
30+
```
31+
32+
This can be avoided by:
33+
- casting the condition to bool: `{!!someValue && <Something />}`
34+
- transforming the binary expression into a ternary expression which returns `null` for falsy values: `{someValue ? <Something /> : null}`
35+
36+
This rule is autofixable, check the Options section to read more about the different strategies available.
37+
38+
Examples of **incorrect** code for this rule:
39+
40+
```jsx
41+
const Component = ({ count, title }) => {
42+
return <div>{count && title}</div>
43+
}
44+
```
45+
46+
```jsx
47+
const Component = ({ count }) => {
48+
return <div>{count && <span>There are {count} results</span>}</div>
49+
}
50+
```
51+
52+
```jsx
53+
const Component = ({ elements }) => {
54+
return <div>{elements.length && <List elements={elements}/>}</div>
55+
}
56+
```
57+
58+
```jsx
59+
const Component = ({ nestedCollection }) => {
60+
return (
61+
<div>
62+
{nestedCollection.elements.length && <List elements={nestedCollection.elements} />}
63+
</div>
64+
)
65+
}
66+
```
67+
68+
```jsx
69+
const Component = ({ elements }) => {
70+
return <div>{elements[0] && <List elements={elements}/>}</div>
71+
}
72+
```
73+
74+
```jsx
75+
const Component = ({ numberA, numberB }) => {
76+
return <div>{(numberA || numberB) && <Results>{numberA+numberB}</Results>}</div>
77+
}
78+
```
79+
80+
```jsx
81+
// If the condition is a boolean value, this rule will report the logical expression
82+
// since it can't infer the type of the condition.
83+
const Component = ({ someBool }) => {
84+
return <div>{someBool && <Results>{numberA+numberB}</Results>}</div>
85+
}
86+
```
87+
88+
Examples of **correct** code for this rule:
89+
90+
```jsx
91+
const Component = ({ elements }) => {
92+
return <div>{elements}</div>
93+
}
94+
```
95+
96+
```jsx
97+
// An OR condition it's considered valid since it's assumed as a way
98+
// to render some fallback if the first value is falsy, not to render something conditionally.
99+
const Component = ({ customTitle }) => {
100+
return <div>{customTitle || defaultTitle}</div>
101+
}
102+
```
103+
104+
```jsx
105+
const Component = ({ elements }) => {
106+
return <div>There are {elements.length} elements</div>
107+
}
108+
```
109+
110+
```jsx
111+
const Component = ({ elements, count }) => {
112+
return <div>{!count && 'No results found'}</div>
113+
}
114+
```
115+
116+
```jsx
117+
const Component = ({ elements }) => {
118+
return <div>{!!elements.length && <List elements={elements}/>}</div>
119+
}
120+
```
121+
122+
```jsx
123+
const Component = ({ elements }) => {
124+
return <div>{Boolean(elements.length) && <List elements={elements}/>}</div>
125+
}
126+
```
127+
128+
```jsx
129+
const Component = ({ elements }) => {
130+
return <div>{elements.length > 0 && <List elements={elements}/>}</div>
131+
}
132+
```
133+
134+
```jsx
135+
const Component = ({ elements }) => {
136+
return <div>{elements.length ? <List elements={elements}/> : null}</div>
137+
}
138+
```
139+
140+
### Options
141+
142+
The supported options are:
143+
144+
### `validStrategies`
145+
An array containing `"cast"`, `"ternary"` or both (default: `["ternary", "cast"]`) - Decide which strategies are considered valid to prevent leaked renders (at least 1 is required). The "cast" option will cast to boolean the condition of the JSX expression. The "ternary" option transforms the binary expression into a ternary expression returning `null` for falsy values. The first option from the array will be used as autofix, so the order of the values matter.
146+
147+
It can be set like:
148+
```json5
149+
{
150+
// ...
151+
"react/jsx-no-leaked-render": [<enabled>, { "validStrategies": ["ternary", "cast"] }]
152+
// ...
153+
}
154+
```
155+
156+
Assuming the following options: `{ "validStrategies": ["ternary"] }`
157+
158+
Examples of **incorrect** code for this rule, with the above configuration:
159+
```jsx
160+
const Component = ({ count, title }) => {
161+
return <div>{count && title}</div>
162+
}
163+
```
164+
165+
```jsx
166+
const Component = ({ count, title }) => {
167+
return <div>{!!count && title}</div>
168+
}
169+
```
170+
171+
Examples of **correct** code for this rule, with the above configuration:
172+
```jsx
173+
const Component = ({ count, title }) => {
174+
return <div>{count ? title : null}</div>
175+
}
176+
```
177+
178+
Assuming the following options: `{ "validStrategies": ["cast"] }`
179+
180+
Examples of **incorrect** code for this rule, with the above configuration:
181+
```jsx
182+
const Component = ({ count, title }) => {
183+
return <div>{count && title}</div>
184+
}
185+
```
186+
187+
```jsx
188+
const Component = ({ count, title }) => {
189+
return <div>{count ? title : null}</div>
190+
}
191+
```
192+
193+
Examples of **correct** code for this rule, with the above configuration:
194+
```jsx
195+
const Component = ({ count, title }) => {
196+
return <div>{!!count && title}</div>
197+
}
198+
```
199+
200+
## When Not To Use It
201+
202+
If you are working in a typed-codebase which encourages you to always use boolean conditions, this rule can be disabled.
203+
204+
## Further Reading
205+
206+
- [React docs: Inline If with Logical && Operator](https://reactjs.org/docs/conditional-rendering.html#inline-if-with-logical--operator)
207+
- [Good advice on JSX conditionals - Beware of zero](https://thoughtspile.github.io/2022/01/17/jsx-conditionals/)
208+
- [Twitter: rendering falsy values in React and React Native](https://twitter.com/kadikraman/status/1507654900376875011?s=21&t=elEXXbHhzWthrgKaPRMjNg)

index.js

+1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const allRules = {
3838
'jsx-no-comment-textnodes': require('./lib/rules/jsx-no-comment-textnodes'),
3939
'jsx-no-constructed-context-values': require('./lib/rules/jsx-no-constructed-context-values'),
4040
'jsx-no-duplicate-props': require('./lib/rules/jsx-no-duplicate-props'),
41+
'jsx-no-leaked-render': require('./lib/rules/jsx-no-leaked-render'),
4142
'jsx-no-literals': require('./lib/rules/jsx-no-literals'),
4243
'jsx-no-script-url': require('./lib/rules/jsx-no-script-url'),
4344
'jsx-no-target-blank': require('./lib/rules/jsx-no-target-blank'),

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

+137
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* @fileoverview Prevent problematic leaked values from being rendered
3+
* @author Mario Beltrán
4+
*/
5+
6+
'use strict';
7+
8+
const docsUrl = require('../util/docsUrl');
9+
const report = require('../util/report');
10+
const isParenthesized = require('../util/ast').isParenthesized;
11+
12+
//------------------------------------------------------------------------------
13+
// Rule Definition
14+
//------------------------------------------------------------------------------
15+
16+
const messages = {
17+
noPotentialLeakedRender: 'Potential leaked value that might cause unintentionally rendered values or rendering crashes',
18+
};
19+
20+
const CAST_STRATEGY = 'cast';
21+
const TERNARY_STRATEGY = 'ternary';
22+
const DEFAULT_VALID_STRATEGIES = [TERNARY_STRATEGY, CAST_STRATEGY];
23+
24+
/**
25+
* @type {import('eslint').Rule.RuleModule}
26+
*/
27+
module.exports = {
28+
meta: {
29+
docs: {
30+
description: 'Prevent problematic leaked values from being rendered',
31+
category: 'Possible Errors',
32+
recommended: false,
33+
url: docsUrl('jsx-no-leaked-render'),
34+
},
35+
36+
messages,
37+
38+
fixable: 'code',
39+
schema: [
40+
{
41+
type: 'object',
42+
properties: {
43+
validStrategies: {
44+
type: 'array',
45+
items: {
46+
enum: [
47+
TERNARY_STRATEGY,
48+
CAST_STRATEGY,
49+
],
50+
},
51+
uniqueItems: true,
52+
default: DEFAULT_VALID_STRATEGIES,
53+
},
54+
},
55+
additionalProperties: false,
56+
},
57+
],
58+
},
59+
60+
create(context) {
61+
const config = context.options[0] || {};
62+
const validStrategies = config.validStrategies || DEFAULT_VALID_STRATEGIES;
63+
const fixStrategy = validStrategies[0];
64+
const areBothStrategiesValid = validStrategies.length === 2;
65+
66+
function trimLeftNode(node) {
67+
// Remove double unary expression (boolean cast), so we avoid trimming valid negations
68+
if (node.type === 'UnaryExpression' && node.argument.type === 'UnaryExpression') {
69+
return trimLeftNode(node.argument.argument);
70+
}
71+
72+
return node;
73+
}
74+
75+
function ruleFixer(fixer, reportedNode, leftNode, rightNode) {
76+
const sourceCode = context.getSourceCode();
77+
const rightSideText = sourceCode.getText(rightNode);
78+
79+
if (fixStrategy === CAST_STRATEGY) {
80+
let leftSideText = sourceCode.getText(leftNode);
81+
if (isParenthesized(context, leftNode)) {
82+
leftSideText = `(${leftSideText})`;
83+
}
84+
85+
const shouldPrefixDoubleNegation = leftNode.type !== 'UnaryExpression';
86+
87+
return fixer.replaceText(reportedNode, `${shouldPrefixDoubleNegation ? '!!' : ''}${leftSideText} && ${rightSideText}`);
88+
}
89+
90+
if (fixStrategy === TERNARY_STRATEGY) {
91+
let leftSideText = sourceCode.getText(trimLeftNode(leftNode));
92+
if (isParenthesized(context, leftNode)) {
93+
leftSideText = `(${leftSideText})`;
94+
}
95+
return fixer.replaceText(reportedNode, `${leftSideText} ? ${rightSideText} : null`);
96+
}
97+
98+
throw new Error('Invalid value for "fixStrategy" option');
99+
}
100+
101+
return {
102+
'JSXExpressionContainer > LogicalExpression[operator="&&"]'(node) {
103+
const leftSide = node.left;
104+
const CAST_VALID_LEFT_SIDE_EXPRESSIONS = ['UnaryExpression', 'BinaryExpression', 'CallExpression'];
105+
const isCastStrategyValid = areBothStrategiesValid || fixStrategy === CAST_STRATEGY;
106+
const isCastValidLeftExpression = CAST_VALID_LEFT_SIDE_EXPRESSIONS.some(
107+
(validExpression) => validExpression === leftSide.type
108+
);
109+
110+
if (isCastStrategyValid && isCastValidLeftExpression) {
111+
return;
112+
}
113+
114+
report(context, messages.noPotentialLeakedRender, 'noPotentialLeakedRender', {
115+
node,
116+
fix(fixer) {
117+
return ruleFixer(fixer, node, leftSide, node.right);
118+
},
119+
});
120+
},
121+
122+
'JSXExpressionContainer > ConditionalExpression'(node) {
123+
const isTernaryStrategyValid = areBothStrategiesValid || fixStrategy === TERNARY_STRATEGY;
124+
if (isTernaryStrategyValid) {
125+
return;
126+
}
127+
128+
report(context, messages.noPotentialLeakedRender, 'noPotentialLeakedRender', {
129+
node,
130+
fix(fixer) {
131+
return ruleFixer(fixer, node, node.test, node.consequent);
132+
},
133+
});
134+
},
135+
};
136+
},
137+
};

0 commit comments

Comments
 (0)