Skip to content

Commit 3b5e304

Browse files
refactor(oxlint-plugin): extract exhaustive-deps symbol-stability cluster
Completes the third targeted P8 extraction (the previously-deferred 'stretch' from the cleanup plan). The 7-helper cluster that answers 'is this captured symbol structurally stable across renders?' now lives in its own module; the low-level helpers it shares with the main rule body (unwrapExpression, getHookName, isOutsideAllFunctions, TRANSPARENT_WRAPPER_TYPES) sit in a sibling 'low-level' module so the main rule and the symbol-stability module both import without a circular dependency. New module layout for the exhaustive-deps rule: exhaustive-deps.ts — analysis logic (1144L) exhaustive-deps-symbol-stability.ts — stability cluster (168L) exhaustive-deps-low-level.ts — shared helpers (80L) exhaustive-deps-settings.ts — config resolver (47L) exhaustive-deps-messages.ts — diagnostic strings (55L) Main rule file: 1311 -> 1144 lines (-167). Behaviour-neutral; the 7 cluster helpers, the 3 low-level helpers, and the TRANSPARENT_WRAPPER_TYPES set are re-imported into the main rule under the same names. The previous `docs(exhaustive-deps): annotate why unwrapExpression doesn't reuse stripParenExpression` (6e20c3c) comment is preserved verbatim in the new low-level module — same rationale, new home. Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
1 parent 76874c0 commit 3b5e304

3 files changed

Lines changed: 261 additions & 180 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { SymbolDescriptor } from "../../semantic/scope-analysis.js";
2+
import type { EsTreeNode } from "../../utils/es-tree-node.js";
3+
import { isNodeOfType } from "../../utils/is-node-of-type.js";
4+
5+
/**
6+
* Lowest-level helpers consumed by both the main `exhaustive-deps`
7+
* rule body AND the symbol-stability cluster
8+
* (`exhaustive-deps-symbol-stability.ts`). Sit in their own module so
9+
* the two top-level files can each import without a circular
10+
* dependency.
11+
*
12+
* Behaviour mirrors the previous inlined versions in `exhaustive-deps.ts`
13+
* exactly. The doc comment that used to argue against reusing the
14+
* shared `stripParenExpression` util still applies: this module's
15+
* `TRANSPARENT_WRAPPER_TYPES.has(...)` membership check is also read
16+
* directly by the member-chain walker in the main rule, so keeping
17+
* both `TRANSPARENT_WRAPPER_TYPES` and `unwrapExpression` co-located
18+
* here keeps that intent in one place.
19+
*/
20+
21+
/**
22+
* Strip TypeScript expression wrappers transparently — `(x as T)`,
23+
* `x satisfies T`, `x!`, `(x)` — so they don't change the dep key.
24+
*/
25+
export const TRANSPARENT_WRAPPER_TYPES: ReadonlySet<string> = new Set([
26+
"TSAsExpression",
27+
"TSSatisfiesExpression",
28+
"TSNonNullExpression",
29+
"TSTypeAssertion",
30+
"ParenthesizedExpression",
31+
"ChainExpression",
32+
]);
33+
34+
export const unwrapExpression = (node: EsTreeNode): EsTreeNode => {
35+
let current = node;
36+
while (TRANSPARENT_WRAPPER_TYPES.has(current.type)) {
37+
const inner = (current as { expression?: EsTreeNode | null }).expression;
38+
if (!inner) return current;
39+
current = inner;
40+
}
41+
return current;
42+
};
43+
44+
/**
45+
* Get the hook name from a call expression's callee, regardless of
46+
* whether the hook is called as `useFoo()` (Identifier) or
47+
* `React.useFoo()` (MemberExpression).
48+
*/
49+
export const getHookName = (callee: EsTreeNode): string | null => {
50+
if (isNodeOfType(callee, "Identifier")) return callee.name;
51+
if (
52+
isNodeOfType(callee, "MemberExpression") &&
53+
!callee.computed &&
54+
isNodeOfType(callee.property, "Identifier")
55+
) {
56+
return callee.property.name;
57+
}
58+
return null;
59+
};
60+
61+
const FUNCTION_SCOPE_KINDS: ReadonlySet<string> = new Set([
62+
"function",
63+
"arrow-function",
64+
"method",
65+
]);
66+
67+
/**
68+
* True for symbols declared at module scope (outside any function
69+
* scope). Module-scope bindings don't change between renders so they
70+
* don't need to live in dependency arrays.
71+
*/
72+
export const isOutsideAllFunctions = (symbol: SymbolDescriptor): boolean => {
73+
let scope: SymbolDescriptor["scope"] | null = symbol.scope;
74+
while (scope) {
75+
if (FUNCTION_SCOPE_KINDS.has(scope.kind)) return false;
76+
if (scope.kind === "module") return true;
77+
scope = scope.parent ?? null;
78+
}
79+
return true;
80+
};
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { closureCaptures } from "../../semantic/closure-captures.js";
2+
import type { ScopeAnalysis, SymbolDescriptor } from "../../semantic/scope-analysis.js";
3+
import type { EsTreeNode } from "../../utils/es-tree-node.js";
4+
import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js";
5+
import { getStaticTemplateLiteralValue } from "../../utils/get-static-template-literal-value.js";
6+
import { isNodeOfType } from "../../utils/is-node-of-type.js";
7+
import {
8+
getHookName,
9+
isOutsideAllFunctions,
10+
unwrapExpression,
11+
} from "./exhaustive-deps-low-level.js";
12+
13+
/**
14+
* Symbol-stability helpers consumed by the `exhaustive-deps` rule.
15+
*
16+
* One cohesive concept: "given a captured symbol, is its value
17+
* structurally stable across re-renders (and therefore unnecessary
18+
* in a deps array)?". The rule reads `symbolHasStableValue` /
19+
* `symbolHasStableHookOrigin` / `symbolHasUseEffectEventOrigin` /
20+
* `isRecursiveInitializerCapture` at multiple sites — extracting
21+
* them lets the rule body stay focused on the diff-the-captured-vs-
22+
* declared logic.
23+
*
24+
* Low-level helpers (`unwrapExpression`, `getHookName`,
25+
* `isOutsideAllFunctions`) live in
26+
* `./exhaustive-deps-low-level.ts` so both this module and the main
27+
* rule can import them without a circular dependency.
28+
*/
29+
30+
/**
31+
* True for symbols whose returned value (or destructured pieces) are
32+
* stable across re-renders and don't need to live in deps arrays:
33+
* - useState's setter (`setX`)
34+
* - useReducer's dispatch
35+
* - useRef's ref object
36+
* - useEffectEvent's return value
37+
* - primitive-literal local consts (the value never changes
38+
* between renders unless the literal does)
39+
*/
40+
export const symbolHasStableHookOrigin = (symbol: SymbolDescriptor): boolean => {
41+
if (symbol.references.some((reference) => reference.flag !== "read")) return false;
42+
// We need the binding's parent context. The symbol's
43+
// declarationNode is the VariableDeclarator (when destructured) or
44+
// the binding identifier itself.
45+
let declarator: EsTreeNode | null | undefined = symbol.declarationNode;
46+
while (declarator && declarator.type !== "VariableDeclarator") {
47+
declarator = declarator.parent ?? null;
48+
}
49+
if (!declarator || !isNodeOfType(declarator, "VariableDeclarator")) return false;
50+
const initializerRaw = declarator.init;
51+
if (!initializerRaw) return false;
52+
const initializer = unwrapExpression(initializerRaw);
53+
54+
// Primitive literal initializer of a `const` binding — the value
55+
// cannot change between renders, so the captured reference is
56+
// structurally stable for dep-array purposes. `let` / `var` could
57+
// be reassigned and don't qualify.
58+
if (symbol.kind === "const") {
59+
if (
60+
isNodeOfType(initializer, "Literal") &&
61+
(initializer.value === null ||
62+
typeof initializer.value === "number" ||
63+
typeof initializer.value === "string" ||
64+
typeof initializer.value === "boolean")
65+
) {
66+
return true;
67+
}
68+
if (
69+
isNodeOfType(initializer, "TemplateLiteral") &&
70+
getStaticTemplateLiteralValue(initializer) !== null
71+
) {
72+
return true;
73+
}
74+
}
75+
76+
if (!isNodeOfType(initializer, "CallExpression")) return false;
77+
const initializerHookName = getHookName(initializer.callee);
78+
if (!initializerHookName) return false;
79+
// useRef returns a stable ref; the binding itself is the ref.
80+
if (initializerHookName === "useRef") return true;
81+
// useEffectEvent returns a stable callback (React's RFC).
82+
if (initializerHookName === "useEffectEvent") return true;
83+
// useState / useReducer: the SECOND destructure element (setter /
84+
// dispatch) is stable; the first is mutable.
85+
if (
86+
initializerHookName === "useState" ||
87+
initializerHookName === "useReducer" ||
88+
initializerHookName === "useActionState" ||
89+
initializerHookName === "useTransition"
90+
) {
91+
if (!isNodeOfType(declarator.id, "ArrayPattern")) return false;
92+
const STABLE_RETURN_INDEX = 1;
93+
const elements = declarator.id.elements;
94+
const stableElement = elements[STABLE_RETURN_INDEX];
95+
if (!stableElement) return false;
96+
const innerBinding = isNodeOfType(stableElement as EsTreeNode, "AssignmentPattern")
97+
? (stableElement as EsTreeNodeOfType<"AssignmentPattern">).left
98+
: (stableElement as EsTreeNode);
99+
return isNodeOfType(innerBinding, "Identifier") && symbol.bindingIdentifier === innerBinding;
100+
}
101+
return false;
102+
};
103+
104+
export const symbolHasUseEffectEventOrigin = (symbol: SymbolDescriptor): boolean => {
105+
const initializer = symbol.initializer ? unwrapExpression(symbol.initializer) : null;
106+
if (!initializer || !isNodeOfType(initializer, "CallExpression")) return false;
107+
return getHookName(initializer.callee) === "useEffectEvent";
108+
};
109+
110+
export const getFunctionValueNode = (symbol: SymbolDescriptor): EsTreeNode | null => {
111+
if (symbol.kind === "function" && isNodeOfType(symbol.declarationNode, "FunctionDeclaration")) {
112+
return symbol.declarationNode;
113+
}
114+
const initializer = symbol.initializer ? unwrapExpression(symbol.initializer) : null;
115+
if (
116+
initializer &&
117+
(isNodeOfType(initializer, "FunctionExpression") ||
118+
isNodeOfType(initializer, "ArrowFunctionExpression"))
119+
) {
120+
return initializer;
121+
}
122+
return null;
123+
};
124+
125+
const isAstDescendant = (inner: EsTreeNode, outer: EsTreeNode): boolean => {
126+
let current: EsTreeNode | null | undefined = inner;
127+
while (current) {
128+
if (current === outer) return true;
129+
current = current.parent ?? null;
130+
}
131+
return false;
132+
};
133+
134+
export const isRecursiveInitializerCapture = (
135+
symbol: SymbolDescriptor,
136+
callback: EsTreeNode,
137+
): boolean => {
138+
const initializer = symbol.initializer;
139+
return Boolean(initializer && isAstDescendant(callback, initializer));
140+
};
141+
142+
const symbolHasStableFunctionOrigin = (
143+
symbol: SymbolDescriptor,
144+
scopes: ScopeAnalysis,
145+
visitedSymbolIds: Set<number>,
146+
): boolean => {
147+
if (visitedSymbolIds.has(symbol.id)) return true;
148+
const functionNode = getFunctionValueNode(symbol);
149+
if (!functionNode) return false;
150+
visitedSymbolIds.add(symbol.id);
151+
for (const reference of closureCaptures(functionNode, scopes)) {
152+
const capturedSymbol = reference.resolvedSymbol;
153+
if (!capturedSymbol) continue;
154+
if (capturedSymbol.id === symbol.id) continue;
155+
if (isOutsideAllFunctions(capturedSymbol)) continue;
156+
if (symbolHasStableValue(capturedSymbol, scopes, visitedSymbolIds)) continue;
157+
return false;
158+
}
159+
return true;
160+
};
161+
162+
export const symbolHasStableValue = (
163+
symbol: SymbolDescriptor,
164+
scopes: ScopeAnalysis,
165+
visitedSymbolIds: Set<number> = new Set(),
166+
): boolean =>
167+
symbolHasStableHookOrigin(symbol) ||
168+
symbolHasStableFunctionOrigin(symbol, scopes, visitedSymbolIds);

0 commit comments

Comments
 (0)