Skip to content

feat(eslint-plugin): add allowlist option to exhaustive-deps rule#10295

Merged
TkDodo merged 7 commits intomainfrom
feat-eslint-exhaustive-deps-allowlist
Mar 22, 2026
Merged

feat(eslint-plugin): add allowlist option to exhaustive-deps rule#10295
TkDodo merged 7 commits intomainfrom
feat-eslint-exhaustive-deps-allowlist

Conversation

@Newbie012
Copy link
Collaborator

@Newbie012 Newbie012 commented Mar 19, 2026

Prior PR - #10258
Relevant issue - #6853

🎯 Changes

The lint rule exhaustive-deps now has an allowlist option 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.

// 🔴 Before: reports "The following dependencies are missing: api"
// 🟢 After: with allowlist: { variables: ["api"] }, no exhaustive-deps error
useQuery({
  queryKey: ['todo', todoId],
  queryFn: () => api.getTodo(todoId),
})

// 🔴 Before: a.b.foo() might suggest only 'a'
// 🟢 After: suggests 'a.b' more precisely for call expressions
useQuery({
  queryKey: ['todo', a],
  queryFn: () => a.b.getTodo(todoId),
})

The allowlist option supports:

  • allowlist.variables: variable names to ignore when checking dependencies
  • allowlist.types: TypeScript type names whose variables are ignored

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm run test:pr.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • New Features

    • Added an allowlist option to ignore specific variables and types.
    • Improved detection and clearer reporting of member-expression dependencies in query functions.
  • Documentation

    • Expanded docs with options, examples, and ESLint config snippets demonstrating allowlist usage.
  • Examples

    • Added a React demo showcasing rule configuration and allowlist scenarios.
  • Tests

    • Vastly expanded test coverage for exhaustive-deps, including allowlist behavior and many member-expression cases.

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.
@github-actions github-actions bot added documentation Improvements or additions to documentation package: eslint-plugin-query labels Mar 19, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 19, 2026

📝 Walkthrough

Walkthrough

Adds an allowlist option (variables and types) to the exhaustive-deps ESLint rule, reworks rule logic and utilities to compute/filter missing dependency paths with allowlist/type checks, expands tests, and adds docs plus an example demo project showing allowlist usage.

Changes

Cohort / File(s) Summary
Changeset & Docs
\.changeset/exhaustive-deps-allowlist.md, docs/eslint/exhaustive-deps.md
Added changeset and documentation describing the new allowlist options (variables, types), updated correct-code examples and options snippet.
Example demo
examples/react/eslint-plugin-demo/eslint.config.js, examples/react/eslint-plugin-demo/package.json, examples/react/eslint-plugin-demo/tsconfig.json, examples/react/eslint-plugin-demo/src/allowlist-demo.tsx
New ESLint flat config, package manifest, tsconfig, and demo TSX showing allowlist usage and intentional rule cases (variable/type allowlist examples).
Rule implementation
packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts
Added RuleOption schema for allowlist, switched visitor to ObjectExpression, integrated utility-driven flow to compute required refs, annotate allowlisted-by-type, compute missing paths, and build suggestions/fixes.
Rule utilities
packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts
Changed collectQueryKeyDeps to return { roots, paths }; added normalizeChain, computeRefPath, getQueryFnNodes, computeFilteredMissingPaths, variableIsAllowlistedByType, collectTypeIdentifiers, and related logic for member-chain, type-analysis, and allowlisting.
AST utilities
packages/eslint-plugin-query/src/utils/ast-utils.ts
Removed isAncestorIsCallee; refined findPropertyWithIdentifierKey to use a typed predicate instead of a type assertion.
Tests
packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts
Large expansion of valid/invalid cases covering member expressions, optional chaining, TS as const, identifier-referenced queryKey arrays, skipToken, and dedicated suites for allowlist.types and allowlist.variables.

Sequence Diagram

sequenceDiagram
    actor ESLint as ESLint Engine
    participant Rule as exhaustive-deps Rule
    participant Utils as ExhaustiveDepsUtils
    participant TypeAnalyzer as Type Analysis

    ESLint->>Rule: Visit query options object
    Rule->>Utils: collectQueryKeyDeps(queryKeyNode)
    Utils-->>Rule: { roots, paths }

    Rule->>Utils: getQueryFnNodes(queryFn)
    Utils-->>Rule: queryFnNodes[]

    loop For each queryFn node
        Rule->>Utils: find relevant references within node
        Utils-->>Rule: requiredRefs[] (identifier, path, root)
    end

    loop For each requiredRef
        Rule->>TypeAnalyzer: variableIsAllowlistedByType(variable)
        TypeAnalyzer-->>Utils: allowlistedByType info
        Utils-->>Rule: annotated requiredRef
    end

    Rule->>Utils: computeFilteredMissingPaths(requiredRefs, allowlistedVariables, roots, paths)
    Utils-->>Rule: missingPaths[]

    alt missingPaths non-empty
        Rule->>ESLint: Report violations + suggestions/fixes
    else
        Rule->>ESLint: No violations
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 I hopped through ASTs with nimble paws,
Found roots and paths and marked their cause.
I whispered “allowlist” to a wary type,
Pruned missing deps with a graceful swipe.
Cheers for cleaner keys and quieter nights! 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(eslint-plugin): add allowlist option to exhaustive-deps rule' directly and clearly summarizes the main change: adding an allowlist option to the exhaustive-deps ESLint rule, matching the primary objective of the PR.
Description check ✅ Passed The description comprehensively covers the changes with clear before/after examples, explains the new allowlist option features (variables and types), references prior work, and completes all required checklist items.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-eslint-exhaustive-deps-allowlist

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

CodeRabbit can scan for known vulnerabilities in your dependencies using OSV Scanner.

OSV Scanner will automatically detect and report security vulnerabilities in your project's dependencies. No additional configuration is required.

@nx-cloud
Copy link

nx-cloud bot commented Mar 19, 2026

View your CI Pipeline Execution ↗ for commit 7382e2a

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded 1m 45s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded <1s View ↗

☁️ Nx Cloud last updated this comment at 2026-03-22 13:18:54 UTC

@github-actions
Copy link
Contributor

github-actions bot commented Mar 19, 2026

🚀 Changeset Version Preview

1 package(s) bumped directly, 23 bumped as dependents.

🟨 Minor bumps

Package Version Reason
@tanstack/eslint-plugin-query 5.94.5 → 5.95.0 Changeset
@tanstack/angular-query-experimental 5.94.5 → 5.95.0 Dependent
@tanstack/angular-query-persist-client 5.94.5 → 5.95.0 Dependent
@tanstack/preact-query 5.94.5 → 5.95.0 Dependent
@tanstack/preact-query-devtools 5.94.5 → 5.95.0 Dependent
@tanstack/preact-query-persist-client 5.94.5 → 5.95.0 Dependent
@tanstack/query-async-storage-persister 5.94.5 → 5.95.0 Dependent
@tanstack/query-broadcast-client-experimental 5.94.5 → 5.95.0 Dependent
@tanstack/query-core 5.94.5 → 5.95.0 Dependent
@tanstack/query-devtools 5.94.5 → 5.95.0 Dependent
@tanstack/query-persist-client-core 5.94.5 → 5.95.0 Dependent
@tanstack/query-sync-storage-persister 5.94.5 → 5.95.0 Dependent
@tanstack/react-query 5.94.5 → 5.95.0 Dependent
@tanstack/react-query-devtools 5.94.5 → 5.95.0 Dependent
@tanstack/react-query-next-experimental 5.94.5 → 5.95.0 Dependent
@tanstack/react-query-persist-client 5.94.5 → 5.95.0 Dependent
@tanstack/solid-query 5.94.5 → 5.95.0 Dependent
@tanstack/solid-query-devtools 5.94.5 → 5.95.0 Dependent
@tanstack/solid-query-persist-client 5.94.5 → 5.95.0 Dependent
@tanstack/vue-query 5.94.5 → 5.95.0 Dependent

🟩 Patch bumps

Package Version Reason
@tanstack/svelte-query 6.1.7 → 6.1.8 Dependent
@tanstack/svelte-query-devtools 6.1.7 → 6.1.8 Dependent
@tanstack/svelte-query-persist-client 6.1.7 → 6.1.8 Dependent
@tanstack/vue-query-devtools 6.1.7 → 6.1.8 Dependent

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 19, 2026

More templates

@tanstack/angular-query-experimental

npm i https://pkg.pr.new/@tanstack/angular-query-experimental@10295

@tanstack/eslint-plugin-query

npm i https://pkg.pr.new/@tanstack/eslint-plugin-query@10295

@tanstack/preact-query

npm i https://pkg.pr.new/@tanstack/preact-query@10295

@tanstack/preact-query-devtools

npm i https://pkg.pr.new/@tanstack/preact-query-devtools@10295

@tanstack/preact-query-persist-client

npm i https://pkg.pr.new/@tanstack/preact-query-persist-client@10295

@tanstack/query-async-storage-persister

npm i https://pkg.pr.new/@tanstack/query-async-storage-persister@10295

@tanstack/query-broadcast-client-experimental

npm i https://pkg.pr.new/@tanstack/query-broadcast-client-experimental@10295

@tanstack/query-core

npm i https://pkg.pr.new/@tanstack/query-core@10295

@tanstack/query-devtools

npm i https://pkg.pr.new/@tanstack/query-devtools@10295

@tanstack/query-persist-client-core

npm i https://pkg.pr.new/@tanstack/query-persist-client-core@10295

@tanstack/query-sync-storage-persister

npm i https://pkg.pr.new/@tanstack/query-sync-storage-persister@10295

@tanstack/react-query

npm i https://pkg.pr.new/@tanstack/react-query@10295

@tanstack/react-query-devtools

npm i https://pkg.pr.new/@tanstack/react-query-devtools@10295

@tanstack/react-query-next-experimental

npm i https://pkg.pr.new/@tanstack/react-query-next-experimental@10295

@tanstack/react-query-persist-client

npm i https://pkg.pr.new/@tanstack/react-query-persist-client@10295

@tanstack/solid-query

npm i https://pkg.pr.new/@tanstack/solid-query@10295

@tanstack/solid-query-devtools

npm i https://pkg.pr.new/@tanstack/solid-query-devtools@10295

@tanstack/solid-query-persist-client

npm i https://pkg.pr.new/@tanstack/solid-query-persist-client@10295

@tanstack/svelte-query

npm i https://pkg.pr.new/@tanstack/svelte-query@10295

@tanstack/svelte-query-devtools

npm i https://pkg.pr.new/@tanstack/svelte-query-devtools@10295

@tanstack/svelte-query-persist-client

npm i https://pkg.pr.new/@tanstack/svelte-query-persist-client@10295

@tanstack/vue-query

npm i https://pkg.pr.new/@tanstack/vue-query@10295

@tanstack/vue-query-devtools

npm i https://pkg.pr.new/@tanstack/vue-query-devtools@10295

commit: 7382e2a

@github-actions
Copy link
Contributor

github-actions bot commented Mar 19, 2026

size-limit report 📦

Path Size
react full 11.98 KB (0%)
react minimal 9.01 KB (0%)

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between b799510 and 7b0f821.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (10)
  • .changeset/exhaustive-deps-allowlist.md
  • docs/eslint/exhaustive-deps.md
  • examples/react/eslint-plugin-demo/eslint.config.js
  • examples/react/eslint-plugin-demo/package.json
  • examples/react/eslint-plugin-demo/src/allowlist-demo.tsx
  • examples/react/eslint-plugin-demo/tsconfig.json
  • packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts
  • packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts
  • packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts
  • packages/eslint-plugin-query/src/utils/ast-utils.ts

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts (1)

385-411: Consider handling TSParenthesizedType for completeness.

The current implementation handles common type patterns (references, unions, intersections, arrays, tuples). For full coverage, you might also want to handle TSParenthesizedType which wraps a type in parentheses.

♻️ Optional enhancement
       case AST_NODE_TYPES.TSTupleType: {
         typeNode.elementTypes.forEach((et) =>
           ExhaustiveDepsUtils.collectTypeIdentifiers(et, out),
         )
         break
       }
+      case AST_NODE_TYPES.TSParenthesizedType: {
+        ExhaustiveDepsUtils.collectTypeIdentifiers(typeNode.typeAnnotation, out)
+        break
+      }
     }
🤖 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 385 - 411, The collectTypeIdentifiers method in ExhaustiveDepsUtils
misses TSParenthesizedType nodes; add a case for
AST_NODE_TYPES.TSParenthesizedType and recursively call
ExhaustiveDepsUtils.collectTypeIdentifiers on the wrapped inner type (use the
node's inner property, e.g., typeAnnotation or type, whichever exists) so
parenthesized types are traversed like unions/arrays/tuples.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts`:
- Around line 385-411: The collectTypeIdentifiers method in ExhaustiveDepsUtils
misses TSParenthesizedType nodes; add a case for
AST_NODE_TYPES.TSParenthesizedType and recursively call
ExhaustiveDepsUtils.collectTypeIdentifiers on the wrapped inner type (use the
node's inner property, e.g., typeAnnotation or type, whichever exists) so
parenthesized types are traversed like unions/arrays/tuples.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dfea3476-2ba0-4e62-96a8-5da983c61dc0

📥 Commits

Reviewing files that changed from the base of the PR and between 7b0f821 and 98868ec.

📒 Files selected for processing (4)
  • docs/eslint/exhaustive-deps.md
  • packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts
  • packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts
  • packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/eslint-plugin-query/src/tests/exhaustive-deps.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/eslint/exhaustive-deps.md

Copy link
Collaborator

@TkDodo TkDodo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seeing some legit results here when running against the sentry codebase, but also some false positives:

getsentry/sentry/static/app/utils/queryClient.tsx
  249:27  error  The following dependencies are missing in your queryKey: TResponseData  @tanstack/query/exhaustive-deps

this fails because we have annotated the return type of the queryFn - I think types should never be reported?

https://github.com/getsentry/sentry/blob/8a4f150b21bbd8980efeb04fa2b2a7c44154ceb9/static/app/utils/queryClient.tsx#L251

getsentry/sentry/static/app/components/forms/fields/choiceMapperField.tsx
  171:39  error  The following dependencies are missing in your queryKey: api, buildQueryParams  @tanstack/query/exhaustive-deps

not sure about buildQueryParams - we use the exact same function call in the queryKey, maybe it needs to be extracted to a const?

https://github.com/getsentry/sentry/blob/8a4f150b21bbd8980efeb04fa2b2a7c44154ceb9/static/app/components/forms/fields/choiceMapperField.tsx#L171-L180

@Newbie012
Copy link
Collaborator Author

Oh wow, nice way of catching false positives! (addressed in 7382e2a) will definitely re-run on this repo next time.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts`:
- Around line 418-437: getQueryFnNodes currently only unwraps one
ConditionalExpression and returns nested ternaries as nodes, which leads
getExternalRefs to call scopeManager.acquire() on ConditionalExpression (no
scope) and miss dependencies; update getQueryFnNodes to recursively flatten
nested ConditionalExpression branches: traverse each consequent/alternate until
reaching non-ConditionalExpression leaves and collect those leaf nodes
(functions/identifiers) into the returned array so getExternalRefs receives real
nodes it can acquire scope for and compute dependencies correctly.
- Around line 249-270: variableIsAllowlistedByType currently relies on
collectTypeIdentifiers but collectTypeIdentifiers only handles simple
identifiers, unions, intersections, arrays and tuples; update
collectTypeIdentifiers to recursively handle additional TS type node shapes so
allowlist detection works for idiomatic TypeScript. Specifically, add cases to
traverse TSTypeReference nodes with TSQualifiedName (extract both left/right
idents), handle TSTypeParameterInstantiation / typeParameters / typeArguments to
recurse into generic type arguments, support TSAsExpression and
TSParenthesizedType by recursing into their inner type/expression type nodes,
and include other nested forms like TSIndexedAccessType and TSMappedType
(recursing into object/index/key types) so variableIsAllowlistedByType (which
calls collectTypeIdentifiers) will pick up qualified/generic/compound type
identifiers such as Api.StableClient, Readonly<StableApi>, and
asserted/satisfies types.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 316ce08d-1d3c-4495-a862-f0acb9246a84

📥 Commits

Reviewing files that changed from the base of the PR and between 98868ec and 7382e2a.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (2)
  • packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts
  • packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/eslint-plugin-query/src/tests/exhaustive-deps.test.ts

Comment on lines +249 to +270
variableIsAllowlistedByType(params: {
allowlistedTypes: Set<string>
variable: TSESLint.Scope.Variable | null
}): boolean {
const { allowlistedTypes, variable } = params
if (allowlistedTypes.size === 0) return false
if (!variable) return false

for (const id of variable.identifiers) {
if (id.typeAnnotation) {
const typeIdentifiers = new Set<string>()
ExhaustiveDepsUtils.collectTypeIdentifiers(
id.typeAnnotation.typeAnnotation,
typeIdentifiers,
)
for (const typeIdentifier of typeIdentifiers) {
if (allowlistedTypes.has(typeIdentifier)) return true
}
}
}

return false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's get the full file to understand the context
wc -l packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts

Repository: TanStack/query

Length of output: 141


🏁 Script executed:

# Read the variableIsAllowlistedByType method and surrounding context
sed -n '240,280p' packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts

Repository: TanStack/query

Length of output: 1170


🏁 Script executed:

# Read the collectTypeIdentifiers method (lines 386-412)
sed -n '380,420p' packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts

Repository: TanStack/query

Length of output: 1382


🏁 Script executed:

# Also check for any type annotation extraction logic in the file
rg "typeAnnotation|as |satisfies|destructuring" packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts -n

Repository: TanStack/query

Length of output: 295


🏁 Script executed:

# Verify if the code actually examines type parameters or qualified names
ast-grep --pattern 'case AST_NODE_TYPES.TSTypeReference: {
  $$$
}'

Repository: TanStack/query

Length of output: 40


🏁 Script executed:

# Check if there's any handling for TSQualifiedName elsewhere in the file
rg "TSQualifiedName|typeParameters" packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts

Repository: TanStack/query

Length of output: 40


🏁 Script executed:

# Look for where allowlist.types is actually used to understand impact
rg "allowlist\\.types|allowlistedTypes" packages/eslint-plugin-query/src/rules/exhaustive-deps/ -A 2 -B 2

Repository: TanStack/query

Length of output: 2804


🏁 Script executed:

# Let's examine the specific part of collectTypeIdentifiers that handles TSTypeReference
sed -n '386,405p' packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts

Repository: TanStack/query

Length of output: 754


🏁 Script executed:

# Check if typeParameters or any qualified name handling exists in the implementation
sed -n '386,420p' packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts | cat -n

Repository: TanStack/query

Length of output: 1519


🏁 Script executed:

# Verify what happens with qualified names - check the full switch statement
sed -n '389,415p' packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts

Repository: TanStack/query

Length of output: 858


🏁 Script executed:

# Check if there's any handling for typeParameters in the codebase
rg "typeParameters" packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts -n

Repository: TanStack/query

Length of output: 40


🏁 Script executed:

# Let's verify what AST_NODE_TYPES are available for qualified names
rg "TSQualifiedName|Qualified" packages/eslint-plugin-query/src/rules/exhaustive-deps/ -n

Repository: TanStack/query

Length of output: 40


🏁 Script executed:

# Check the TypeScript ESTree types to understand the structure of generic types
# Look for how Readonly<Type> or similar generics would be structured
sed -n '1,50p' packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts | head -30

Repository: TanStack/query

Length of output: 1029


🏁 Script executed:

# Let's check the test files to understand the intended scope of allowlist.types
find packages/eslint-plugin-query -name "*.test.ts" -o -name "*.spec.ts" | head -5

Repository: TanStack/query

Length of output: 431


🏁 Script executed:

# Search for test cases related to allowlist
rg "allowlist.*types|allowlistedTypes" packages/eslint-plugin-query --type ts -A 3 -B 1 | head -60

Repository: TanStack/query

Length of output: 6036


🏁 Script executed:

# Check the PR or recent commits to understand what was intended for this feature
git log --oneline -20 -- packages/eslint-plugin-query/src/rules/exhaustive-deps/

Repository: TanStack/query

Length of output: 90


🏁 Script executed:

# Let's look at more comprehensive allowlist.types tests
sed -n '1,100p' packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts | grep -A 50 "allowlist.types"

Repository: TanStack/query

Length of output: 40


🏁 Script executed:

# Search for tests with generics, qualified names, or type assertions
rg "Readonly|namespace|Namespace|\bas\b.*Type|satisfies" packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts -C 3

Repository: TanStack/query

Length of output: 40


🏁 Script executed:

# Let's see the complete test cases for allowlist.types to understand scope
sed -n '1000,1200p' packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts | grep -A 30 "allowlist.types"

Repository: TanStack/query

Length of output: 40


🏁 Script executed:

# Find the line number where allowlist.types tests are defined
rg -n "allowlist.types" packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts

Repository: TanStack/query

Length of output: 303


🏁 Script executed:

# Get the test file size and examine the allowlist.types test section
wc -l packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts

Repository: TanStack/query

Length of output: 129


🏁 Script executed:

# Extract the allowlist.types test cases
sed -n '2350,2550p' packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts

Repository: TanStack/query

Length of output: 40


🏁 Script executed:

# Extract the allowlist.types test section from line 1926 onwards
sed -n '1926,2100p' packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts

Repository: TanStack/query

Length of output: 5341


🏁 Script executed:

# Let's check if there are any test cases with generics or qualified names we might have missed
grep -n "Readonly\|Record\|namespace\|\bas\s\|satisfies" packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts

Repository: TanStack/query

Length of output: 2196


🏁 Script executed:

# Check the PR or documentation for the allowlist.types feature
rg -i "allowlist.*types|generic|qualified.*name" packages/eslint-plugin-query --type md

Repository: TanStack/query

Length of output: 40


🏁 Script executed:

# Let's verify the current behavior with a qualified name example by checking TypeScript ESTree AST types
# Search for any documentation or references about TSQualifiedName handling
rg "TSQualifiedName" packages/eslint-plugin-query/src

Repository: TanStack/query

Length of output: 40


allowlist.types misses common explicit TypeScript type forms.

The collectTypeIdentifiers function only handles simple identifier types (TSTypeReference with plain Identifiers), unions, intersections, arrays, and tuples. Qualified names (e.g., const api: Api.StableClient), generic type parameters (e.g., Readonly<StableApi> will extract only "Readonly" but not the inner type), and type assertions (as StableApi) or satisfies expressions are not extracted. This causes the allowlist to silently fail on idiomatic TypeScript code. Broaden the type node handling to recursively extract identifiers from qualified names, generic parameters, and other complex type forms.

🤖 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 249 - 270, variableIsAllowlistedByType currently relies on
collectTypeIdentifiers but collectTypeIdentifiers only handles simple
identifiers, unions, intersections, arrays and tuples; update
collectTypeIdentifiers to recursively handle additional TS type node shapes so
allowlist detection works for idiomatic TypeScript. Specifically, add cases to
traverse TSTypeReference nodes with TSQualifiedName (extract both left/right
idents), handle TSTypeParameterInstantiation / typeParameters / typeArguments to
recurse into generic type arguments, support TSAsExpression and
TSParenthesizedType by recursing into their inner type/expression type nodes,
and include other nested forms like TSIndexedAccessType and TSMappedType
(recursing into object/index/key types) so variableIsAllowlistedByType (which
calls collectTypeIdentifiers) will pick up qualified/generic/compound type
identifiers such as Api.StableClient, Readonly<StableApi>, and
asserted/satisfies types.

Comment on lines +418 to +437
getQueryFnNodes(queryFn: TSESTree.Property): Array<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]
}

if (
queryFn.value.alternate.type === AST_NODE_TYPES.Identifier &&
queryFn.value.alternate.name === 'skipToken'
) {
return [queryFn.value.consequent]
}

return [queryFn.value.consequent, queryFn.value.alternate]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd packages/eslint-plugin-query && find . -name "*.ts" -o -name "*.tsx" | head -20

Repository: TanStack/query

Length of output: 981


🏁 Script executed:

cat packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts | wc -l

Repository: TanStack/query

Length of output: 61


🏁 Script executed:

sed -n '418,437p' packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts

Repository: TanStack/query

Length of output: 664


🏁 Script executed:

rg -n "isRelevantReference" packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts -A 10

Repository: TanStack/query

Length of output: 499


🏁 Script executed:

rg -n "scopeManager.acquire" packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts -B 2 -A 5

Repository: TanStack/query

Length of output: 621


🏁 Script executed:

find packages/eslint-plugin-query -name "*.test.*" -o -name "*.spec.*" | grep -i exhaustive

Repository: TanStack/query

Length of output: 124


🏁 Script executed:

sed -n '6,30p' packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts

Repository: TanStack/query

Length of output: 817


🏁 Script executed:

grep -n "ConditionalExpression" packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts

Repository: TanStack/query

Length of output: 132


🏁 Script executed:

grep -n "nested\|ternary" packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts -i

Repository: TanStack/query

Length of output: 787


🏁 Script executed:

rg -n "skipToken" packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts -B 5 -A 5

Repository: TanStack/query

Length of output: 2618


🏁 Script executed:

grep -n "? .* ?" packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts | head -20

Repository: TanStack/query

Length of output: 40


🏁 Script executed:

grep -n "consequent.*ConditionalExpression\|alternate.*ConditionalExpression" packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts -i

Repository: TanStack/query

Length of output: 40


🏁 Script executed:

sed -n '513,525p' packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts

Repository: TanStack/query

Length of output: 451


🏁 Script executed:

sed -n '1,50p' packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts

Repository: TanStack/query

Length of output: 1495


🏁 Script executed:

sed -n '880,910p' packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts

Repository: TanStack/query

Length of output: 839


🏁 Script executed:

grep -n "should pass when queryFn is ternary with both branches having deps" packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts -A 20

Repository: TanStack/query

Length of output: 764


🏁 Script executed:

python3 << 'PYTHON_EOF'
# Check if there are any nested ternaries in the test file
with open('packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts', 'r') as f:
    content = f.read()
    
# Look for patterns like "? ... ? ... : ... :" which indicate nested ternaries
import re
nested_patterns = re.findall(r'queryFn:\s*(?:condition|[a-z]+)\s*\?\s*(?:(?:[^?:]*\?[^:]*:[^?]*)|[^:]*)\s*:\s*', content)
if nested_patterns:
    print("Found potential nested ternaries:")
    for p in nested_patterns[:5]:
        print(f"  {p[:80]}...")
else:
    print("No nested ternary patterns found in tests")
    
# Also check the actual getQueryFnNodes implementation behavior
print("\nAnalyzing getQueryFnNodes logic:")
print("1. If not ConditionalExpression: return [node]")
print("2. If consequent is skipToken: return [alternate]")
print("3. If alternate is skipToken: return [consequent]")
print("4. Otherwise: return [consequent, alternate]")
print("\nIf alternate or consequent is another ConditionalExpression:")
print("  - It will be returned as-is from getQueryFnNodes")
print("  - Then isRelevantReference will call scopeManager.acquire() on it")
PYTHON_EOF

Repository: TanStack/query

Length of output: 723


🏁 Script executed:

rg "ConditionalExpression" packages/eslint-plugin-query/src -A 5 -B 5 --type ts

Repository: TanStack/query

Length of output: 2585


🏁 Script executed:

# Look specifically for how the nodes returned from getQueryFnNodes are used
rg "getQueryFnNodes" packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts -A 10 -B 2

Repository: TanStack/query

Length of output: 509


🏁 Script executed:

rg "getQueryFnNodes" packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts -B 5 -A 10

Repository: TanStack/query

Length of output: 540


🏁 Script executed:

sed -n '140,180p' packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts

Repository: TanStack/query

Length of output: 1290


🏁 Script executed:

# Search for where the returned nodes from getQueryFnNodes are iterated
rg "getQueryFnNodes\|queryFnNodes" packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts -B 3 -A 8

Repository: TanStack/query

Length of output: 40


🏁 Script executed:

rg "getExternalRefs" packages/eslint-plugin-query/src/utils/ast-utils.ts -B 3 -A 15

Repository: TanStack/query

Length of output: 620


🏁 Script executed:

find packages/eslint-plugin-query/src -name "*.ts" -exec grep -l "getExternalRefs" {} \;

Repository: TanStack/query

Length of output: 188


🏁 Script executed:

# Let's look for ASTUtils file
find packages/eslint-plugin-query -name "ast-utils.ts"

Repository: TanStack/query

Length of output: 109


🏁 Script executed:

sed -n '1,50p' packages/eslint-plugin-query/src/utils/ast-utils.ts

Repository: TanStack/query

Length of output: 1807


🏁 Script executed:

rg "getExternalRefs" packages/eslint-plugin-query/src/utils/ast-utils.ts -A 30

Repository: TanStack/query

Length of output: 1019


🏁 Script executed:

# Create a test to verify the issue exists
python3 << 'PYTHON_EOF'
# Let's trace through the logic flow:
print("=== FLOW ANALYSIS ===\n")

print("1. getQueryFnNodes returns nodes from ConditionalExpression branches")
print("   - If both branches are functions: returns [consequent, alternate]")
print("   - If one is skipToken: returns the other branch")
print("   - If neither branch is ConditionalExpression: works fine")
print("   - If a branch IS ConditionalExpression: returns that ConditionalExpression\n")

print("2. For each returned node, getExternalRefs is called:")
print("   - Calls scopeManager.acquire(node)")
print("   - ConditionalExpression nodes don't have their own scope")
print("   - So scopeManager.acquire(ConditionalExpression) returns null\n")

print("3. If scope is null, getExternalRefs returns []:")
print("   - No references are collected")
print("   - Dependencies inside that ConditionalExpression are MISSED\n")

print("=== EXAMPLE CASE ===")
print("queryFn: cond1 ? (cond2 ? () => fetchA(a) : () => fetchB(b)) : () => fetchC(c)")
print("")
print("Step 1: getQueryFnNodes receives cond1 ConditionalExpression")
print("        Returns [cond2 ConditionalExpression, () => fetchC(c) ArrowFunction]")
print("")
print("Step 2: getExternalRefs called on cond2 ConditionalExpression")
print("        scopeManager.acquire(cond2 ConditionalExpression) returns null")
print("        Returns []  <-- MISSES dependencies 'a' and 'b'")
print("")
print("Step 3: getExternalRefs called on () => fetchC(c) ArrowFunction")
print("        Returns references correctly")
print("")
print("Result: Dependencies 'a' and 'b' are NOT detected")
PYTHON_EOF

Repository: TanStack/query

Length of output: 1354


🏁 Script executed:

# Check if there are any test cases with nested ternaries
grep -n "cond.*?.*?.*:" packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts | head -5

Repository: TanStack/query

Length of output: 40


🏁 Script executed:

# Final confirmation: check if ConditionalExpression has scope in ESLint
python3 << 'PYTHON_EOF'
# ESLint's scopeManager.acquire() returns scope for nodes that create scope:
# - Functions (arrow, regular, async)
# - Blocks (if/for/while, etc.)
# - Class definitions
# 
# ConditionalExpression (ternary) does NOT create a scope
# This is a standard JavaScript behavior - ternaries are expressions, not statements

print("ESLint Scope Manager Behavior:")
print("================================")
print("Nodes that CREATE scope:")
print("  - FunctionExpression, ArrowFunctionExpression")
print("  - FunctionDeclaration")
print("  - ClassDeclaration, ClassExpression")
print("  - BlockStatement, IfStatement, etc.")
print("")
print("Nodes that DON'T create scope:")
print("  - ConditionalExpression (ternary)")
print("  - BinaryExpression, LogicalExpression")
print("  - CallExpression, MemberExpression")
print("")
print("When scopeManager.acquire(ConditionalExpression) is called:")
print("  Returns: null")
print("  Result in getExternalRefs: returns []")
EOF

Repository: TanStack/query

Length of output: 784


🏁 Script executed:

# Double-check by looking at actual test coverage
grep -c "ConditionalExpression" packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts

Repository: TanStack/query

Length of output: 59


🏁 Script executed:

sed -n '160,175p' packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts

Repository: TanStack/query

Length of output: 363


🏁 Script executed:

# Verify the exact usage location where getQueryFnNodes is called
sed -n '145,165p' packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts

Repository: TanStack/query

Length of output: 697


🏁 Script executed:

# Check if there are any integration tests that might catch this
rg "nested.*ternary|ternary.*nested" packages/eslint-plugin-query/src/__tests__/ -i

Repository: TanStack/query

Length of output: 40


Flatten nested queryFn ternaries to avoid missing dependencies.

getQueryFnNodes only unwraps one ConditionalExpression layer. If a branch is itself a ternary, it returns that ConditionalExpression node, and getExternalRefs calls scopeManager.acquire() on it—which returns null because ternaries don't create scopes. When scope is null, getExternalRefs returns an empty array, missing dependencies. A nested case like cond1 ? (cond2 ? () => fetchA(a) : () => fetchB(b)) : () => fetchC(c) will miss a and b.

♻️ Proposed fix
   getQueryFnNodes(queryFn: TSESTree.Property): Array<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]
-    }
-
-    if (
-      queryFn.value.alternate.type === AST_NODE_TYPES.Identifier &&
-      queryFn.value.alternate.name === 'skipToken'
-    ) {
-      return [queryFn.value.consequent]
-    }
-
-    return [queryFn.value.consequent, queryFn.value.alternate]
+    function collect(node: TSESTree.Node): Array<TSESTree.Node> {
+      if (node.type !== AST_NODE_TYPES.ConditionalExpression) {
+        return [node]
+      }
+
+      if (
+        node.consequent.type === AST_NODE_TYPES.Identifier &&
+        node.consequent.name === 'skipToken'
+      ) {
+        return collect(node.alternate)
+      }
+
+      if (
+        node.alternate.type === AST_NODE_TYPES.Identifier &&
+        node.alternate.name === 'skipToken'
+      ) {
+        return collect(node.consequent)
+      }
+
+      return [...collect(node.consequent), ...collect(node.alternate)]
+    }
+
+    return collect(queryFn.value)
   },
🤖 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 418 - 437, getQueryFnNodes currently only unwraps one
ConditionalExpression and returns nested ternaries as nodes, which leads
getExternalRefs to call scopeManager.acquire() on ConditionalExpression (no
scope) and miss dependencies; update getQueryFnNodes to recursively flatten
nested ConditionalExpression branches: traverse each consequent/alternate until
reaching non-ConditionalExpression leaves and collect those leaf nodes
(functions/identifiers) into the returned array so getExternalRefs receives real
nodes it can acquire scope for and compute dependencies correctly.

@TkDodo
Copy link
Collaborator

TkDodo commented Mar 22, 2026

looks good! all the other 16 errors I’m seeing are legit findings, so let’s ship it.

@TkDodo TkDodo merged commit 3443736 into main Mar 22, 2026
8 checks passed
@TkDodo TkDodo deleted the feat-eslint-exhaustive-deps-allowlist branch March 22, 2026 17:33
@github-actions github-actions bot mentioned this pull request Mar 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation package: eslint-plugin-query

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants