@blumintinc/eslint-plugin-blumint 1.20.104 → 1.20.105
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-dynamic-firebase-imports.d.ts +2 -2
- package/lib/rules/enforce-dynamic-firebase-imports.js +193 -54
- package/lib/rules/enforce-empty-object-check.js +14 -0
- package/lib/rules/memoize-root-level-hocs.d.ts +2 -1
- package/lib/rules/memoize-root-level-hocs.js +167 -11
- package/package.json +1 -1
- package/release-manifest.json +31 -0
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { TSESTree } from '@typescript-eslint/utils';
|
|
2
|
-
declare const enforceFirebaseImports:
|
|
1
|
+
import { TSESLint, TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
declare const enforceFirebaseImports: TSESLint.RuleModule<"noDynamicImport", never[], {} | {
|
|
3
3
|
ImportDeclaration(node: TSESTree.ImportDeclaration): void;
|
|
4
4
|
}>;
|
|
5
5
|
export default enforceFirebaseImports;
|
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
3
4
|
const createRule_1 = require("../utils/createRule");
|
|
5
|
+
const isFunctionNode = (node) => node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
6
|
+
node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
|
|
7
|
+
node.type === utils_1.AST_NODE_TYPES.FunctionExpression;
|
|
8
|
+
/**
|
|
9
|
+
* Walks outward from a reference to the innermost `async` function whose block
|
|
10
|
+
* body contains it.
|
|
11
|
+
*
|
|
12
|
+
* A reference sitting in a *synchronous* callback nested inside an async
|
|
13
|
+
* function still resolves once the declaration heads the async body, because
|
|
14
|
+
* the callback cannot run before the first statement of the body it is created
|
|
15
|
+
* in — so the walk continues past non-async functions rather than giving up.
|
|
16
|
+
*
|
|
17
|
+
* The containment check is against the body rather than the function: a
|
|
18
|
+
* reference in a parameter default or a signature type annotation is evaluated
|
|
19
|
+
* before the body runs, so a declaration at the top of the body would come too
|
|
20
|
+
* late for it.
|
|
21
|
+
*/
|
|
22
|
+
const enclosingAsyncBodyOf = (identifier) => {
|
|
23
|
+
let current = identifier.parent;
|
|
24
|
+
while (current) {
|
|
25
|
+
if (isFunctionNode(current) &&
|
|
26
|
+
current.async &&
|
|
27
|
+
current.body.type === utils_1.AST_NODE_TYPES.BlockStatement &&
|
|
28
|
+
identifier.range[0] >= current.body.range[0] &&
|
|
29
|
+
identifier.range[1] <= current.body.range[1]) {
|
|
30
|
+
return current;
|
|
31
|
+
}
|
|
32
|
+
current = current.parent;
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
};
|
|
36
|
+
const THIRD_PARTY_DIRECTORY = /(^|\/)node_modules(\/|$)/;
|
|
37
|
+
// Anchored at the end of the path so multi-part suffixes such as
|
|
38
|
+
// `useStartMatch.integration.test.ts` are recognized while production modules
|
|
39
|
+
// that merely contain the word (`latest.tsx`, `contest.ts`, `testHelpers.ts`)
|
|
40
|
+
// keep their enforcement.
|
|
41
|
+
const TEST_FILE_SUFFIX = /\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
42
|
+
// Jest convention directories hold test-only modules regardless of file name.
|
|
43
|
+
const TEST_FILE_DIRECTORY = /(^|\/)(__tests__|__mocks__)\//;
|
|
44
|
+
/**
|
|
45
|
+
* The rule's rationale is bundle weight: a static import pulls Firebase into the
|
|
46
|
+
* initial client chunk. A suite, a Jest manual mock and a declaration file are
|
|
47
|
+
* never part of that chunk, so there is nothing to inflate and the rule has
|
|
48
|
+
* nothing to enforce there.
|
|
49
|
+
*
|
|
50
|
+
* The exemption is load-bearing rather than cosmetic because the rule is
|
|
51
|
+
* fixable: a suite's static binding is exactly what `jest.mock()` hoisting
|
|
52
|
+
* intercepts, and rewriting it emits a module-scope `await import(...)` that a
|
|
53
|
+
* CommonJS test transform cannot even parse (issue #1715).
|
|
54
|
+
*/
|
|
55
|
+
const isNeverBundled = (filename) => filename.endsWith('.d.ts') ||
|
|
56
|
+
TEST_FILE_SUFFIX.test(filename) ||
|
|
57
|
+
TEST_FILE_DIRECTORY.test(filename);
|
|
4
58
|
const enforceFirebaseImports = (0, createRule_1.createRule)({
|
|
5
59
|
name: 'enforce-dynamic-firebase-imports',
|
|
6
60
|
meta: {
|
|
@@ -13,18 +67,26 @@ const enforceFirebaseImports = (0, createRule_1.createRule)({
|
|
|
13
67
|
hasSuggestions: true,
|
|
14
68
|
schema: [],
|
|
15
69
|
messages: {
|
|
16
|
-
noDynamicImport: 'Static import from firebaseCloud path "{{importPath}}" eagerly bundles Firebase code into the initial client chunk, which inflates startup time and prevents lazy loading.
|
|
70
|
+
noDynamicImport: 'Static import from firebaseCloud path "{{importPath}}" eagerly bundles Firebase code into the initial client chunk, which inflates startup time and prevents lazy loading. Load it at the call site instead, inside an async function body (e.g., `const { export } = await import(\'{{importPath}}\')`). Keep it out of module scope: a top-level `await import(...)` defers nothing and does not parse once the module is compiled to CommonJS.',
|
|
17
71
|
},
|
|
18
72
|
},
|
|
19
73
|
defaultOptions: [],
|
|
20
74
|
create(context) {
|
|
75
|
+
const sourceCode = context.getSourceCode();
|
|
76
|
+
// Normalize Windows backslash separators so the forward-slash directory
|
|
77
|
+
// checks match on every platform. Without this, `getFilename()` returns
|
|
78
|
+
// `C:\repo\src\hooks\__tests__\Foo.ts` on Windows and the exemption
|
|
79
|
+
// silently fails there.
|
|
80
|
+
const filename = (context.getFilename?.() ?? '').replace(/\\/g, '/');
|
|
81
|
+
// `<input>`/`<text>` are the synthetic names RuleTester uses when a case
|
|
82
|
+
// declares no filename. They match none of the exemptions below, so a
|
|
83
|
+
// snippet keeps its enforcement — unlike a path-gated rule, this one has no
|
|
84
|
+
// include list to fall outside of.
|
|
85
|
+
if (THIRD_PARTY_DIRECTORY.test(filename) || isNeverBundled(filename)) {
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
21
88
|
return {
|
|
22
89
|
ImportDeclaration(node) {
|
|
23
|
-
// Skip third-party files
|
|
24
|
-
const filename = context.getFilename?.();
|
|
25
|
-
if (filename && /(^|[\\/])node_modules([\\/]|$)/.test(filename)) {
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
90
|
// Skip type-only import declarations
|
|
29
91
|
if (node.importKind === 'type') {
|
|
30
92
|
return;
|
|
@@ -51,75 +113,152 @@ const enforceFirebaseImports = (0, createRule_1.createRule)({
|
|
|
51
113
|
? spec.imported.name
|
|
52
114
|
: `${spec.imported.name} as ${spec.local.name}`)
|
|
53
115
|
.join(', ');
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
116
|
+
const destructureEntry = (spec) => spec.imported.name === spec.local.name
|
|
117
|
+
? spec.local.name
|
|
118
|
+
: `${spec.imported.name}: ${spec.local.name}`;
|
|
119
|
+
const buildValueStatements = () => {
|
|
59
120
|
if (namespaceSpecifier) {
|
|
60
121
|
const nsLocal = namespaceSpecifier.local.name;
|
|
61
|
-
|
|
122
|
+
const statements = [
|
|
123
|
+
`const ${nsLocal} = await import('${importPath}');`,
|
|
124
|
+
];
|
|
62
125
|
if (defaultSpecifier) {
|
|
63
|
-
const
|
|
64
|
-
statements.push(`const ${defLocal} = ${nsLocal}.default;`);
|
|
126
|
+
statements.push(`const ${defaultSpecifier.local.name} = ${nsLocal}.default;`);
|
|
65
127
|
}
|
|
66
|
-
const destructureFromNamespace = [];
|
|
67
128
|
if (namedSpecifiers.length > 0) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return imported === local ? imported : `${imported}: ${local}`;
|
|
72
|
-
});
|
|
73
|
-
destructureFromNamespace.push(...destructureParts);
|
|
129
|
+
statements.push(`const { ${namedSpecifiers
|
|
130
|
+
.map(destructureEntry)
|
|
131
|
+
.join(', ')} } = ${nsLocal};`);
|
|
74
132
|
}
|
|
75
|
-
|
|
76
|
-
statements.push(`const { ${destructureFromNamespace.join(', ')} } = ${nsLocal};`);
|
|
77
|
-
}
|
|
78
|
-
return statements.join(' ');
|
|
133
|
+
return statements;
|
|
79
134
|
}
|
|
80
|
-
const destructureParts = [
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
135
|
+
const destructureParts = [
|
|
136
|
+
...(defaultSpecifier
|
|
137
|
+
? [`default: ${defaultSpecifier.local.name}`]
|
|
138
|
+
: []),
|
|
139
|
+
...namedSpecifiers.map(destructureEntry),
|
|
140
|
+
];
|
|
141
|
+
// A side-effect import binds nothing, so there is no declaration to
|
|
142
|
+
// relocate — the awaited call would have to stay at module scope.
|
|
143
|
+
return destructureParts.length > 0
|
|
144
|
+
? [
|
|
145
|
+
`const { ${destructureParts.join(', ')} } = await import('${importPath}');`,
|
|
146
|
+
]
|
|
147
|
+
: [];
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* An `ImportDeclaration` only ever sits at module scope, so rewriting
|
|
151
|
+
* it in place can only ever produce a module-scope `await import(...)`
|
|
152
|
+
* — which defers nothing (the module still awaits it during
|
|
153
|
+
* evaluation) and does not even parse once the file is compiled to
|
|
154
|
+
* CommonJS, where top-level await does not exist (issue #1716).
|
|
155
|
+
*
|
|
156
|
+
* The rewrite is therefore only expressible when every value reference
|
|
157
|
+
* lives in one async function body: the declaration can then head that
|
|
158
|
+
* body, exactly the shape the codebase writes by hand. Anything else
|
|
159
|
+
* is a per-call-site refactor the fixer declines rather than corrupts.
|
|
160
|
+
*/
|
|
161
|
+
const findRelocationTarget = () => {
|
|
162
|
+
const valueLocalNames = new Set([
|
|
163
|
+
defaultSpecifier?.local.name,
|
|
164
|
+
namespaceSpecifier?.local.name,
|
|
165
|
+
...namedSpecifiers.map((spec) => spec.local.name),
|
|
166
|
+
].filter((name) => name !== undefined));
|
|
167
|
+
const references = context
|
|
168
|
+
.getDeclaredVariables(node)
|
|
169
|
+
.filter((variable) => valueLocalNames.has(variable.name))
|
|
170
|
+
.flatMap((variable) => variable.references);
|
|
171
|
+
// Nothing reads the binding, so there is no call site to defer to.
|
|
172
|
+
if (references.length === 0) {
|
|
173
|
+
return undefined;
|
|
84
174
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
175
|
+
let target;
|
|
176
|
+
for (const reference of references) {
|
|
177
|
+
const enclosing = enclosingAsyncBodyOf(reference.identifier);
|
|
178
|
+
if (!enclosing || (target && target !== enclosing)) {
|
|
179
|
+
return undefined;
|
|
90
180
|
}
|
|
181
|
+
target = enclosing;
|
|
91
182
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
183
|
+
return target;
|
|
184
|
+
};
|
|
185
|
+
const indentationAt = (line) => /^[ \t]*/.exec(sourceCode.lines[line - 1] ?? '')?.[0] ?? '';
|
|
186
|
+
/**
|
|
187
|
+
* Consumes the import's own trailing whitespace, and its line break
|
|
188
|
+
* when the import owns the line, so the removal strands neither a blank
|
|
189
|
+
* line nor the indentation of whatever shared the line with it.
|
|
190
|
+
* Anything that is not whitespace — a trailing comment, a statement —
|
|
191
|
+
* is left untouched.
|
|
192
|
+
*/
|
|
193
|
+
const removalEnd = () => {
|
|
194
|
+
const text = sourceCode.getText();
|
|
195
|
+
let cursor = node.range[1];
|
|
196
|
+
while (cursor < text.length &&
|
|
197
|
+
(text[cursor] === ' ' || text[cursor] === '\t')) {
|
|
198
|
+
cursor += 1;
|
|
199
|
+
}
|
|
200
|
+
if (text[cursor] === '\n') {
|
|
201
|
+
return cursor + 1;
|
|
95
202
|
}
|
|
96
|
-
if (
|
|
97
|
-
return
|
|
98
|
-
|
|
99
|
-
|
|
203
|
+
if (text[cursor] === '\r' && text[cursor + 1] === '\n') {
|
|
204
|
+
return cursor + 2;
|
|
205
|
+
}
|
|
206
|
+
return cursor;
|
|
207
|
+
};
|
|
208
|
+
const buildFix = (fixer) => {
|
|
209
|
+
const target = findRelocationTarget();
|
|
210
|
+
const statements = buildValueStatements();
|
|
211
|
+
if (!target || statements.length === 0) {
|
|
212
|
+
return null;
|
|
100
213
|
}
|
|
101
|
-
|
|
214
|
+
const body = target.body;
|
|
215
|
+
// A directive stops being a directive the moment a declaration
|
|
216
|
+
// precedes it, so `'use server'` on a server action would silently
|
|
217
|
+
// become a discarded string expression. The declaration goes after
|
|
218
|
+
// the whole prologue instead.
|
|
219
|
+
const prologueLength = body.body.findIndex((statement) => statement.type !== utils_1.AST_NODE_TYPES.ExpressionStatement ||
|
|
220
|
+
statement.expression.type !== utils_1.AST_NODE_TYPES.Literal ||
|
|
221
|
+
typeof statement.expression.value !== 'string');
|
|
222
|
+
const directives = body.body.slice(0, prologueLength === -1 ? body.body.length : prologueLength);
|
|
223
|
+
const lastDirective = directives[directives.length - 1];
|
|
224
|
+
const following = body.body[directives.length];
|
|
225
|
+
const anchorLine = lastDirective
|
|
226
|
+
? lastDirective.loc.end.line
|
|
227
|
+
: body.loc.start.line;
|
|
228
|
+
const neighbour = following ?? lastDirective;
|
|
229
|
+
// A body written on one line keeps its shape; a multi-line body gets
|
|
230
|
+
// the declaration on its own line at the body's own indentation.
|
|
231
|
+
const insertion = following && following.loc.start.line === anchorLine
|
|
232
|
+
? ` ${statements.join(' ')}`
|
|
233
|
+
: statements
|
|
234
|
+
.map((statement) => {
|
|
235
|
+
const indent = neighbour
|
|
236
|
+
? indentationAt(neighbour.loc.start.line)
|
|
237
|
+
: `${indentationAt(target.loc.start.line)} `;
|
|
238
|
+
return `\n${indent}${statement}`;
|
|
239
|
+
})
|
|
240
|
+
.join('');
|
|
241
|
+
return [
|
|
242
|
+
// Type-only specifiers are erased at compile time, so they stay
|
|
243
|
+
// where they are instead of riding along into the function body.
|
|
244
|
+
typeOnlySpecifiers.length > 0
|
|
245
|
+
? fixer.replaceText(node, `import type { ${buildTypeNames()} } from '${importPath}';`)
|
|
246
|
+
: fixer.removeRange([node.range[0], removalEnd()]),
|
|
247
|
+
lastDirective
|
|
248
|
+
? fixer.insertTextAfter(lastDirective, insertion)
|
|
249
|
+
: fixer.insertTextAfterRange([body.range[0], body.range[0] + 1], insertion),
|
|
250
|
+
];
|
|
102
251
|
};
|
|
103
252
|
context.report({
|
|
104
253
|
node,
|
|
105
254
|
messageId: 'noDynamicImport',
|
|
106
255
|
data: { importPath },
|
|
107
|
-
fix
|
|
108
|
-
const replacement = buildReplacement();
|
|
109
|
-
return replacement ? fixer.replaceText(node, replacement) : null;
|
|
110
|
-
},
|
|
256
|
+
fix: buildFix,
|
|
111
257
|
suggest: [
|
|
112
258
|
{
|
|
113
259
|
messageId: 'noDynamicImport',
|
|
114
260
|
data: { importPath },
|
|
115
|
-
fix
|
|
116
|
-
const replacement = buildReplacement({
|
|
117
|
-
allowSideEffectFix: true,
|
|
118
|
-
});
|
|
119
|
-
return replacement
|
|
120
|
-
? fixer.replaceText(node, replacement)
|
|
121
|
-
: null;
|
|
122
|
-
},
|
|
261
|
+
fix: buildFix,
|
|
123
262
|
},
|
|
124
263
|
],
|
|
125
264
|
});
|
|
@@ -215,6 +215,20 @@ function isObjectLikeType(type, checker) {
|
|
|
215
215
|
if (type.getCallSignatures().length > 0) {
|
|
216
216
|
return 'non-object';
|
|
217
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* A construct-signature-only type — a class reference, a `…Constructor<P>`
|
|
220
|
+
* interface, the `ComponentClass` half of `ComponentType` — carries behaviour,
|
|
221
|
+
* not data. Its own properties are statics, so `Object.keys()` is `[]` for a
|
|
222
|
+
* plain class or component even when a valid value was supplied, and the
|
|
223
|
+
* emptiness check this rule prescribes would invert the guard rather than
|
|
224
|
+
* harden it. Unions reach this branch through the recursive call above, which
|
|
225
|
+
* matters because a union counts as an object when ANY member does: without
|
|
226
|
+
* this, the constructor half alone classified a whole `ComponentType` union as
|
|
227
|
+
* a data object.
|
|
228
|
+
*/
|
|
229
|
+
if (type.getConstructSignatures().length > 0) {
|
|
230
|
+
return 'non-object';
|
|
231
|
+
}
|
|
218
232
|
if (hasRequiredProperties(type, checker)) {
|
|
219
233
|
return 'non-object';
|
|
220
234
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { TSESLint } from '@typescript-eslint/utils';
|
|
1
2
|
type Options = [
|
|
2
3
|
{
|
|
3
4
|
additionalHocNames?: string[];
|
|
4
5
|
}
|
|
5
6
|
];
|
|
6
|
-
export declare const memoizeRootLevelHocs:
|
|
7
|
+
export declare const memoizeRootLevelHocs: TSESLint.RuleModule<"wrapHocInUseMemo", Options, TSESLint.RuleListener>;
|
|
7
8
|
export {};
|
|
@@ -121,26 +121,150 @@ const getCallableIdentifierName = (callee) => {
|
|
|
121
121
|
}
|
|
122
122
|
return null;
|
|
123
123
|
};
|
|
124
|
-
const
|
|
125
|
-
if (additionalHocs.has(name)) {
|
|
126
|
-
return true;
|
|
127
|
-
}
|
|
124
|
+
const hasHocNameShape = (name) => {
|
|
128
125
|
if (!name.startsWith('with')) {
|
|
129
126
|
return false;
|
|
130
127
|
}
|
|
131
128
|
const suffix = name.charAt(4);
|
|
132
129
|
return Boolean(suffix) && /^[A-Z]$/.test(suffix);
|
|
133
130
|
};
|
|
134
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Strips wrappers that carry no runtime meaning so a component argument stays
|
|
133
|
+
* recognizable behind `as`, `!`, `satisfies` and optional-chaining nodes.
|
|
134
|
+
*/
|
|
135
|
+
const unwrapExpression = (node) => {
|
|
136
|
+
let current = node;
|
|
137
|
+
for (;;) {
|
|
138
|
+
if (current.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
|
|
139
|
+
current.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression ||
|
|
140
|
+
current.type === utils_1.AST_NODE_TYPES.TSNonNullExpression ||
|
|
141
|
+
current.type === utils_1.AST_NODE_TYPES.TSTypeAssertion ||
|
|
142
|
+
current.type === utils_1.AST_NODE_TYPES.TSInstantiationExpression ||
|
|
143
|
+
current.type === utils_1.AST_NODE_TYPES.ChainExpression) {
|
|
144
|
+
current = current.expression;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
return current;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const findHocNameMatch = (node, additionalHocs) => {
|
|
135
151
|
const identifier = getCallableIdentifierName(node.callee);
|
|
136
|
-
if (identifier
|
|
137
|
-
|
|
152
|
+
if (identifier) {
|
|
153
|
+
if (additionalHocs.has(identifier)) {
|
|
154
|
+
return { name: identifier, configured: true };
|
|
155
|
+
}
|
|
156
|
+
if (hasHocNameShape(identifier)) {
|
|
157
|
+
return { name: identifier, configured: false };
|
|
158
|
+
}
|
|
138
159
|
}
|
|
139
|
-
|
|
140
|
-
|
|
160
|
+
const callee = unwrapExpression(node.callee);
|
|
161
|
+
if (callee.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
162
|
+
return findHocNameMatch(callee, additionalHocs);
|
|
141
163
|
}
|
|
142
164
|
return null;
|
|
143
165
|
};
|
|
166
|
+
/**
|
|
167
|
+
* Collects the arguments of every call in a curried chain, so the component
|
|
168
|
+
* passed to `withStyles(styles)(Component)` still counts as evidence for the
|
|
169
|
+
* outer call even though it names the HOC through its callee.
|
|
170
|
+
*/
|
|
171
|
+
const collectCallChainArguments = (node) => {
|
|
172
|
+
const args = [];
|
|
173
|
+
let current = node;
|
|
174
|
+
while (current.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
175
|
+
args.push(...current.arguments);
|
|
176
|
+
current = unwrapExpression(current.callee);
|
|
177
|
+
}
|
|
178
|
+
return args;
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* A `with[A-Z]…` name alone says nothing: string utilities such as
|
|
182
|
+
* `withOpacity(color, 0.3)` share the shape. Reporting requires positive
|
|
183
|
+
* structural evidence that the call operates on a component.
|
|
184
|
+
*/
|
|
185
|
+
const isComponentEvidence = (node, ctx) => {
|
|
186
|
+
const target = unwrapExpression(node);
|
|
187
|
+
if (ctx.visiting.has(target)) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
ctx.visiting.add(target);
|
|
191
|
+
try {
|
|
192
|
+
switch (target.type) {
|
|
193
|
+
case utils_1.AST_NODE_TYPES.Identifier:
|
|
194
|
+
return (isComponentName(target.name) || resolvesToComponentValue(target, ctx));
|
|
195
|
+
case utils_1.AST_NODE_TYPES.MemberExpression:
|
|
196
|
+
return (!target.computed &&
|
|
197
|
+
target.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
198
|
+
isComponentName(target.property.name));
|
|
199
|
+
case utils_1.AST_NODE_TYPES.ArrowFunctionExpression:
|
|
200
|
+
case utils_1.AST_NODE_TYPES.FunctionExpression:
|
|
201
|
+
case utils_1.AST_NODE_TYPES.FunctionDeclaration:
|
|
202
|
+
return containsJsx(getBodyNodeForJsxCheck(target));
|
|
203
|
+
case utils_1.AST_NODE_TYPES.ClassExpression:
|
|
204
|
+
case utils_1.AST_NODE_TYPES.ClassDeclaration:
|
|
205
|
+
return true;
|
|
206
|
+
case utils_1.AST_NODE_TYPES.CallExpression:
|
|
207
|
+
return getHocName(target, ctx) !== null;
|
|
208
|
+
case utils_1.AST_NODE_TYPES.ConditionalExpression:
|
|
209
|
+
return (isComponentEvidence(target.consequent, ctx) ||
|
|
210
|
+
isComponentEvidence(target.alternate, ctx));
|
|
211
|
+
case utils_1.AST_NODE_TYPES.LogicalExpression:
|
|
212
|
+
return (isComponentEvidence(target.left, ctx) ||
|
|
213
|
+
isComponentEvidence(target.right, ctx));
|
|
214
|
+
case utils_1.AST_NODE_TYPES.SpreadElement:
|
|
215
|
+
return isComponentEvidence(target.argument, ctx);
|
|
216
|
+
default:
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
ctx.visiting.delete(target);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* Resolves a lowercase argument through scope analysis rather than assuming it
|
|
226
|
+
* might be a component. Component bindings are conventionally capitalized, so a
|
|
227
|
+
* lowercase name only counts when its declaration proves it holds a component —
|
|
228
|
+
* which keeps `withPortal(build)` reported while leaving `withOpacity(color,
|
|
229
|
+
* 0.3)` alone. A binding the scope cannot resolve (an import, a parameter, a
|
|
230
|
+
* global) proves nothing and is therefore not evidence.
|
|
231
|
+
*/
|
|
232
|
+
const resolvesToComponentValue = (identifier, ctx) => {
|
|
233
|
+
const variable = ctx.resolveVariable(identifier);
|
|
234
|
+
if (!variable) {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
return variable.defs.some((def) => {
|
|
238
|
+
const defNode = def.node;
|
|
239
|
+
if (defNode.type === utils_1.AST_NODE_TYPES.VariableDeclarator) {
|
|
240
|
+
return (defNode.id === def.name &&
|
|
241
|
+
Boolean(defNode.init) &&
|
|
242
|
+
isComponentEvidence(defNode.init, ctx));
|
|
243
|
+
}
|
|
244
|
+
// A parameter definition shares its node with the enclosing function, so the
|
|
245
|
+
// function's own JSX must not be credited to the parameter.
|
|
246
|
+
if (defNode.type === utils_1.AST_NODE_TYPES.FunctionDeclaration) {
|
|
247
|
+
return (defNode.id === def.name && containsJsx(getBodyNodeForJsxCheck(defNode)));
|
|
248
|
+
}
|
|
249
|
+
if (defNode.type === utils_1.AST_NODE_TYPES.ClassDeclaration) {
|
|
250
|
+
return defNode.id === def.name;
|
|
251
|
+
}
|
|
252
|
+
return false;
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
const getHocName = (node, ctx) => {
|
|
256
|
+
const match = findHocNameMatch(node, ctx.additionalHocs);
|
|
257
|
+
if (!match) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
// An explicitly configured name is a deliberate opt-in, so it is trusted
|
|
261
|
+
// without any structural confirmation.
|
|
262
|
+
if (match.configured) {
|
|
263
|
+
return match.name;
|
|
264
|
+
}
|
|
265
|
+
const hasEvidence = collectCallChainArguments(node).some((argument) => isComponentEvidence(argument, ctx));
|
|
266
|
+
return hasEvidence ? match.name : null;
|
|
267
|
+
};
|
|
144
268
|
/**
|
|
145
269
|
* Detects chained HOC calls where an inner call is immediately invoked by
|
|
146
270
|
* another call (for example, withHoc(Component)()). We only treat calls as
|
|
@@ -183,6 +307,38 @@ exports.memoizeRootLevelHocs = (0, createRule_1.createRule)({
|
|
|
183
307
|
defaultOptions,
|
|
184
308
|
create(context, [options]) {
|
|
185
309
|
const additionalHocs = new Set(options?.additionalHocNames ?? []);
|
|
310
|
+
const sourceCode = context.getSourceCode();
|
|
311
|
+
/**
|
|
312
|
+
* Walks outward to the nearest scope owning the identifier, then up the
|
|
313
|
+
* scope chain. Acquiring scopes from the node keeps resolution independent
|
|
314
|
+
* of where the traversal currently sits.
|
|
315
|
+
*/
|
|
316
|
+
const resolveVariable = (identifier) => {
|
|
317
|
+
const { scopeManager } = sourceCode;
|
|
318
|
+
if (!scopeManager) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
let scope = null;
|
|
322
|
+
let current = identifier;
|
|
323
|
+
while (current && !scope) {
|
|
324
|
+
scope = scopeManager.acquire(current, true);
|
|
325
|
+
current = current.parent;
|
|
326
|
+
}
|
|
327
|
+
scope = scope ?? scopeManager.globalScope;
|
|
328
|
+
while (scope) {
|
|
329
|
+
const variable = scope.variables.find((candidate) => candidate.name === identifier.name);
|
|
330
|
+
if (variable) {
|
|
331
|
+
return variable;
|
|
332
|
+
}
|
|
333
|
+
scope = scope.upper;
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
};
|
|
337
|
+
const hocContext = {
|
|
338
|
+
additionalHocs,
|
|
339
|
+
resolveVariable,
|
|
340
|
+
visiting: new Set(),
|
|
341
|
+
};
|
|
186
342
|
const reportUnmemoizedHoc = (node, hocName, contextInfo) => {
|
|
187
343
|
context.report({
|
|
188
344
|
node,
|
|
@@ -194,9 +350,9 @@ exports.memoizeRootLevelHocs = (0, createRule_1.createRule)({
|
|
|
194
350
|
});
|
|
195
351
|
};
|
|
196
352
|
const checkHocCall = (callExpr, contextInfo) => {
|
|
197
|
-
const hocName =
|
|
353
|
+
const hocName = getHocName(callExpr, hocContext);
|
|
198
354
|
const parentCall = getParentCallExpression(callExpr);
|
|
199
|
-
const parentHocName = parentCall &&
|
|
355
|
+
const parentHocName = parentCall && getHocName(parentCall, hocContext);
|
|
200
356
|
if (hocName && !isPartOfHocChain(hocName, parentHocName)) {
|
|
201
357
|
reportUnmemoizedHoc(callExpr, hocName, contextInfo);
|
|
202
358
|
}
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,35 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.105",
|
|
4
|
+
"date": "2026-08-04T22:42:11.381Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "enforce-dynamic-firebase-imports",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1715,
|
|
11
|
+
1716
|
|
12
|
+
],
|
|
13
|
+
"summary": "relocate the dynamic import to its call site (closes #1716); exempt never-bundled files (closes #1715)"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "enforce-empty-object-check",
|
|
17
|
+
"changeType": "fix",
|
|
18
|
+
"issues": [
|
|
19
|
+
1718
|
|
20
|
+
],
|
|
21
|
+
"summary": "exempt constructable types, not just callable ones (closes #1718)"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"name": "memoize-root-level-hocs",
|
|
25
|
+
"changeType": "fix",
|
|
26
|
+
"issues": [
|
|
27
|
+
1717
|
|
28
|
+
],
|
|
29
|
+
"summary": "require component evidence, not just a with[A-Z] name (closes #1717)"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
},
|
|
2
33
|
{
|
|
3
34
|
"version": "1.20.104",
|
|
4
35
|
"date": "2026-08-04T20:46:47.102Z",
|