Skip to content

Commit 3f7a3e3

Browse files
perf: reorder declarations to fix React Compiler scope pruning (#24098)
1 parent 17a71ae commit 3f7a3e3

File tree

5 files changed

+295
-91
lines changed

5 files changed

+295
-91
lines changed

.github/workflows/typos.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ typ = "typ"
3636
styl = "styl"
3737
edn = "edn"
3838
Inferrable = "Inferrable"
39+
IIF = "IIF"
3940

4041
[files]
4142
extend-exclude = [

site/scripts/check-compiler.mjs

Lines changed: 110 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ const skipPatterns = [".test.", ".stories.", ".jest."];
2727
// Maximum length for truncated error messages in the report.
2828
const MAX_ERROR_LENGTH = 120;
2929

30+
// Patterns that identify a function/closure value on the RHS of an
31+
// assignment. Primitives (strings, numbers, booleans) are fine without
32+
// memoization because `!==` compares them by value. Only reference types
33+
// (closures, objects, arrays) cause problems.
34+
const CLOSURE_RHS = /^\s*(?:const|let)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)\s*=>|\w+\s*=>|function\s*\()/;
35+
36+
// Matches a `$[N] !== name` fragment inside an `if (...)` guard.
37+
const DEP_CHECK = /\$\[\d+\]\s*!==\s*(\w+)/g;
38+
3039
// ---------------------------------------------------------------------------
3140
// File collection
3241
// ---------------------------------------------------------------------------
@@ -69,8 +78,8 @@ function collectFiles(dir) {
6978
//
7079
// We use transformSync deliberately. The React Compiler plugin is
7180
// CPU-bound (parse-only takes ~2s vs ~19s with the compiler over all
72-
// of site/src), so transformAsync + Promise.all gives no speedup
73-
// Node still runs all transforms on a single thread. Benchmarked
81+
// of site/src), so transformAsync + Promise.all gives no speedup
82+
// because Node still runs all transforms on a single thread. Benchmarked
7483
// sync, async-sequential, and async-parallel: all land within noise
7584
// of each other. The sync API keeps the code simple.
7685
// ---------------------------------------------------------------------------
@@ -153,11 +162,13 @@ function compileFile(file) {
153162

154163
return {
155164
compiled: compiledCount,
165+
code: result?.code ?? "",
156166
diagnostics: deduplicateDiagnostics(diagnostics),
157167
};
158168
} catch (e) {
159169
return {
160170
compiled: 0,
171+
code: "",
161172
diagnostics: [{
162173
line: 0,
163174
// Truncate to keep the one-line report readable.
@@ -167,6 +178,70 @@ function compileFile(file) {
167178
}
168179
}
169180

181+
// ---------------------------------------------------------------------------
182+
// Scope-pruning detection
183+
//
184+
// The compiler's flattenScopesWithHooksOrUse pass silently drops
185+
// memoization scopes that span across hook calls. A closure whose
186+
// scope is pruned appears as a bare `const name = (...) =>` with
187+
// no `$[N]` guard, yet it may still be listed as a dependency in a
188+
// downstream JSX memoization block (`$[N] !== name`). That means
189+
// the JSX cache check fails every render because `name` is a new
190+
// function reference each time.
191+
//
192+
// findUnmemoizedClosureDeps detects this pattern in compiled output:
193+
// 1. Collect every name that appears in a `$[N] !== name` dep check.
194+
// 2. For each, check if the name is assigned a function value
195+
// (arrow or function expression) outside any `$[N]` guard.
196+
// 3. If so, the closure is unmemoized but used as a reactive dep,
197+
// which defeats the downstream memoization.
198+
// ---------------------------------------------------------------------------
199+
200+
/**
201+
* Scan compiled output for closures that appear as dependencies in
202+
* memoization guards but are not themselves memoized. Returns an
203+
* array of `{ name, line }` objects for each finding.
204+
*/
205+
export function findUnmemoizedClosureDeps(code) {
206+
if (!code) return [];
207+
208+
const lines = code.split("\n");
209+
210+
// Pass 1: collect every name used in a $[N] !== name dep check.
211+
const depNames = new Set();
212+
for (const line of lines) {
213+
for (const m of line.matchAll(DEP_CHECK)) {
214+
depNames.add(m[1]);
215+
}
216+
}
217+
if (depNames.size === 0) return [];
218+
219+
// Pass 2: find closure definitions that are directly assigned a
220+
// function value (not assigned from a temp like `const x = t1`).
221+
// A memoized closure uses the temp pattern:
222+
// if ($[N] !== dep) { t1 = () => {...}; } else { t1 = $[N]; }
223+
// const name = t1;
224+
// An unmemoized closure is assigned the function directly:
225+
// const name = () => {...};
226+
const findings = [];
227+
for (let i = 0; i < lines.length; i++) {
228+
const match = lines[i].match(CLOSURE_RHS);
229+
if (!match) continue;
230+
231+
const name = match[1];
232+
if (!depNames.has(name)) continue;
233+
234+
// Compiler temporaries are named t0, t1, ... tN. If the
235+
// variable name matches that pattern it's an intermediate,
236+
// not a user-visible declaration.
237+
if (/^t\d+$/.test(name)) continue;
238+
239+
findings.push({ name, line: i + 1 });
240+
}
241+
242+
return findings;
243+
}
244+
170245
// ---------------------------------------------------------------------------
171246
// Report
172247
// ---------------------------------------------------------------------------
@@ -206,27 +281,47 @@ function printReport(failures, totalCompiled, fileCount, hadErrors) {
206281
// Main
207282
// ---------------------------------------------------------------------------
208283

284+
// Tracks whether collectFiles encountered a missing directory.
285+
// Module-scoped so the function can set it and the main block can
286+
// read it after collection finishes.
287+
let hadCollectionErrors = false;
288+
209289
// Only run the main block when executed directly, not when imported
210290
// by tests for the exported pure functions.
211291
if (process.argv[1] === fileURLToPath(import.meta.url)) {
212-
let hadCollectionErrors = false;
213292

214293
const files = targetDirs.flatMap((d) => collectFiles(join(siteDir, d)));
215294

216-
let totalCompiled = 0;
217-
const failures = [];
295+
let totalCompiled = 0;
296+
const failures = [];
218297

219-
for (const file of files) {
220-
const { compiled, diagnostics } = compileFile(file);
221-
totalCompiled += compiled;
222-
if (diagnostics.length > 0) {
223-
failures.push({ file, compiled, diagnostics });
298+
const scopePruned = [];
299+
300+
for (const file of files) {
301+
const { compiled, code, diagnostics } = compileFile(file);
302+
totalCompiled += compiled;
303+
if (diagnostics.length > 0) {
304+
failures.push({ file, compiled, diagnostics });
305+
}
306+
const pruned = findUnmemoizedClosureDeps(code);
307+
if (pruned.length > 0) {
308+
scopePruned.push({ file, closures: pruned });
309+
}
224310
}
225-
}
226311

227-
printReport(failures, totalCompiled, files.length, hadCollectionErrors);
312+
printReport(failures, totalCompiled, files.length, hadCollectionErrors);
228313

229-
if (failures.length > 0 || hadCollectionErrors) {
230-
process.exitCode = 1;
231-
}
314+
if (scopePruned.length > 0) {
315+
console.log("\nUnmemoized closures used as reactive dependencies:");
316+
console.log("(Move these after all hook calls to restore memoization)\n");
317+
for (const { file, closures } of scopePruned) {
318+
for (const c of closures) {
319+
console.log(` ✗ ${shortPath(file)}: ${c.name}`);
320+
}
321+
}
322+
}
323+
324+
if (failures.length > 0 || hadCollectionErrors || scopePruned.length > 0) {
325+
process.exitCode = 1;
326+
}
232327
}

site/scripts/check-compiler.test.mjs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import {
33
deduplicateDiagnostics,
4+
findUnmemoizedClosureDeps,
45
shortPath,
56
shortenMessage,
67
} from "./check-compiler.mjs";
@@ -93,3 +94,110 @@ describe("shortPath", () => {
9394
.toBe("src/utils/helper.ts");
9495
});
9596
});
97+
98+
describe("findUnmemoizedClosureDeps", () => {
99+
it("detects a bare closure used in a dep check", () => {
100+
const code = [
101+
"const urlTransform = url => {",
102+
" return rewrite(url);",
103+
"};",
104+
"let t0;",
105+
"if ($[0] !== urlTransform) {",
106+
" t0 = <View urlTransform={urlTransform} />;",
107+
"}",
108+
].join("\n");
109+
expect(findUnmemoizedClosureDeps(code)).toEqual([
110+
{ name: "urlTransform", line: 1 },
111+
]);
112+
});
113+
114+
it("ignores a memoized closure (preceded by else branch)", () => {
115+
const code = [
116+
"let t1;",
117+
"if ($[0] !== proxyHost) {",
118+
" t1 = url => rewrite(url, proxyHost);",
119+
" $[0] = proxyHost;",
120+
" $[1] = t1;",
121+
"} else {",
122+
" t1 = $[1];",
123+
"}",
124+
"const urlTransform = t1;",
125+
"if ($[2] !== urlTransform) {",
126+
" t2 = <View urlTransform={urlTransform} />;",
127+
"}",
128+
].join("\n");
129+
expect(findUnmemoizedClosureDeps(code)).toEqual([]);
130+
});
131+
132+
it("ignores primitives (not closures)", () => {
133+
const code = [
134+
"const offset = (page - 1) * pageSize;",
135+
"if ($[0] !== offset) {",
136+
" t0 = <View offset={offset} />;",
137+
"}",
138+
].join("\n");
139+
expect(findUnmemoizedClosureDeps(code)).toEqual([]);
140+
});
141+
142+
it("ignores closures not referenced in any dep check", () => {
143+
const code = [
144+
"const handler = () => console.log('hi');",
145+
"return <View />;",
146+
].join("\n");
147+
expect(findUnmemoizedClosureDeps(code)).toEqual([]);
148+
});
149+
150+
it("detects async closures", () => {
151+
const code = [
152+
"const doWork = async (id) => {",
153+
" await api.call(id);",
154+
"};",
155+
"if ($[0] !== doWork) {",
156+
" t0 = <View handler={doWork} />;",
157+
"}",
158+
].join("\n");
159+
expect(findUnmemoizedClosureDeps(code)).toEqual([
160+
{ name: "doWork", line: 1 },
161+
]);
162+
});
163+
164+
it("returns empty for empty input", () => {
165+
expect(findUnmemoizedClosureDeps("")).toEqual([]);
166+
expect(findUnmemoizedClosureDeps(null)).toEqual([]);
167+
expect(findUnmemoizedClosureDeps(undefined)).toEqual([]);
168+
});
169+
170+
it("detects multiple unmemoized closures", () => {
171+
const code = [
172+
"const fn1 = (x) => x + 1;",
173+
"const fn2 = (y) => y * 2;",
174+
"if ($[0] !== fn1 || $[1] !== fn2) {",
175+
" t0 = <View a={fn1} b={fn2} />;",
176+
"}",
177+
].join("\n");
178+
const result = findUnmemoizedClosureDeps(code);
179+
expect(result).toHaveLength(2);
180+
expect(result[0].name).toBe("fn1");
181+
expect(result[1].name).toBe("fn2");
182+
});
183+
184+
// The CLOSURE_RHS regex also matches IIFEs like `const x = (() => {...})();`.
185+
// The compiler does not emit IIFEs in compiled output, so this is not
186+
// a real-world false positive today. This test documents the assumption
187+
// so it breaks visibly if the compiler changes its output shape.
188+
it("matches IIFEs (documents known regex limitation)", () => {
189+
const code = [
190+
"const config = (() => {",
191+
" return { theme: 'dark' };",
192+
"})();",
193+
"if ($[0] !== config) {",
194+
" t0 = <View config={config} />;",
195+
"}",
196+
].join("\n");
197+
// CLOSURE_RHS matches the IIFE because it starts with `(() =>`.
198+
// This is a known false positive that does not occur in practice.
199+
expect(findUnmemoizedClosureDeps(code)).toEqual([
200+
{ name: "config", line: 1 },
201+
]);
202+
});
203+
});

site/src/pages/AgentsPage/AgentChatPage.tsx

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -573,22 +573,6 @@ const AgentChatPage: FC = () => {
573573
const workspaceAgent = getWorkspaceAgent(workspace, undefined);
574574
const { proxy } = useProxy();
575575

576-
// Extract the primitive fields used by the transform so the
577-
// compiler can see the real dependencies and avoid invalidating
578-
// the closure when the workspace object reference changes but
579-
// the relevant fields haven't.
580-
const proxyHost = proxy.preferredWildcardHostname;
581-
const agentName = workspaceAgent?.name;
582-
const wsName = workspace?.name;
583-
const wsOwner = workspace?.owner_name;
584-
585-
const urlTransform: UrlTransform = (url) => {
586-
if (!proxyHost || !agentName || !wsName || !wsOwner) {
587-
return url;
588-
}
589-
return rewriteLocalhostURL(url, proxyHost, agentName, wsName, wsOwner);
590-
};
591-
592576
const chatRecord = chatQuery.data;
593577

594578
// Initialize MCP selection from chat record or defaults.
@@ -1090,6 +1074,19 @@ const AgentChatPage: FC = () => {
10901074
agentId,
10911075
]);
10921076

1077+
// Primitives extracted from proxy/workspace so the compiler
1078+
// tracks stable strings, not object identity.
1079+
const proxyHost = proxy.preferredWildcardHostname;
1080+
const agentName = workspaceAgent?.name;
1081+
const wsName = workspace?.name;
1082+
const wsOwner = workspace?.owner_name;
1083+
const urlTransform: UrlTransform = (url) => {
1084+
if (!proxyHost || !agentName || !wsName || !wsOwner) {
1085+
return url;
1086+
}
1087+
return rewriteLocalhostURL(url, proxyHost, agentName, wsName, wsOwner);
1088+
};
1089+
10931090
const handleRegenerateTitle = () => {
10941091
if (!agentId || isRegenerateTitleDisabled || !onRegenerateTitle) {
10951092
return;

0 commit comments

Comments
 (0)