Skip to content

Commit f79b10e

Browse files
implement no deep equal in memo rule
1 parent 46456cb commit f79b10e

File tree

3 files changed

+329
-0
lines changed

3 files changed

+329
-0
lines changed

eslint-plugin-expensify/CONST.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const CONST = {
3939
NO_USE_STATE_INITIALIZER_CALL_FUNCTION:
4040
'Avoid calling a function directly in the useState initializer. Use an initializer function instead (a callback).',
4141
NO_OBJECT_KEYS_INCLUDES: 'Avoid Object.keys({{object}}).includes({{key}}) for O(n) complexity. Use {{key}} in {{object}} or !!{{object}}[{{key}}] for O(1) complexity.',
42+
NO_DEEP_EQUAL_IN_MEMO: 'Avoid using deep equality checks in React.memo comparison functions. Use shallow comparisons of specific properties instead for better performance.',
4243
},
4344
};
4445

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import _ from 'lodash';
2+
import CONST from './CONST.js';
3+
4+
const meta = {
5+
type: 'problem',
6+
docs: {
7+
description:
8+
'Disallow deep equality checks in React.memo comparison functions',
9+
recommended: 'error',
10+
},
11+
schema: [],
12+
messages: {
13+
noDeepEqualInMemo: CONST.MESSAGE.NO_DEEP_EQUAL_IN_MEMO,
14+
},
15+
};
16+
17+
const DEEP_EQUAL_FUNCTIONS = new Set(['deepEqual', 'isEqual', 'isDeepEqual']);
18+
19+
/**
20+
* Check if a node is a React.memo or memo call
21+
* @param {Object} node
22+
* @returns {Boolean}
23+
*/
24+
function isMemoCall(node) {
25+
if (node.type !== 'CallExpression') {
26+
return false;
27+
}
28+
29+
const callee = node.callee;
30+
31+
// Check for React.memo()
32+
if (callee.type === 'MemberExpression') {
33+
const object = _.get(callee, 'object.name');
34+
const property = _.get(callee, 'property.name');
35+
return object === 'React' && property === 'memo';
36+
}
37+
38+
// Check for memo()
39+
if (callee.type === 'Identifier') {
40+
return callee.name === 'memo';
41+
}
42+
43+
return false;
44+
}
45+
46+
/**
47+
* Check if a node is a deep equality function call
48+
* @param {Object} node
49+
* @returns {Boolean}
50+
*/
51+
function isDeepEqualCall(node) {
52+
if (node.type !== 'CallExpression') {
53+
return false;
54+
}
55+
56+
const callee = node.callee;
57+
58+
// Check for direct function calls like deepEqual(), isEqual(), etc.
59+
if (callee.type === 'Identifier') {
60+
return DEEP_EQUAL_FUNCTIONS.has(callee.name);
61+
}
62+
63+
// Check for method calls like _.isEqual(), lodash.isEqual(), etc.
64+
if (callee.type === 'MemberExpression') {
65+
const methodName = _.get(callee, 'property.name');
66+
return DEEP_EQUAL_FUNCTIONS.has(methodName);
67+
}
68+
69+
return false;
70+
}
71+
72+
/**
73+
* Check if a node is a JSON.stringify comparison
74+
* @param {Object} node
75+
* @returns {Boolean}
76+
*/
77+
function isJSONStringifyComparison(node) {
78+
if (node.type !== 'BinaryExpression') {
79+
return false;
80+
}
81+
82+
const {left, right, operator} = node;
83+
84+
// Check if operator is === or !==
85+
if (operator !== '===' && operator !== '!==') {
86+
return false;
87+
}
88+
89+
// Check if either side is JSON.stringify()
90+
const isLeftStringify = left.type === 'CallExpression'
91+
&& left.callee.type === 'MemberExpression'
92+
&& _.get(left, 'callee.object.name') === 'JSON'
93+
&& _.get(left, 'callee.property.name') === 'stringify';
94+
95+
const isRightStringify = right.type === 'CallExpression'
96+
&& right.callee.type === 'MemberExpression'
97+
&& _.get(right, 'callee.object.name') === 'JSON'
98+
&& _.get(right, 'callee.property.name') === 'stringify';
99+
100+
return isLeftStringify || isRightStringify;
101+
}
102+
103+
/**
104+
* Recursively check if a node or its descendants contain deep equality checks
105+
* @param {Object} node
106+
* @param {Object} context
107+
*/
108+
function checkForDeepEqual(node, context) {
109+
if (!node) {
110+
return;
111+
}
112+
113+
// Check if current node is a deep equal call
114+
if (isDeepEqualCall(node)) {
115+
context.report({
116+
node,
117+
messageId: 'noDeepEqualInMemo',
118+
});
119+
return;
120+
}
121+
122+
// Check if current node is a JSON.stringify comparison
123+
if (isJSONStringifyComparison(node)) {
124+
context.report({
125+
node,
126+
messageId: 'noDeepEqualInMemo',
127+
});
128+
return;
129+
}
130+
131+
// Recursively check child nodes
132+
_.keys(node).forEach((key) => {
133+
if (key === 'parent' || key === 'loc' || key === 'range') {
134+
return;
135+
}
136+
137+
const child = node[key];
138+
139+
if (_.isArray(child)) {
140+
child.forEach(item => checkForDeepEqual(item, context));
141+
} else if (child && typeof child === 'object' && child.type) {
142+
checkForDeepEqual(child, context);
143+
}
144+
});
145+
}
146+
147+
function create(context) {
148+
return {
149+
CallExpression(node) {
150+
// Check if this is a React.memo() or memo() call
151+
if (!isMemoCall(node)) {
152+
return;
153+
}
154+
155+
// Check if there's a second argument (comparison function)
156+
if (node.arguments.length < 2) {
157+
return;
158+
}
159+
160+
const comparisonFunction = node.arguments[1];
161+
162+
// Only check inline function expressions or arrow functions
163+
// Note: We don't check variable references (e.g., memo(Component, myCompareFunc))
164+
// because tracking variable definitions across scopes is complex and would require
165+
// significant scope analysis. Users should inline their comparison functions for this
166+
// rule to work effectively.
167+
if (
168+
comparisonFunction.type !== 'FunctionExpression'
169+
&& comparisonFunction.type !== 'ArrowFunctionExpression'
170+
) {
171+
return;
172+
}
173+
174+
// Check the body of the comparison function for deep equality checks
175+
checkForDeepEqual(comparisonFunction.body, context);
176+
},
177+
};
178+
}
179+
180+
export {meta, create};
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import {RuleTester} from '@typescript-eslint/rule-tester';
2+
import parser from '@typescript-eslint/parser';
3+
import * as rule from '../no-deep-equal-in-memo.js';
4+
5+
const ruleTester = new RuleTester({
6+
languageOptions: {
7+
parser,
8+
parserOptions: {
9+
sourceType: 'module',
10+
ecmaVersion: 2020,
11+
ecmaFeatures: {
12+
jsx: true,
13+
},
14+
},
15+
},
16+
});
17+
18+
ruleTester.run('no-deep-equal-in-memo', rule, {
19+
valid: [
20+
{
21+
name: 'React.memo with shallow property comparisons',
22+
code: `
23+
import React, { memo } from 'react';
24+
const ReportActionItem = memo(Component, (prevProps, nextProps) =>
25+
prevProps.report.type === nextProps.report.type &&
26+
prevProps.report.reportID === nextProps.report.reportID &&
27+
prevProps.isSelected === nextProps.isSelected
28+
);
29+
`,
30+
},
31+
{
32+
name: 'React.memo without custom comparison function',
33+
code: `
34+
import React, { memo } from 'react';
35+
const MyComponent = memo(Component);
36+
`,
37+
},
38+
{
39+
name: 'memo with Object.is comparison',
40+
code: `
41+
import { memo } from 'react';
42+
const MyComponent = memo(Component, (prevProps, nextProps) =>
43+
Object.is(prevProps.id, nextProps.id)
44+
);
45+
`,
46+
},
47+
{
48+
name: 'Regular function with deepEqual (not in memo)',
49+
code: `
50+
import { deepEqual } from 'lodash';
51+
function compareObjects(a, b) {
52+
return deepEqual(a, b);
53+
}
54+
`,
55+
},
56+
{
57+
name: 'useMemo with deepEqual (not a memo comparison)',
58+
code: `
59+
import { useMemo } from 'react';
60+
import { deepEqual } from 'lodash';
61+
const value = useMemo(() => deepEqual(obj1, obj2), [obj1, obj2]);
62+
`,
63+
},
64+
],
65+
invalid: [
66+
{
67+
name: 'React.memo with lodash deepEqual',
68+
code: `
69+
import React, { memo } from 'react';
70+
import { deepEqual } from 'lodash';
71+
const ReportActionItem = memo(Component, (prevProps, nextProps) =>
72+
deepEqual(prevProps.report, nextProps.report) &&
73+
prevProps.isSelected === nextProps.isSelected
74+
);
75+
`,
76+
errors: [
77+
{
78+
messageId: 'noDeepEqualInMemo',
79+
line: 5,
80+
},
81+
],
82+
},
83+
{
84+
name: 'React.memo with underscore isEqual',
85+
code: `
86+
import React from 'react';
87+
import { isEqual } from 'underscore';
88+
const MyComponent = React.memo(Component, (prevProps, nextProps) =>
89+
isEqual(prevProps.data, nextProps.data)
90+
);
91+
`,
92+
errors: [
93+
{
94+
messageId: 'noDeepEqualInMemo',
95+
line: 5,
96+
},
97+
],
98+
},
99+
{
100+
name: 'memo with lodash isEqual',
101+
code: `
102+
import { memo } from 'react';
103+
import { isEqual } from 'lodash';
104+
const MyComponent = memo(Component, (prevProps, nextProps) =>
105+
isEqual(prevProps, nextProps)
106+
);
107+
`,
108+
errors: [
109+
{
110+
messageId: 'noDeepEqualInMemo',
111+
line: 5,
112+
},
113+
],
114+
},
115+
{
116+
name: 'memo with deepEqual and shallow comparisons mixed',
117+
code: `
118+
import { memo } from 'react';
119+
import { deepEqual } from 'lodash';
120+
const MyComponent = memo(Component, (prevProps, nextProps) =>
121+
prevProps.id === nextProps.id &&
122+
deepEqual(prevProps.config, nextProps.config)
123+
);
124+
`,
125+
errors: [
126+
{
127+
messageId: 'noDeepEqualInMemo',
128+
line: 6,
129+
},
130+
],
131+
},
132+
{
133+
name: 'memo with JSON.stringify comparison',
134+
code: `
135+
import { memo } from 'react';
136+
const MyComponent = memo(Component, (prevProps, nextProps) =>
137+
JSON.stringify(prevProps) === JSON.stringify(nextProps)
138+
);
139+
`,
140+
errors: [
141+
{
142+
messageId: 'noDeepEqualInMemo',
143+
line: 4,
144+
},
145+
],
146+
},
147+
],
148+
});

0 commit comments

Comments
 (0)