Replace 30 scattered `useAtomRefresh()` call sites with effect-atom's `reactivityKeys` invalidation. Every query declares its key at the atom definition; every mutation passes the matching key at the call site, and effect-atom's Reactivity service auto-refetches on mutation completion. Adds a client-only optimistic-update layer (`usePendingSources` + `useSourcesWithPending`) so the sidebar shows a placeholder row the instant a user submits an add form, rather than waiting for the network roundtrip. A CI lint script (`lint:reactivity-keys`) fails any build that defines a write mutation call without `reactivityKeys`, locking the convention in. Key files: - packages/react/src/api/reactivity-keys.tsx — canonical key constants - packages/react/src/api/optimistic.tsx — pending-resource atom family - scripts/check-reactivity-keys.ts — CI enforcement
Showing the changes to scripts (1 of 30 files). Show all
Unified · Split
| @@ -0,0 +1,124 @@ | |||
| 1 | #!/usr/bin/env bun | ||
| 2 | // Fail CI when a frontend file calls a mutation via `useAtomSet(<atom>, { mode: "promise" })` | ||
| 3 | // without ever passing `reactivityKeys` in the corresponding call args. | ||
| 4 | // | ||
| 5 | // The check is intentionally coarse: per-file we count `useAtomSet(...)` sites | ||
| 6 | // and `reactivityKeys` mentions. A file with N mutation sites must mention | ||
| 7 | // `reactivityKeys` at least N times. Read-only mutations (probes, previews, | ||
| 8 | // auth-flow starts) are allow-listed so the convention can be locked in | ||
| 9 | // without false positives. | ||
| 10 | // | ||
| 11 | // Run: `bun run scripts/check-reactivity-keys.ts` | ||
| 12 | // Exits 1 with a punch list when violations exist. | ||
| 13 | |||
| 14 | import { Glob } from "bun"; | ||
| 15 | |||
| 16 | const ROOTS = ["packages", "apps"]; | ||
| 17 | |||
| 18 | // Mutations that intentionally do not invalidate any cached query (probes, | ||
| 19 | // previews, OAuth-flow kickoffs that just open a window). | ||
| 20 | const READ_ONLY_MUTATIONS = new Set<string>([ | ||
| 21 | "probeMcpEndpoint", | ||
| 22 | "startMcpOAuth", | ||
| 23 | "probeGoogleDiscovery", | ||
| 24 | "startGoogleDiscoveryOAuth", | ||
| 25 | "previewOpenApiSpec", | ||
| 26 | "startOpenApiOAuth", | ||
| 27 | "resolveSecret", | ||
| 28 | "detectSource", | ||
| 29 | "getDomainVerificationLink", | ||
| 30 | ]); | ||
| 31 | |||
| 32 | interface Violation { | ||
| 33 | readonly file: string; | ||
| 34 | readonly line: number; | ||
| 35 | readonly mutationVar: string; | ||
| 36 | } | ||
| 37 | |||
| 38 | const violations: Violation[] = []; | ||
| 39 | |||
| 40 | const useAtomSetRegex = /useAtomSet\(\s*([A-Za-z_][\w]*)\s*,\s*\{\s*mode:\s*"promise"\s*\}\s*\)/g; | ||
| 41 | |||
| 42 | for (const root of ROOTS) { | ||
| 43 | const glob = new Glob(`${root}/**/*.{ts,tsx}`); | ||
| 44 | for await (const path of glob.scan({ cwd: import.meta.dir + "/.." })) { | ||
| 45 | if (path.includes("node_modules") || path.includes(".test.") || path.endsWith(".d.ts")) continue; | ||
| 46 | const file = Bun.file(`${import.meta.dir}/../${path}`); | ||
| 47 | const text = await file.text(); | ||
| 48 | if (!text.includes("useAtomSet")) continue; | ||
| 49 | |||
| 50 | const lines = text.split("\n"); | ||
| 51 | let cursor = 0; | ||
| 52 | for (const line of lines) { | ||
| 53 | cursor++; | ||
| 54 | useAtomSetRegex.lastIndex = 0; | ||
| 55 | const match = useAtomSetRegex.exec(line); | ||
| 56 | if (!match) continue; | ||
| 57 | const mutationVar = match[1] ?? "<unknown>"; | ||
| 58 | // The variable being declared from useAtomSet is what the file later awaits. | ||
| 59 | // Find the binding name to look up the await call. | ||
| 60 | const bindingMatch = line.match(/const\s+(\w+)\s*=\s*useAtomSet/); | ||
| 61 | const binding = bindingMatch?.[1]; | ||
| 62 | |||
| 63 | if (READ_ONLY_MUTATIONS.has(mutationVar)) continue; | ||
| 64 | if (!binding) continue; | ||
| 65 | |||
| 66 | // Look for `await <binding>(...)` calls in the rest of the file and | ||
| 67 | // check whether the *args object* contains `reactivityKeys`. We scan | ||
| 68 | // line-by-line and grow a brace-aware window once we see the call. | ||
| 69 | const callRegex = new RegExp(`await\\s+${binding}\\s*\\(`); | ||
| 70 | let i = cursor; | ||
| 71 | while (i < lines.length) { | ||
| 72 | const l = lines[i] ?? ""; | ||
| 73 | if (callRegex.test(l)) { | ||
| 74 | // Extract the args block from the opening paren until balanced close. | ||
| 75 | let depth = 0; | ||
| 76 | let started = false; | ||
| 77 | let argText = ""; | ||
| 78 | let j = i; | ||
| 79 | while (j < lines.length) { | ||
| 80 | const cur = lines[j] ?? ""; | ||
| 81 | for (const ch of cur) { | ||
| 82 | if (ch === "(") { | ||
| 83 | depth++; | ||
| 84 | started = true; | ||
| 85 | continue; | ||
| 86 | } | ||
| 87 | if (started && ch === ")") { | ||
| 88 | depth--; | ||
| 89 | if (depth === 0) break; | ||
| 90 | } | ||
| 91 | if (started) argText += ch; | ||
| 92 | } | ||
| 93 | if (started && depth === 0) break; | ||
| 94 | j++; | ||
| 95 | if (j - i > 60) break; // bail on runaway | ||
| 96 | } | ||
| 97 | if (!argText.includes("reactivityKeys")) { | ||
| 98 | violations.push({ file: path, line: i + 1, mutationVar }); | ||
| 99 | } | ||
| 100 | break; | ||
| 101 | } | ||
| 102 | i++; | ||
| 103 | if (i - cursor > 80) break; // give up on this site if the await is far away | ||
| 104 | } | ||
| 105 | } | ||
| 106 | } | ||
| 107 | } | ||
| 108 | |||
| 109 | if (violations.length === 0) { | ||
| 110 | console.log("✓ reactivityKeys check passed"); | ||
| 111 | process.exit(0); | ||
| 112 | } | ||
| 113 | |||
| 114 | console.error(`✗ reactivityKeys check failed — ${violations.length} mutation call(s) missing reactivityKeys:\n`); | ||
| 115 | for (const v of violations) { | ||
| 116 | console.error(` ${v.file}:${v.line} — ${v.mutationVar}`); | ||
| 117 | } | ||
| 118 | console.error( | ||
| 119 | `\nEvery write mutation must pass reactivityKeys at the call site.\n` + | ||
| 120 | `See packages/react/src/api/reactivity-keys.tsx for canonical key arrays.\n` + | ||
| 121 | `If this mutation truly is read-only (probe/preview/OAuth-start), add it to\n` + | ||
| 122 | `the READ_ONLY_MUTATIONS allowlist in scripts/check-reactivity-keys.ts.`, | ||
| 123 | ); | ||
| 124 | process.exit(1); | ||