Skip to content

Commit cc16d23

Browse files
authored
fix(react-query): close the lint's blind spots, and the drift they hid (#7020)
`check-react-query-patterns.ts` reported a clean strict zone while never looking at part of it. Two gaps in one regex: `\buseQuery\s*\(` does not match `useQuery<Row[]>({ ... })` — a type argument sits between the name and the paren. Twenty query calls carry one, ten of them inside the zero-tolerance zone, so that zone's "0 violations" was partly a statement about what the scan could see. `useQueries` was absent from both the call pattern and the file pre-filter, where `\buse(Query|...)\b` rejects it on the trailing `s`. All sixteen call sites were unscanned, and its options nest one level deeper — inside a `queries` array — so it needs its own pass per entry rather than one that reads the wrapper and takes a single `staleTime` anywhere inside as covering them all. With both closed, three real violations surfaced: - `knowledge-base-selector` served `knowledgeKeys.detail(id)` with an inline `60 * 1000` while `useKnowledgeBaseQuery` serves the same cache key from `KNOWLEDGE_BASE_DETAIL_STALE_TIME`. The two agree only by coincidence, and TanStack resolves staleTime per observer, so tuning the constant would have left this component on the old window for the same entry. - The same call dropped the `AbortSignal`, which `fetchKnowledgeBase` accepts. - `use-permission-config` gave `staleTime` as a literal with no named constant. The new `stale-time-literal` category makes the second half of the CLAUDE.md rule enforceable — it required a named constant, and only the presence of `staleTime` was ever checked. `0` is exempt: it is the sentinel for "always refetch", not a window anyone keeps in step with a prefetch. Verified the new rules can fail by reverting each fix and watching the audit report it, then restoring.
1 parent 7e0d868 commit cc16d23

3 files changed

Lines changed: 132 additions & 6 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflow
1414
import type { SubBlockConfig } from '@/blocks/types'
1515
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
1616
import { useFolderMap } from '@/hooks/queries/folders'
17-
import { fetchKnowledgeBase } from '@/hooks/queries/kb/knowledge'
17+
import { fetchKnowledgeBase, KNOWLEDGE_BASE_DETAIL_STALE_TIME } from '@/hooks/queries/kb/knowledge'
1818
import { collectDuplicateNames, disambiguateLabelByFolder } from '@/hooks/queries/utils/folder-tree'
1919
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
2020

@@ -75,9 +75,9 @@ export function KnowledgeBaseSelector({
7575
const selectedKnowledgeBaseQueries = useQueries({
7676
queries: selectedIds.map((selectedId) => ({
7777
queryKey: knowledgeKeys.detail(selectedId),
78-
queryFn: () => fetchKnowledgeBase(selectedId),
78+
queryFn: ({ signal }: { signal: AbortSignal }) => fetchKnowledgeBase(selectedId, signal),
7979
enabled: Boolean(selectedId),
80-
staleTime: 60 * 1000,
80+
staleTime: KNOWLEDGE_BASE_DETAIL_STALE_TIME,
8181
})),
8282
})
8383

apps/sim/hooks/use-permission-config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,13 @@ const allowedIntegrationsKeys = {
5050
env: () => [...allowedIntegrationsKeys.all, 'env'] as const,
5151
}
5252

53+
export const ALLOWED_INTEGRATIONS_STALE_TIME = 5 * 60 * 1000
54+
5355
function useAllowedIntegrationsFromEnv() {
5456
return useQuery<GetAllowedIntegrationsResponse>({
5557
queryKey: allowedIntegrationsKeys.env(),
5658
queryFn: ({ signal }) => requestJson(getAllowedIntegrationsContract, { signal }),
57-
staleTime: 5 * 60 * 1000,
59+
staleTime: ALLOWED_INTEGRATIONS_STALE_TIME,
5860
})
5961
}
6062

scripts/check-react-query-patterns.ts

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
*
99
* 1. missing-stale-time — useQuery/useInfiniteQuery/useSuspenseQuery without an explicit `staleTime`
1010
* 2. queryfn-no-signal — an inline `queryFn` that takes no args (cannot forward the AbortSignal)
11+
* 2b. stale-time-literal — `staleTime` given as a numeric literal rather than a named constant, so a
12+
* server prefetch hydrating the same key cannot import the one value and the
13+
* two drift apart silently. `0` is exempt: it is a sentinel meaning "always
14+
* refetch", not a duration anyone tunes.
1115
* 3. inline-query-key — `queryKey: ['literal', ...]` instead of a colocated key factory
1216
* 4. key-factory-no-root — a `*Keys` factory in hooks/queries/** without an `all` root key
1317
* 5. key-fetch-arg-drift — an identifier the queryFn forwards into the fetch (e.g. `workspaceId`)
@@ -44,6 +48,7 @@ const ALLOW = 'rq-lint-allow:'
4448

4549
type Category =
4650
| 'missing-stale-time'
51+
| 'stale-time-literal'
4752
| 'queryfn-no-signal'
4853
| 'inline-query-key'
4954
| 'key-factory-no-root'
@@ -60,6 +65,9 @@ interface Violation {
6065
const SUGGESTION: Record<Category, string> = {
6166
'missing-stale-time':
6267
'add an explicit staleTime (default 0 is rarely correct); e.g. staleTime: 60 * 1000',
68+
'stale-time-literal':
69+
'assign staleTime from a named exported constant (e.g. ENTITY_LIST_STALE_TIME) so a server ' +
70+
'prefetch on the same query key can import the one value instead of restating it',
6371
'queryfn-no-signal':
6472
'destructure the AbortSignal: queryFn: ({ signal }) => fetchX(..., signal) and forward it',
6573
'inline-query-key':
@@ -131,7 +139,75 @@ function hasAllow(lines: string[], line: number): boolean {
131139
return false
132140
}
133141

134-
const QUERY_CALL = /\b(useQuery|useInfiniteQuery|useSuspenseQuery|useSuspenseInfiniteQuery)\s*\(/g
142+
/**
143+
* The optional explicit type argument on a query call — `useQuery<Row[]>({ ... })`.
144+
*
145+
* Matched rather than ignored because a call carrying one is still a query call: without this
146+
* the scan skipped every generically-typed query, including ten in the strict zone, which then
147+
* reported zero violations while never having looked at them. One level of nesting is enough
148+
* for the shapes that occur here (`useQuery<Record<string, T>>`).
149+
*/
150+
const TYPE_ARGS = String.raw`(?:\s*<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>)?`
151+
152+
const QUERY_CALL = new RegExp(
153+
String.raw`\b(useQuery|useInfiniteQuery|useSuspenseQuery|useSuspenseInfiniteQuery)${TYPE_ARGS}\s*\(`,
154+
'g'
155+
)
156+
157+
/**
158+
* `useQueries` nests its option objects one level deeper — `useQueries({ queries: [ {...} ] })` —
159+
* so the single-object walk above would read the wrapper and see one `staleTime` anywhere inside
160+
* the array as covering every entry. It gets its own pass that visits each entry.
161+
*/
162+
const USE_QUERIES_CALL = new RegExp(String.raw`\buseQueries${TYPE_ARGS}\s*\(`, 'g')
163+
164+
/**
165+
* Every top-level `{ ... }` inside a `queries` value, whether written as an array literal or
166+
* produced by a `.map(...)` callback — both forms put each query's options in a brace group at
167+
* the same nesting depth, so one balanced scan covers them.
168+
*/
169+
function splitObjectLiterals(value: string): string[] {
170+
const out: string[] = []
171+
let depth = 0
172+
let start = -1
173+
let inStr: string | null = null
174+
for (let i = 0; i < value.length; i++) {
175+
const c = value[i]
176+
if (inStr) {
177+
if (c === inStr && value[i - 1] !== '\\') inStr = null
178+
continue
179+
}
180+
if (c === '"' || c === "'" || c === '`') {
181+
inStr = c
182+
continue
183+
}
184+
if (c === '{') {
185+
if (depth === 0) start = i
186+
depth++
187+
continue
188+
}
189+
if (c === '}') {
190+
depth--
191+
if (depth === 0 && start !== -1) {
192+
out.push(value.slice(start, i + 1))
193+
start = -1
194+
}
195+
}
196+
}
197+
return out
198+
}
199+
200+
/**
201+
* Whether a `staleTime` value is a named constant rather than a literal duration.
202+
*
203+
* `0` is allowed: it is the sentinel for "always refetch", not a number anyone tunes, and the
204+
* drift this rule prevents cannot occur when there is no window to keep in step.
205+
*/
206+
function isNamedStaleTime(value: string): boolean {
207+
const trimmed = value.trim()
208+
if (trimmed === '0') return true
209+
return !/^[0-9]/.test(trimmed)
210+
}
135211
const QUERYFN_NOARG = /queryFn\s*:\s*(?:async\s+)?\(\s*\)\s*=>/
136212
const QUERYFN_PRESENT = /queryFn\s*:/
137213
const INLINE_KEY = /queryKey\s*:\s*\[\s*[`'"]/
@@ -278,6 +354,14 @@ function scanFile(rel: string, content: string): Violation[] {
278354
if (!/\bstaleTime\b/.test(obj) && !/\.\.\.\w/.test(obj)) {
279355
add(m.index, 'missing-stale-time', `${m[1]}({ ... }) without staleTime`)
280356
}
357+
const staleTimeValue = extractOptionValue(obj, 'staleTime')
358+
if (staleTimeValue !== null && !isNamedStaleTime(staleTimeValue)) {
359+
add(
360+
m.index,
361+
'stale-time-literal',
362+
`${m[1]} staleTime is the literal ${staleTimeValue.trim()}`
363+
)
364+
}
281365
if (QUERYFN_PRESENT.test(obj) && QUERYFN_NOARG.test(obj)) {
282366
add(m.index, 'queryfn-no-signal', `${m[1]} queryFn takes no args`)
283367
}
@@ -290,6 +374,44 @@ function scanFile(rel: string, content: string): Violation[] {
290374
}
291375
}
292376

377+
// 2b: useQueries — same checks, applied to each entry of its `queries` array
378+
USE_QUERIES_CALL.lastIndex = 0
379+
let q: RegExpExecArray | null = USE_QUERIES_CALL.exec(content)
380+
for (; q !== null; q = USE_QUERIES_CALL.exec(content)) {
381+
const parenStart = q.index + q[0].length - 1
382+
const arg = matchBalanced(content, parenStart, '(', ')')
383+
const braceRel = arg.indexOf('{')
384+
if (braceRel === -1) continue
385+
const wrapper = matchBalanced(arg, braceRel, '{', '}')
386+
const queriesValue = extractOptionValue(wrapper, 'queries')
387+
if (queriesValue === null) continue
388+
389+
for (const entry of splitObjectLiterals(queriesValue)) {
390+
if (/\.\.\.\w/.test(entry)) continue
391+
if (!/\bstaleTime\b/.test(entry)) {
392+
add(q.index, 'missing-stale-time', 'useQueries entry without staleTime')
393+
}
394+
const entryStaleTime = extractOptionValue(entry, 'staleTime')
395+
if (entryStaleTime !== null && !isNamedStaleTime(entryStaleTime)) {
396+
add(
397+
q.index,
398+
'stale-time-literal',
399+
`useQueries entry staleTime is the literal ${entryStaleTime.trim()}`
400+
)
401+
}
402+
if (QUERYFN_PRESENT.test(entry) && QUERYFN_NOARG.test(entry)) {
403+
add(q.index, 'queryfn-no-signal', 'useQueries entry queryFn takes no args')
404+
}
405+
for (const id of findKeyFetchArgDrift(entry)) {
406+
add(
407+
q.index,
408+
'key-fetch-arg-drift',
409+
`useQueries: '${id}' passed to fetch but absent from queryKey`
410+
)
411+
}
412+
}
413+
}
414+
293415
// 3: inline query keys
294416
for (let i = 0; i < lines.length; i++) {
295417
if (INLINE_KEY.test(lines[i])) {
@@ -345,7 +467,9 @@ async function main() {
345467
for (const file of files) {
346468
const rel = path.relative(ROOT, file)
347469
const content = await readFile(file, 'utf8')
348-
if (!/\buse(Query|InfiniteQuery|SuspenseQuery|Mutation)\b|[kK]eys\s*[:=]/.test(content))
470+
/* `useQueries` must be listed before `useQuery`: the alternation is ordered, and a trailing
471+
`\b` after `useQuery` would reject it outright on the `s`. */
472+
if (!/\buse(Queries|Query|InfiniteQuery|SuspenseQuery|Mutation)\b|[kK]eys\s*[:=]/.test(content))
349473
continue
350474
all.push(...scanFile(rel, content))
351475
}

0 commit comments

Comments
 (0)