@blumintinc/eslint-plugin-blumint 1.20.150 → 1.20.151
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/lib/index.js +1 -1
- package/lib/rules/enforce-exported-function-types.js +39 -2
- package/lib/rules/enforce-firestore-doc-ref-generic.js +140 -10
- package/lib/rules/no-redundant-usecallback-wrapper.js +60 -1
- package/lib/rules/no-unused-props.js +63 -4
- package/lib/rules/prefer-use-base62-id.js +72 -14
- package/package.json +1 -1
- package/release-manifest.json +46 -0
package/lib/index.js
CHANGED
|
@@ -65,6 +65,42 @@ function unwrapComponentFunction(node, resolveComponent, isWrapped = false, seen
|
|
|
65
65
|
}
|
|
66
66
|
return undefined;
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Resolves the component a default export ships.
|
|
70
|
+
*
|
|
71
|
+
* `require-memo` cannot rewrite `export default function Banner(props: P)` in
|
|
72
|
+
* place, since `export default const Banner = memo(...)` is a syntax error, so
|
|
73
|
+
* it splits the declaration from the export instead:
|
|
74
|
+
*
|
|
75
|
+
* ```
|
|
76
|
+
* const Banner = memo(function BannerUnmemoized(props: P) {...});
|
|
77
|
+
* export default Banner;
|
|
78
|
+
* ```
|
|
79
|
+
*
|
|
80
|
+
* The exported expression is then a bare identifier, and the component it
|
|
81
|
+
* stands for is one hop away on a declaration carrying no `export` of its own —
|
|
82
|
+
* invisible to every other visitor here.
|
|
83
|
+
*
|
|
84
|
+
* Following a bare identifier stays confined to the default export, where the
|
|
85
|
+
* named declaration IS the component this module ships. The named form
|
|
86
|
+
* `export const Banner = Other` re-exports a value whose props are the other
|
|
87
|
+
* declaration's concern, and an identifier belonging to another module resolves
|
|
88
|
+
* to nothing either way.
|
|
89
|
+
*/
|
|
90
|
+
function unwrapDefaultExportComponent(declaration, resolveComponent) {
|
|
91
|
+
if (declaration.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
92
|
+
return unwrapComponentFunction(declaration, resolveComponent);
|
|
93
|
+
}
|
|
94
|
+
// The exported name counts as already followed, so a declaration naming
|
|
95
|
+
// itself (`const Banner = memo(Banner)`) terminates on the hop back.
|
|
96
|
+
const component = unwrapComponentFunction(resolveComponent(declaration.name), resolveComponent, false, new Set([declaration.name]));
|
|
97
|
+
if (!component)
|
|
98
|
+
return undefined;
|
|
99
|
+
return {
|
|
100
|
+
...component,
|
|
101
|
+
resolvedName: component.resolvedName ?? declaration.name,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
68
104
|
exports.enforceExportedFunctionTypes = (0, createRule_1.createRule)({
|
|
69
105
|
name: 'enforce-exported-function-types',
|
|
70
106
|
meta: {
|
|
@@ -732,9 +768,10 @@ exports.enforceExportedFunctionTypes = (0, createRule_1.createRule)({
|
|
|
732
768
|
},
|
|
733
769
|
// `export default memo(function Banner(props: P) {...})` mirrors the
|
|
734
770
|
// `export default function Banner(props: P)` form the declaration
|
|
735
|
-
// visitors already cover
|
|
771
|
+
// visitors already cover, as does `export default Banner` naming either
|
|
772
|
+
// of them on a separate declaration.
|
|
736
773
|
ExportDefaultDeclaration(node) {
|
|
737
|
-
const component =
|
|
774
|
+
const component = unwrapDefaultExportComponent(node.declaration, findModuleScopeDeclaration);
|
|
738
775
|
if (!component)
|
|
739
776
|
return;
|
|
740
777
|
// The binding a wrapper argument named outranks the inner function's
|
|
@@ -20,7 +20,20 @@ const REFERENCE_TYPE_NAMES = new Set([
|
|
|
20
20
|
]);
|
|
21
21
|
/**
|
|
22
22
|
* The final segment of a type reference's name, so `FirebaseFirestore.
|
|
23
|
-
* DocumentReference` is
|
|
23
|
+
* DocumentReference` is read as the same name as `DocumentReference`.
|
|
24
|
+
*/
|
|
25
|
+
const rightmostTypeName = (typeName) => {
|
|
26
|
+
if (typeName.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
27
|
+
return typeName.name;
|
|
28
|
+
}
|
|
29
|
+
if (typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName &&
|
|
30
|
+
typeName.right.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
31
|
+
return typeName.right.name;
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* The reference type a name states, matched on that final segment.
|
|
24
37
|
*
|
|
25
38
|
* The rightmost segment is the right granularity because the namespace is
|
|
26
39
|
* arbitrary — `FirebaseFirestore.`, `admin.firestore.` and any
|
|
@@ -29,16 +42,69 @@ const REFERENCE_TYPE_NAMES = new Set([
|
|
|
29
42
|
* `DocumentReference` is not a realistic collision.
|
|
30
43
|
*/
|
|
31
44
|
const referenceTypeNameOf = (typeName) => {
|
|
32
|
-
|
|
33
|
-
|
|
45
|
+
const name = rightmostTypeName(typeName);
|
|
46
|
+
return name && REFERENCE_TYPE_NAMES.has(name) ? name : undefined;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* The type names an assertion can state that carry a document-shape generic.
|
|
50
|
+
*
|
|
51
|
+
* `Query` joins the three reference types here alone: `.where(...)` narrows a
|
|
52
|
+
* collection to it while keeping the document generic, so `as Query<User>`
|
|
53
|
+
* states the schema exactly as `as CollectionReference<User>` does. It is not a
|
|
54
|
+
* reference type the rule reports on, which is why it is not in
|
|
55
|
+
* `REFERENCE_TYPE_NAMES`.
|
|
56
|
+
*/
|
|
57
|
+
const SCHEMA_TYPE_NAMES = new Set([...REFERENCE_TYPE_NAMES, 'Query']);
|
|
58
|
+
/**
|
|
59
|
+
* The name a type reference or an interface heritage clause states, whichever
|
|
60
|
+
* spelling names it, so `FirebaseFirestore.DocumentReference` and
|
|
61
|
+
* `DocumentReference` are read as the same type.
|
|
62
|
+
*/
|
|
63
|
+
const namedTypeOf = (node) => {
|
|
64
|
+
if (node.type === utils_1.AST_NODE_TYPES.TSTypeReference) {
|
|
65
|
+
return rightmostTypeName(node.typeName);
|
|
34
66
|
}
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
67
|
+
if (node.type === utils_1.AST_NODE_TYPES.TSInterfaceHeritage) {
|
|
68
|
+
const { expression } = node;
|
|
69
|
+
if (expression.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
70
|
+
return expression.name;
|
|
71
|
+
}
|
|
72
|
+
if (expression.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
73
|
+
expression.property.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
74
|
+
return expression.property.name;
|
|
75
|
+
}
|
|
39
76
|
}
|
|
40
77
|
return undefined;
|
|
41
78
|
};
|
|
79
|
+
/**
|
|
80
|
+
* The child nodes of a node, read generically so that every type syntax — an
|
|
81
|
+
* array, a union, a tuple, a mapped or conditional type — is traversed without
|
|
82
|
+
* enumerating the kinds one by one. `parent` is skipped because following it
|
|
83
|
+
* walks back out of the subtree and never terminates.
|
|
84
|
+
*/
|
|
85
|
+
const childNodesOf = (node) => {
|
|
86
|
+
const children = [];
|
|
87
|
+
for (const [key, value] of Object.entries(node)) {
|
|
88
|
+
if (key === 'parent') {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (Array.isArray(value)) {
|
|
92
|
+
for (const item of value) {
|
|
93
|
+
if (ASTHelpers_1.ASTHelpers.isNode(item)) {
|
|
94
|
+
children.push(item);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
|
|
99
|
+
children.push(value);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return children;
|
|
103
|
+
};
|
|
104
|
+
/** The type nodes a declaration states about the type it declares. */
|
|
105
|
+
const statedTypeNodesOf = (declaration) => declaration.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration
|
|
106
|
+
? [declaration.typeAnnotation]
|
|
107
|
+
: declaration.extends ?? [];
|
|
42
108
|
/**
|
|
43
109
|
* The expression an optional link wraps, so a receiver spelled with `?.` is
|
|
44
110
|
* read as the expression it actually evaluates.
|
|
@@ -136,6 +202,56 @@ function declarationOfType(from, name) {
|
|
|
136
202
|
return undefined;
|
|
137
203
|
});
|
|
138
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Whether an asserted type states a Firestore reference surface, and so
|
|
207
|
+
* describes the document schema of the expression it is applied to.
|
|
208
|
+
*
|
|
209
|
+
* The presence of an assertion is not evidence on its own. `as const` states no
|
|
210
|
+
* type at all — it preserves whatever the operand already infers to and adds
|
|
211
|
+
* `readonly` — so every reference beneath one keeps the loose `DocumentData`
|
|
212
|
+
* schema this rule exists to reject, byte for byte. `global-const-style` ships
|
|
213
|
+
* `error` in the same recommended config and is fixable, and its fix appends
|
|
214
|
+
* exactly that assertion to a module-scope literal, so crediting any ancestor
|
|
215
|
+
* assertion lets one `eslint --fix` pass silence the rule without repairing
|
|
216
|
+
* anything (#2007).
|
|
217
|
+
*
|
|
218
|
+
* The search is structural rather than positional, because an assertion types
|
|
219
|
+
* the references inside a literal it wraps: `[db.collection('a')] as
|
|
220
|
+
* CollectionReference<T>[]` and `{...} as Record<string, CollectionReference<T>>`
|
|
221
|
+
* both state the schema from several type nodes away. Proximity therefore
|
|
222
|
+
* cannot be the test — the minimal repro, `db.collection('x') as const`, has the
|
|
223
|
+
* assertion as the call's own parent.
|
|
224
|
+
*
|
|
225
|
+
* A name that resolves to a declaration in the file is followed, since an alias
|
|
226
|
+
* states what it stands for. Both declaration spellings are read for the reason
|
|
227
|
+
* `declaredMembersOf` reads both: `prefer-type-over-interface` ships in the same
|
|
228
|
+
* config and is fixable, so an interface and the alias it becomes have to answer
|
|
229
|
+
* alike.
|
|
230
|
+
*/
|
|
231
|
+
function statesDocumentSchema(node, visited) {
|
|
232
|
+
const name = namedTypeOf(node);
|
|
233
|
+
if (name) {
|
|
234
|
+
if (SCHEMA_TYPE_NAMES.has(name)) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
if (declaredTypeStatesDocumentSchema(node, name, visited)) {
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return childNodesOf(node).some((child) => statesDocumentSchema(child, visited));
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* The declaration set doubles as the recursion guard, so a self-referential
|
|
245
|
+
* alias such as `type Loop = Loop[]` terminates instead of exhausting the stack.
|
|
246
|
+
*/
|
|
247
|
+
function declaredTypeStatesDocumentSchema(reference, name, visited) {
|
|
248
|
+
const declaration = declarationOfType(reference, name);
|
|
249
|
+
if (!declaration || visited.has(declaration)) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
visited.add(declaration);
|
|
253
|
+
return statedTypeNodesOf(declaration).some((stated) => statesDocumentSchema(stated, visited));
|
|
254
|
+
}
|
|
139
255
|
/**
|
|
140
256
|
* @type {import('eslint').Rule.RuleModule}
|
|
141
257
|
*/
|
|
@@ -325,8 +441,12 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
|
|
|
325
441
|
let previous;
|
|
326
442
|
let current = node;
|
|
327
443
|
while (current) {
|
|
328
|
-
// Type assertions using 'as' keyword
|
|
329
|
-
|
|
444
|
+
// Type assertions using 'as' keyword, credited only when the asserted
|
|
445
|
+
// type states the document schema. A non-stating assertion does not end
|
|
446
|
+
// the walk: `db.doc(p) as unknown as DocumentReference<User>` reaches
|
|
447
|
+
// the stating one an assertion further out.
|
|
448
|
+
if (current.type === utils_1.AST_NODE_TYPES.TSAsExpression &&
|
|
449
|
+
statesDocumentSchema(current.typeAnnotation, new Set())) {
|
|
330
450
|
nodeCache.set(node, true);
|
|
331
451
|
return true;
|
|
332
452
|
}
|
|
@@ -428,7 +548,17 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
|
|
|
428
548
|
return isTypedCollectionReferenceCache.get(node);
|
|
429
549
|
}
|
|
430
550
|
let result = false;
|
|
431
|
-
|
|
551
|
+
// A receiver reached through an assertion states its schema exactly as an
|
|
552
|
+
// annotated binding does, and `isTypedCollectionInitializer` already reads
|
|
553
|
+
// the same spelling one hop later. Without this, the receiver in
|
|
554
|
+
// `(matchRef.collection('m') as CollectionReference<T>).doc(id)` looks
|
|
555
|
+
// untyped and `.doc()` draws a report whose only remedy — `doc<T>(id)` —
|
|
556
|
+
// does not compile, since `CollectionReference<T>.doc` declares zero type
|
|
557
|
+
// parameters.
|
|
558
|
+
if (node.type === utils_1.AST_NODE_TYPES.TSAsExpression) {
|
|
559
|
+
result = hasCollectionReferenceType(node.typeAnnotation);
|
|
560
|
+
}
|
|
561
|
+
else if (node.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
432
562
|
node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
433
563
|
node.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
434
564
|
node.callee.property.name === 'collection' &&
|
|
@@ -79,6 +79,46 @@ function producesFunction(factory) {
|
|
|
79
79
|
}
|
|
80
80
|
return isFunctionLiteral(unwrapValueExpression(only.argument));
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Whether the binding is declared where the program evaluates it exactly once.
|
|
84
|
+
*
|
|
85
|
+
* A function's identity is fixed by the execution of its declaration, so what
|
|
86
|
+
* makes one a stable callback is not the function but the scope holding it.
|
|
87
|
+
* Only a module-level declaration answers yes: the scope chain reaches the
|
|
88
|
+
* `Program` without crossing a function, so nothing between the declaration and
|
|
89
|
+
* a render re-runs it. A function declared in a component body — or in any
|
|
90
|
+
* nested block of one, or in a custom hook — is a *fresh* function on every
|
|
91
|
+
* render, and there the wrapper is the only thing stabilizing it.
|
|
92
|
+
*
|
|
93
|
+
* Module-level blocks and loop bodies qualify: they run before any render, and
|
|
94
|
+
* each closure a loop creates captures the one binding it was created with.
|
|
95
|
+
*/
|
|
96
|
+
function isEvaluatedOnce(scope) {
|
|
97
|
+
return scope.variableScope.block.type === utils_1.AST_NODE_TYPES.Program;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Whether anything assigns to the binding after its declaration.
|
|
101
|
+
*
|
|
102
|
+
* The `const` keyword already rules this out for a declarator, but a function
|
|
103
|
+
* declaration binds a writable name, and the question the proof needs is about
|
|
104
|
+
* writes rather than about a keyword. Asking it of the resolved variable keeps
|
|
105
|
+
* one answer for every declaration form.
|
|
106
|
+
*/
|
|
107
|
+
function isNeverReassigned(variable) {
|
|
108
|
+
return variable.references.every((reference) => !reference.isWrite() || reference.init === true);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Whether the binding holds one function for the program's lifetime.
|
|
112
|
+
*
|
|
113
|
+
* The two questions are asked of the *binding* rather than of the syntax that
|
|
114
|
+
* introduced it, so a `const` arrow and the `function` declaration it is one
|
|
115
|
+
* rewrite away from answer alike. A proof that changed with the spelling would
|
|
116
|
+
* either withhold the report from half the codebase or bless code it flags in
|
|
117
|
+
* the other half.
|
|
118
|
+
*/
|
|
119
|
+
function isProgramLifetimeFunction(variable) {
|
|
120
|
+
return isEvaluatedOnce(variable.scope) && isNeverReassigned(variable);
|
|
121
|
+
}
|
|
82
122
|
function isHookLikeName(name) {
|
|
83
123
|
return name.startsWith('use');
|
|
84
124
|
}
|
|
@@ -237,7 +277,7 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
237
277
|
};
|
|
238
278
|
/**
|
|
239
279
|
* Whether the binding this identifier resolves to holds a callback whose
|
|
240
|
-
*
|
|
280
|
+
* stability is visible in this very file.
|
|
241
281
|
*
|
|
242
282
|
* `memoizedHookNames` exists for callbacks whose stability only the consumer
|
|
243
283
|
* knows about. A `const` initialized from `useCallback`, `useLatestCallback`
|
|
@@ -247,6 +287,10 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
247
287
|
* which the rule can report nothing at all in a config that does not set
|
|
248
288
|
* `memoizedHookNames`.
|
|
249
289
|
*
|
|
290
|
+
* A module-level function is the same proof without the call: a memoizing
|
|
291
|
+
* hook exists to give a function one identity across renders, and a
|
|
292
|
+
* declaration the program evaluates once already has one.
|
|
293
|
+
*
|
|
250
294
|
* The binding is resolved through scope analysis rather than matched by
|
|
251
295
|
* name, because a name set cannot tell the memoized `inner` of one component
|
|
252
296
|
* from the `inner` prop of the next, and would report the prop — the wrapper
|
|
@@ -258,6 +302,11 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
258
302
|
return false;
|
|
259
303
|
}
|
|
260
304
|
const declarator = variable.defs[0].node;
|
|
305
|
+
// A function declaration binds the function itself, so the binding's
|
|
306
|
+
// lifetime settles it outright — there is no initializer to read.
|
|
307
|
+
if (declarator.type === utils_1.AST_NODE_TYPES.FunctionDeclaration) {
|
|
308
|
+
return isProgramLifetimeFunction(variable);
|
|
309
|
+
}
|
|
261
310
|
if (declarator.type !== utils_1.AST_NODE_TYPES.VariableDeclarator ||
|
|
262
311
|
declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
263
312
|
!declarator.init) {
|
|
@@ -277,6 +326,16 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
277
326
|
return false;
|
|
278
327
|
}
|
|
279
328
|
const init = unwrapValueExpression(declarator.init);
|
|
329
|
+
// A function literal needs no memoizing call to be stable: it is created
|
|
330
|
+
// once by its declaration, so a module-level one holds a single identity
|
|
331
|
+
// for the program's lifetime — at least as stable as anything `useMemo`
|
|
332
|
+
// or `useCallback` hands back, and stable for the same reason. The
|
|
333
|
+
// conservative stance stays in force for *calls*: `memoize(fn)` is an
|
|
334
|
+
// unknown function whose result the rule cannot reason about, whatever it
|
|
335
|
+
// returns, and hoisting such a call to module scope does not change that.
|
|
336
|
+
if (isFunctionLiteral(init)) {
|
|
337
|
+
return isProgramLifetimeFunction(variable);
|
|
338
|
+
}
|
|
280
339
|
if (init.type !== utils_1.AST_NODE_TYPES.CallExpression) {
|
|
281
340
|
return false;
|
|
282
341
|
}
|
|
@@ -6,6 +6,62 @@ const createRule_1 = require("../utils/createRule");
|
|
|
6
6
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
7
|
const PAREN_TYPE = utils_1.AST_NODE_TYPES.TSParenthesizedType ??
|
|
8
8
|
'TSParenthesizedType';
|
|
9
|
+
/**
|
|
10
|
+
* `require-memo` rewrites `const Widget = (props: WidgetProps) => ...` into
|
|
11
|
+
* `const Widget = memo(...)`, and pairs that wrapper with `forwardRef` whenever
|
|
12
|
+
* a ref is forwarded, so a component declarator's initializer is a wrapper CALL
|
|
13
|
+
* at least as often as it is a function. Peeling them is what keeps the
|
|
14
|
+
* recommended config's own autofix from hiding the component from this rule
|
|
15
|
+
* (#2004).
|
|
16
|
+
*/
|
|
17
|
+
const COMPONENT_WRAPPERS = new Set(['memo', 'forwardRef']);
|
|
18
|
+
const isComponentWrapperCallee = (callee) => {
|
|
19
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
20
|
+
return COMPONENT_WRAPPERS.has(callee.name);
|
|
21
|
+
}
|
|
22
|
+
if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
23
|
+
!callee.computed &&
|
|
24
|
+
callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
25
|
+
return COMPONENT_WRAPPERS.has(callee.property.name);
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The function that receives the props behind any nesting of component
|
|
31
|
+
* wrappers (`memo(forwardRef(fn))`), or `null` when no function is reachable.
|
|
32
|
+
* Only the first argument is followed: a wrapper's remaining arguments are
|
|
33
|
+
* comparators (`memo(fn, compareDeeply('used'))`), never the component.
|
|
34
|
+
*
|
|
35
|
+
* An argument that merely NAMES a function (`memo(WidgetUnmemoized)`) is left
|
|
36
|
+
* alone, unlike in `enforce-exported-function-types`. That name belongs to a
|
|
37
|
+
* declarator of its own, which reaches this rule on its own and reports against
|
|
38
|
+
* the same props-type member; following the binding would report every unused
|
|
39
|
+
* prop once per binding that re-wraps the component.
|
|
40
|
+
*/
|
|
41
|
+
const unwrapComponentFunction = (node) => {
|
|
42
|
+
if (!node) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
// ESTree wraps `memo?.(fn)` and `React?.memo(fn)` in a ChainExpression, so an
|
|
46
|
+
// optionally-called wrapper sits one node deeper than the plain spelling.
|
|
47
|
+
if (node.type === utils_1.AST_NODE_TYPES.ChainExpression) {
|
|
48
|
+
return unwrapComponentFunction(node.expression);
|
|
49
|
+
}
|
|
50
|
+
if (node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
51
|
+
node.type === utils_1.AST_NODE_TYPES.FunctionExpression) {
|
|
52
|
+
return node;
|
|
53
|
+
}
|
|
54
|
+
if (node.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
55
|
+
isComponentWrapperCallee(node.callee)) {
|
|
56
|
+
const [firstArgument] = node.arguments;
|
|
57
|
+
// A spread argument (`memo(...candidates)`) hides which value is wrapped.
|
|
58
|
+
if (!firstArgument || firstArgument.type === utils_1.AST_NODE_TYPES.SpreadElement) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return unwrapComponentFunction(firstArgument);
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
};
|
|
9
65
|
exports.noUnusedProps = (0, createRule_1.createRule)({
|
|
10
66
|
name: 'no-unused-props',
|
|
11
67
|
meta: {
|
|
@@ -469,14 +525,17 @@ exports.noUnusedProps = (0, createRule_1.createRule)({
|
|
|
469
525
|
* holds no props-typed function. Each declarator answers on its own: this
|
|
470
526
|
* rule reports a member of a props type and rewrites nothing, so a sibling
|
|
471
527
|
* binding in the same statement has no bearing on the verdict (#1890).
|
|
528
|
+
*
|
|
529
|
+
* The props come from the wrapped function's own parameter, so a wrapper
|
|
530
|
+
* changes nothing about the verdict — only about how far in the function
|
|
531
|
+
* sits. The declarator's binding still supplies any FC-shaped annotation.
|
|
472
532
|
*/
|
|
473
533
|
const componentUsageOfDeclarator = (declaration) => {
|
|
474
|
-
const
|
|
475
|
-
if (
|
|
476
|
-
init?.type !== utils_1.AST_NODE_TYPES.FunctionExpression) {
|
|
534
|
+
const fn = unwrapComponentFunction(declaration.init);
|
|
535
|
+
if (!fn) {
|
|
477
536
|
return null;
|
|
478
537
|
}
|
|
479
|
-
return componentUsageOfFunction(
|
|
538
|
+
return componentUsageOfFunction(fn, declaration.id);
|
|
480
539
|
};
|
|
481
540
|
return {
|
|
482
541
|
TSTypeAliasDeclaration(node) {
|
|
@@ -35,6 +35,37 @@ function isHookName(name) {
|
|
|
35
35
|
function isPascalCase(name) {
|
|
36
36
|
return /^[A-Z]/.test(name);
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Wrappers that exist purely at the type level: they leave the wrapped
|
|
40
|
+
* expression's runtime value untouched, so a value wrapped in them is still the
|
|
41
|
+
* value the enclosing declarator binds.
|
|
42
|
+
*/
|
|
43
|
+
const TYPE_ONLY_WRAPPERS = new Set([
|
|
44
|
+
utils_1.AST_NODE_TYPES.TSAsExpression,
|
|
45
|
+
utils_1.AST_NODE_TYPES.TSSatisfiesExpression,
|
|
46
|
+
utils_1.AST_NODE_TYPES.TSNonNullExpression,
|
|
47
|
+
utils_1.AST_NODE_TYPES.TSTypeAssertion,
|
|
48
|
+
]);
|
|
49
|
+
/**
|
|
50
|
+
* `require-memo` rewrites `const Widget = (props) => ...` into
|
|
51
|
+
* `const Widget = memo(...)`, and pairs that wrapper with `forwardRef` whenever
|
|
52
|
+
* a ref is forwarded, so a component's function reaches this rule behind a
|
|
53
|
+
* wrapper CALL at least as often as it sits directly under its declarator.
|
|
54
|
+
* Climbing them is what keeps the recommended config's own autofix from hiding
|
|
55
|
+
* the component from every handler here (#2005).
|
|
56
|
+
*/
|
|
57
|
+
const COMPONENT_WRAPPERS = new Set(['memo', 'forwardRef']);
|
|
58
|
+
function isComponentWrapperCallee(callee) {
|
|
59
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
60
|
+
return COMPONENT_WRAPPERS.has(callee.name);
|
|
61
|
+
}
|
|
62
|
+
if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
63
|
+
!callee.computed &&
|
|
64
|
+
callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
65
|
+
return COMPONENT_WRAPPERS.has(callee.property.name);
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
38
69
|
/**
|
|
39
70
|
* Checks whether a node is a React component or hook function.
|
|
40
71
|
* Hooks: function whose name starts with "use" followed by an uppercase letter.
|
|
@@ -47,15 +78,53 @@ function isComponentOrHook(node) {
|
|
|
47
78
|
}
|
|
48
79
|
// FunctionExpression or ArrowFunctionExpression assigned to a variable:
|
|
49
80
|
// const MyComponent = () => {} or const useHook = () => {}
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
52
|
-
const id =
|
|
81
|
+
const declarator = getBindingDeclarator(node);
|
|
82
|
+
if (declarator) {
|
|
83
|
+
const id = declarator.id;
|
|
53
84
|
if (id.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
54
85
|
return isPascalCase(id.name) || isHookName(id.name);
|
|
55
86
|
}
|
|
56
87
|
}
|
|
57
88
|
return false;
|
|
58
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* The declarator whose binding names the given function, seen through the
|
|
92
|
+
* wrappers that may stand between the two, or undefined when the function is
|
|
93
|
+
* not bound by a declarator at all.
|
|
94
|
+
*
|
|
95
|
+
* Only the FIRST argument of a wrapper is followed: a wrapper's remaining
|
|
96
|
+
* arguments are comparators (`memo(Widget, compareDeeply('id'))`), which run per
|
|
97
|
+
* comparison rather than per render and are no part of the component. Wrappers
|
|
98
|
+
* nest (`memo(forwardRef(fn))`), so the climb loops.
|
|
99
|
+
*/
|
|
100
|
+
function getBindingDeclarator(node) {
|
|
101
|
+
let child = node;
|
|
102
|
+
let current = node.parent;
|
|
103
|
+
while (current) {
|
|
104
|
+
// A type-only wrapper may sit on either side of the call, as in
|
|
105
|
+
// `const Widget = memo(fn) as FC`, and changes nothing at runtime.
|
|
106
|
+
// ESTree wraps `memo?.(fn)` and `React?.memo(fn)` in a ChainExpression, so
|
|
107
|
+
// an optionally-called wrapper sits one node deeper than the plain
|
|
108
|
+
// spelling.
|
|
109
|
+
if (TYPE_ONLY_WRAPPERS.has(current.type) ||
|
|
110
|
+
current.type === utils_1.AST_NODE_TYPES.ChainExpression) {
|
|
111
|
+
child = current;
|
|
112
|
+
current = current.parent;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (current.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
116
|
+
isComponentWrapperCallee(current.callee) &&
|
|
117
|
+
current.arguments[0] === child) {
|
|
118
|
+
child = current;
|
|
119
|
+
current = current.parent;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
return current?.type === utils_1.AST_NODE_TYPES.VariableDeclarator
|
|
125
|
+
? current
|
|
126
|
+
: undefined;
|
|
127
|
+
}
|
|
59
128
|
/**
|
|
60
129
|
* Recursively searches the given node tree for any CallExpression that calls
|
|
61
130
|
* one of the tracked uuidv4Base62 local names.
|
|
@@ -131,17 +200,6 @@ function hasEmptyDepsArray(callNode) {
|
|
|
131
200
|
const deps = callNode.arguments[1];
|
|
132
201
|
return (deps.type === utils_1.AST_NODE_TYPES.ArrayExpression && deps.elements.length === 0);
|
|
133
202
|
}
|
|
134
|
-
/**
|
|
135
|
-
* Wrappers that exist purely at the type level: they leave the wrapped
|
|
136
|
-
* expression's runtime value untouched, so a value wrapped in them is still the
|
|
137
|
-
* value the enclosing declarator binds.
|
|
138
|
-
*/
|
|
139
|
-
const TYPE_ONLY_WRAPPERS = new Set([
|
|
140
|
-
utils_1.AST_NODE_TYPES.TSAsExpression,
|
|
141
|
-
utils_1.AST_NODE_TYPES.TSSatisfiesExpression,
|
|
142
|
-
utils_1.AST_NODE_TYPES.TSNonNullExpression,
|
|
143
|
-
utils_1.AST_NODE_TYPES.TSTypeAssertion,
|
|
144
|
-
]);
|
|
145
203
|
/**
|
|
146
204
|
* Returns the nearest ancestor that carries runtime meaning, skipping the
|
|
147
205
|
* type-only wrappers that may sit between an expression and its binding site.
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,50 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.151",
|
|
4
|
+
"date": "2026-08-14T02:27:13.012Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "enforce-exported-function-types",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
2006
|
|
11
|
+
],
|
|
12
|
+
"summary": "follow a default-exported identifier to its local memo() declaration (closes #2006)"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "enforce-firestore-doc-ref-generic",
|
|
16
|
+
"changeType": "fix",
|
|
17
|
+
"issues": [
|
|
18
|
+
2007
|
|
19
|
+
],
|
|
20
|
+
"summary": "require an ancestor assertion to state a document schema (closes #2007)"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "no-redundant-usecallback-wrapper",
|
|
24
|
+
"changeType": "fix",
|
|
25
|
+
"issues": [
|
|
26
|
+
2008
|
|
27
|
+
],
|
|
28
|
+
"summary": "treat a module-level function as a stable delegate (closes #2008)"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "no-unused-props",
|
|
32
|
+
"changeType": "fix",
|
|
33
|
+
"issues": [
|
|
34
|
+
2004
|
|
35
|
+
],
|
|
36
|
+
"summary": "see through memo/forwardRef wrappers require-memo emits (closes #2004)"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "prefer-use-base62-id",
|
|
40
|
+
"changeType": "fix",
|
|
41
|
+
"issues": [
|
|
42
|
+
2005
|
|
43
|
+
],
|
|
44
|
+
"summary": "climb wrapper calls so memo() no longer hides a component (closes #2005)"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
},
|
|
2
48
|
{
|
|
3
49
|
"version": "1.20.150",
|
|
4
50
|
"date": "2026-08-13T19:12:01.840Z",
|