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); | |