Files
sim/scripts/check-react-query-patterns.ts
T
Waleed d8da1e2577 fix(state): align server/client state with best practices (query-key bugs, persist hygiene, useState) (#5166)
* fix(queries): close React Query key/fetch-arg drift cache collisions

Several query hooks fetched with an identifier that was absent from their
queryKey, so distinct fetch args shared one cache entry. Thread the missing
args into the key factories and update all callsites/invalidations.

- organization: useOrganization always fetched the ACTIVE org via
  getFullOrganization() while caching under detail(orgId). Pass orgId through
  to the better-auth call (query.organizationId); active-org behavior unchanged.
- logs: logKeys.detail now keys on (workspaceId, logId) to prevent cross-
  workspace collision; updated useLogDetail, useLogByExecutionId, prefetchLogDetail,
  useCancelExecution optimistic path, and external callsites.
- inbox: inboxKeys.taskList now includes cursor/limit (pagination args were sent
  but omitted from the key); keepPreviousData pagination UX preserved.
- a2a: narrow create/update byWorkflows() invalidation to byWorkflow(ws, wf)
  since their responses reliably carry both ids; delete/publish stay broad.

Not bugs (verified, left unchanged):
- kb/connectors update/delete invalidate knowledgeKeys.detail(kbId), which is a
  prefix of connectorKeys.all(kbId) — connector list/detail are invalidated
  transitively by React Query prefix matching.

Harness: add a key-fetch-arg-drift check to check-react-query-patterns.ts that
flags a camelCase identifier the queryFn forwards into the fetch but is absent
from the queryKey (excludes the requestJson contract arg, PascalCase/SCREAMING
constants, and signal/pageParam machinery). Document the rule in sim-queries.md.
tables.useTable annotated rq-lint-allow (tableId globally unique; workspaceId is
only an authz scope).

* fix(stores): whitelist durable fields in persist partialize

chat/terminal/panel persist configs leaked actions and transient state
into localStorage. Replace the chat full-state spread with an explicit
durable whitelist, and add partialize to terminal and panel (which had
none) so isResizing and _hasHydrated are no longer persisted. Panel keeps
activeTab + panelWidth because the layout.tsx blocking script reads them
from panel-state to set data-panel-active-tab before hydration (SSR
tab-flash prevention).

Harden sim-stores doctrine: persist MUST use an explicit partialize
whitelist; never persist transient flags or _hasHydrated.

* fix(state): model component useState as single source of truth

- edit-knowledge-base-modal: reset fields on closed→open via prevOpenRef
  render idiom instead of mirroring props into state through useEffect
  (a prop change while open no longer clobbers in-progress edits)
- use-verification: collapse contradictory isLoading/isVerified/isInvalidOtp
  booleans into a single status enum + errorMessage; consumer derives flags
- contact-form / demo-request-modal: derive busy/success from the mutation
  object; delete duplicated submitSuccess local state
- sim-hooks.md: add state-shape rule (no props-into-state, status enum,
  derive mutation state)

* fix(verify): clear lingering message on complete OTP (restore parity)

* docs(state): convert inline reset comment to TSDoc

* docs(state): tighten harness rules for accuracy (queryFn forwards, partialize whitelist, mutation-flag caveat)

* fix(verify): block auto-verify while a resend is in flight (restore parity)

* fix(logs): key cancel optimistic detail by route workspaceId (not the log row)
2026-06-21 22:44:14 -07:00

412 lines
15 KiB
TypeScript

#!/usr/bin/env bun
/**
* Enforces the project's React Query (TanStack Query v5) conventions.
*
* Biome and `check-api-validation-contracts.ts` cover formatting and the
* raw-fetch / contract boundary. This script catches React-Query-specific
* anti-patterns that neither covers:
*
* 1. missing-stale-time — useQuery/useInfiniteQuery/useSuspenseQuery without an explicit `staleTime`
* 2. queryfn-no-signal — an inline `queryFn` that takes no args (cannot forward the AbortSignal)
* 3. inline-query-key — `queryKey: ['literal', ...]` instead of a colocated key factory
* 4. key-factory-no-root — a `*Keys` factory in hooks/queries/** without an `all` root key
* 5. key-fetch-arg-drift — an identifier the queryFn forwards into the fetch (e.g. `workspaceId`)
* that is absent from the queryKey, so distinct fetch args collide on one
* cache entry. Conservative: only bare camelCase identifiers (never the
* requestJson contract arg, PascalCase/SCREAMING constants, or signal/
* pageParam machinery) are checked, and only when both a queryKey and an
* inline-arrow queryFn with a recognizable call are present.
*
* Enforcement model (mirrors check-api-validation-contracts.ts):
* - STRICT ZONE (apps/sim/hooks/queries/**): zero tolerance — any violation fails.
* - Elsewhere under apps/sim/**: ratcheted against scripts/check-react-query-patterns.baseline.json
* (fails only when a category's count rises above the recorded baseline).
*
* Escape hatch: put `// rq-lint-allow: <reason>` on the line directly above the
* flagged construct (up to 3 preceding comment lines tolerated). The reason must
* be non-empty.
*
* Usage:
* bun run scripts/check-react-query-patterns.ts # report
* bun run scripts/check-react-query-patterns.ts --check # CI gate (strict zone + ratchet)
* bun run scripts/check-react-query-patterns.ts --update-baseline
*/
import { readdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
const ROOT = path.resolve(import.meta.dir, '..')
const APP_DIR = path.join(ROOT, 'apps/sim')
const BASELINE_PATH = path.join(ROOT, 'scripts/check-react-query-patterns.baseline.json')
const SKIP_DIRS = new Set(['node_modules', '.next', '.turbo', 'coverage', 'dist', 'build'])
const STRICT_PREFIX = 'apps/sim/hooks/queries/'
const ALLOW = 'rq-lint-allow:'
type Category =
| 'missing-stale-time'
| 'queryfn-no-signal'
| 'inline-query-key'
| 'key-factory-no-root'
| 'key-fetch-arg-drift'
interface Violation {
file: string
line: number
category: Category
message: string
snippet: string
}
const SUGGESTION: Record<Category, string> = {
'missing-stale-time':
'add an explicit staleTime (default 0 is rarely correct); e.g. staleTime: 60 * 1000',
'queryfn-no-signal':
'destructure the AbortSignal: queryFn: ({ signal }) => fetchX(..., signal) and forward it',
'inline-query-key':
'use a colocated hierarchical key factory (entityKeys.list(id)) instead of an inline literal key',
'key-factory-no-root':
'every *Keys factory in hooks/queries/** must expose an `all` root key for prefix invalidation',
'key-fetch-arg-drift':
'every identifier the queryFn passes to fetch/requestJson must also appear in the queryKey — ' +
'otherwise distinct fetch args collide on one cache entry (cross-tenant/param cache collision)',
}
async function walk(dir: string, out: string[] = []): Promise<string[]> {
let entries
try {
entries = await readdir(dir, { withFileTypes: true })
} catch {
return out
}
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) await walk(full, out)
else if (/\.(ts|tsx)$/.test(entry.name) && !/\.(test|spec)\.(ts|tsx)$/.test(entry.name))
out.push(full)
}
return out
}
/** Returns the substring of `src` for the balanced bracket group starting at `open` (index of the opening bracket). */
function matchBalanced(src: string, open: number, openCh: string, closeCh: string): string {
let depth = 0
let inStr: string | null = null
for (let i = open; i < src.length; i++) {
const ch = src[i]
const prev = src[i - 1]
if (inStr) {
if (ch === inStr && prev !== '\\') inStr = null
continue
}
if (ch === '"' || ch === "'" || ch === '`') {
inStr = ch
continue
}
if (ch === openCh) depth++
else if (ch === closeCh) {
depth--
if (depth === 0) return src.slice(open, i + 1)
}
}
return src.slice(open)
}
function lineAt(content: string, index: number): number {
let line = 1
for (let i = 0; i < index && i < content.length; i++) if (content[i] === '\n') line++
return line
}
/** True if a `// rq-lint-allow: <reason>` annotation sits within the 3 lines above `line` (1-based). */
function hasAllow(lines: string[], line: number): boolean {
for (let i = line - 2; i >= 0 && i >= line - 5; i--) {
const text = lines[i]?.trim() ?? ''
if (text.includes(ALLOW)) {
const reason = text.slice(text.indexOf(ALLOW) + ALLOW.length).trim()
return reason.length > 0
}
if (text.length > 0 && !text.startsWith('//') && !text.startsWith('*')) break
}
return false
}
const QUERY_CALL = /\b(useQuery|useInfiniteQuery|useSuspenseQuery|useSuspenseInfiniteQuery)\s*\(/g
const QUERYFN_NOARG = /queryFn\s*:\s*(?:async\s+)?\(\s*\)\s*=>/
const QUERYFN_PRESENT = /queryFn\s*:/
const INLINE_KEY = /queryKey\s*:\s*\[\s*[`'"]/
const KEYS_FACTORY = /\b(?:export\s+)?const\s+\w*[kK]eys\s*[:=][^=]*?=?\s*\{/g
/**
* Identifiers that are part of the queryFn machinery (not fetch params) and
* must never be flagged as drift even though they appear in the call.
*/
const QUERYFN_NOISE = new Set([
'signal',
'pageParam',
'meta',
'queryKey',
'direction',
'client',
'true',
'false',
'null',
'undefined',
])
/**
* Extracts the value slice of `key: ...` from an options object, reading up to
* the property-terminating top-level comma. Nested brackets (arrays, calls, and
* arrow-function bodies including their own param parens) are skipped via depth
* tracking, so this correctly returns the whole `({ signal }) => fetchX(...)`
* arrow for `queryFn`, not just its parameter list.
*/
function extractOptionValue(obj: string, key: string): string | null {
const re = new RegExp(`\\b${key}\\s*:`, 'g')
const m = re.exec(obj)
if (!m) return null
let i = m.index + m[0].length
while (i < obj.length && /\s/.test(obj[i])) i++
let depth = 0
let inStr: string | null = null
let j = i
for (; j < obj.length; j++) {
const c = obj[j]
const prev = obj[j - 1]
if (inStr) {
if (c === inStr && prev !== '\\') inStr = null
continue
}
if (c === '"' || c === "'" || c === '`') {
inStr = c
continue
}
if (c === '(' || c === '[' || c === '{') depth++
else if (c === ')' || c === ']' || c === '}') {
if (depth === 0) break
depth--
} else if (c === ',' && depth === 0) break
}
return obj.slice(i, j)
}
/**
* "key/fetch-arg drift": an identifier the queryFn forwards into the fetch
* (the args of the first call expression inside the queryFn body) that does NOT
* textually appear in the queryKey. Conservative — only fires when both the
* queryKey and an inline-arrow queryFn with a recognizable call are present, and
* only for bare identifiers (optionally `x as T` / `x!`), never member access,
* literals, or queryFn machinery (signal/pageParam/etc.).
*/
function findKeyFetchArgDrift(obj: string): string[] {
const keyExpr = extractOptionValue(obj, 'queryKey')
const fnExpr = extractOptionValue(obj, 'queryFn')
if (!keyExpr || !fnExpr) return []
// Drop the arrow prefix (`async`, `(params) =>`, optional `{ return`) so the
// search begins at the queryFn body, then locate the first call expression and
// balance its argument list.
const bodyStart = /=>\s*(?:\{\s*(?:return\s+)?)?/.exec(fnExpr)
const body = bodyStart ? fnExpr.slice(bodyStart.index + bodyStart[0].length) : fnExpr
const call = /(?:await\s+)?([A-Za-z_$][\w$.]*)\s*\(/.exec(body)
if (!call) return []
const callee = call[1]
const openParen = call.index + call[0].length - 1
const argList = matchBalanced(body, openParen, '(', ')')
// Split top-level args.
const args: string[] = []
let depth = 0
let cur = ''
for (let i = 1; i < argList.length - 1; i++) {
const c = argList[i]
if (c === '(' || c === '[' || c === '{') depth++
else if (c === ')' || c === ']' || c === '}') depth--
if (c === ',' && depth === 0) {
args.push(cur)
cur = ''
} else cur += c
}
if (cur.trim()) args.push(cur)
// `requestJson(contract, ...)` / `ensureQueryData(contract, ...)` etc. take the
// contract constant as their first arg — that is module-level config, never a
// per-hook identifier, so drop it before checking.
const dropsFirstArg = /(?:^|\.)(requestJson|requestText|ensureQueryData|fetchQuery)$/.test(callee)
const checkArgs = dropsFirstArg ? args.slice(1) : args
const drifted: string[] = []
for (const raw of checkArgs) {
const id = raw
.trim()
.replace(/\s+as\s+[\w$.<>[\]| ]+$/, '')
.replace(/!+$/, '')
.trim()
// Only bare identifiers; skip member access, calls, literals, destructures, spreads.
if (!/^[A-Za-z_$][\w$]*$/.test(id)) continue
if (QUERYFN_NOISE.has(id)) continue
// Skip module-level constants (contracts/config), which are camelCase config
// ending in `Contract`, PascalCase, or SCREAMING_SNAKE — never hook params.
if (/Contract$/.test(id) || /^[A-Z]/.test(id) || /^[A-Z0-9_]+$/.test(id)) continue
// Present in the key (as a whole word) → no drift.
const inKey = new RegExp(`\\b${id}\\b`).test(keyExpr)
if (!inKey && !drifted.includes(id)) drifted.push(id)
}
return drifted
}
function scanFile(rel: string, content: string): Violation[] {
const lines = content.split('\n')
const violations: Violation[] = []
const add = (index: number, category: Category, snippet: string) => {
const line = lineAt(content, index)
if (hasAllow(lines, line)) return
violations.push({ file: rel, line, category, message: SUGGESTION[category], snippet })
}
// 1 & 2: query call objects — staleTime + queryFn signal
QUERY_CALL.lastIndex = 0
let m: RegExpExecArray | null = QUERY_CALL.exec(content)
for (; m !== null; m = QUERY_CALL.exec(content)) {
const parenStart = m.index + m[0].length - 1
const arg = matchBalanced(content, parenStart, '(', ')')
// Only inspect the literal options object form `useQuery({ ... })`.
const braceRel = arg.indexOf('{')
if (braceRel === -1) continue
const obj = matchBalanced(arg, braceRel, '{', '}')
// Accept both `staleTime: ...` and the shorthand `staleTime,`; skip objects that spread options (...opts).
if (!/\bstaleTime\b/.test(obj) && !/\.\.\.\w/.test(obj)) {
add(m.index, 'missing-stale-time', `${m[1]}({ ... }) without staleTime`)
}
if (QUERYFN_PRESENT.test(obj) && QUERYFN_NOARG.test(obj)) {
add(m.index, 'queryfn-no-signal', `${m[1]} queryFn takes no args`)
}
for (const id of findKeyFetchArgDrift(obj)) {
add(
m.index,
'key-fetch-arg-drift',
`${m[1]}: '${id}' passed to fetch but absent from queryKey`
)
}
}
// 3: inline query keys
for (let i = 0; i < lines.length; i++) {
if (INLINE_KEY.test(lines[i])) {
const line = i + 1
if (!hasAllow(lines, line)) {
violations.push({
file: rel,
line,
category: 'inline-query-key',
message: SUGGESTION['inline-query-key'],
snippet: lines[i].trim(),
})
}
}
}
// 4: key factory must have an `all` root (hooks/queries/** only, excluding util key files that compose others)
if (rel.startsWith(STRICT_PREFIX)) {
KEYS_FACTORY.lastIndex = 0
let k: RegExpExecArray | null = KEYS_FACTORY.exec(content)
for (; k !== null; k = KEYS_FACTORY.exec(content)) {
const braceIdx = content.indexOf('{', k.index)
if (braceIdx === -1) continue
const obj = matchBalanced(content, braceIdx, '{', '}')
if (!/\ball\s*:/.test(obj)) {
add(k.index, 'key-factory-no-root', 'key factory missing `all` root key')
}
}
}
return violations
}
interface Baseline {
generatedFrom: string
counts: Record<string, number>
}
async function loadBaseline(): Promise<Baseline> {
try {
return JSON.parse(await readFile(BASELINE_PATH, 'utf8'))
} catch {
return { generatedFrom: 'none', counts: {} }
}
}
async function main() {
const update = process.argv.includes('--update-baseline')
const check = process.argv.includes('--check')
const files = await walk(APP_DIR)
const all: Violation[] = []
for (const file of files) {
const rel = path.relative(ROOT, file)
const content = await readFile(file, 'utf8')
if (!/\buse(Query|InfiniteQuery|SuspenseQuery|Mutation)\b|[kK]eys\s*[:=]/.test(content))
continue
all.push(...scanFile(rel, content))
}
const strict = all.filter((v) => v.file.startsWith(STRICT_PREFIX))
const ratchet = all.filter((v) => !v.file.startsWith(STRICT_PREFIX))
const counts: Record<string, number> = {}
for (const v of ratchet) counts[v.category] = (counts[v.category] ?? 0) + 1
if (update) {
const baseline: Baseline = { generatedFrom: 'apps/sim (non-strict zone)', counts }
await writeFile(BASELINE_PATH, `${JSON.stringify(baseline, null, 2)}\n`)
console.log(`✓ Baseline written: ${JSON.stringify(counts)}`)
process.exit(0)
}
console.log(`React Query pattern audit — scanned ${files.length} files`)
console.log(` strict zone (${STRICT_PREFIX}**) violations: ${strict.length}`)
console.log(` ratchet zone violations: ${ratchet.length} ${JSON.stringify(counts)}`)
let failed = false
if (strict.length > 0) {
failed = true
console.error(`\n✗ ${strict.length} violation(s) in the strict zone (${STRICT_PREFIX}**):\n`)
for (const v of strict) {
console.error(` ${v.file}:${v.line} [${v.category}]`)
console.error(` ${v.snippet}`)
console.error(` → ${v.message}\n`)
}
}
if (!check && ratchet.length > 0) {
console.error(`\nRatchet-zone occurrences (not failing without --check):`)
for (const v of ratchet) {
console.error(` ${v.file}:${v.line} [${v.category}] ${v.snippet}`)
}
}
if (check) {
const baseline = await loadBaseline()
for (const [category, count] of Object.entries(counts)) {
const base = baseline.counts[category] ?? 0
if (count > base) {
failed = true
console.error(
`\n✗ ratchet regression: ${category} rose to ${count} (baseline ${base}). ` +
`Fix the new occurrence(s) or annotate with // ${ALLOW} <reason>.`
)
for (const v of ratchet.filter((x) => x.category === category)) {
console.error(` ${v.file}:${v.line} ${v.snippet}`)
}
}
}
}
if (failed) process.exit(1)
console.log('\n✓ React Query pattern audit passed.')
process.exit(0)
}
main()