@blumintinc/eslint-plugin-blumint 1.20.55 → 1.20.57
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/no-entire-object-hook-deps.js +188 -1
- package/package.json +1 -1
- package/release-manifest.json +28 -0
package/lib/index.js
CHANGED
|
@@ -5,10 +5,77 @@ const utils_1 = require("@typescript-eslint/utils");
|
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
6
|
const typescript_1 = require("typescript");
|
|
7
7
|
const HOOK_NAMES = new Set(['useEffect', 'useCallback', 'useMemo']);
|
|
8
|
+
/**
|
|
9
|
+
* Hooks that run for their side effects rather than producing a value. An
|
|
10
|
+
* unread dependency means something different here than in useMemo/useCallback
|
|
11
|
+
* — see `callsCorrespondingSetter`.
|
|
12
|
+
*/
|
|
13
|
+
const EFFECT_HOOK_NAMES = new Set(['useEffect']);
|
|
8
14
|
function isHookCall(node) {
|
|
9
15
|
const callee = node.callee;
|
|
10
16
|
return (callee.type === utils_1.AST_NODE_TYPES.Identifier && HOOK_NAMES.has(callee.name));
|
|
11
17
|
}
|
|
18
|
+
function isEffectHookCall(node) {
|
|
19
|
+
const callee = node.callee;
|
|
20
|
+
return (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
21
|
+
EFFECT_HOOK_NAMES.has(callee.name));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The rule whose suppression marks a dependency array as hand-maintained.
|
|
25
|
+
*/
|
|
26
|
+
const EXHAUSTIVE_DEPS_RULE = 'react-hooks/exhaustive-deps';
|
|
27
|
+
/**
|
|
28
|
+
* Matches the keyword of an `eslint-disable`, `eslint-disable-next-line` or
|
|
29
|
+
* `eslint-disable-line` directive, leaving the rule list as the remainder. The
|
|
30
|
+
* lookahead keeps `eslint-disabled-something` (and prose that merely opens with
|
|
31
|
+
* the same letters) from parsing as a directive.
|
|
32
|
+
*/
|
|
33
|
+
const DISABLE_DIRECTIVE = /^\s*eslint-disable(-next-line|-line)?(?![\w-])/u;
|
|
34
|
+
/**
|
|
35
|
+
* ESLint splits a directive's rule list from its ` -- justification` suffix on
|
|
36
|
+
* this separator, so the rule list must be read the same way.
|
|
37
|
+
*/
|
|
38
|
+
const JUSTIFICATION_SEPARATOR = /\s-{2,}\s/u;
|
|
39
|
+
/**
|
|
40
|
+
* The line an exhaustive-deps disable comment covers, `'file'` for the
|
|
41
|
+
* whole-file form, or null when the comment is not such a directive.
|
|
42
|
+
*
|
|
43
|
+
* why: `eslint-disable-next-line` covers the line after the comment ends, while
|
|
44
|
+
* `eslint-disable-line` covers the comment's own line. Only the block form of
|
|
45
|
+
* the bare `eslint-disable` is a file-level directive to ESLint, so a line
|
|
46
|
+
* comment starting with it is not treated as one here either. The file form
|
|
47
|
+
* counts for the whole file rather than from its own position onward: erring
|
|
48
|
+
* toward exempting a hook keeps a hand-managed array intact, which is the safe
|
|
49
|
+
* direction for a deleting fixer.
|
|
50
|
+
*/
|
|
51
|
+
function readExhaustiveDepsDisable(comment) {
|
|
52
|
+
const [directive] = comment.value.split(JUSTIFICATION_SEPARATOR);
|
|
53
|
+
const match = DISABLE_DIRECTIVE.exec(directive);
|
|
54
|
+
if (!match) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
// why: a bare directive with no rule list says nothing about dependency
|
|
58
|
+
// management, so only an explicit mention of exhaustive-deps counts.
|
|
59
|
+
const namesExhaustiveDeps = directive
|
|
60
|
+
.slice(match[0].length)
|
|
61
|
+
.split(',')
|
|
62
|
+
.some((ruleId) => ruleId.trim() === EXHAUSTIVE_DEPS_RULE);
|
|
63
|
+
if (!namesExhaustiveDeps) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const scope = match[1];
|
|
67
|
+
if (scope === '-next-line') {
|
|
68
|
+
return comment.loc.end.line + 1;
|
|
69
|
+
}
|
|
70
|
+
if (scope === '-line') {
|
|
71
|
+
return comment.loc.start.line;
|
|
72
|
+
}
|
|
73
|
+
return comment.type === utils_1.AST_TOKEN_TYPES.Block ? 'file' : null;
|
|
74
|
+
}
|
|
75
|
+
/** `channelGroupActive` -> `setChannelGroupActive`, `a` -> `setA`. */
|
|
76
|
+
function toSetterName(dependencyName) {
|
|
77
|
+
return `set${dependencyName.charAt(0).toUpperCase()}${dependencyName.slice(1)}`;
|
|
78
|
+
}
|
|
12
79
|
function isArrayOrPrimitive(checker, esTreeNode, nodeMap) {
|
|
13
80
|
try {
|
|
14
81
|
const tsNode = nodeMap.get(esTreeNode);
|
|
@@ -80,6 +147,55 @@ function unwrapExpression(expr) {
|
|
|
80
147
|
}
|
|
81
148
|
return current;
|
|
82
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Whether the hook body anywhere calls the state setter that corresponds to
|
|
152
|
+
* `dependencyName` (dep `count` -> `setCount(...)`).
|
|
153
|
+
*
|
|
154
|
+
* why: for an effect, an unread dependency is React's reset-on-scope-change
|
|
155
|
+
* idiom — a deliberate re-run trigger. The one shape where an unread dependency
|
|
156
|
+
* is genuinely wrong is the circular dependency, where the effect writes the
|
|
157
|
+
* very value it depends on and so re-triggers itself. The setter call is that
|
|
158
|
+
* signature. It can sit arbitrarily deep (inside an inner async function, a
|
|
159
|
+
* `startTransition` callback, a `.then()`), so the whole body is searched.
|
|
160
|
+
*/
|
|
161
|
+
function callsCorrespondingSetter(hookBody, dependencyName) {
|
|
162
|
+
const setterName = toSetterName(dependencyName);
|
|
163
|
+
const visited = new Set();
|
|
164
|
+
function visit(node) {
|
|
165
|
+
if (!node || visited.has(node))
|
|
166
|
+
return false;
|
|
167
|
+
visited.add(node);
|
|
168
|
+
if (node.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
169
|
+
const callee = unwrapExpression(node.callee);
|
|
170
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
171
|
+
callee.name === setterName) {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const key in node) {
|
|
176
|
+
if (key === 'parent')
|
|
177
|
+
continue; // Skip parent references to avoid cycles
|
|
178
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
179
|
+
const child = node[key];
|
|
180
|
+
if (!child || typeof child !== 'object')
|
|
181
|
+
continue;
|
|
182
|
+
if (Array.isArray(child)) {
|
|
183
|
+
for (const item of child) {
|
|
184
|
+
if (item && typeof item === 'object' && 'type' in item) {
|
|
185
|
+
if (visit(item))
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else if ('type' in child) {
|
|
191
|
+
if (visit(child))
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
return visit(hookBody);
|
|
198
|
+
}
|
|
83
199
|
function getObjectUsagesInHook(hookBody, objectName) {
|
|
84
200
|
const usages = new Map(); // Track usage and its position
|
|
85
201
|
// why: derived dependency paths (first-optional intermediate, array base)
|
|
@@ -545,6 +661,53 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
|
|
|
545
661
|
// In a real environment, we would want to enforce this
|
|
546
662
|
// throw new Error('You have to enable the `project` setting in parser options to use this rule');
|
|
547
663
|
}
|
|
664
|
+
const sourceCode = context.getSourceCode();
|
|
665
|
+
// why: scanning every comment once per file rather than once per hook call
|
|
666
|
+
// keeps the check off the hot path of files with many hooks.
|
|
667
|
+
let manuallyManagedLines = null;
|
|
668
|
+
let disabledForWholeFile = false;
|
|
669
|
+
function collectDisableDirectives() {
|
|
670
|
+
if (manuallyManagedLines) {
|
|
671
|
+
return manuallyManagedLines;
|
|
672
|
+
}
|
|
673
|
+
const lines = new Set();
|
|
674
|
+
for (const comment of sourceCode.getAllComments()) {
|
|
675
|
+
const scope = readExhaustiveDepsDisable(comment);
|
|
676
|
+
if (scope === 'file') {
|
|
677
|
+
disabledForWholeFile = true;
|
|
678
|
+
}
|
|
679
|
+
else if (scope !== null) {
|
|
680
|
+
lines.add(scope);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
manuallyManagedLines = lines;
|
|
684
|
+
return lines;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Whether the author has taken manual control of this hook's dependency
|
|
688
|
+
* array by suppressing `react-hooks/exhaustive-deps` for it.
|
|
689
|
+
*
|
|
690
|
+
* why: exhaustive-deps is the rule that would otherwise force every read
|
|
691
|
+
* value into the array, so disabling it declares the array hand-maintained.
|
|
692
|
+
* Entries in such an array are load-bearing by construction — an unread one
|
|
693
|
+
* is a deliberate recompute trigger (a hydration flag, a change-detecting
|
|
694
|
+
* hash) whose deletion silently returns a stale value. The comment can sit
|
|
695
|
+
* above the hook call, above the dependency array, or above the closing
|
|
696
|
+
* `}, [...])` line, so any directive landing anywhere within the call
|
|
697
|
+
* counts.
|
|
698
|
+
*/
|
|
699
|
+
function hasManuallyManagedDeps(node) {
|
|
700
|
+
const lines = collectDisableDirectives();
|
|
701
|
+
if (disabledForWholeFile) {
|
|
702
|
+
return true;
|
|
703
|
+
}
|
|
704
|
+
for (let line = node.loc.start.line; line <= node.loc.end.line; line += 1) {
|
|
705
|
+
if (lines.has(line)) {
|
|
706
|
+
return true;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
548
711
|
return {
|
|
549
712
|
CallExpression(node) {
|
|
550
713
|
if (!isHookCall(node)) {
|
|
@@ -562,6 +725,9 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
|
|
|
562
725
|
callbackArg.type !== utils_1.AST_NODE_TYPES.FunctionExpression)) {
|
|
563
726
|
return;
|
|
564
727
|
}
|
|
728
|
+
const callbackBody = callbackArg.body;
|
|
729
|
+
const isEffect = isEffectHookCall(node);
|
|
730
|
+
const manuallyManagedDeps = hasManuallyManagedDeps(node);
|
|
565
731
|
// Check each dependency in the array
|
|
566
732
|
depsArg.elements.forEach((element) => {
|
|
567
733
|
const unwrappedElement = element ? unwrapExpression(element) : null;
|
|
@@ -579,9 +745,30 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
|
|
|
579
745
|
}
|
|
580
746
|
}
|
|
581
747
|
// For testing without TypeScript services, we'll assume all identifiers are objects
|
|
582
|
-
const result = getObjectUsagesInHook(
|
|
748
|
+
const result = getObjectUsagesInHook(callbackBody, objectName);
|
|
583
749
|
// If the object is not used at all, suggest removing it
|
|
584
750
|
if (result.notUsed) {
|
|
751
|
+
// why: deleting an entry from an array the author maintains by
|
|
752
|
+
// hand is presumptuous — the suppression is the declaration that
|
|
753
|
+
// the entries were chosen deliberately, and an unread one is a
|
|
754
|
+
// recompute trigger whose removal yields a stale value. Narrowing
|
|
755
|
+
// an entire object (avoidEntireObject) is a different transform
|
|
756
|
+
// and stays enabled: it preserves the dependency, it does not
|
|
757
|
+
// drop it.
|
|
758
|
+
if (manuallyManagedDeps) {
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
// why: an effect reruns for its side effects, so a dependency the
|
|
762
|
+
// body never reads is normally a deliberate re-run trigger
|
|
763
|
+
// (React's reset-on-scope-change idiom) — deleting it silently
|
|
764
|
+
// stops the effect from rerunning. Only when the body also writes
|
|
765
|
+
// that value (setX for dep x) is the dependency a circular one
|
|
766
|
+
// worth removing. Value-producing hooks (useMemo/useCallback)
|
|
767
|
+
// gain nothing from an unread dependency, so they still report.
|
|
768
|
+
if (isEffect &&
|
|
769
|
+
!callsCorrespondingSetter(callbackBody, objectName)) {
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
585
772
|
context.report({
|
|
586
773
|
node: element,
|
|
587
774
|
messageId: 'removeUnusedDependency',
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.57",
|
|
4
|
+
"date": "2026-08-01T03:42:42.343Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "no-entire-object-hook-deps",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1547
|
|
11
|
+
],
|
|
12
|
+
"summary": "never prune deps from a hand-maintained dependency array (closes #1547)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "1.20.56",
|
|
18
|
+
"date": "2026-08-01T03:08:15.343Z",
|
|
19
|
+
"rules": [
|
|
20
|
+
{
|
|
21
|
+
"name": "no-entire-object-hook-deps",
|
|
22
|
+
"changeType": "fix",
|
|
23
|
+
"issues": [
|
|
24
|
+
1546
|
|
25
|
+
],
|
|
26
|
+
"summary": "treat unread useEffect deps as re-run triggers unless the effect sets them (closes #1546)"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
2
30
|
{
|
|
3
31
|
"version": "1.20.55",
|
|
4
32
|
"date": "2026-08-01T02:47:13.295Z",
|