Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 45 additions & 9 deletions lib/rules/jsx-no-leaked-render.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@

const find = require('es-iterator-helpers/Iterator.prototype.find');
const from = require('es-iterator-helpers/Iterator.from');
const arrayIncludes = require('array-includes');

const getText = require('../util/eslint').getText;
const eslintUtil = require('../util/eslint');
const docsUrl = require('../util/docsUrl');
const report = require('../util/report');
const variableUtil = require('../util/variable');
const testReactVersion = require('../util/version').testReactVersion;
const isParenthesized = require('../util/ast').isParenthesized;

const getText = eslintUtil.getText;

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
Expand All @@ -27,7 +30,39 @@ const COERCE_STRATEGY = 'coerce';
const TERNARY_STRATEGY = 'ternary';
const DEFAULT_VALID_STRATEGIES = [TERNARY_STRATEGY, COERCE_STRATEGY];
const COERCE_VALID_LEFT_SIDE_EXPRESSIONS = ['UnaryExpression', 'BinaryExpression', 'CallExpression'];
const TERNARY_INVALID_ALTERNATE_VALUES = [undefined, null, false];
const TERNARY_INVALID_ALTERNATE_VALUES = [null, false];

function isGlobalUndefined(context, node) {
const variable = variableUtil.getVariableFromContext(context, node, node.name);
return !variable || !variable.defs || variable.defs.length === 0;
}

function isInvalidTernaryAlternate(context, node) {
if (node.type === 'Identifier') {
return node.name === 'undefined' && isGlobalUndefined(context, node);
}
if (node.type === 'UnaryExpression') {
return node.operator === 'void' && node.argument.type === 'Literal';
}
return node.type === 'Literal' && arrayIncludes(TERNARY_INVALID_ALTERNATE_VALUES, node.value);
}

function isFalseLiteral(node) {
return node.type === 'Literal' && node.value === false;
}

function getCoerceAlternateText(context, node) {
const nodeText = getText(context, node);
if (
node.type === 'AssignmentExpression'
|| node.type === 'ConditionalExpression'
|| node.type === 'LogicalExpression'
|| node.type === 'SequenceExpression'
) {
return `(${nodeText})`;
}
return nodeText;
}

function trimLeftNode(node) {
// Remove double unary expression (boolean coercion), so we avoid trimming valid negations
Expand Down Expand Up @@ -80,19 +115,22 @@ function ruleFixer(context, fixStrategy, fixer, reportedNode, leftNode, rightNod
if (isParenthesized(context, node)) {
nodeText = `(${nodeText})`;
}
if (node.parent && node.parent.type === 'ConditionalExpression' && node.parent.consequent.value === false) {
if (node.parent && node.parent.type === 'ConditionalExpression' && isFalseLiteral(node.parent.consequent)) {
return `${getIsCoerceValidNestedLogicalExpression(node) ? '' : '!'}${nodeText}`;
}
return `${getIsCoerceValidNestedLogicalExpression(node) ? '' : '!!'}${nodeText}`;
}).join(' && ');

if (rightNode.parent && rightNode.parent.type === 'ConditionalExpression' && rightNode.parent.consequent.value === false) {
if (rightNode.parent && rightNode.parent.type === 'ConditionalExpression' && isFalseLiteral(rightNode.parent.consequent)) {
const consequentVal = rightNode.parent.consequent.raw || rightNode.parent.consequent.name;
const alternateVal = rightNode.parent.alternate.raw || rightNode.parent.alternate.name;
const alternateVal = getCoerceAlternateText(context, rightNode.parent.alternate);
if (rightNode.parent.test && rightNode.parent.test.type === 'LogicalExpression') {
return fixer.replaceText(reportedNode, `${newText} ? ${consequentVal} : ${alternateVal}`);
}
return fixer.replaceText(reportedNode, `${newText} && ${alternateVal}`);
return fixer.replaceText(
reportedNode,
`${newText} && ${alternateVal}`
);
}

if (rightNode.type === 'ConditionalExpression' || rightNode.type === 'LogicalExpression') {
Expand Down Expand Up @@ -211,9 +249,7 @@ module.exports = {
return;
}

const isValidTernaryAlternate = TERNARY_INVALID_ALTERNATE_VALUES.indexOf(node.alternate.value) === -1;
const isJSXElementAlternate = node.alternate.type === 'JSXElement';
if (isValidTernaryAlternate || isJSXElementAlternate) {
if (!isInvalidTernaryAlternate(context, node.alternate) && !isFalseLiteral(node.consequent)) {
return;
}

Expand Down
180 changes: 180 additions & 0 deletions tests/lib/rules/jsx-no-leaked-render.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ ruleTester.run('jsx-no-leaked-render', rule, {
`,
options: [{ validStrategies: ['coerce', 'ternary'] }],
},
{
code: `
function Component({ count, title }, undefined) {
return <div>{count ? title : undefined}</div>
}
`,
options: [{ validStrategies: ['coerce'] }],
},

// Fixes for:
// - https://github.com/jsx-eslint/eslint-plugin-react/issues/3292
Expand Down Expand Up @@ -169,6 +177,34 @@ ruleTester.run('jsx-no-leaked-render', rule, {
`,
options: [{ validStrategies: ['coerce'] }],
},
{
// It shouldn't delete valid alternate branches from ternary expressions when "coerce" is the only valid strategy
code: `
const Component = ({ filteredOptions, selectVal, options }) => {
return (
<Select
options={
filteredOptions.length
? groupedOptions(filteredOptions)
: selectVal
? []
: groupedOptions(options)
}
/>
)
}
`,
options: [{ validStrategies: ['coerce'] }],
},
{
// It shouldn't drop side effects from ternary alternate branches when "coerce" is the only valid strategy
code: `
const Component = ({ count, title }) => {
return <div>{count ? title : void sideEffect()}</div>
}
`,
options: [{ validStrategies: ['coerce'] }],
},
// Fixes for:
// - https://github.com/jsx-eslint/eslint-plugin-react/issues/3354
{
Expand Down Expand Up @@ -791,6 +827,42 @@ ruleTester.run('jsx-no-leaked-render', rule, {
}
`,
},
{
code: `
const Component = ({ count, title }) => {
return <div>{count ? title : undefined}</div>
}
`,
options: [{ validStrategies: ['coerce'] }],
errors: [{
message: 'Potential leaked value that might cause unintentionally rendered values or rendering crashes',
line: 3,
column: 24,
}],
output: `
const Component = ({ count, title }) => {
return <div>{!!count && title}</div>
}
`,
},
{
code: `
const Component = ({ count, title }) => {
return <div>{count ? title : void 0}</div>
}
`,
options: [{ validStrategies: ['coerce'] }],
errors: [{
message: 'Potential leaked value that might cause unintentionally rendered values or rendering crashes',
line: 3,
column: 24,
}],
output: `
const Component = ({ count, title }) => {
return <div>{!!count && title}</div>
}
`,
},
{
code: `
const Component = ({ count, title }) => {
Expand Down Expand Up @@ -924,6 +996,114 @@ ruleTester.run('jsx-no-leaked-render', rule, {
column: 38,
}],
},
semver.satisfies(eslintPkg.version, '> 4') ? {
code: `
const MyComponent = ({ Empty }) => {
return <div>{isIndeterminate ? false : <Empty />}</div>
}
`,
output: `
const MyComponent = ({ Empty }) => {
return <div>{!isIndeterminate && <Empty />}</div>
}
`,
options: [{ validStrategies: ['coerce'] }],
errors: [{
message: 'Potential leaked value that might cause unintentionally rendered values or rendering crashes',
line: 3,
column: 24,
}],
} : [],
semver.satisfies(eslintPkg.version, '> 4') ? {
code: `
const MyComponent = () => {
return <div>{isIndeterminate ? false : getContent()}</div>
}
`,
output: `
const MyComponent = () => {
return <div>{!isIndeterminate && getContent()}</div>
}
`,
options: [{ validStrategies: ['coerce'] }],
errors: [{
message: 'Potential leaked value that might cause unintentionally rendered values or rendering crashes',
line: 3,
column: 24,
}],
} : [],
semver.satisfies(eslintPkg.version, '> 4') ? {
code: `
const MyComponent = ({ isFoo }) => {
return <div>{isIndeterminate ? false : isFoo ? <Aaa /> : <Bbb />}</div>
}
`,
output: `
const MyComponent = ({ isFoo }) => {
return <div>{!isIndeterminate && (isFoo ? <Aaa /> : <Bbb />)}</div>
}
`,
options: [{ validStrategies: ['coerce'] }],
errors: [{
message: 'Potential leaked value that might cause unintentionally rendered values or rendering crashes',
line: 3,
column: 24,
}],
} : [],
semver.satisfies(eslintPkg.version, '> 4') ? {
code: `
const MyComponent = ({ fallback }) => {
return <div>{isIndeterminate ? false : fallback || <Empty />}</div>
}
`,
output: `
const MyComponent = ({ fallback }) => {
return <div>{!isIndeterminate && (fallback || <Empty />)}</div>
}
`,
options: [{ validStrategies: ['coerce'] }],
errors: [{
message: 'Potential leaked value that might cause unintentionally rendered values or rendering crashes',
line: 3,
column: 24,
}],
} : [],
semver.satisfies(eslintPkg.version, '> 4') ? {
code: `
const MyComponent = ({ fallback }) => {
return <div>{ready && isIndeterminate ? false : fallback || <Empty />}</div>
}
`,
output: `
const MyComponent = ({ fallback }) => {
return <div>{!!ready && !!isIndeterminate ? false : (fallback || <Empty />)}</div>
}
`,
options: [{ validStrategies: ['coerce'] }],
errors: [{
message: 'Potential leaked value that might cause unintentionally rendered values or rendering crashes',
line: 3,
column: 24,
}],
} : [],
semver.satisfies(eslintPkg.version, '> 4') ? {
code: `
const MyComponent = () => {
return <div>{ready && isIndeterminate ? false : (track(), <Empty />)}</div>
}
`,
output: `
const MyComponent = () => {
return <div>{!!ready && !!isIndeterminate ? false : (track(), <Empty />)}</div>
}
`,
options: [{ validStrategies: ['coerce'] }],
errors: [{
message: 'Potential leaked value that might cause unintentionally rendered values or rendering crashes',
line: 3,
column: 24,
}],
} : [],
{
code: `
const MyComponent = () => {
Expand Down
Loading