@kernlang/review 3.1.9 → 3.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cache.js +143 -2
- package/dist/cache.js.map +1 -1
- package/dist/call-graph.d.ts +4 -1
- package/dist/call-graph.js +290 -25
- package/dist/call-graph.js.map +1 -1
- package/dist/external-tools.d.ts +23 -4
- package/dist/external-tools.js +68 -12
- package/dist/external-tools.js.map +1 -1
- package/dist/file-context.d.ts +6 -0
- package/dist/file-context.js +6 -1
- package/dist/file-context.js.map +1 -1
- package/dist/graph.js +149 -39
- package/dist/graph.js.map +1 -1
- package/dist/index.d.ts +27 -3
- package/dist/index.js +254 -41
- package/dist/index.js.map +1 -1
- package/dist/inferrer.d.ts +5 -0
- package/dist/inferrer.js +1 -1
- package/dist/inferrer.js.map +1 -1
- package/dist/mappers/ts-concepts.js +31 -6
- package/dist/mappers/ts-concepts.js.map +1 -1
- package/dist/public-api.d.ts +73 -0
- package/dist/public-api.js +351 -0
- package/dist/public-api.js.map +1 -0
- package/dist/reporter.d.ts +5 -0
- package/dist/reporter.js +119 -84
- package/dist/reporter.js.map +1 -1
- package/dist/review-health.d.ts +38 -0
- package/dist/review-health.js +60 -0
- package/dist/review-health.js.map +1 -0
- package/dist/rules/a11y.d.ts +10 -0
- package/dist/rules/a11y.js +294 -0
- package/dist/rules/a11y.js.map +1 -0
- package/dist/rules/async.d.ts +8 -0
- package/dist/rules/async.js +142 -0
- package/dist/rules/async.js.map +1 -0
- package/dist/rules/base.js +112 -87
- package/dist/rules/base.js.map +1 -1
- package/dist/rules/confidence.d.ts +2 -2
- package/dist/rules/confidence.js +32 -15
- package/dist/rules/confidence.js.map +1 -1
- package/dist/rules/dead-code.d.ts +2 -1
- package/dist/rules/dead-code.js +49 -3
- package/dist/rules/dead-code.js.map +1 -1
- package/dist/rules/index.d.ts +12 -0
- package/dist/rules/index.js +414 -4
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/ink.js +41 -0
- package/dist/rules/ink.js.map +1 -1
- package/dist/rules/kern-source-cross-file.d.ts +2 -0
- package/dist/rules/kern-source-cross-file.js +102 -0
- package/dist/rules/kern-source-cross-file.js.map +1 -0
- package/dist/rules/kern-source.js +145 -18
- package/dist/rules/kern-source.js.map +1 -1
- package/dist/rules/nextjs-app-router.d.ts +11 -0
- package/dist/rules/nextjs-app-router.js +1182 -0
- package/dist/rules/nextjs-app-router.js.map +1 -0
- package/dist/rules/nextjs.js +266 -7
- package/dist/rules/nextjs.js.map +1 -1
- package/dist/rules/perf.d.ts +11 -0
- package/dist/rules/perf.js +131 -0
- package/dist/rules/perf.js.map +1 -0
- package/dist/rules/react-composition.d.ts +12 -0
- package/dist/rules/react-composition.js +741 -0
- package/dist/rules/react-composition.js.map +1 -0
- package/dist/rules/react-hooks.d.ts +11 -0
- package/dist/rules/react-hooks.js +429 -0
- package/dist/rules/react-hooks.js.map +1 -0
- package/dist/rules/react.js +265 -49
- package/dist/rules/react.js.map +1 -1
- package/dist/rules/security-v5.d.ts +11 -0
- package/dist/rules/security-v5.js +200 -0
- package/dist/rules/security-v5.js.map +1 -0
- package/dist/rules/utils.d.ts +52 -1
- package/dist/rules/utils.js +159 -0
- package/dist/rules/utils.js.map +1 -1
- package/dist/semantic-diff.js +1 -1
- package/dist/semantic-diff.js.map +1 -1
- package/dist/taint-ast.js +260 -10
- package/dist/taint-ast.js.map +1 -1
- package/dist/taint-crossfile.d.ts +30 -2
- package/dist/taint-crossfile.js +280 -59
- package/dist/taint-crossfile.js.map +1 -1
- package/dist/taint-findings.js +3 -0
- package/dist/taint-findings.js.map +1 -1
- package/dist/taint-types.d.ts +4 -3
- package/dist/taint-types.js +70 -6
- package/dist/taint-types.js.map +1 -1
- package/dist/taint.d.ts +1 -1
- package/dist/taint.js +1 -1
- package/dist/taint.js.map +1 -1
- package/dist/types.d.ts +98 -0
- package/dist/types.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React composition rules — catch prop-drilling and "parent rerenders child
|
|
3
|
+
* that doesn't depend on parent state" antipatterns.
|
|
4
|
+
*
|
|
5
|
+
* These rules push toward the `children` prop pattern, which preserves
|
|
6
|
+
* element identity across parent renders and lets React skip reconciliation
|
|
7
|
+
* of unchanged subtrees.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync } from 'fs';
|
|
10
|
+
import { dirname, resolve } from 'path';
|
|
11
|
+
import { Node, Project, SyntaxKind } from 'ts-morph';
|
|
12
|
+
import { finding, nodeSpan } from './utils.js';
|
|
13
|
+
/** Is this node a React component function? (Capitalized name + returns JSX) */
|
|
14
|
+
function isComponentFunction(node) {
|
|
15
|
+
let name = '';
|
|
16
|
+
if (Node.isFunctionDeclaration(node)) {
|
|
17
|
+
name = node.getName() ?? '';
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
// Arrow/function expression — look at the parent variable declaration
|
|
21
|
+
const parent = node.getParent();
|
|
22
|
+
if (parent && Node.isVariableDeclaration(parent)) {
|
|
23
|
+
const n = parent.getNameNode();
|
|
24
|
+
if (Node.isIdentifier(n))
|
|
25
|
+
name = n.getText();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (!name || !/^[A-Z]/.test(name))
|
|
29
|
+
return { name, isComponent: false };
|
|
30
|
+
// Must contain JSX somewhere in the body
|
|
31
|
+
const body = node.getBody();
|
|
32
|
+
if (!body)
|
|
33
|
+
return { name, isComponent: false };
|
|
34
|
+
const hasJsx = body.getDescendantsOfKind(SyntaxKind.JsxOpeningElement).length > 0 ||
|
|
35
|
+
body.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement).length > 0 ||
|
|
36
|
+
body.getDescendantsOfKind(SyntaxKind.JsxFragment).length > 0;
|
|
37
|
+
return { name, isComponent: hasJsx };
|
|
38
|
+
}
|
|
39
|
+
/** Extract destructured prop names from the first parameter of a component function. */
|
|
40
|
+
function getDestructuredPropBindings(fn) {
|
|
41
|
+
const params = fn.getParameters();
|
|
42
|
+
if (params.length === 0)
|
|
43
|
+
return undefined;
|
|
44
|
+
const nameNode = params[0].getNameNode();
|
|
45
|
+
if (!Node.isObjectBindingPattern(nameNode))
|
|
46
|
+
return undefined;
|
|
47
|
+
const bindings = [];
|
|
48
|
+
for (const el of nameNode.getElements()) {
|
|
49
|
+
// Use the property name if aliased, otherwise the binding name
|
|
50
|
+
const propName = el.getPropertyNameNode()?.getText() ?? el.getNameNode().getText();
|
|
51
|
+
const localName = el.getNameNode().getText();
|
|
52
|
+
bindings.push({ propName, localName });
|
|
53
|
+
}
|
|
54
|
+
return bindings;
|
|
55
|
+
}
|
|
56
|
+
function getPropsParamName(fn) {
|
|
57
|
+
const params = fn.getParameters();
|
|
58
|
+
if (params.length === 0)
|
|
59
|
+
return undefined;
|
|
60
|
+
const nameNode = params[0].getNameNode();
|
|
61
|
+
if (Node.isIdentifier(nameNode))
|
|
62
|
+
return nameNode.getText();
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
function iterComponentFunctions(ctx) {
|
|
66
|
+
const results = [];
|
|
67
|
+
for (const fn of ctx.sourceFile.getFunctions()) {
|
|
68
|
+
const info = isComponentFunction(fn);
|
|
69
|
+
if (info.isComponent)
|
|
70
|
+
results.push(fn);
|
|
71
|
+
}
|
|
72
|
+
for (const stmt of ctx.sourceFile.getVariableStatements()) {
|
|
73
|
+
for (const decl of stmt.getDeclarations()) {
|
|
74
|
+
const init = decl.getInitializer();
|
|
75
|
+
if (!init)
|
|
76
|
+
continue;
|
|
77
|
+
if (Node.isArrowFunction(init) || Node.isFunctionExpression(init)) {
|
|
78
|
+
const info = isComponentFunction(init);
|
|
79
|
+
if (info.isComponent)
|
|
80
|
+
results.push(init);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
86
|
+
// ── Rule: children-not-used ──────────────────────────────────────────────
|
|
87
|
+
// Component accepts `children` in its destructured props but never renders it.
|
|
88
|
+
function childrenNotUsed(ctx) {
|
|
89
|
+
const findings = [];
|
|
90
|
+
for (const fn of iterComponentFunctions(ctx)) {
|
|
91
|
+
const propBindings = getDestructuredPropBindings(fn);
|
|
92
|
+
if (!propBindings?.some((p) => p.propName === 'children'))
|
|
93
|
+
continue;
|
|
94
|
+
const body = fn.getBody();
|
|
95
|
+
if (!body)
|
|
96
|
+
continue;
|
|
97
|
+
// Look for any identifier reference to `children` in the body
|
|
98
|
+
let rendered = false;
|
|
99
|
+
for (const id of body.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
100
|
+
if (id.getText() !== 'children')
|
|
101
|
+
continue;
|
|
102
|
+
// Skip the declaration in the parameter binding — we want usage, not the binding itself
|
|
103
|
+
const parent = id.getParent();
|
|
104
|
+
if (parent && Node.isBindingElement(parent))
|
|
105
|
+
continue;
|
|
106
|
+
rendered = true;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
if (!rendered) {
|
|
110
|
+
const { name } = isComponentFunction(fn);
|
|
111
|
+
// Autofix: remove the `children` entry from the destructured props
|
|
112
|
+
// pattern. Only applies when the binding pattern is simple (no renames,
|
|
113
|
+
// defaults, or rest — those are fine, we just leave them alone here).
|
|
114
|
+
let autofixAction;
|
|
115
|
+
const firstParam = fn.getParameters()[0];
|
|
116
|
+
if (firstParam) {
|
|
117
|
+
const nameNode = firstParam.getNameNode();
|
|
118
|
+
if (Node.isObjectBindingPattern(nameNode)) {
|
|
119
|
+
const elements = nameNode.getElements();
|
|
120
|
+
const remaining = elements.filter((el) => {
|
|
121
|
+
const propName = el.getPropertyNameNode()?.getText() ?? el.getNameNode().getText();
|
|
122
|
+
return propName !== 'children';
|
|
123
|
+
});
|
|
124
|
+
// Reconstruct a clean `{ a, b, c }` pattern using each element's
|
|
125
|
+
// original text. Preserves renames, defaults, and rest operators.
|
|
126
|
+
const rebuilt = `{ ${remaining.map((el) => el.getText()).join(', ')} }`;
|
|
127
|
+
autofixAction = {
|
|
128
|
+
type: 'replace',
|
|
129
|
+
span: nodeSpan(nameNode, ctx.filePath),
|
|
130
|
+
replacement: rebuilt,
|
|
131
|
+
description: `Remove unused 'children' from the props destructuring`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
findings.push(finding('children-not-used', 'warning', 'pattern', `'${name}' destructures 'children' from props but never renders it — dead API or forgotten {children}`, ctx.filePath, fn.getStartLineNumber(), 1, {
|
|
136
|
+
suggestion: `Render {children} in the JSX output, or remove 'children' from the props destructuring if the component should not accept children`,
|
|
137
|
+
...(autofixAction ? { autofix: autofixAction } : {}),
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return findings;
|
|
142
|
+
}
|
|
143
|
+
// ── Rule: prop-drill-passthrough ─────────────────────────────────────────
|
|
144
|
+
// Component receives >= 3 props, body is a single JSX element, and >= 2 of
|
|
145
|
+
// those props are passed unchanged to that element without being read anywhere
|
|
146
|
+
// else. Suggest `children` or context.
|
|
147
|
+
function getSingleReturnedJsx(fn) {
|
|
148
|
+
const body = fn.getBody();
|
|
149
|
+
if (!body)
|
|
150
|
+
return undefined;
|
|
151
|
+
// Case 1: arrow function with implicit return — body IS the JSX
|
|
152
|
+
if (Node.isJsxElement(body))
|
|
153
|
+
return body.getOpeningElement();
|
|
154
|
+
if (Node.isJsxSelfClosingElement(body))
|
|
155
|
+
return body;
|
|
156
|
+
if (Node.isJsxFragment(body))
|
|
157
|
+
return undefined; // fragments have multiple children
|
|
158
|
+
// Case 2: block body — look for a single return statement at the top level
|
|
159
|
+
if (Node.isBlock(body)) {
|
|
160
|
+
const statements = body.getStatements();
|
|
161
|
+
// Allow preamble (const x = ..., hook calls) but require the LAST statement to be a return with a single JSX root
|
|
162
|
+
const ret = statements.find((s) => Node.isReturnStatement(s));
|
|
163
|
+
if (!ret || !Node.isReturnStatement(ret))
|
|
164
|
+
return undefined;
|
|
165
|
+
const expr = ret.getExpression();
|
|
166
|
+
if (!expr)
|
|
167
|
+
return undefined;
|
|
168
|
+
// Walk through parentheses
|
|
169
|
+
let unwrapped = expr;
|
|
170
|
+
while (Node.isParenthesizedExpression(unwrapped)) {
|
|
171
|
+
unwrapped = unwrapped.getExpression();
|
|
172
|
+
}
|
|
173
|
+
if (Node.isJsxElement(unwrapped))
|
|
174
|
+
return unwrapped.getOpeningElement();
|
|
175
|
+
if (Node.isJsxSelfClosingElement(unwrapped))
|
|
176
|
+
return unwrapped;
|
|
177
|
+
}
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
function analyzePassthroughComponent(fn) {
|
|
181
|
+
const propBindings = getDestructuredPropBindings(fn) ?? [];
|
|
182
|
+
const propsParamName = getPropsParamName(fn);
|
|
183
|
+
if (propBindings.length === 0 && !propsParamName)
|
|
184
|
+
return undefined;
|
|
185
|
+
const root = getSingleReturnedJsx(fn);
|
|
186
|
+
if (!root)
|
|
187
|
+
return undefined;
|
|
188
|
+
const tag = root.getTagNameNode().getText();
|
|
189
|
+
if (!/^[A-Z]/.test(tag))
|
|
190
|
+
return undefined;
|
|
191
|
+
const bindingByLocal = new Map(propBindings.map((b) => [b.localName, b]));
|
|
192
|
+
const passedToChild = new Map();
|
|
193
|
+
for (const attr of root.getAttributes()) {
|
|
194
|
+
if (!Node.isJsxAttribute(attr))
|
|
195
|
+
continue;
|
|
196
|
+
const init = attr.getInitializer();
|
|
197
|
+
if (!init)
|
|
198
|
+
continue;
|
|
199
|
+
if (!Node.isJsxExpression(init))
|
|
200
|
+
continue;
|
|
201
|
+
const expr = init.getExpression();
|
|
202
|
+
if (!expr)
|
|
203
|
+
continue;
|
|
204
|
+
if (Node.isIdentifier(expr)) {
|
|
205
|
+
const binding = bindingByLocal.get(expr.getText());
|
|
206
|
+
if (binding) {
|
|
207
|
+
passedToChild.set(binding.propName, { attrExpr: expr, localName: binding.localName });
|
|
208
|
+
}
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (propsParamName && Node.isPropertyAccessExpression(expr)) {
|
|
212
|
+
const obj = expr.getExpression();
|
|
213
|
+
if (Node.isIdentifier(obj) && obj.getText() === propsParamName) {
|
|
214
|
+
passedToChild.set(expr.getName(), { attrExpr: expr });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (passedToChild.size < 2)
|
|
219
|
+
return undefined;
|
|
220
|
+
const body = fn.getBody();
|
|
221
|
+
if (!body)
|
|
222
|
+
return undefined;
|
|
223
|
+
const consumedProps = new Set();
|
|
224
|
+
for (const [propName, { attrExpr, localName }] of passedToChild) {
|
|
225
|
+
if (propName === 'children')
|
|
226
|
+
continue;
|
|
227
|
+
if (localName) {
|
|
228
|
+
for (const id of body.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
229
|
+
if (id.getText() !== localName)
|
|
230
|
+
continue;
|
|
231
|
+
const parent = id.getParent();
|
|
232
|
+
if (parent && Node.isBindingElement(parent))
|
|
233
|
+
continue;
|
|
234
|
+
if (parent && Node.isJsxAttribute(parent) && parent.getNameNode() === id)
|
|
235
|
+
continue;
|
|
236
|
+
if (id === attrExpr)
|
|
237
|
+
continue;
|
|
238
|
+
consumedProps.add(propName);
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
else if (propsParamName) {
|
|
243
|
+
for (const access of body.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
|
|
244
|
+
if (access === attrExpr)
|
|
245
|
+
continue;
|
|
246
|
+
const obj = access.getExpression();
|
|
247
|
+
if (Node.isIdentifier(obj) && obj.getText() === propsParamName && access.getName() === propName) {
|
|
248
|
+
consumedProps.add(propName);
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const passthroughProps = [...passedToChild.keys()].filter((p) => p !== 'children' && !consumedProps.has(p));
|
|
255
|
+
if (passthroughProps.length < 2)
|
|
256
|
+
return undefined;
|
|
257
|
+
const info = isComponentFunction(fn);
|
|
258
|
+
return {
|
|
259
|
+
componentName: info.name,
|
|
260
|
+
childTag: tag,
|
|
261
|
+
passthroughProps,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
function findComponentFunctionByName(sourceFile, componentName) {
|
|
265
|
+
for (const fn of sourceFile.getFunctions()) {
|
|
266
|
+
const info = isComponentFunction(fn);
|
|
267
|
+
if (info.isComponent && info.name === componentName)
|
|
268
|
+
return fn;
|
|
269
|
+
}
|
|
270
|
+
for (const stmt of sourceFile.getVariableStatements()) {
|
|
271
|
+
for (const decl of stmt.getDeclarations()) {
|
|
272
|
+
const init = decl.getInitializer();
|
|
273
|
+
if (!init)
|
|
274
|
+
continue;
|
|
275
|
+
if (!Node.isArrowFunction(init) && !Node.isFunctionExpression(init))
|
|
276
|
+
continue;
|
|
277
|
+
const info = isComponentFunction(init);
|
|
278
|
+
if (info.isComponent && info.name === componentName)
|
|
279
|
+
return init;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
function isMemoCall(expr) {
|
|
285
|
+
return Node.isCallExpression(expr) && ['memo', 'React.memo'].includes(expr.getExpression().getText());
|
|
286
|
+
}
|
|
287
|
+
function findVariableDeclarationByName(sourceFile, variableName) {
|
|
288
|
+
for (const stmt of sourceFile.getVariableStatements()) {
|
|
289
|
+
for (const decl of stmt.getDeclarations()) {
|
|
290
|
+
if (decl.getName() === variableName)
|
|
291
|
+
return decl;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
function findImportBinding(ctx, localName) {
|
|
297
|
+
for (const decl of ctx.sourceFile.getImportDeclarations()) {
|
|
298
|
+
const defaultImport = decl.getDefaultImport();
|
|
299
|
+
if (defaultImport?.getText() === localName) {
|
|
300
|
+
return { importDecl: decl, importedName: 'default', isDefault: true };
|
|
301
|
+
}
|
|
302
|
+
for (const named of decl.getNamedImports()) {
|
|
303
|
+
const boundLocal = named.getAliasNode()?.getText() ?? named.getNameNode().getText();
|
|
304
|
+
if (boundLocal === localName) {
|
|
305
|
+
return { importDecl: decl, importedName: named.getNameNode().getText(), isDefault: false };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
function findDefaultExportedComponentFunction(sourceFile) {
|
|
312
|
+
for (const fn of sourceFile.getFunctions()) {
|
|
313
|
+
const info = isComponentFunction(fn);
|
|
314
|
+
if (info.isComponent && fn.isDefaultExport())
|
|
315
|
+
return fn;
|
|
316
|
+
}
|
|
317
|
+
for (const assign of sourceFile.getExportAssignments()) {
|
|
318
|
+
const expr = assign.getExpression();
|
|
319
|
+
if (!expr)
|
|
320
|
+
continue;
|
|
321
|
+
if (Node.isIdentifier(expr)) {
|
|
322
|
+
const resolved = findComponentFunctionByName(sourceFile, expr.getText());
|
|
323
|
+
if (resolved)
|
|
324
|
+
return resolved;
|
|
325
|
+
}
|
|
326
|
+
if (isMemoCall(expr)) {
|
|
327
|
+
const firstArg = expr.getArguments()[0];
|
|
328
|
+
if (firstArg && (Node.isArrowFunction(firstArg) || Node.isFunctionExpression(firstArg))) {
|
|
329
|
+
const info = isComponentFunction(firstArg);
|
|
330
|
+
if (info.isComponent)
|
|
331
|
+
return firstArg;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
function findImportedComponentFunction(sourceFile, binding) {
|
|
338
|
+
return binding.isDefault
|
|
339
|
+
? findDefaultExportedComponentFunction(sourceFile)
|
|
340
|
+
: findComponentFunctionByName(sourceFile, binding.importedName);
|
|
341
|
+
}
|
|
342
|
+
function isMemoizedExport(sourceFile, binding) {
|
|
343
|
+
if (binding.isDefault) {
|
|
344
|
+
for (const assign of sourceFile.getExportAssignments()) {
|
|
345
|
+
const expr = assign.getExpression();
|
|
346
|
+
if (!expr)
|
|
347
|
+
continue;
|
|
348
|
+
if (isMemoCall(expr))
|
|
349
|
+
return true;
|
|
350
|
+
if (Node.isIdentifier(expr)) {
|
|
351
|
+
const decl = findVariableDeclarationByName(sourceFile, expr.getText());
|
|
352
|
+
if (decl && isMemoCall(decl.getInitializer()))
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
const decl = findVariableDeclarationByName(sourceFile, binding.importedName);
|
|
359
|
+
return !!decl && isMemoCall(decl.getInitializer());
|
|
360
|
+
}
|
|
361
|
+
function resolveImportedSourceFile(ctx, importDecl) {
|
|
362
|
+
let resolved;
|
|
363
|
+
try {
|
|
364
|
+
resolved = importDecl.getModuleSpecifierSourceFile() ?? undefined;
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
if (resolved) {
|
|
370
|
+
// The main Project caches resolved source files across reviewFile calls. If the file on disk
|
|
371
|
+
// changed since the last review (watch mode, test re-runs), refresh it so the rule sees fresh content.
|
|
372
|
+
try {
|
|
373
|
+
resolved.refreshFromFileSystemSync();
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
// File may have been deleted — caller will decide.
|
|
377
|
+
}
|
|
378
|
+
return resolved;
|
|
379
|
+
}
|
|
380
|
+
const spec = importDecl.getModuleSpecifierValue();
|
|
381
|
+
if (!spec.startsWith('.'))
|
|
382
|
+
return undefined;
|
|
383
|
+
const baseDir = dirname(ctx.filePath);
|
|
384
|
+
const candidates = [];
|
|
385
|
+
if (/\.[cm]?[jt]sx?$/.test(spec)) {
|
|
386
|
+
candidates.push(resolve(baseDir, spec));
|
|
387
|
+
if (spec.endsWith('.js')) {
|
|
388
|
+
candidates.push(resolve(baseDir, `${spec.slice(0, -3)}.ts`));
|
|
389
|
+
candidates.push(resolve(baseDir, `${spec.slice(0, -3)}.tsx`));
|
|
390
|
+
}
|
|
391
|
+
else if (spec.endsWith('.jsx')) {
|
|
392
|
+
candidates.push(resolve(baseDir, `${spec.slice(0, -4)}.tsx`));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
candidates.push(resolve(baseDir, `${spec}.ts`));
|
|
397
|
+
candidates.push(resolve(baseDir, `${spec}.tsx`));
|
|
398
|
+
candidates.push(resolve(baseDir, `${spec}/index.ts`));
|
|
399
|
+
candidates.push(resolve(baseDir, `${spec}/index.tsx`));
|
|
400
|
+
}
|
|
401
|
+
for (const candidate of candidates) {
|
|
402
|
+
if (!existsSync(candidate))
|
|
403
|
+
continue;
|
|
404
|
+
const auxProject = new Project({
|
|
405
|
+
useInMemoryFileSystem: true,
|
|
406
|
+
skipAddingFilesFromTsConfig: true,
|
|
407
|
+
compilerOptions: { target: 99, module: 99, moduleResolution: 100, jsx: 4 },
|
|
408
|
+
});
|
|
409
|
+
return auxProject.createSourceFile(candidate, readFileSync(candidate, 'utf-8'), { overwrite: true });
|
|
410
|
+
}
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
function propDrillPassthrough(ctx) {
|
|
414
|
+
const findings = [];
|
|
415
|
+
for (const fn of iterComponentFunctions(ctx)) {
|
|
416
|
+
const analysis = analyzePassthroughComponent(fn);
|
|
417
|
+
if (analysis) {
|
|
418
|
+
const passthroughCount = analysis.passthroughProps.length;
|
|
419
|
+
findings.push(finding('prop-drill-passthrough', 'warning', 'pattern', `'${analysis.componentName}' passes ${passthroughCount} prop${passthroughCount === 1 ? '' : 's'} (${analysis.passthroughProps.join(', ')}) through to <${analysis.childTag}> without reading ${passthroughCount === 1 ? 'it' : 'them'} — consider 'children' prop or React context`, ctx.filePath, fn.getStartLineNumber(), 1, {
|
|
420
|
+
suggestion: `Accept <${analysis.childTag} .../> as the 'children' prop, or move the shared data into a React context. Passing props through an intermediate component forces it to re-render whenever any of them change.`,
|
|
421
|
+
}));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return findings;
|
|
425
|
+
}
|
|
426
|
+
// ── Rule: prop-drill-chain ───────────────────────────────────────────────
|
|
427
|
+
// Current file passes props into an imported wrapper component that itself
|
|
428
|
+
// passes those same props onward without reading them. Walks up to MAX_HOPS
|
|
429
|
+
// imported components to detect drilling that spans 3+ files, not just 2.
|
|
430
|
+
const MAX_PROP_DRILL_HOPS = 3;
|
|
431
|
+
function walkPropDrillChain(initialCarriedProps, initialBinding, ctx) {
|
|
432
|
+
const hops = [];
|
|
433
|
+
const visitedFiles = new Set([ctx.filePath]);
|
|
434
|
+
const analysisCache = new Map();
|
|
435
|
+
let currentCarriedProps = initialCarriedProps;
|
|
436
|
+
let currentBinding = initialBinding;
|
|
437
|
+
let currentSf;
|
|
438
|
+
for (let hopIdx = 0; hopIdx < MAX_PROP_DRILL_HOPS; hopIdx++) {
|
|
439
|
+
if (!currentBinding)
|
|
440
|
+
break;
|
|
441
|
+
currentSf = resolveImportedSourceFile(hopIdx === 0 ? ctx : { ...ctx, filePath: currentSf.getFilePath(), sourceFile: currentSf }, currentBinding.importDecl);
|
|
442
|
+
if (!currentSf)
|
|
443
|
+
break;
|
|
444
|
+
const nextFilePath = currentSf.getFilePath();
|
|
445
|
+
if (visitedFiles.has(nextFilePath))
|
|
446
|
+
break;
|
|
447
|
+
visitedFiles.add(nextFilePath);
|
|
448
|
+
const importedFn = findImportedComponentFunction(currentSf, currentBinding);
|
|
449
|
+
if (!importedFn)
|
|
450
|
+
break;
|
|
451
|
+
const cacheKey = `${nextFilePath}::${currentBinding.importedName}::${currentBinding.isDefault}`;
|
|
452
|
+
let analysis = analysisCache.get(cacheKey);
|
|
453
|
+
if (analysis === undefined) {
|
|
454
|
+
analysis = analyzePassthroughComponent(importedFn);
|
|
455
|
+
analysisCache.set(cacheKey, analysis);
|
|
456
|
+
}
|
|
457
|
+
if (!analysis)
|
|
458
|
+
break;
|
|
459
|
+
const sharedProps = currentCarriedProps.filter((p) => analysis.passthroughProps.includes(p));
|
|
460
|
+
if (sharedProps.length < 2)
|
|
461
|
+
break;
|
|
462
|
+
hops.push({
|
|
463
|
+
componentName: analysis.componentName,
|
|
464
|
+
childTag: analysis.childTag,
|
|
465
|
+
filePath: nextFilePath,
|
|
466
|
+
props: sharedProps,
|
|
467
|
+
});
|
|
468
|
+
const nextCtx = { ...ctx, filePath: nextFilePath, sourceFile: currentSf };
|
|
469
|
+
const nextBinding = findImportBinding(nextCtx, analysis.childTag);
|
|
470
|
+
if (!nextBinding)
|
|
471
|
+
break;
|
|
472
|
+
currentCarriedProps = sharedProps;
|
|
473
|
+
currentBinding = nextBinding;
|
|
474
|
+
}
|
|
475
|
+
return hops;
|
|
476
|
+
}
|
|
477
|
+
function propDrillChain(ctx) {
|
|
478
|
+
const findings = [];
|
|
479
|
+
for (const fn of iterComponentFunctions(ctx)) {
|
|
480
|
+
const localAnalysis = analyzePassthroughComponent(fn);
|
|
481
|
+
if (!localAnalysis)
|
|
482
|
+
continue;
|
|
483
|
+
const binding = findImportBinding(ctx, localAnalysis.childTag);
|
|
484
|
+
if (!binding)
|
|
485
|
+
continue;
|
|
486
|
+
const hops = walkPropDrillChain(localAnalysis.passthroughProps, binding, ctx);
|
|
487
|
+
if (hops.length === 0)
|
|
488
|
+
continue;
|
|
489
|
+
const firstHop = hops[0];
|
|
490
|
+
const sharedProps = firstHop.props;
|
|
491
|
+
// Describe the chain: local → first imported wrapper → ... → last wrapper's child
|
|
492
|
+
const chainDesc = hops.length === 1
|
|
493
|
+
? `<${localAnalysis.childTag}>, which then passes them through to <${firstHop.childTag}>`
|
|
494
|
+
: `<${localAnalysis.childTag}> → ${hops.map((h) => `<${h.componentName}>`).join(' → ')} → <${hops[hops.length - 1].childTag}>`;
|
|
495
|
+
findings.push(finding('prop-drill-chain', 'warning', 'pattern', `'${localAnalysis.componentName}' drills props (${sharedProps.join(', ')}) across ${hops.length + 1} component${hops.length + 1 === 1 ? '' : 's'}: ${chainDesc}`, ctx.filePath, fn.getStartLineNumber(), 1, {
|
|
496
|
+
suggestion: 'Collapse the intermediate wrappers, switch to children-based composition, or lift the shared data into React context so the props stop crossing multiple component boundaries',
|
|
497
|
+
}));
|
|
498
|
+
}
|
|
499
|
+
return findings;
|
|
500
|
+
}
|
|
501
|
+
// ── Rule: memoized-child-inline-prop ─────────────────────────────────────
|
|
502
|
+
// Inline object/array/function props create a new identity every render and
|
|
503
|
+
// defeat React.memo's shallow prop comparison for that child.
|
|
504
|
+
function collectMemoizedComponentNames(ctx) {
|
|
505
|
+
const names = new Set();
|
|
506
|
+
for (const decl of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
507
|
+
if (!isMemoCall(decl.getInitializer()))
|
|
508
|
+
continue;
|
|
509
|
+
const nameNode = decl.getNameNode();
|
|
510
|
+
if (Node.isIdentifier(nameNode) && /^[A-Z]/.test(nameNode.getText())) {
|
|
511
|
+
names.add(nameNode.getText());
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return names;
|
|
515
|
+
}
|
|
516
|
+
function memoizedChildInlineProp(ctx) {
|
|
517
|
+
const findings = [];
|
|
518
|
+
const memoizedNames = collectMemoizedComponentNames(ctx);
|
|
519
|
+
const memoizedImportCache = new Map();
|
|
520
|
+
for (const fn of iterComponentFunctions(ctx)) {
|
|
521
|
+
const body = fn.getBody();
|
|
522
|
+
if (!body)
|
|
523
|
+
continue;
|
|
524
|
+
const jsxNodes = [
|
|
525
|
+
...body.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
|
|
526
|
+
...body.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
|
|
527
|
+
];
|
|
528
|
+
for (const jsx of jsxNodes) {
|
|
529
|
+
const tag = jsx.getTagNameNode().getText();
|
|
530
|
+
let isMemoizedChild = memoizedNames.has(tag);
|
|
531
|
+
if (!isMemoizedChild) {
|
|
532
|
+
if (!memoizedImportCache.has(tag)) {
|
|
533
|
+
const binding = findImportBinding(ctx, tag);
|
|
534
|
+
const importedSf = binding ? resolveImportedSourceFile(ctx, binding.importDecl) : undefined;
|
|
535
|
+
memoizedImportCache.set(tag, !!(binding && importedSf && isMemoizedExport(importedSf, binding)));
|
|
536
|
+
}
|
|
537
|
+
isMemoizedChild = memoizedImportCache.get(tag) ?? false;
|
|
538
|
+
}
|
|
539
|
+
if (!isMemoizedChild)
|
|
540
|
+
continue;
|
|
541
|
+
const unstableProps = [];
|
|
542
|
+
for (const attr of jsx.getAttributes()) {
|
|
543
|
+
if (!Node.isJsxAttribute(attr))
|
|
544
|
+
continue;
|
|
545
|
+
const attrName = attr.getNameNode().getText();
|
|
546
|
+
const init = attr.getInitializer();
|
|
547
|
+
if (!init || !Node.isJsxExpression(init))
|
|
548
|
+
continue;
|
|
549
|
+
const expr = init.getExpression();
|
|
550
|
+
if (!expr)
|
|
551
|
+
continue;
|
|
552
|
+
const isUnstable = Node.isArrowFunction(expr) ||
|
|
553
|
+
Node.isFunctionExpression(expr) ||
|
|
554
|
+
Node.isObjectLiteralExpression(expr) ||
|
|
555
|
+
Node.isArrayLiteralExpression(expr);
|
|
556
|
+
if (isUnstable)
|
|
557
|
+
unstableProps.push(attrName);
|
|
558
|
+
}
|
|
559
|
+
if (unstableProps.length === 0)
|
|
560
|
+
continue;
|
|
561
|
+
findings.push(finding('memoized-child-inline-prop', 'warning', 'pattern', `<${tag}> is memoized with React.memo, but inline prop${unstableProps.length === 1 ? '' : 's'} (${unstableProps.join(', ')}) create a new identity every render and defeat memoization`, ctx.filePath, jsx.getStartLineNumber(), 1, {
|
|
562
|
+
suggestion: 'Hoist static literals, memoize object/array props with useMemo, and memoize callback props with useCallback before passing them to a memoized child',
|
|
563
|
+
}));
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return findings;
|
|
567
|
+
}
|
|
568
|
+
// ── Rule: memoized-child-inline-children ─────────────────────────────────
|
|
569
|
+
// Inline JSX children create fresh React element objects every render, so a
|
|
570
|
+
// React.memo child receiving them through `children` cannot bail out.
|
|
571
|
+
function memoizedChildInlineChildren(ctx) {
|
|
572
|
+
const findings = [];
|
|
573
|
+
const memoizedNames = collectMemoizedComponentNames(ctx);
|
|
574
|
+
const memoizedImportCache = new Map();
|
|
575
|
+
for (const fn of iterComponentFunctions(ctx)) {
|
|
576
|
+
const body = fn.getBody();
|
|
577
|
+
if (!body)
|
|
578
|
+
continue;
|
|
579
|
+
for (const jsx of body.getDescendantsOfKind(SyntaxKind.JsxElement)) {
|
|
580
|
+
const opening = jsx.getOpeningElement();
|
|
581
|
+
const tag = opening.getTagNameNode().getText();
|
|
582
|
+
let isMemoizedChild = memoizedNames.has(tag);
|
|
583
|
+
if (!isMemoizedChild) {
|
|
584
|
+
if (!memoizedImportCache.has(tag)) {
|
|
585
|
+
const binding = findImportBinding(ctx, tag);
|
|
586
|
+
const importedSf = binding ? resolveImportedSourceFile(ctx, binding.importDecl) : undefined;
|
|
587
|
+
memoizedImportCache.set(tag, !!(binding && importedSf && isMemoizedExport(importedSf, binding)));
|
|
588
|
+
}
|
|
589
|
+
isMemoizedChild = memoizedImportCache.get(tag) ?? false;
|
|
590
|
+
}
|
|
591
|
+
if (!isMemoizedChild)
|
|
592
|
+
continue;
|
|
593
|
+
const unstableChildren = jsx.getJsxChildren().filter((child) => Node.isJsxElement(child) ||
|
|
594
|
+
Node.isJsxSelfClosingElement(child) ||
|
|
595
|
+
Node.isJsxFragment(child) ||
|
|
596
|
+
(Node.isJsxExpression(child) &&
|
|
597
|
+
(() => {
|
|
598
|
+
const expr = child.getExpression();
|
|
599
|
+
return (expr != null &&
|
|
600
|
+
(Node.isArrowFunction(expr) ||
|
|
601
|
+
Node.isFunctionExpression(expr) ||
|
|
602
|
+
Node.isObjectLiteralExpression(expr) ||
|
|
603
|
+
Node.isArrayLiteralExpression(expr)));
|
|
604
|
+
})()));
|
|
605
|
+
if (unstableChildren.length === 0)
|
|
606
|
+
continue;
|
|
607
|
+
findings.push(finding('memoized-child-inline-children', 'warning', 'pattern', `<${tag}> is memoized with React.memo, but its inline children create new React element identities every render and defeat memoization`, ctx.filePath, opening.getStartLineNumber(), 1, {
|
|
608
|
+
suggestion: 'Hoist the child subtree outside the parent render, memoize it with useMemo, or restructure the component so the memoized child receives stable primitive props instead of inline children',
|
|
609
|
+
}));
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return findings;
|
|
613
|
+
}
|
|
614
|
+
// ── Rule: parent-rerender-via-state ──────────────────────────────────────
|
|
615
|
+
// Component holds useState AND renders a child component that receives NEITHER
|
|
616
|
+
// the state variables NOR the setters. That child will re-render on every
|
|
617
|
+
// state change for no reason — lifting it to `children` preserves its element
|
|
618
|
+
// identity and avoids the re-render.
|
|
619
|
+
/**
|
|
620
|
+
* Get the DIRECT-child JSX elements of the top-level return. Skips nested
|
|
621
|
+
* descendants, elements inside callbacks (map renderers), and elements deep
|
|
622
|
+
* in conditional branches. This is the key guard against false positives:
|
|
623
|
+
* we only care about JSX that the parent component's own render produces
|
|
624
|
+
* positionally — those are the elements that could be lifted to `children`.
|
|
625
|
+
*/
|
|
626
|
+
function getDirectChildrenOfReturn(root) {
|
|
627
|
+
// If the root is already a self-closing element, there are no direct JSX children.
|
|
628
|
+
if (Node.isJsxSelfClosingElement(root))
|
|
629
|
+
return [root];
|
|
630
|
+
// Root is a JsxOpeningElement — walk its parent JsxElement children once.
|
|
631
|
+
const parent = root.getParent();
|
|
632
|
+
if (!parent || !Node.isJsxElement(parent))
|
|
633
|
+
return [root];
|
|
634
|
+
const result = [root];
|
|
635
|
+
for (const child of parent.getJsxChildren()) {
|
|
636
|
+
if (Node.isJsxElement(child)) {
|
|
637
|
+
result.push(child.getOpeningElement());
|
|
638
|
+
}
|
|
639
|
+
else if (Node.isJsxSelfClosingElement(child)) {
|
|
640
|
+
result.push(child);
|
|
641
|
+
}
|
|
642
|
+
// Skip JsxExpression / JsxText / JsxFragment content — too dynamic to reason about
|
|
643
|
+
}
|
|
644
|
+
return result;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Does this expression text mention any of the state variables? Wraps each
|
|
648
|
+
* variable in \b boundaries and tests the combined text. Handles callbacks
|
|
649
|
+
* too (e.g. onClick={() => setCount(c => c + 1)} — we treat ANY reference
|
|
650
|
+
* to setCount as a legitimate state dependency).
|
|
651
|
+
*/
|
|
652
|
+
function mentionsStateVars(text, stateVars) {
|
|
653
|
+
for (const v of stateVars) {
|
|
654
|
+
if (new RegExp(`\\b${v}\\b`).test(text))
|
|
655
|
+
return true;
|
|
656
|
+
}
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
function parentRerenderViaState(ctx) {
|
|
660
|
+
const findings = [];
|
|
661
|
+
for (const fn of iterComponentFunctions(ctx)) {
|
|
662
|
+
const body = fn.getBody();
|
|
663
|
+
if (!body)
|
|
664
|
+
continue;
|
|
665
|
+
// Collect state variable names AND setter names from useState/useReducer.
|
|
666
|
+
// Both the value and the setter count as "state refs" — a child that
|
|
667
|
+
// receives `setCount` is wiring to state and should NOT be flagged.
|
|
668
|
+
const stateVars = new Set();
|
|
669
|
+
for (const decl of body.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
670
|
+
const init = decl.getInitializer();
|
|
671
|
+
if (!init || !Node.isCallExpression(init))
|
|
672
|
+
continue;
|
|
673
|
+
const calleeText = init.getExpression().getText();
|
|
674
|
+
const calleeName = calleeText.includes('.') ? calleeText.split('.').pop() : calleeText;
|
|
675
|
+
if (calleeName !== 'useState' && calleeName !== 'useReducer')
|
|
676
|
+
continue;
|
|
677
|
+
const nameNode = decl.getNameNode();
|
|
678
|
+
if (!Node.isArrayBindingPattern(nameNode))
|
|
679
|
+
continue;
|
|
680
|
+
for (const el of nameNode.getElements()) {
|
|
681
|
+
if (Node.isBindingElement(el)) {
|
|
682
|
+
stateVars.add(el.getNameNode().getText());
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
if (stateVars.size === 0)
|
|
687
|
+
continue;
|
|
688
|
+
// Already composing with children? Skip — the user is on the correct path.
|
|
689
|
+
const propBindings = getDestructuredPropBindings(fn);
|
|
690
|
+
const alreadyComposesChildren = propBindings?.some((p) => p.propName === 'children') ?? false;
|
|
691
|
+
if (alreadyComposesChildren)
|
|
692
|
+
continue;
|
|
693
|
+
// Require a clean single-root returned JSX tree. Fragments, conditional
|
|
694
|
+
// returns, and dynamic structures are too ambiguous to reason about
|
|
695
|
+
// without a real dataflow pass — skip them.
|
|
696
|
+
const root = getSingleReturnedJsx(fn);
|
|
697
|
+
if (!root)
|
|
698
|
+
continue;
|
|
699
|
+
// Only look at the DIRECT children of the returned root. Nested helper
|
|
700
|
+
// JSX inside map callbacks, conditional branches, or deep descendants
|
|
701
|
+
// are not flaggable — they may close over state transitively.
|
|
702
|
+
const candidates = getDirectChildrenOfReturn(root);
|
|
703
|
+
for (const el of candidates) {
|
|
704
|
+
const tag = el.getTagNameNode().getText();
|
|
705
|
+
if (!/^[A-Z]/.test(tag))
|
|
706
|
+
continue; // HTML element — not a rerender target we care about
|
|
707
|
+
// Does this child receive any state var (or setter) via attributes?
|
|
708
|
+
// Scan the entire attribute bag's text in one pass so callback props
|
|
709
|
+
// like onClick={() => setCount(c => c + 1)} count as state-dependent.
|
|
710
|
+
const attrsText = el
|
|
711
|
+
.getAttributes()
|
|
712
|
+
.map((a) => (Node.isJsxAttribute(a) ? a.getText() : ''))
|
|
713
|
+
.join(' ');
|
|
714
|
+
if (mentionsStateVars(attrsText, stateVars))
|
|
715
|
+
continue;
|
|
716
|
+
// Is this element inside a JsxExpression that references state (a
|
|
717
|
+
// conditional render like `{count > 0 && <Child />}` or a map based
|
|
718
|
+
// on state)? Walk up the JSX container chain.
|
|
719
|
+
const containingExpr = el.getFirstAncestorByKind(SyntaxKind.JsxExpression);
|
|
720
|
+
if (containingExpr && mentionsStateVars(containingExpr.getText(), stateVars))
|
|
721
|
+
continue;
|
|
722
|
+
// Flag: this direct child never sees state and re-renders unnecessarily.
|
|
723
|
+
const info = isComponentFunction(fn);
|
|
724
|
+
findings.push(finding('parent-rerender-via-state', 'info', 'pattern', `<${tag}> is rendered by '${info.name}' but does not receive any of its state variables (${[...stateVars].slice(0, 3).join(', ')}${stateVars.size > 3 ? '…' : ''}) — it re-renders on every state change. Consider lifting it to the 'children' prop so React can reuse the element.`, ctx.filePath, el.getStartLineNumber(), 1, {
|
|
725
|
+
suggestion: `Accept <${tag}> as the 'children' prop of '${info.name}' and render it with {children}. The caller composes: <${info.name}><${tag} /></${info.name}>. React will reuse the child element across re-renders.`,
|
|
726
|
+
}));
|
|
727
|
+
break; // one finding per component is enough — avoid noise
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return findings;
|
|
731
|
+
}
|
|
732
|
+
// ── Exported composition rules ───────────────────────────────────────────
|
|
733
|
+
export const reactCompositionRules = [
|
|
734
|
+
childrenNotUsed,
|
|
735
|
+
propDrillPassthrough,
|
|
736
|
+
propDrillChain,
|
|
737
|
+
memoizedChildInlineProp,
|
|
738
|
+
memoizedChildInlineChildren,
|
|
739
|
+
parentRerenderViaState,
|
|
740
|
+
];
|
|
741
|
+
//# sourceMappingURL=react-composition.js.map
|