@blumintinc/eslint-plugin-blumint 1.20.67 → 1.20.69
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-early-destructuring.js +20 -1
- package/lib/rules/enforce-render-hits-memoization.d.ts +2 -1
- package/lib/rules/enforce-render-hits-memoization.js +149 -93
- package/lib/rules/enforce-transform-memoization.js +27 -1
- package/lib/rules/optimize-object-boolean-conditions.js +19 -1
- package/lib/rules/prefer-type-over-interface.js +38 -0
- package/lib/rules/prefer-union-from-const-array.js +17 -6
- package/lib/rules/use-latest-callback.d.ts +6 -1
- package/lib/rules/use-latest-callback.js +235 -46
- package/lib/utils/ruleTester.d.ts +5 -3
- package/lib/utils/ruleTester.js +24 -3
- package/package.json +1 -1
- package/release-manifest.json +70 -0
package/lib/index.js
CHANGED
|
@@ -3,6 +3,25 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.enforceEarlyDestructuring = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
/**
|
|
7
|
+
* Source forms that already bind tighter than `??`, so wrapping them in the
|
|
8
|
+
* hoisted `(obj) ?? {}` initializer adds parentheses a formatter then strips.
|
|
9
|
+
*
|
|
10
|
+
* The set is deliberately limited to what this rule can actually emit: only an
|
|
11
|
+
* identifier-rooted source is ever hoisted, so a call, conditional or logical
|
|
12
|
+
* source never reaches here. Anything absent keeps its parentheses, which is the
|
|
13
|
+
* safe direction — a stray pair is cosmetic, a missing pair changes what the
|
|
14
|
+
* initializer evaluates. `as` and `satisfies` are excluded on purpose: TypeScript
|
|
15
|
+
* rejects them beside `??` unparenthesized.
|
|
16
|
+
*/
|
|
17
|
+
const TIGHTER_THAN_NULLISH = new Set([
|
|
18
|
+
utils_1.AST_NODE_TYPES.Identifier,
|
|
19
|
+
utils_1.AST_NODE_TYPES.ThisExpression,
|
|
20
|
+
utils_1.AST_NODE_TYPES.MemberExpression,
|
|
21
|
+
utils_1.AST_NODE_TYPES.ChainExpression,
|
|
22
|
+
utils_1.AST_NODE_TYPES.TSNonNullExpression,
|
|
23
|
+
]);
|
|
24
|
+
const nullishSourceText = (objectText, init) => init && TIGHTER_THAN_NULLISH.has(init.type) ? objectText : `(${objectText})`;
|
|
6
25
|
const HOOK_NAMES = new Set([
|
|
7
26
|
'useEffect',
|
|
8
27
|
'useMemo',
|
|
@@ -875,7 +894,7 @@ function generateHoistingFixes(groups, callback, depsArray, depTexts, insertionS
|
|
|
875
894
|
for (const group of groups.values()) {
|
|
876
895
|
const sortedProps = Array.from(group.properties.values()).sort((a, b) => a.order - b.order);
|
|
877
896
|
const pattern = `{ ${sortedProps.map((p) => p.text).join(', ')} }`;
|
|
878
|
-
hoistedLines.push(`${indent}const ${pattern} =
|
|
897
|
+
hoistedLines.push(`${indent}const ${pattern} = ${nullishSourceText(group.objectText, group.inits[0])} ?? {};`);
|
|
879
898
|
}
|
|
880
899
|
reservedNamesByScope.set(scope, updatedReservedNames);
|
|
881
900
|
const newDepSet = new Set(newDepTexts);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TSESLint } from '@typescript-eslint/utils';
|
|
1
2
|
type MessageIds = 'requireMemoizedTransformBefore' | 'requireMemoizedRender' | 'requireMemoizedRenderHits' | 'noDirectComponentInRender';
|
|
2
|
-
export declare const enforceRenderHitsMemoization:
|
|
3
|
+
export declare const enforceRenderHitsMemoization: TSESLint.RuleModule<MessageIds, [], TSESLint.RuleListener>;
|
|
3
4
|
export {};
|
|
@@ -3,6 +3,31 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.enforceRenderHitsMemoization = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const LATEST_CALLBACK_MODULE = 'use-latest-callback';
|
|
7
|
+
const LATEST_CALLBACK_HOOK = 'useLatestCallback';
|
|
8
|
+
/**
|
|
9
|
+
* Declaration forms whose binding is created once for the program's lifetime.
|
|
10
|
+
*
|
|
11
|
+
* `let` and `var` are deliberately excluded: a reassignable binding can hand
|
|
12
|
+
* `useRenderHits` a different function on a later render, which is precisely
|
|
13
|
+
* the instability this rule exists to catch.
|
|
14
|
+
*/
|
|
15
|
+
function isStableDeclaration(def) {
|
|
16
|
+
switch (def.type) {
|
|
17
|
+
case utils_1.TSESLint.Scope.DefinitionType.FunctionName:
|
|
18
|
+
return true;
|
|
19
|
+
case utils_1.TSESLint.Scope.DefinitionType.ImportBinding:
|
|
20
|
+
// A type-only import binds no value, so its local name names nothing
|
|
21
|
+
// callable — the same reason the memoization-callee set rejects one.
|
|
22
|
+
return (def.parent.importKind !== 'type' &&
|
|
23
|
+
(def.node.type !== utils_1.AST_NODE_TYPES.ImportSpecifier ||
|
|
24
|
+
def.node.importKind !== 'type'));
|
|
25
|
+
case utils_1.TSESLint.Scope.DefinitionType.Variable:
|
|
26
|
+
return def.parent.kind === 'const';
|
|
27
|
+
default:
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
6
31
|
exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
|
|
7
32
|
name: 'enforce-render-hits-memoization',
|
|
8
33
|
meta: {
|
|
@@ -13,14 +38,36 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
|
|
|
13
38
|
},
|
|
14
39
|
schema: [],
|
|
15
40
|
messages: {
|
|
16
|
-
requireMemoizedTransformBefore: 'transformBefore
|
|
17
|
-
requireMemoizedRender: 'render
|
|
18
|
-
requireMemoizedRenderHits: 'renderHits
|
|
41
|
+
requireMemoizedTransformBefore: 'transformBefore is recreated on every render, so useRenderHits sees a new transform identity each pass and re-derives (and re-renders) the whole hit list even when the hits did not change. Memoize it with useCallback, useMemo, or useLatestCallback so the reference stays stable across renders.',
|
|
42
|
+
requireMemoizedRender: 'render is recreated on every render, so useRenderHits sees a new render identity each pass and re-renders every hit even when the hits did not change. Memoize it with useCallback, useMemo, or useLatestCallback so the reference stays stable across renders.',
|
|
43
|
+
requireMemoizedRenderHits: 'renderHits builds a fresh element for every hit, so calling it outside a memoization boundary re-creates the entire list on each render. Wrap the call in useCallback, useMemo, or useLatestCallback so the elements are rebuilt only when the hits they came from change.',
|
|
19
44
|
noDirectComponentInRender: 'Do not pass React components directly to render prop, use a memoized arrow function instead',
|
|
20
45
|
},
|
|
21
46
|
},
|
|
22
47
|
defaultOptions: [],
|
|
23
48
|
create(context) {
|
|
49
|
+
// Every callee this rule accepts as a memoization boundary. `useCallback`
|
|
50
|
+
// and `useMemo` are seeded bare because the rule never resolves React's
|
|
51
|
+
// import either; local names bound from `use-latest-callback` are added as
|
|
52
|
+
// its imports are visited.
|
|
53
|
+
//
|
|
54
|
+
// `useLatestCallback` belongs in the same set rather than a separate one:
|
|
55
|
+
// nothing here inspects a dependency array, so the hook taking none (it
|
|
56
|
+
// keeps the latest callback behind a ref that is stable for the component's
|
|
57
|
+
// whole life) costs the rule no precision. It has to be here because
|
|
58
|
+
// `use-latest-callback` — 'error' in the same recommended config, and
|
|
59
|
+
// fixable — rewrites every `useCallback` into it, and ESLint re-lints until
|
|
60
|
+
// the output settles, so one `eslint --fix` run does both steps. Without
|
|
61
|
+
// this entry correctly memoized code goes in and a demand to memoize it
|
|
62
|
+
// comes out, and the demand names the very hook the sibling fixer just
|
|
63
|
+
// removed, so following it loops forever (issue #1585).
|
|
64
|
+
const memoizationCallees = new Set([
|
|
65
|
+
'useCallback',
|
|
66
|
+
'useMemo',
|
|
67
|
+
LATEST_CALLBACK_HOOK,
|
|
68
|
+
]);
|
|
69
|
+
const isMemoizationCallee = (node) => node.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
70
|
+
memoizationCallees.has(node.name);
|
|
24
71
|
const isReactComponent = (node) => {
|
|
25
72
|
if (node.type !== utils_1.AST_NODE_TYPES.Identifier)
|
|
26
73
|
return false;
|
|
@@ -29,17 +76,30 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
|
|
|
29
76
|
const isMemoizedCall = (node) => {
|
|
30
77
|
if (node.type !== utils_1.AST_NODE_TYPES.CallExpression)
|
|
31
78
|
return false;
|
|
32
|
-
if (!node.callee
|
|
79
|
+
if (!node.callee)
|
|
33
80
|
return false;
|
|
34
|
-
return (node.callee
|
|
81
|
+
return isMemoizationCallee(node.callee);
|
|
82
|
+
};
|
|
83
|
+
const isWithinMemoizationCall = (node) => {
|
|
84
|
+
let current = node;
|
|
85
|
+
while (current?.parent) {
|
|
86
|
+
if (current.parent.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
87
|
+
isMemoizationCallee(current.parent.callee)) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
current = current.parent;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
35
93
|
};
|
|
36
94
|
const isMemoizedVariable = (node) => {
|
|
37
95
|
if (node.type !== utils_1.AST_NODE_TYPES.Identifier)
|
|
38
96
|
return false;
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
97
|
+
// The whole scope chain has to be searched rather than the current
|
|
98
|
+
// scope's own variable list: a useRenderHits call sitting inside a nested
|
|
99
|
+
// block or a nested component reaches its memoized declaration through an
|
|
100
|
+
// enclosing scope, and reading one scope's `variables` would miss it and
|
|
101
|
+
// demand a useCallback around a value that already has one.
|
|
102
|
+
const variable = utils_1.ASTUtils.findVariable(context.getScope(), node);
|
|
43
103
|
if (!variable)
|
|
44
104
|
return false;
|
|
45
105
|
// Check if the variable is initialized with a memoized call
|
|
@@ -55,6 +115,45 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
|
|
|
55
115
|
}
|
|
56
116
|
return false;
|
|
57
117
|
};
|
|
118
|
+
/**
|
|
119
|
+
* A prop pointing at a declaration that lives outside every component body.
|
|
120
|
+
*
|
|
121
|
+
* Module scope creates the binding once for the program's lifetime, so its
|
|
122
|
+
* identity is strictly more stable than anything a hook can hand back:
|
|
123
|
+
* demanding a `useCallback` wrapper around it asks for work that can only
|
|
124
|
+
* make the reference less stable, never more.
|
|
125
|
+
*
|
|
126
|
+
* Both `module` and `global` count. Under `sourceType: 'script'` — the
|
|
127
|
+
* parser default, and what a consumer's config may well leave in place — a
|
|
128
|
+
* top-level declaration binds to the *global* scope and no module scope
|
|
129
|
+
* exists at all (issue #1578), so keying on `module` alone would silently
|
|
130
|
+
* drop the carve-out for exactly the consumers who never opted into module
|
|
131
|
+
* parsing.
|
|
132
|
+
*
|
|
133
|
+
* The shape is not hypothetical:
|
|
134
|
+
* `no-empty-dependency-use-callbacks` — 'error' in the same recommended
|
|
135
|
+
* config, and fixable — hoists a dependency-free callback to module scope
|
|
136
|
+
* and drops the hook, so one `eslint --fix` run rewrites memoized code into
|
|
137
|
+
* exactly this form. Without the carve-out the config demands the very hook
|
|
138
|
+
* its own fixer just removed (issue #1586).
|
|
139
|
+
*/
|
|
140
|
+
const isStableOuterScopeBinding = (node) => {
|
|
141
|
+
if (node.type !== utils_1.AST_NODE_TYPES.Identifier)
|
|
142
|
+
return false;
|
|
143
|
+
// The scope chain has to be walked rather than a single scope's variable
|
|
144
|
+
// list read: the useRenderHits call sits inside the component, so a
|
|
145
|
+
// module-scope declaration is never among the current scope's own
|
|
146
|
+
// variables.
|
|
147
|
+
const variable = utils_1.ASTUtils.findVariable(context.getScope(), node);
|
|
148
|
+
if (!variable)
|
|
149
|
+
return false;
|
|
150
|
+
const scopeType = variable.scope.type;
|
|
151
|
+
if (scopeType !== utils_1.TSESLint.Scope.ScopeType.module &&
|
|
152
|
+
scopeType !== utils_1.TSESLint.Scope.ScopeType.global) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
return variable.defs.some(isStableDeclaration);
|
|
156
|
+
};
|
|
58
157
|
const isInsideMemoizedCall = (node) => {
|
|
59
158
|
// Handle the case when node is already a memoized call
|
|
60
159
|
if (isMemoizedCall(node))
|
|
@@ -62,18 +161,13 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
|
|
|
62
161
|
// Check if the node is a reference to a memoized variable
|
|
63
162
|
if (isMemoizedVariable(node))
|
|
64
163
|
return true;
|
|
164
|
+
// A declaration outside every component body needs no memoization: it is
|
|
165
|
+
// already as stable as a reference can be.
|
|
166
|
+
if (isStableOuterScopeBinding(node))
|
|
167
|
+
return true;
|
|
65
168
|
// Check if the node is inside a memoization hook call
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (current.parent.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
69
|
-
const callee = current.parent.callee;
|
|
70
|
-
if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
71
|
-
(callee.name === 'useCallback' || callee.name === 'useMemo')) {
|
|
72
|
-
return true;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
current = current.parent;
|
|
76
|
-
}
|
|
169
|
+
if (isWithinMemoizationCall(node))
|
|
170
|
+
return true;
|
|
77
171
|
// Check if the node is a reference to a memoized value
|
|
78
172
|
const scope = context.getScope();
|
|
79
173
|
// Make sure node is an Identifier before accessing name property
|
|
@@ -88,44 +182,22 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
|
|
|
88
182
|
for (const def of variable.defs) {
|
|
89
183
|
const parent = def.node.parent;
|
|
90
184
|
if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
|
|
91
|
-
parent.init?.type === utils_1.AST_NODE_TYPES.CallExpression
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
(callee.name === 'useCallback' || callee.name === 'useMemo')) {
|
|
95
|
-
return true;
|
|
96
|
-
}
|
|
185
|
+
parent.init?.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
186
|
+
isMemoizationCallee(parent.init.callee)) {
|
|
187
|
+
return true;
|
|
97
188
|
}
|
|
98
189
|
}
|
|
99
190
|
// Check if any reference is inside a memoized call
|
|
100
191
|
for (const ref of variable.references) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (current.parent.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
104
|
-
const callee = current.parent.callee;
|
|
105
|
-
if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
106
|
-
(callee.name === 'useCallback' || callee.name === 'useMemo')) {
|
|
107
|
-
return true;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
current = current.parent;
|
|
111
|
-
}
|
|
192
|
+
if (isWithinMemoizationCall(ref.identifier))
|
|
193
|
+
return true;
|
|
112
194
|
}
|
|
113
195
|
// Check if the node is a property of an object that is memoized
|
|
114
196
|
const parent = node.parent;
|
|
115
197
|
if (parent?.type === utils_1.AST_NODE_TYPES.Property &&
|
|
116
198
|
parent.parent?.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
while (current?.parent) {
|
|
120
|
-
if (current.parent.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
121
|
-
const callee = current.parent.callee;
|
|
122
|
-
if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
123
|
-
(callee.name === 'useCallback' || callee.name === 'useMemo')) {
|
|
124
|
-
return true;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
current = current.parent;
|
|
128
|
-
}
|
|
199
|
+
if (isWithinMemoizationCall(parent.parent))
|
|
200
|
+
return true;
|
|
129
201
|
}
|
|
130
202
|
return false;
|
|
131
203
|
};
|
|
@@ -133,6 +205,23 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
|
|
|
133
205
|
let renderHitsName = 'renderHits';
|
|
134
206
|
return {
|
|
135
207
|
ImportDeclaration(node) {
|
|
208
|
+
// The module's sole export is the hook, so its DEFAULT specifier binds
|
|
209
|
+
// it under whatever local name the file chose — a shape a set of bare
|
|
210
|
+
// hook names cannot see. `use-latest-callback`'s own fixer picks that
|
|
211
|
+
// name, falling back to `useLatestCallback2` when `useLatestCallback`
|
|
212
|
+
// is already taken in the file, so the alias is not hypothetical.
|
|
213
|
+
if (node.source.value === LATEST_CALLBACK_MODULE &&
|
|
214
|
+
(!node.importKind || node.importKind === 'value')) {
|
|
215
|
+
for (const specifier of node.specifiers) {
|
|
216
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier ||
|
|
217
|
+
(specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
218
|
+
specifier.importKind !== 'type' &&
|
|
219
|
+
specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
220
|
+
specifier.imported.name === LATEST_CALLBACK_HOOK)) {
|
|
221
|
+
memoizationCallees.add(specifier.local.name);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
136
225
|
if (node.source.value.endsWith('useRenderHits')) {
|
|
137
226
|
for (const specifier of node.specifiers) {
|
|
138
227
|
if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
@@ -160,43 +249,19 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
|
|
|
160
249
|
const options = node.arguments[0];
|
|
161
250
|
if (options.type !== utils_1.AST_NODE_TYPES.ObjectExpression)
|
|
162
251
|
return;
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
for (
|
|
170
|
-
if (prop.type !== utils_1.AST_NODE_TYPES.Property)
|
|
171
|
-
continue;
|
|
172
|
-
if (prop.key.type !== utils_1.AST_NODE_TYPES.Identifier)
|
|
173
|
-
continue;
|
|
174
|
-
// If it's shorthand property syntax like { transformBefore } and already a memoized variable
|
|
175
|
-
if (prop.key.name === 'transformBefore' &&
|
|
176
|
-
prop.shorthand &&
|
|
177
|
-
prop.key.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
178
|
-
checkProps.transformBefore = !isMemoizedVariable(prop.key);
|
|
179
|
-
}
|
|
180
|
-
else if (prop.key.name === 'render' &&
|
|
181
|
-
prop.shorthand &&
|
|
182
|
-
prop.key.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
183
|
-
checkProps.render = !isMemoizedVariable(prop.key);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
// Second pass: Check non-shorthand properties
|
|
252
|
+
// Shorthand props are checked exactly like written-out ones. `{ render }`
|
|
253
|
+
// and `render: render` describe the same value, and the config's own
|
|
254
|
+
// fixable `object-shorthand: ['error', 'always']` rewrites the second
|
|
255
|
+
// into the first, so exempting the shorthand form would let a single
|
|
256
|
+
// `eslint --fix` erase every report this rule makes about a prop whose
|
|
257
|
+
// variable happens to share the API's name — the shape idiomatic code
|
|
258
|
+
// reaches for first (issue #1588).
|
|
187
259
|
for (const prop of options.properties) {
|
|
188
260
|
if (prop.type !== utils_1.AST_NODE_TYPES.Property)
|
|
189
261
|
continue;
|
|
190
262
|
if (prop.key.type !== utils_1.AST_NODE_TYPES.Identifier)
|
|
191
263
|
continue;
|
|
192
|
-
|
|
193
|
-
if (prop.shorthand)
|
|
194
|
-
continue;
|
|
195
|
-
if (prop.key.name === 'transformBefore' &&
|
|
196
|
-
checkProps.transformBefore) {
|
|
197
|
-
// Skip if the value is already a memoized call
|
|
198
|
-
if (isMemoizedCall(prop.value))
|
|
199
|
-
continue;
|
|
264
|
+
if (prop.key.name === 'transformBefore') {
|
|
200
265
|
if (!isInsideMemoizedCall(prop.value)) {
|
|
201
266
|
context.report({
|
|
202
267
|
node: prop.value,
|
|
@@ -204,7 +269,7 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
|
|
|
204
269
|
});
|
|
205
270
|
}
|
|
206
271
|
}
|
|
207
|
-
else if (prop.key.name === 'render'
|
|
272
|
+
else if (prop.key.name === 'render') {
|
|
208
273
|
if (isReactComponent(prop.value)) {
|
|
209
274
|
context.report({
|
|
210
275
|
node: prop.value,
|
|
@@ -222,17 +287,8 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
|
|
|
222
287
|
}
|
|
223
288
|
if (node.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
224
289
|
node.callee.name === renderHitsName) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (current.parent.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
228
|
-
const callee = current.parent.callee;
|
|
229
|
-
if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
230
|
-
(callee.name === 'useCallback' || callee.name === 'useMemo')) {
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
current = current.parent;
|
|
235
|
-
}
|
|
290
|
+
if (isWithinMemoizationCall(node))
|
|
291
|
+
return;
|
|
236
292
|
context.report({
|
|
237
293
|
node,
|
|
238
294
|
messageId: 'requireMemoizedRenderHits',
|
|
@@ -4,6 +4,8 @@ exports.enforceTransformMemoization = void 0;
|
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
6
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
|
+
const LATEST_CALLBACK_MODULE = 'use-latest-callback';
|
|
8
|
+
const LATEST_CALLBACK_HOOK = 'useLatestCallback';
|
|
7
9
|
exports.enforceTransformMemoization = (0, createRule_1.createRule)({
|
|
8
10
|
name: 'enforce-transform-memoization',
|
|
9
11
|
meta: {
|
|
@@ -26,7 +28,14 @@ exports.enforceTransformMemoization = (0, createRule_1.createRule)({
|
|
|
26
28
|
const scopeManager = sourceCode.scopeManager;
|
|
27
29
|
const adaptValueNames = new Set(['adaptValue']);
|
|
28
30
|
const memoizingHooks = new Set(['useMemo', 'useCallback']);
|
|
29
|
-
|
|
31
|
+
// Hooks that hand back a reference stable for the component's whole life and
|
|
32
|
+
// take no dependency array, so there is none to audit. `useLatestCallback`
|
|
33
|
+
// belongs here because `use-latest-callback` — 'error' in the same
|
|
34
|
+
// recommended config, and fixable — rewrites every `useCallback` into it and
|
|
35
|
+
// drops the array. ESLint re-lints until the output settles, so one
|
|
36
|
+
// `eslint --fix` run does both steps: without this entry, correctly
|
|
37
|
+
// memoized code goes in and a demand to memoize it comes out (issue #1584).
|
|
38
|
+
const stabilizingUtilities = new Set(['useEvent', LATEST_CALLBACK_HOOK]);
|
|
30
39
|
const getPropertyName = (key) => {
|
|
31
40
|
if (key.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
32
41
|
return key.name;
|
|
@@ -378,6 +387,23 @@ exports.enforceTransformMemoization = (0, createRule_1.createRule)({
|
|
|
378
387
|
return {
|
|
379
388
|
ImportDeclaration(node) {
|
|
380
389
|
const sourceValue = typeof node.source.value === 'string' ? node.source.value : '';
|
|
390
|
+
// The module's sole export is the hook, so its DEFAULT specifier binds
|
|
391
|
+
// it under whatever local name the file chose — a shape a set of bare
|
|
392
|
+
// hook names cannot see. `use-latest-callback`'s own fixer picks that
|
|
393
|
+
// name, and falls back to `useLatestCallback2` when `useLatestCallback`
|
|
394
|
+
// is already taken in the file, so the alias is not hypothetical.
|
|
395
|
+
if (sourceValue === LATEST_CALLBACK_MODULE &&
|
|
396
|
+
(!node.importKind || node.importKind === 'value')) {
|
|
397
|
+
for (const specifier of node.specifiers) {
|
|
398
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier ||
|
|
399
|
+
(specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
400
|
+
specifier.importKind !== 'type' &&
|
|
401
|
+
specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
402
|
+
specifier.imported.name === LATEST_CALLBACK_HOOK)) {
|
|
403
|
+
stabilizingUtilities.add(specifier.local.name);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
381
407
|
for (const specifier of node.specifiers) {
|
|
382
408
|
if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
383
409
|
specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
@@ -101,6 +101,12 @@ function isPrimitiveTypeNode(node) {
|
|
|
101
101
|
* operands: comparisons and arithmetic yield numbers/strings/booleans, and `!`,
|
|
102
102
|
* `typeof` and friends yield booleans/strings/numbers.
|
|
103
103
|
*/
|
|
104
|
+
/** `as const` — a type reference whose name is the `const` contextual keyword. */
|
|
105
|
+
function isConstAssertion(typeNode) {
|
|
106
|
+
return (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
107
|
+
typeNode.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
108
|
+
typeNode.typeName.name === 'const');
|
|
109
|
+
}
|
|
104
110
|
function isPrimitiveExpression(node) {
|
|
105
111
|
if (!node) {
|
|
106
112
|
return false;
|
|
@@ -120,7 +126,19 @@ function isPrimitiveExpression(node) {
|
|
|
120
126
|
case utils_1.AST_NODE_TYPES.BinaryExpression:
|
|
121
127
|
return true;
|
|
122
128
|
case utils_1.AST_NODE_TYPES.TSAsExpression:
|
|
123
|
-
|
|
129
|
+
// `as const` narrows rather than retypes, so the asserted expression
|
|
130
|
+
// decides. Recursing keeps `{ a: 1 } as const` an object while accepting
|
|
131
|
+
// `0 as const` — the form global-const-style and
|
|
132
|
+
// enforce-object-literal-as-const rewrite bare constants into, so without
|
|
133
|
+
// this the plugin's own fixers manufacture the report (#1581).
|
|
134
|
+
return isConstAssertion(node.typeAnnotation)
|
|
135
|
+
? isPrimitiveExpression(node.expression)
|
|
136
|
+
: isPrimitiveTypeNode(node.typeAnnotation);
|
|
137
|
+
// `satisfies` never changes the value, only checks it.
|
|
138
|
+
case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
|
|
139
|
+
return isPrimitiveExpression(node.expression);
|
|
140
|
+
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
141
|
+
return isPrimitiveExpression(node.expression);
|
|
124
142
|
default:
|
|
125
143
|
return false;
|
|
126
144
|
}
|
|
@@ -47,6 +47,38 @@ function isInsideModuleAugmentation(node) {
|
|
|
47
47
|
}
|
|
48
48
|
return false;
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Declaration merging is what `interface` can do and `type` cannot, so a name
|
|
52
|
+
* carrying more than one declaration is not a stylistic choice: rewriting one
|
|
53
|
+
* of the halves emits two declarations of the same name (TS2300) and silently
|
|
54
|
+
* splits the merged shape, so members that used to coexist no longer do.
|
|
55
|
+
*
|
|
56
|
+
* The check keys on the **declaring scope**, not on a count of the name across
|
|
57
|
+
* the file, because merging is a property of the declaration space. Two
|
|
58
|
+
* same-named interfaces in different function bodies or blocks are distinct
|
|
59
|
+
* types that never merge, and each of those still converts cleanly.
|
|
60
|
+
*
|
|
61
|
+
* Any second definition counts, not only another interface. `class A {}` and
|
|
62
|
+
* `enum A {}` occupy the same type-space slot a type alias would claim, so
|
|
63
|
+
* those conversions break the build too; `function A() {}` and
|
|
64
|
+
* `namespace A {}` are value-space only and would survive the rewrite, but
|
|
65
|
+
* they are rare enough that treating every co-declaration as merged costs
|
|
66
|
+
* almost nothing and keeps the rule from having to model TypeScript's merging
|
|
67
|
+
* table.
|
|
68
|
+
*/
|
|
69
|
+
function isMergedDeclaration(context, node) {
|
|
70
|
+
// The variable is located by the definition that points back at *this*
|
|
71
|
+
// declaration rather than by taking the first entry, so a parser that also
|
|
72
|
+
// surfaces the type parameters here cannot make the count read off the
|
|
73
|
+
// wrong name.
|
|
74
|
+
const declared = context
|
|
75
|
+
.getDeclaredVariables(node)
|
|
76
|
+
.find((variable) => variable.defs.some((definition) => definition.node === node));
|
|
77
|
+
// A scope manager that never registered the name tells us nothing about
|
|
78
|
+
// merging; falling through preserves the report rather than exempting on a
|
|
79
|
+
// missing signal.
|
|
80
|
+
return declared !== undefined && declared.defs.length > 1;
|
|
81
|
+
}
|
|
50
82
|
exports.preferTypeOverInterface = (0, createRule_1.createRule)({
|
|
51
83
|
name: 'prefer-type-over-interface',
|
|
52
84
|
meta: {
|
|
@@ -70,6 +102,12 @@ exports.preferTypeOverInterface = (0, createRule_1.createRule)({
|
|
|
70
102
|
if (isInsideModuleAugmentation(node)) {
|
|
71
103
|
return;
|
|
72
104
|
}
|
|
105
|
+
// Reporting without a fix would be unactionable here — the author
|
|
106
|
+
// cannot honour it without collapsing the merge by hand — and an
|
|
107
|
+
// unactionable report is what manufactures `eslint-disable` (#1568).
|
|
108
|
+
if (isMergedDeclaration(context, node)) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
73
111
|
context.report({
|
|
74
112
|
node,
|
|
75
113
|
messageId: 'preferType',
|
|
@@ -54,17 +54,28 @@ function isNameInScope(scope, name) {
|
|
|
54
54
|
* between consecutive lines. Reading it from the source keeps emitted code in
|
|
55
55
|
* the author's units instead of assuming a two-space, space-indented file.
|
|
56
56
|
*/
|
|
57
|
-
function indentUnitOf(
|
|
57
|
+
function indentUnitOf(sourceCode) {
|
|
58
|
+
const text = sourceCode.getText();
|
|
59
|
+
const blockComments = sourceCode
|
|
60
|
+
.getAllComments()
|
|
61
|
+
.filter((comment) => comment.type === utils_1.AST_TOKEN_TYPES.Block)
|
|
62
|
+
.map((comment) => comment.range);
|
|
63
|
+
// A block comment's interior lines carry whatever alignment the comment uses
|
|
64
|
+
// — the `*` one column in from its own indentation, or, for commented-out
|
|
65
|
+
// code, the original code's depths. Neither is a nesting step of the file, and
|
|
66
|
+
// counting them makes a JSDoc-heavy file look 1-space indented. Keying on the
|
|
67
|
+
// comment's range rather than a leading `*` also covers a body without them.
|
|
68
|
+
const continuesBlockComment = (offset) => blockComments.some(([start, end]) => start < offset && offset < end);
|
|
58
69
|
const frequencies = new Map();
|
|
59
70
|
let previous = '';
|
|
71
|
+
let offset = 0;
|
|
60
72
|
for (const line of text.split('\n')) {
|
|
73
|
+
const lineStart = offset;
|
|
74
|
+
offset += line.length + 1;
|
|
61
75
|
if (line.trim() === '') {
|
|
62
76
|
continue;
|
|
63
77
|
}
|
|
64
|
-
|
|
65
|
-
// the comment's own indentation. That is comment alignment, not a nesting
|
|
66
|
-
// step, and counting it makes any JSDoc-heavy file look 1-space indented.
|
|
67
|
-
if (line.trimStart().startsWith('*')) {
|
|
78
|
+
if (continuesBlockComment(lineStart)) {
|
|
68
79
|
continue;
|
|
69
80
|
}
|
|
70
81
|
const match = /^[ \t]*/.exec(line);
|
|
@@ -121,7 +132,7 @@ exports.preferUnionFromConstArray = (0, createRule_1.createRule)({
|
|
|
121
132
|
let indentUnit = null;
|
|
122
133
|
const fileIndentUnit = () => {
|
|
123
134
|
if (indentUnit === null) {
|
|
124
|
-
indentUnit = indentUnitOf(sourceCode
|
|
135
|
+
indentUnit = indentUnitOf(sourceCode);
|
|
125
136
|
}
|
|
126
137
|
return indentUnit;
|
|
127
138
|
};
|
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
import type { TSESLint } from '@typescript-eslint/utils';
|
|
2
|
-
|
|
2
|
+
type Options = [
|
|
3
|
+
{
|
|
4
|
+
printWidth?: number;
|
|
5
|
+
}
|
|
6
|
+
];
|
|
7
|
+
export declare const useLatestCallback: TSESLint.RuleModule<"useLatestCallback", Options, TSESLint.RuleListener>;
|
|
3
8
|
export default useLatestCallback;
|
|
@@ -4,6 +4,13 @@ exports.useLatestCallback = void 0;
|
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
6
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
|
+
/**
|
|
8
|
+
* Matches Prettier's own default. Dropping the dependency array lets the call
|
|
9
|
+
* collapse onto one line, so the fixer decides a line break a formatter owns: a
|
|
10
|
+
* line it emits past this width is rewritten on the next `prettier --write`, and
|
|
11
|
+
* fails `prettier --check` in the meantime.
|
|
12
|
+
*/
|
|
13
|
+
const DEFAULT_PRINT_WIDTH = 80;
|
|
7
14
|
const LATEST_CALLBACK_MODULE = 'use-latest-callback';
|
|
8
15
|
const LATEST_CALLBACK_HOOK = 'useLatestCallback';
|
|
9
16
|
/**
|
|
@@ -44,6 +51,7 @@ const findLatestCallbackImport = (program) => {
|
|
|
44
51
|
}
|
|
45
52
|
return null;
|
|
46
53
|
};
|
|
54
|
+
const isComma = (token) => !!token && token.type === utils_1.AST_TOKEN_TYPES.Punctuator && token.value === ',';
|
|
47
55
|
/** The leading whitespace of the line the offset sits on. */
|
|
48
56
|
const indentationAt = (sourceCode, offset) => {
|
|
49
57
|
const text = sourceCode.getText();
|
|
@@ -51,6 +59,75 @@ const indentationAt = (sourceCode, offset) => {
|
|
|
51
59
|
const match = /^[ \t]*/.exec(text.slice(lineStart, offset));
|
|
52
60
|
return match ? match[0] : '';
|
|
53
61
|
};
|
|
62
|
+
/** The indentation of an offset that starts its own line, else null. */
|
|
63
|
+
const ownLineIndentAt = (sourceCode, offset) => {
|
|
64
|
+
const text = sourceCode.getText();
|
|
65
|
+
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
66
|
+
const lead = text.slice(lineStart, offset);
|
|
67
|
+
return lead.trim() === '' ? lead : null;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* The file's own nesting step, taken as the most common indentation increase
|
|
71
|
+
* between consecutive lines. Reading it from the source keeps emitted code in
|
|
72
|
+
* the author's units instead of assuming a two-space, space-indented file.
|
|
73
|
+
*/
|
|
74
|
+
const indentUnitOf = (sourceCode) => {
|
|
75
|
+
const text = sourceCode.getText();
|
|
76
|
+
const blockComments = sourceCode
|
|
77
|
+
.getAllComments()
|
|
78
|
+
.filter((comment) => comment.type === utils_1.AST_TOKEN_TYPES.Block)
|
|
79
|
+
.map((comment) => comment.range);
|
|
80
|
+
// A block comment's interior lines carry whatever alignment the comment uses
|
|
81
|
+
// — the `*` one column in from its own indentation, or, for commented-out
|
|
82
|
+
// code, the original code's depths. Neither is a nesting step of the file, and
|
|
83
|
+
// counting them makes a JSDoc-heavy file look 1-space indented. Keying on the
|
|
84
|
+
// comment's range rather than a leading `*` also covers a body without them.
|
|
85
|
+
const continuesBlockComment = (offset) => blockComments.some(([start, end]) => start < offset && offset < end);
|
|
86
|
+
const frequencies = new Map();
|
|
87
|
+
let previous = '';
|
|
88
|
+
let offset = 0;
|
|
89
|
+
for (const line of text.split('\n')) {
|
|
90
|
+
const lineStart = offset;
|
|
91
|
+
offset += line.length + 1;
|
|
92
|
+
if (line.trim() === '') {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (continuesBlockComment(lineStart)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const match = /^[ \t]*/.exec(line);
|
|
99
|
+
const indent = match ? match[0] : '';
|
|
100
|
+
if (indent.length > previous.length && indent.startsWith(previous)) {
|
|
101
|
+
const delta = indent.slice(previous.length);
|
|
102
|
+
frequencies.set(delta, (frequencies.get(delta) ?? 0) + 1);
|
|
103
|
+
}
|
|
104
|
+
previous = indent;
|
|
105
|
+
}
|
|
106
|
+
let unit = ' ';
|
|
107
|
+
let best = 0;
|
|
108
|
+
for (const [delta, count] of frequencies) {
|
|
109
|
+
if (count > best) {
|
|
110
|
+
unit = delta;
|
|
111
|
+
best = count;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return unit;
|
|
115
|
+
};
|
|
116
|
+
/** The source with every planned edit applied, used to measure emitted lines. */
|
|
117
|
+
const applyEdits = (text, edits) => {
|
|
118
|
+
const ordered = [...edits].sort((a, b) => b.range[0] - a.range[0]);
|
|
119
|
+
let result = text;
|
|
120
|
+
for (const edit of ordered) {
|
|
121
|
+
result = `${result.slice(0, edit.range[0])}${edit.text}${result.slice(edit.range[1])}`;
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
};
|
|
125
|
+
/** The length of the line `offset` sits on. */
|
|
126
|
+
const lineLengthAt = (text, offset) => {
|
|
127
|
+
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
128
|
+
const lineBreak = text.indexOf('\n', offset);
|
|
129
|
+
return (lineBreak === -1 ? text.length : lineBreak) - lineStart;
|
|
130
|
+
};
|
|
54
131
|
/**
|
|
55
132
|
* Ranges whose interior line breaks carry string data rather than formatting.
|
|
56
133
|
* A multi-line template literal (or a string spliced together with line
|
|
@@ -65,42 +142,42 @@ const stringDataRangesOf = (sourceCode, node) => sourceCode
|
|
|
65
142
|
token.loc.start.line !== token.loc.end.line)
|
|
66
143
|
.map((token) => token.range);
|
|
67
144
|
/**
|
|
68
|
-
*
|
|
145
|
+
* A per-line transform moving text written at `fromIndent` to `toIndent`, or
|
|
146
|
+
* null when neither indentation is a prefix of the other (tabs against spaces),
|
|
147
|
+
* where no delta can be applied without corrupting the layout.
|
|
148
|
+
*/
|
|
149
|
+
const lineShifterBetween = (fromIndent, toIndent) => {
|
|
150
|
+
if (fromIndent === toIndent) {
|
|
151
|
+
return (line) => line;
|
|
152
|
+
}
|
|
153
|
+
if (fromIndent.startsWith(toIndent)) {
|
|
154
|
+
const removed = fromIndent.slice(toIndent.length);
|
|
155
|
+
return (line) => line.startsWith(removed) ? line.slice(removed.length) : line;
|
|
156
|
+
}
|
|
157
|
+
if (toIndent.startsWith(fromIndent)) {
|
|
158
|
+
const added = toIndent.slice(fromIndent.length);
|
|
159
|
+
return (line) => `${added}${line}`;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* The callback's text with its continuation lines moved from the depth it was
|
|
165
|
+
* written at to `toIndent`, or null when that move is not expressible.
|
|
69
166
|
*
|
|
70
|
-
* Dropping the dependency array
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* and the indentation of the line the call starts on. Preserving the original
|
|
76
|
-
* multi-line layout instead is not an option: a lone function argument is
|
|
77
|
-
* hugged onto the call line by the formatter, so the broken-out form would be
|
|
78
|
-
* reformatted away on the next write.
|
|
167
|
+
* Dropping the dependency array changes the nesting level the callback sits at,
|
|
168
|
+
* so emitting it verbatim would leave every interior line indented for a level
|
|
169
|
+
* it no longer occupies (issue #1559). The first line is excluded because it is
|
|
170
|
+
* spliced in directly after the call's open paren (or after a fresh indent when
|
|
171
|
+
* the call is broken open), so it has no indentation of its own left to adjust.
|
|
79
172
|
*/
|
|
80
|
-
const
|
|
173
|
+
const reindentedText = (sourceCode, callback, fromIndent, toIndent) => {
|
|
81
174
|
const text = sourceCode.getText(callback);
|
|
82
|
-
|
|
83
|
-
const callbackIndent = indentationAt(sourceCode, callback.range[0]);
|
|
84
|
-
// A callback already on the call's line loses no nesting level, so its body
|
|
85
|
-
// must be reproduced byte for byte.
|
|
86
|
-
if (callIndent === callbackIndent) {
|
|
175
|
+
if (!text.includes('\n')) {
|
|
87
176
|
return text;
|
|
88
177
|
}
|
|
89
|
-
const shiftLine = (
|
|
90
|
-
if (callbackIndent.startsWith(callIndent)) {
|
|
91
|
-
const removed = callbackIndent.slice(callIndent.length);
|
|
92
|
-
return (line) => line.startsWith(removed) ? line.slice(removed.length) : line;
|
|
93
|
-
}
|
|
94
|
-
if (callIndent.startsWith(callbackIndent)) {
|
|
95
|
-
const added = callIndent.slice(callbackIndent.length);
|
|
96
|
-
return (line) => `${added}${line}`;
|
|
97
|
-
}
|
|
98
|
-
// Indent characters that disagree give no delta that can be applied
|
|
99
|
-
// without corrupting the layout, so the text is left as the author wrote it.
|
|
100
|
-
return null;
|
|
101
|
-
})();
|
|
178
|
+
const shiftLine = lineShifterBetween(fromIndent, toIndent);
|
|
102
179
|
if (!shiftLine) {
|
|
103
|
-
return
|
|
180
|
+
return null;
|
|
104
181
|
}
|
|
105
182
|
const stringData = stringDataRangesOf(sourceCode, callback);
|
|
106
183
|
const carriesStringData = (offset) => stringData.some(([start, end]) => start < offset && offset < end);
|
|
@@ -110,8 +187,6 @@ const reindentedCallbackText = (sourceCode, call, callback) => {
|
|
|
110
187
|
.map((line, index) => {
|
|
111
188
|
const lineStart = offset;
|
|
112
189
|
offset += line.length + 1;
|
|
113
|
-
// The first line is spliced in after the call's open paren, so it has no
|
|
114
|
-
// indentation of its own left to adjust.
|
|
115
190
|
if (index === 0 || line.trim() === '' || carriesStringData(lineStart)) {
|
|
116
191
|
return line;
|
|
117
192
|
}
|
|
@@ -119,6 +194,75 @@ const reindentedCallbackText = (sourceCode, call, callback) => {
|
|
|
119
194
|
})
|
|
120
195
|
.join('\n');
|
|
121
196
|
};
|
|
197
|
+
/**
|
|
198
|
+
* The callback's text for the collapsed, single-line call form. An indentation
|
|
199
|
+
* delta that cannot be applied leaves the text as the author wrote it, which is
|
|
200
|
+
* still valid code at the depth the formatter will correct it from.
|
|
201
|
+
*/
|
|
202
|
+
const collapsedCallbackText = (sourceCode, call, callback) => reindentedText(sourceCode, callback, indentationAt(sourceCode, callback.range[0]), indentationAt(sourceCode, call.range[0])) ?? sourceCode.getText(callback);
|
|
203
|
+
/**
|
|
204
|
+
* Whether Prettier answers an over-long call by breaking the argument list
|
|
205
|
+
* open. It does for an arrow, and for a parameter-less function expression —
|
|
206
|
+
* but a function expression WITH parameters is instead hugged onto the call
|
|
207
|
+
* line with its parameter list broken, a shape this fixer cannot author. Left
|
|
208
|
+
* collapsed, such a call at least keeps the first line Prettier keeps.
|
|
209
|
+
*/
|
|
210
|
+
const breaksOpenWhenLong = (callback) => callback.type !== utils_1.AST_NODE_TYPES.FunctionExpression ||
|
|
211
|
+
callback.params.length === 0;
|
|
212
|
+
/**
|
|
213
|
+
* Whether the callback's own head — its parameter list, up to where its body
|
|
214
|
+
* begins — is spelled across several lines. An arrow written that way is never
|
|
215
|
+
* hugged onto the call's line, because that would leave the call's open paren
|
|
216
|
+
* and the arrow's dangling at the end of one line; Prettier breaks the argument
|
|
217
|
+
* list open instead however short the collapsed line measures. A function
|
|
218
|
+
* expression is the exception handled above: its broken parameter list IS hugged.
|
|
219
|
+
*/
|
|
220
|
+
const headSpansLines = (sourceCode, callback) => callback.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
221
|
+
sourceCode
|
|
222
|
+
.getText()
|
|
223
|
+
.slice(callback.range[0], callback.body.range[0])
|
|
224
|
+
.includes('\n');
|
|
225
|
+
/**
|
|
226
|
+
* Whether the rewritten call should end its argument list with a comma. The
|
|
227
|
+
* answer is the formatter's `trailingComma` setting, which is read off the call
|
|
228
|
+
* being rewritten whenever it was already broken open — that is exactly the
|
|
229
|
+
* shape the setting governs. A call the author kept on one line says nothing
|
|
230
|
+
* about it, so the modern default (and Prettier's own, from v3) is assumed.
|
|
231
|
+
*/
|
|
232
|
+
const wantsTrailingComma = (sourceCode, call) => {
|
|
233
|
+
const lastArgument = call.arguments[call.arguments.length - 1];
|
|
234
|
+
const closeParen = sourceCode.getLastToken(call);
|
|
235
|
+
if (!lastArgument || !closeParen) {
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
if (isComma(sourceCode.getTokenBefore(closeParen))) {
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
return closeParen.loc.start.line === lastArgument.loc.end.line;
|
|
242
|
+
};
|
|
243
|
+
/**
|
|
244
|
+
* The rewritten call with its argument list broken open — the callee on its own
|
|
245
|
+
* line, the callback one nesting step in, and the closing paren back at the
|
|
246
|
+
* statement's indentation. Null when the callback's lines cannot be moved to
|
|
247
|
+
* that depth, which asks the caller to keep the collapsed form.
|
|
248
|
+
*/
|
|
249
|
+
const brokenOpenCallText = (sourceCode, call, callback, head, indentUnit) => {
|
|
250
|
+
const callIndent = indentationAt(sourceCode, call.range[0]);
|
|
251
|
+
// A callback the author already broke onto its own line keeps the exact
|
|
252
|
+
// indentation it was written at, so that layout survives byte for byte.
|
|
253
|
+
const ownLineIndent = ownLineIndentAt(sourceCode, callback.range[0]);
|
|
254
|
+
const calleeIndent = ownLineIndent !== null &&
|
|
255
|
+
ownLineIndent.length > callIndent.length &&
|
|
256
|
+
ownLineIndent.startsWith(callIndent)
|
|
257
|
+
? ownLineIndent
|
|
258
|
+
: `${callIndent}${indentUnit}`;
|
|
259
|
+
const moved = reindentedText(sourceCode, callback, indentationAt(sourceCode, callback.range[0]), calleeIndent);
|
|
260
|
+
if (moved === null) {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
const comma = wantsTrailingComma(sourceCode, call) ? ',' : '';
|
|
264
|
+
return `${head}(\n${calleeIndent}${moved}${comma}\n${callIndent})`;
|
|
265
|
+
};
|
|
122
266
|
exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
123
267
|
name: 'use-latest-callback',
|
|
124
268
|
meta: {
|
|
@@ -128,13 +272,27 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
128
272
|
recommended: 'error',
|
|
129
273
|
},
|
|
130
274
|
fixable: 'code',
|
|
131
|
-
schema: [
|
|
275
|
+
schema: [
|
|
276
|
+
{
|
|
277
|
+
type: 'object',
|
|
278
|
+
properties: {
|
|
279
|
+
printWidth: {
|
|
280
|
+
type: 'number',
|
|
281
|
+
minimum: 1,
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
additionalProperties: false,
|
|
285
|
+
},
|
|
286
|
+
],
|
|
132
287
|
messages: {
|
|
133
288
|
useLatestCallback: 'Replace {{currentHook}} with {{recommendedHook}} from "use-latest-callback" so the callback keeps a stable reference while still reading the latest props/state. useCallback recreates functions whenever dependencies change, which can trigger needless renders and stale closures. Drop the dependency array when switching to {{recommendedHook}}.',
|
|
134
289
|
},
|
|
135
290
|
},
|
|
136
|
-
defaultOptions: [],
|
|
137
|
-
create(context) {
|
|
291
|
+
defaultOptions: [{}],
|
|
292
|
+
create(context, [options]) {
|
|
293
|
+
const printWidth = typeof options.printWidth === 'number' && options.printWidth > 0
|
|
294
|
+
? options.printWidth
|
|
295
|
+
: DEFAULT_PRINT_WIDTH;
|
|
138
296
|
const filename = context.getFilename();
|
|
139
297
|
if (filename.includes('/node_modules/')) {
|
|
140
298
|
return {};
|
|
@@ -360,9 +518,6 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
360
518
|
// The react import statement participates in the change set only when
|
|
361
519
|
// it binds useCallback or anchors a React.useCallback member call.
|
|
362
520
|
const touchesImport = specifiers.length > 0 || hasReactMemberUseCallback;
|
|
363
|
-
const isComma = (token) => !!token &&
|
|
364
|
-
token.type === utils_1.AST_TOKEN_TYPES.Punctuator &&
|
|
365
|
-
token.value === ',';
|
|
366
521
|
/**
|
|
367
522
|
* Splices out one named specifier and the comma that separates it from
|
|
368
523
|
* the list, touching nothing else. Re-emitting the surviving specifiers
|
|
@@ -480,13 +635,46 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
480
635
|
}
|
|
481
636
|
return fixes;
|
|
482
637
|
};
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
638
|
+
/**
|
|
639
|
+
* The replacement text for every batched call, collapsed onto one line
|
|
640
|
+
* except where that line would overrun the print width.
|
|
641
|
+
*
|
|
642
|
+
* Prettier hugs a lone function argument onto the call line while it
|
|
643
|
+
* fits, so collapsing is the shape that survives the next write — and
|
|
644
|
+
* over-wrapping is not the safe direction, since a broken-open call
|
|
645
|
+
* short enough to fit is collapsed straight back. Past the width the
|
|
646
|
+
* two swap places, so the emitted line is MEASURED against the source
|
|
647
|
+
* with every collapse already applied, rather than predicted from the
|
|
648
|
+
* call's parts (issue #1579).
|
|
649
|
+
*/
|
|
650
|
+
const conversionTexts = () => {
|
|
651
|
+
const headOf = (call) => `${recommendedHook}${call.typeParameters ? sourceCode.getText(call.typeParameters) : ''}`;
|
|
652
|
+
const collapsed = batchedConversions.map((conversion) => ({
|
|
653
|
+
conversion,
|
|
654
|
+
edit: {
|
|
655
|
+
range: conversion.node.range,
|
|
656
|
+
text: `${headOf(conversion.node)}(${collapsedCallbackText(sourceCode, conversion.node, conversion.node.arguments[0])})`,
|
|
657
|
+
},
|
|
658
|
+
}));
|
|
659
|
+
const source = sourceCode.getText();
|
|
660
|
+
const simulated = applyEdits(source, collapsed.map(({ edit }) => edit));
|
|
661
|
+
const shiftBefore = (offset) => collapsed
|
|
662
|
+
.filter(({ edit }) => edit.range[1] <= offset)
|
|
663
|
+
.reduce((total, { edit }) => total + edit.text.length - (edit.range[1] - edit.range[0]), 0);
|
|
664
|
+
const indentUnit = indentUnitOf(sourceCode);
|
|
665
|
+
const texts = new Map();
|
|
666
|
+
for (const { conversion, edit } of collapsed) {
|
|
667
|
+
const { node } = conversion;
|
|
668
|
+
const start = node.range[0] + shiftBefore(node.range[0]);
|
|
669
|
+
const callback = node.arguments[0];
|
|
670
|
+
const overflows = lineLengthAt(simulated, start) > printWidth ||
|
|
671
|
+
headSpansLines(sourceCode, callback);
|
|
672
|
+
const broken = overflows && breaksOpenWhenLong(callback)
|
|
673
|
+
? brokenOpenCallText(sourceCode, node, callback, headOf(node), indentUnit)
|
|
674
|
+
: null;
|
|
675
|
+
texts.set(node, broken ?? edit.text);
|
|
676
|
+
}
|
|
677
|
+
return texts;
|
|
490
678
|
};
|
|
491
679
|
// Every call-site conversion and the import rewrite ride on ONE fix
|
|
492
680
|
// from ONE report. ESLint discards a multi-part fix wholesale when any
|
|
@@ -512,8 +700,9 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
512
700
|
if (!batchedConversions.every(reachesHook)) {
|
|
513
701
|
return null;
|
|
514
702
|
}
|
|
703
|
+
const texts = conversionTexts();
|
|
515
704
|
return [
|
|
516
|
-
...batchedConversions.map((conversion) =>
|
|
705
|
+
...batchedConversions.map((conversion) => fixer.replaceText(conversion.node, texts.get(conversion.node))),
|
|
517
706
|
...importFixes(fixer),
|
|
518
707
|
];
|
|
519
708
|
},
|
|
@@ -17,9 +17,11 @@ type ParserOptionsCarrier = {
|
|
|
17
17
|
* and its rule silently drops out of the fixer, convergence, collision and
|
|
18
18
|
* crash sweeps. `src/tests/no-local-rule-tester.test.ts` enforces that.
|
|
19
19
|
*
|
|
20
|
-
* A file needing parser options the shared instances do not declare —
|
|
21
|
-
*
|
|
22
|
-
* forking a tester.
|
|
20
|
+
* A file needing parser options the shared instances do not declare — a
|
|
21
|
+
* type-aware `project`, a non-default `ecmaVersion` — attaches them per case
|
|
22
|
+
* rather than forking a tester. The shared instances already declare ES module
|
|
23
|
+
* scope analysis, so a case restating `sourceType: 'module'` is redundant but
|
|
24
|
+
* harmless. RuleTester deep merges each case's config over the tester's,
|
|
23
25
|
* and options a case declares itself still win over the ones applied here, so a
|
|
24
26
|
* case keeps any override it already had. Carrying the options on the case also
|
|
25
27
|
* means the harvested corpus records the parser configuration a snippet was
|
package/lib/utils/ruleTester.js
CHANGED
|
@@ -3,12 +3,31 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.withParserOptions = exports.ruleTesterMarkdown = exports.ruleTesterJson = exports.ruleTesterJsx = exports.ruleTesterTs = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const eslint_1 = require("eslint");
|
|
6
|
+
/**
|
|
7
|
+
* `sourceType: 'module'` matches how the linted codebase actually parses: every
|
|
8
|
+
* consumer source file is an ES module, so its top-level declarations live in a
|
|
9
|
+
* module scope nested inside the global one.
|
|
10
|
+
*
|
|
11
|
+
* `@typescript-eslint/parser` defaults to `'script'`, under which a top-level
|
|
12
|
+
* `import`/`const` still binds — the binding hangs off the *global* scope rather
|
|
13
|
+
* than a module scope. References therefore still resolve either way, but the
|
|
14
|
+
* scope shape differs: under `'script'` `globalScope.variables` carries the
|
|
15
|
+
* file's own declarations and there is no module scope at all. A rule that
|
|
16
|
+
* discriminates on `scope.type`, walks `globalScope.childScopes`, or reads
|
|
17
|
+
* `globalScope.variables` would otherwise be exercised against a shape no real
|
|
18
|
+
* file has. `src/tests/rule-tester-parse-mode.test.ts` pins this.
|
|
19
|
+
*/
|
|
20
|
+
const SHARED_PARSER_OPTIONS = {
|
|
21
|
+
sourceType: 'module',
|
|
22
|
+
};
|
|
6
23
|
exports.ruleTesterTs = new utils_1.ESLintUtils.RuleTester({
|
|
7
24
|
parser: '@typescript-eslint/parser',
|
|
25
|
+
parserOptions: { ...SHARED_PARSER_OPTIONS },
|
|
8
26
|
});
|
|
9
27
|
exports.ruleTesterJsx = new utils_1.ESLintUtils.RuleTester({
|
|
10
28
|
parser: '@typescript-eslint/parser',
|
|
11
29
|
parserOptions: {
|
|
30
|
+
...SHARED_PARSER_OPTIONS,
|
|
12
31
|
ecmaFeatures: {
|
|
13
32
|
jsx: true,
|
|
14
33
|
},
|
|
@@ -32,9 +51,11 @@ exports.ruleTesterMarkdown = new eslint_1.RuleTester({
|
|
|
32
51
|
* and its rule silently drops out of the fixer, convergence, collision and
|
|
33
52
|
* crash sweeps. `src/tests/no-local-rule-tester.test.ts` enforces that.
|
|
34
53
|
*
|
|
35
|
-
* A file needing parser options the shared instances do not declare —
|
|
36
|
-
*
|
|
37
|
-
* forking a tester.
|
|
54
|
+
* A file needing parser options the shared instances do not declare — a
|
|
55
|
+
* type-aware `project`, a non-default `ecmaVersion` — attaches them per case
|
|
56
|
+
* rather than forking a tester. The shared instances already declare ES module
|
|
57
|
+
* scope analysis, so a case restating `sourceType: 'module'` is redundant but
|
|
58
|
+
* harmless. RuleTester deep merges each case's config over the tester's,
|
|
38
59
|
* and options a case declares itself still win over the ones applied here, so a
|
|
39
60
|
* case keeps any override it already had. Carrying the options on the case also
|
|
40
61
|
* means the harvested corpus records the parser configuration a snippet was
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,74 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.69",
|
|
4
|
+
"date": "2026-08-01T20:48:40.484Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "enforce-early-destructuring",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1580
|
|
11
|
+
],
|
|
12
|
+
"summary": "stop parenthesizing a source that already binds tighter than ?? (closes #1580)"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "enforce-render-hits-memoization",
|
|
16
|
+
"changeType": "fix",
|
|
17
|
+
"issues": [
|
|
18
|
+
1585,
|
|
19
|
+
1586,
|
|
20
|
+
1588
|
|
21
|
+
],
|
|
22
|
+
"summary": "report shorthand props (closes #1588); accept a module-scope function as already stable (closes #1586); recognise useLatestCallback as a memoization boundary (closes #1585)"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "enforce-transform-memoization",
|
|
26
|
+
"changeType": "fix",
|
|
27
|
+
"issues": [
|
|
28
|
+
1584
|
|
29
|
+
],
|
|
30
|
+
"summary": "recognise useLatestCallback as a stabilizing wrapper (closes #1584)"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "optimize-object-boolean-conditions",
|
|
34
|
+
"changeType": "fix",
|
|
35
|
+
"issues": [
|
|
36
|
+
1581
|
|
37
|
+
],
|
|
38
|
+
"summary": "see through `as const` when judging a primitive (closes #1581)"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "prefer-type-over-interface",
|
|
42
|
+
"changeType": "fix",
|
|
43
|
+
"issues": [
|
|
44
|
+
1583
|
|
45
|
+
],
|
|
46
|
+
"summary": "exempt merged interface declarations (closes #1583)"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "use-latest-callback",
|
|
50
|
+
"changeType": "fix",
|
|
51
|
+
"issues": [
|
|
52
|
+
1579
|
|
53
|
+
],
|
|
54
|
+
"summary": "keep the call broken open when collapsing overflows the print width (closes #1579)"
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"version": "1.20.68",
|
|
60
|
+
"date": "2026-08-01T18:03:14.950Z",
|
|
61
|
+
"rules": [
|
|
62
|
+
{
|
|
63
|
+
"name": "prefer-union-from-const-array",
|
|
64
|
+
"changeType": "fix",
|
|
65
|
+
"issues": [
|
|
66
|
+
1577
|
|
67
|
+
],
|
|
68
|
+
"summary": "exclude block comments by range when inferring the indent unit (closes #1577)"
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
},
|
|
2
72
|
{
|
|
3
73
|
"version": "1.20.67",
|
|
4
74
|
"date": "2026-08-01T17:17:30.817Z",
|