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
14import { Glob } from "bun";
15
16const 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).
20const 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
32interface Violation {
33 readonly file: string;
34 readonly line: number;
35 readonly mutationVar: string;
36}
37
38const violations: Violation[] = [];
39
40const useAtomSetRegex = /useAtomSet\(\s*([A-Za-z_][\w]*)\s*,\s*\{\s*mode:\s*"promise"\s*\}\s*\)/g;
41
42for (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
109if (violations.length === 0) {
110 console.log("✓ reactivityKeys check passed");
111 process.exit(0);
112}
113
114console.error(`✗ reactivityKeys check failed — ${violations.length} mutation call(s) missing reactivityKeys:\n`);
115for (const v of violations) {
116 console.error(` ${v.file}:${v.line} — ${v.mutationVar}`);
117}
118console.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);
124process.exit(1);