feat(eslint-plugin): add allowlist option to exhaustive-deps rule#10295
feat(eslint-plugin): add allowlist option to exhaustive-deps rule#10295
Conversation
Introduce an `allowlist` option with `variables` and `types` arrays so stable variables and types can be excluded from dependency enforcement. Also report member expression dependencies more granularly for call expressions (e.g. `a.b.foo()` suggests `a.b` instead of only `a`). BREAKING CHANGE: exhaustive-deps now reports member expression deps more granularly, so some previously passing code may now report missing deps. Use the allowlist option to exclude stable variables/types as needed.
📝 WalkthroughWalkthroughThe changes introduce an Changes
Sequence DiagramsequenceDiagram
actor ESLint as ESLint Engine
participant Rule as exhaustive-deps Rule
participant Utils as ExhaustiveDepsUtils
participant TypeCheck as Type Analysis
ESLint->>Rule: Process queryOptions object
Rule->>Utils: collectQueryKeyDeps(queryKeyNode)
Utils-->>Rule: {roots, paths}
Rule->>Rule: Extract queryFn from object
Rule->>Utils: getQueryFnFunctionExpression(queryFn)
Utils-->>Rule: Expression node
Rule->>Utils: Find required references in queryFn
Note over Rule,Utils: Analyze identifiers & member expressions
Utils-->>Rule: requiredRefs[]
loop For each required ref
Rule->>TypeCheck: Check variable type annotations
TypeCheck->>Utils: variableIsAllowlistedByType()
Utils->>Utils: collectTypeIdentifiers(typeNode)
Utils-->>Rule: allowlistedByType: boolean
end
Rule->>Utils: computeFilteredMissingPaths()
Note over Utils: Filter by allowlist.variables<br/>and allowlistedByType
Utils-->>Rule: missingPaths[]
alt Missing paths found
Rule->>ESLint: Report violations with fixes
else All dependencies allowed
Rule->>ESLint: No violations
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
View your CI Pipeline Execution ↗ for commit 7b0f821
☁️ Nx Cloud last updated this comment at |
🚀 Changeset Version Preview1 package(s) bumped directly, 0 bumped as dependents. 🟨 Minor bumps
|
size-limit report 📦
|
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.changeset/exhaustive-deps-allowlist.md:
- Around line 2-5: The changeset currently marks '@tanstack/eslint-plugin-query'
as a minor release but introduces a breaking behavioral change for the
`exhaustive-deps` rule; update the changeset to a major release by changing the
release type for '@tanstack/eslint-plugin-query' from "minor" to "major" and
keep the description about the `exhaustive-deps` rule and its new `allowlist`
option (`variables` and `types`) intact so the package is versioned
appropriately.
In `@docs/eslint/exhaustive-deps.md`:
- Around line 39-45: Move the note about { allowlist: { variables: ["todos"] } }
above the very first createTodos() example and explicitly state that the first
"correct" snippet (where todoQueries.detail closes over todos) requires that
allowlist to be enabled; alternatively, if you want the snippet to be valid
without the allowlist, update todoQueries.detail so its queryKey includes the
closed-over variable (e.g., include todos in the key) — reference createTodos,
todoQueries.detail, and Component when making the clarification or code change.
In `@examples/react/eslint-plugin-demo/package.json`:
- Around line 8-16: The package.json currently pins TanStack packages with
semver ranges, so tests may install published releases instead of the workspace
packages; update the dependency entries for "@tanstack/react-query" and
"@tanstack/eslint-plugin-query" in package.json to use workspace refs (e.g.,
"workspace:*" or an appropriate workspace:... spec) so the test:eslint script
runs against the in-repo builds from this PR branch rather than npm releases;
ensure the updated entries are in the "dependencies" and "devDependencies"
sections respectively and run pnpm install to lock the workspace resolution.
In
`@packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts`:
- Around line 152-168: The array-suggestion builder currently composes
fixedQueryKeyValue by string replacement (using fixedQueryKeyValue,
queryKeyValue, missingAsText and queryKeyNode) which breaks when arrays have
spaces, trailing commas or multiline formatting; replace this string-based
method with a token-based insertion: use context.sourceCode to locate the
array's closing bracket token (e.g. sourceCode.getLastToken(queryKeyNode) or
getTokenBefore/After) and then produce a suggestion that calls the fixer to
insert the dependency text right before that closing bracket (using
fixer.insertTextBefore/insertTextBeforeRange or similar) so you correctly handle
`[ ]`, trailing commas, and multiline arrays instead of using fixer.replaceText;
update the suggestions push logic for queryKeyNode.type ===
AST_NODE_TYPES.ArrayExpression to use this token-based fixer insertion.
In
`@packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts`:
- Around line 84-96: The loop currently adds every missing path (variable
`missingPaths`) including deeper members like "api.part" even when the root
"api" is missing; change it to collapse descendants per root by tracking the
chosen shortest missing path for each `root` (e.g., maintain a map or set keyed
by `root`) so that if the root itself is missing you skip deeper members, and if
you add a shorter path for a `root` you remove/replace any previously recorded
longer paths for that same `root`; use the existing identifiers (`requiredRefs`,
`root`, `path`, `existingRootIdentifiers`, `allowlistedVariables`,
`existingFullPaths`, `allowlistedByType`, `missingPaths`) to locate and
implement this behavior.
- Around line 412-425: The helper getQueryFnFunctionExpression currently returns
only the consequent for ConditionalExpression branches (unless the consequent is
skipToken), which misses scanning the alternate; change the logic so that when
queryFn.value is a ConditionalExpression and neither branch is the skipToken
identifier, you return the entire ConditionalExpression (queryFn.value) instead
of always returning the consequent; keep the existing special-case that returns
alternate when consequent is skipToken and symmetrically return consequent when
alternate is skipToken, but otherwise return queryFn.value so both branches get
scanned.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 99a8602d-e3e9-4243-948d-6d6bf5dc99f5
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (10)
.changeset/exhaustive-deps-allowlist.mddocs/eslint/exhaustive-deps.mdexamples/react/eslint-plugin-demo/eslint.config.jsexamples/react/eslint-plugin-demo/package.jsonexamples/react/eslint-plugin-demo/src/allowlist-demo.tsxexamples/react/eslint-plugin-demo/tsconfig.jsonpackages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.tspackages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.tspackages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.tspackages/eslint-plugin-query/src/utils/ast-utils.ts
| const missingAsText = missingPaths.join(', ') | ||
| const queryKeyValue = context.sourceCode.getText(queryKeyNode) | ||
|
|
||
| const fixedQueryKeyValue = | ||
| queryKeyValue === '[]' | ||
| ? `[${missingAsText}]` | ||
| : queryKeyValue.replace(/\]$/, `, ${missingAsText}]`) | ||
|
|
||
| const suggestions: TSESLint.ReportSuggestionArray<string> = [] | ||
|
|
||
| if (queryKeyNode.type === AST_NODE_TYPES.ArrayExpression) { | ||
| suggestions.push({ | ||
| messageId: 'fixTo', | ||
| data: { result: fixedQueryKeyValue }, | ||
| fix(fixer) { | ||
| return fixer.replaceText(queryKeyNode, fixedQueryKeyValue) | ||
| }, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
python - <<'PY'
def current_fix(query_key, dep="missing"):
return f"[{dep}]" if query_key == "[]" else query_key[:-1] + f", {dep}]"
for query_key in ["[]", "[ ]", "['todo']", "['todo',]", "[\n 'todo',\n]"]:
print(f"{query_key!r} -> {current_fix(query_key)!r}")
PYRepository: TanStack/query
Length of output: 219
🏁 Script executed:
fd "exhaustive-deps.rule.ts" --type fRepository: TanStack/query
Length of output: 136
🏁 Script executed:
# Once located, read the file around lines 152-168
cat -n packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts | sed -n '140,175p'Repository: TanStack/query
Length of output: 1478
🏁 Script executed:
# Check if there's any existing handling for whitespace or edge cases
rg "queryKeyValue.*replace\]" packages/eslint-plugin-query/src/rules/exhaustive-deps/Repository: TanStack/query
Length of output: 40
🏁 Script executed:
# Check if getLastToken and getTokenBefore are commonly used in this codebase
rg "getLastToken|getTokenBefore" packages/eslint-plugin-query/src/Repository: TanStack/query
Length of output: 40
🏁 Script executed:
# Check for fixer usage patterns in the codebase
rg "fixer\." packages/eslint-plugin-query/src/ -A 1 | head -40Repository: TanStack/query
Length of output: 841
🏁 Script executed:
# Look for any array element access patterns
rg "queryKeyNode\.elements" packages/eslint-plugin-query/src/Repository: TanStack/query
Length of output: 40
Fix the array suggestion builder to handle all array formats correctly.
The current string-based approach with replace(/\]$/) generates invalid syntax for arrays with whitespace or trailing commas:
[ ]becomes[ , missing](space before comma)['todo',]becomes['todo',, missing](double comma)- Multiline arrays with trailing commas similarly break
Use the token-based approach below to safely insert dependencies before the closing bracket, which handles all formatting variations:
Suggested fix
- const queryKeyValue = context.sourceCode.getText(queryKeyNode)
-
- const fixedQueryKeyValue =
- queryKeyValue === '[]'
- ? `[${missingAsText}]`
- : queryKeyValue.replace(/\]$/, `, ${missingAsText}]`)
-
const suggestions: TSESLint.ReportSuggestionArray<string> = []
if (queryKeyNode.type === AST_NODE_TYPES.ArrayExpression) {
+ const closingBracket = context.sourceCode.getLastToken(queryKeyNode)
+ const tokenBeforeClosingBracket =
+ closingBracket && context.sourceCode.getTokenBefore(closingBracket)
+ const separator =
+ queryKeyNode.elements.length === 0
+ ? ''
+ : tokenBeforeClosingBracket?.value === ','
+ ? ' '
+ : ', '
+
suggestions.push({
messageId: 'fixTo',
- data: { result: fixedQueryKeyValue },
+ data: { result: `[${[
+ ...queryKeyNode.elements.flatMap((element) =>
+ element ? [context.sourceCode.getText(element)] : [],
+ ),
+ ...missingPaths,
+ ].join(', ')}]` },
fix(fixer) {
- return fixer.replaceText(queryKeyNode, fixedQueryKeyValue)
+ return closingBracket
+ ? fixer.insertTextBefore(
+ closingBracket,
+ `${separator}${missingAsText}`,
+ )
+ : null
},
})
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const missingAsText = missingPaths.join(', ') | |
| const queryKeyValue = context.sourceCode.getText(queryKeyNode) | |
| const fixedQueryKeyValue = | |
| queryKeyValue === '[]' | |
| ? `[${missingAsText}]` | |
| : queryKeyValue.replace(/\]$/, `, ${missingAsText}]`) | |
| const suggestions: TSESLint.ReportSuggestionArray<string> = [] | |
| if (queryKeyNode.type === AST_NODE_TYPES.ArrayExpression) { | |
| suggestions.push({ | |
| messageId: 'fixTo', | |
| data: { result: fixedQueryKeyValue }, | |
| fix(fixer) { | |
| return fixer.replaceText(queryKeyNode, fixedQueryKeyValue) | |
| }, | |
| const missingAsText = missingPaths.join(', ') | |
| const suggestions: TSESLint.ReportSuggestionArray<string> = [] | |
| if (queryKeyNode.type === AST_NODE_TYPES.ArrayExpression) { | |
| const closingBracket = context.sourceCode.getLastToken(queryKeyNode) | |
| const tokenBeforeClosingBracket = | |
| closingBracket && context.sourceCode.getTokenBefore(closingBracket) | |
| const separator = | |
| queryKeyNode.elements.length === 0 | |
| ? '' | |
| : tokenBeforeClosingBracket?.value === ',' | |
| ? ' ' | |
| : ', ' | |
| suggestions.push({ | |
| messageId: 'fixTo', | |
| data: { result: `[${[ | |
| ...queryKeyNode.elements.flatMap((element) => | |
| element ? [context.sourceCode.getText(element)] : [], | |
| ), | |
| ...missingPaths, | |
| ].join(', ')}]` }, | |
| fix(fixer) { | |
| return closingBracket | |
| ? fixer.insertTextBefore( | |
| closingBracket, | |
| `${separator}${missingAsText}`, | |
| ) | |
| : null | |
| }, | |
| }) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts`
around lines 152 - 168, The array-suggestion builder currently composes
fixedQueryKeyValue by string replacement (using fixedQueryKeyValue,
queryKeyValue, missingAsText and queryKeyNode) which breaks when arrays have
spaces, trailing commas or multiline formatting; replace this string-based
method with a token-based insertion: use context.sourceCode to locate the
array's closing bracket token (e.g. sourceCode.getLastToken(queryKeyNode) or
getTokenBefore/After) and then produce a suggestion that calls the fixer to
insert the dependency text right before that closing bracket (using
fixer.insertTextBefore/insertTextBeforeRange or similar) so you correctly handle
`[ ]`, trailing commas, and multiline arrays instead of using fixer.replaceText;
update the suggestions push logic for queryKeyNode.type ===
AST_NODE_TYPES.ArrayExpression to use this token-based fixer insertion.
| const missingPaths = new Set<string>() | ||
|
|
||
| for (const { root, path, allowlistedByType } of requiredRefs) { | ||
| // If root itself is present in the key, it covers all members | ||
| if (existingRootIdentifiers.has(root)) continue | ||
| if (allowlistedVariables.has(root)) continue | ||
| if (existingFullPaths.has(path)) continue | ||
| if (allowlistedByType) continue | ||
|
|
||
| missingPaths.add(path) | ||
| } | ||
|
|
||
| return Array.from(missingPaths) |
There was a problem hiding this comment.
Collapse descendants once their root is already missing.
If a queryFn reads both api.fetch() and api.part.load() with an empty key, this loop reports api, api.part. Adding api already satisfies the deeper member access, so the diagnostic/fix becomes noisier than necessary. Prefer the shortest missing path per root.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts`
around lines 84 - 96, The loop currently adds every missing path (variable
`missingPaths`) including deeper members like "api.part" even when the root
"api" is missing; change it to collapse descendants per root by tracking the
chosen shortest missing path for each `root` (e.g., maintain a map or set keyed
by `root`) so that if the root itself is missing you skip deeper members, and if
you add a shorter path for a `root` you remove/replace any previously recorded
longer paths for that same `root`; use the existing identifiers (`requiredRefs`,
`root`, `path`, `existingRootIdentifiers`, `allowlistedVariables`,
`existingFullPaths`, `allowlistedByType`, `missingPaths`) to locate and
implement this behavior.
| getQueryFnFunctionExpression(queryFn: TSESTree.Property): TSESTree.Node { | ||
| if (queryFn.value.type !== AST_NODE_TYPES.ConditionalExpression) { | ||
| return queryFn.value | ||
| } | ||
|
|
||
| if ( | ||
| queryFn.value.consequent.type === AST_NODE_TYPES.Identifier && | ||
| queryFn.value.consequent.name === 'skipToken' | ||
| ) { | ||
| return queryFn.value.alternate | ||
| } | ||
|
|
||
| return queryFn.value.consequent | ||
| }, |
There was a problem hiding this comment.
Don’t ignore the alternate queryFn branch.
For queryFn: cond ? () => fetchA(a) : () => fetchB(b), this helper only returns the consequent unless that branch is literally skipToken. The alternate function never gets scanned, so b can be omitted from queryKey without a lint error.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts`
around lines 412 - 425, The helper getQueryFnFunctionExpression currently
returns only the consequent for ConditionalExpression branches (unless the
consequent is skipToken), which misses scanning the alternate; change the logic
so that when queryFn.value is a ConditionalExpression and neither branch is the
skipToken identifier, you return the entire ConditionalExpression
(queryFn.value) instead of always returning the consequent; keep the existing
special-case that returns alternate when consequent is skipToken and
symmetrically return consequent when alternate is skipToken, but otherwise
return queryFn.value so both branches get scanned.
Prior PR - #10258
Relevant issue - #6853
🎯 Changes
The lint rule
exhaustive-depsnow has anallowlistoption so stable variables and types can be excluded from dependency checks. It also reports member expression dependencies in a finer-grained way for call expressions.The
allowlistoption supports:allowlist.variables: variable names to ignore when checking dependenciesallowlist.types: TypeScript type names whose variables are ignored✅ Checklist
pnpm run test:pr.🚀 Release Impact
Summary by CodeRabbit
New Features
allowlistoption toexhaustive-depsrule, enabling developers to exclude specific variables and types from dependency checking enforcement.Documentation
exhaustive-depsdocumentation with configuration examples and detailed allowlist option specifications.