History

refactor(react): declarative reactivity keys + optimistic source add

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

Rhys Sullivan committed 7 months ago

Commit c9a91b91 parent 6e4fc7930 files · +544 −119Browse files

Showing the changes to scripts (1 of 30 files). Show all

Unified · Split

scripts/check-reactivity-keys.ts+124−0
@@ -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);