Skip to content

Commit c0744c1

Browse files
implement no-deep-equal-in-memo
1 parent 857328d commit c0744c1

File tree

3 files changed

+423
-0
lines changed

3 files changed

+423
-0
lines changed
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* ESLint rule: no-deep-equal-in-memo
3+
*
4+
* Enforces using shallow comparisons instead of deep equality checks in React.memo
5+
* comparison functions for better performance.
6+
*
7+
* This rule implements the PERF-5 guideline: Use shallow comparisons instead of deep comparisons.
8+
*/
9+
10+
const name = 'no-deep-equal-in-memo';
11+
12+
const meta = {
13+
type: 'problem',
14+
docs: {
15+
description: 'Disallow deep equality checks in React.memo comparison functions. Use shallow comparisons of specific properties instead.',
16+
recommended: 'error',
17+
},
18+
schema: [],
19+
messages: {
20+
noDeepEqualInMemo: 'Avoid using deep equality checks ({{functionName}}) in React.memo. Compare specific relevant properties with shallow equality instead for better performance.',
21+
},
22+
};
23+
24+
/**
25+
* Set of deep comparison function names to detect
26+
*/
27+
const DEEP_COMPARISON_FUNCTIONS = new Set([
28+
'deepEqual',
29+
'isEqual',
30+
]);
31+
32+
/**
33+
* Check if a call expression is a deep comparison function
34+
* @param {Node} node - The CallExpression node
35+
* @returns {{isDeepComparison: boolean, functionName: string|null}}
36+
*/
37+
function isDeepComparisonCall(node) {
38+
if (node.type !== 'CallExpression') {
39+
return {isDeepComparison: false, functionName: null};
40+
}
41+
42+
// Check for direct function calls: deepEqual(), isEqual()
43+
if (node.callee.type === 'Identifier' && DEEP_COMPARISON_FUNCTIONS.has(node.callee.name)) {
44+
return {isDeepComparison: true, functionName: node.callee.name};
45+
}
46+
47+
// Check for lodash calls: _.isEqual()
48+
if (
49+
node.callee.type === 'MemberExpression'
50+
&& node.callee.object.type === 'Identifier'
51+
&& node.callee.object.name === '_'
52+
&& node.callee.property.type === 'Identifier'
53+
&& DEEP_COMPARISON_FUNCTIONS.has(node.callee.property.name)
54+
) {
55+
return {isDeepComparison: true, functionName: `_.${node.callee.property.name}`};
56+
}
57+
58+
return {isDeepComparison: false, functionName: null};
59+
}
60+
61+
/**
62+
* Check if a call expression is React.memo or memo
63+
* @param {Node} node - The CallExpression node
64+
* @returns {boolean}
65+
*/
66+
function isReactMemoCall(node) {
67+
if (node.type !== 'CallExpression') {
68+
return false;
69+
}
70+
71+
// Check for React.memo()
72+
if (
73+
node.callee.type === 'MemberExpression'
74+
&& node.callee.object.type === 'Identifier'
75+
&& node.callee.object.name === 'React'
76+
&& node.callee.property.type === 'Identifier'
77+
&& node.callee.property.name === 'memo'
78+
) {
79+
return true;
80+
}
81+
82+
// Check for memo() (named import)
83+
if (node.callee.type === 'Identifier' && node.callee.name === 'memo') {
84+
return true;
85+
}
86+
87+
return false;
88+
}
89+
90+
/**
91+
* Traverse AST to find deep comparison calls
92+
* @param {Node} node - The node to traverse
93+
* @param {Function} callback - Called when a deep comparison is found
94+
* @param {Set<Node>} visited - Set of visited nodes to prevent cycles
95+
*/
96+
function traverseForDeepComparisons(node, callback, visited = new Set()) {
97+
if (!node || !node.type || visited.has(node)) {
98+
return;
99+
}
100+
101+
visited.add(node);
102+
103+
// Check if this node is a deep comparison call
104+
const {isDeepComparison, functionName} = isDeepComparisonCall(node);
105+
if (isDeepComparison) {
106+
callback(node, functionName);
107+
}
108+
109+
// Traverse child nodes
110+
for (const key of Object.keys(node)) {
111+
// Skip metadata and parent references to avoid cycles
112+
if (key === 'parent' || key === 'type' || key === 'range' || key === 'loc') {
113+
continue;
114+
}
115+
116+
const value = node[key];
117+
118+
if (Array.isArray(value)) {
119+
for (const item of value) {
120+
if (item && typeof item === 'object' && item.type) {
121+
traverseForDeepComparisons(item, callback, visited);
122+
}
123+
}
124+
} else if (value && typeof value === 'object' && value.type) {
125+
traverseForDeepComparisons(value, callback, visited);
126+
}
127+
}
128+
}
129+
130+
function create(context) {
131+
return {
132+
CallExpression(node) {
133+
// Check if this is a React.memo() or memo() call
134+
if (!isReactMemoCall(node)) {
135+
return;
136+
}
137+
138+
// Check if there's a second argument (comparison function)
139+
if (node.arguments.length < 2) {
140+
return;
141+
}
142+
143+
const comparisonFunction = node.arguments[1];
144+
145+
// The comparison function should be a function expression or arrow function
146+
if (
147+
comparisonFunction.type !== 'FunctionExpression'
148+
&& comparisonFunction.type !== 'ArrowFunctionExpression'
149+
) {
150+
return;
151+
}
152+
153+
// Traverse the comparison function to find deep comparison calls
154+
const visited = new Set();
155+
traverseForDeepComparisons(
156+
comparisonFunction.body,
157+
(deepComparisonNode, functionName) => {
158+
context.report({
159+
node: deepComparisonNode,
160+
messageId: 'noDeepEqualInMemo',
161+
data: {
162+
functionName,
163+
},
164+
});
165+
},
166+
visited,
167+
);
168+
},
169+
};
170+
}
171+
172+
export {name, meta, create};
173+

0 commit comments

Comments
 (0)