Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/soft-pianos-sip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/eslint-plugin-query': patch
---

Relax `exhaustive-deps` so function call targets are not required in query keys while values referenced in nested callbacks are still checked.
28 changes: 9 additions & 19 deletions docs/eslint/exhaustive-deps.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ id: exhaustive-deps
title: Exhaustive dependencies for query keys
---

Query keys should be seen like a dependency array to your query function: Every variable that is used inside the queryFn should be added to the query key.
This makes sure that queries are cached independently and that queries are refetched automatically when the variables changes.
Query keys should contain the serializable values that identify the data returned by your queryFn.
This makes sure that queries are cached independently and that queries are refetched automatically when those values change.

Function call targets are not query key dependencies. For example, `fetchTodoById(todoId)` needs `todoId` in the query key, but not `fetchTodoById`. Values referenced inside nested callbacks are still dependencies, so `promise.then(() => todoId)` also needs `todoId` in the query key.

## Rule Details

Expand All @@ -29,7 +31,7 @@ Examples of **correct** code for this rule:
const Component = ({ todoId }) => {
const todos = useTodos()
useQuery({
queryKey: ['todo', todos, todoId],
queryKey: ['todo', todoId],
queryFn: () => todos.getTodo(todoId),
})
}
Expand All @@ -46,24 +48,12 @@ const todoQueries = {
```

```tsx
// with { allowlist: { variables: ["todos"] }}
const Component = ({ todoId }) => {
const todos = useTodos()
useQuery({
queryKey: ['todo', todoId],
queryFn: () => todos.getTodo(todoId),
})
}
```

```tsx
// with { allowlist: { types: ["TodosClient"] }}
class TodosClient { ... }
const Component = ({ todoId }) => {
const todos: TodosClient = new TodosClient()
// with { allowlist: { types: ["Config"] }}
class Config { ... }
const Component = ({ todoId, config }: { todoId: string, config: Config }) => {
useQuery({
queryKey: ['todo', todoId],
queryFn: () => todos.getTodo(todoId),
queryFn: () => fetchTodo(todoId, config.baseUrl),
})
}
```
Expand Down
53 changes: 53 additions & 0 deletions packages/eslint-plugin-query/src/__tests__/ast-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { AST_NODE_TYPES } from '@typescript-eslint/utils'
import { describe, expect, it } from 'vitest'
import { ExhaustiveDepsUtils } from '../rules/exhaustive-deps/exhaustive-deps.utils'
import { ASTUtils } from '../utils/ast-utils'
import type { TSESLint, TSESTree } from '@typescript-eslint/utils'

function createIdentifier(name: string): TSESTree.Identifier {
return { type: AST_NODE_TYPES.Identifier, name } as TSESTree.Identifier
}

describe('ASTUtils', () => {
it('stops member traversal when a node has no parent', () => {
const identifier = createIdentifier('value')

expect(ASTUtils.traverseUpMemberExpression(identifier)).toBe(identifier)
})

it('handles an external reference without a parent', () => {
const operation = createIdentifier('operation')
const reference = {
identifier: operation,
isRead: () => true,
resolved: null,
} as TSESLint.Scope.Reference
const scope = {
childScopes: [],
references: [reference],
set: new Map(),
} as unknown as TSESLint.Scope.Scope
const scopeManager = {
acquire: () => scope,
} as unknown as TSESLint.Scope.ScopeManager
const sourceCode = {
getText: () => 'operation',
} as unknown as Readonly<TSESLint.SourceCode>

expect(
ASTUtils.getExternalRefs({
scopeManager,
sourceCode,
node: operation,
}),
).toEqual([reference])
})
})

describe('ExhaustiveDepsUtils', () => {
it('does not treat a detached identifier as a function call target', () => {
expect(
ExhaustiveDepsUtils.isFunctionCallTarget(createIdentifier('fetchTodos')),
).toBe(false)
})
})
Loading