1// @effect-diagnostics nodeBuiltinImport:off - compiles real source files on disk.
2import * as NodeFS from "node:fs";
3import * as NodeModule from "node:module";
4import * as NodePath from "node:path";
5import { describe, expect, it } from "vite-plus/test";
6
7const mobileRoot = NodePath.resolve(__dirname, "..");
8const requireFromMobile = NodeModule.createRequire(NodePath.join(mobileRoot, "package.json"));
9const babel: {
10 transformFileSync: (file: string, options: Record<string, unknown>) => unknown;
11} = requireFromMobile("@babel/core");
12const metroConfig: Promise<{ transformer: { babelTransformerPath: string } }> =
13 requireFromMobile("./metro.config.js");
14
15// Remove bailout entries and manual memoization when compilation starts; this check rejects stale entries.
16const KNOWN_BAILOUTS: Record<string, string> = {
17 "src/components/ui/Menu.tsx": "ref read during render",
18 "src/features/diffs/nativeReviewDiffSurface.ts": "ref read during render",
19 "src/features/home/thread-swipe-actions.tsx": "ref read during render",
20 "src/features/threads/ThreadArrangementSheet.tsx": "ref read during render",
21 "src/features/voice-input/useVoiceInputController.ts": "ref read during render",
22 "src/native/T3ComposerEditor.ios.tsx": "ref read during render",
23 "src/native/T3ComposerEditor.native.tsx": "ref read during render",
24};
25
26// Use Metro's iOS release flags; experiments.reactCompiler enables supportsReactCompiler.
27const metroCaller = {
28 name: "metro",
29 bundler: "metro",
30 platform: "ios",
31 isDev: false,
32 isServer: false,
33 isNodeModule: false,
34 supportsStaticESM: true,
35 supportsReactCompiler: true,
36};
37
38const sourceFiles = NodeFS.readdirSync(NodePath.join(mobileRoot, "src"), { recursive: true })
39 .map(String)
40 .filter((file) => /\.tsx?$/.test(file) && !/\.(test\.tsx?|d\.ts)$/.test(file))
41 .map((file) => `src/${file.split(NodePath.sep).join("/")}`)
42 .sort();
43
44type CompilerLoc = { readonly start?: { readonly line?: number } } | null;
45
46interface CompilerEvent {
47 readonly kind: string;
48 readonly detail?: {
49 readonly options?: {
50 readonly category?: string;
51 readonly reason?: string;
52 readonly loc?: CompilerLoc;
53 readonly details?: readonly { readonly loc?: CompilerLoc }[];
54 };
55 };
56}
57
58// babel.config.js is the file Metro loads; the logger rides on the preset's
59// own react-compiler option so the plugin runs with Metro's exact settings.
60function compileLikeMetro(file: string, events: CompilerEvent[], output: Record<string, unknown>) {
61 const babelConfig = requireFromMobile("./babel.config.js")({ cache: () => {} });
62 const presets = babelConfig.presets.map(([name, options]: [string, object]) =>
63 name === "babel-preset-expo"
64 ? [
65 requireFromMobile.resolve(name),
66 {
67 ...options,
68 "react-compiler": {
69 logger: { logEvent: (_: string, event: CompilerEvent) => events.push(event) },
70 },
71 },
72 ]
73 : [name, options],
74 );
75 return babel.transformFileSync(NodePath.join(mobileRoot, file), {
76 babelrc: false,
77 configFile: false,
78 cwd: mobileRoot,
79 presets,
80 caller: metroCaller,
81 code: false,
82 ...output,
83 });
84}
85
86function compilerBailouts(file: string): string[] {
87 const events: CompilerEvent[] = [];
88 compileLikeMetro(file, events, {});
89 return events
90 .filter((event) => event.kind === "CompileError" || event.kind === "CompileSkip")
91 .map(
92 (event) =>
93 `line ${(event.detail?.options?.loc ?? event.detail?.options?.details?.[0]?.loc)?.start?.line ?? "?"}: ${event.detail?.options?.category ?? event.kind} ${event.detail?.options?.reason ?? ""}`,
94 );
95}
96
97interface AstNode {
98 readonly type: string;
99 readonly [key: string]: unknown;
100}
101
102const isAstNode = (value: unknown): value is AstNode =>
103 typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
104
105function walkAst(
106 value: unknown,
107 visit: (node: AstNode, ancestors: readonly AstNode[]) => void,
108 ancestors: readonly AstNode[] = [],
109) {
110 if (typeof value !== "object" || value === null) return;
111 const node = isAstNode(value) ? value : null;
112 if (node) visit(node, ancestors);
113 const childAncestors = node ? [...ancestors, node] : ancestors;
114 for (const child of Object.values(value)) walkAst(child, visit, childAncestors);
115}
116
117// For each call to `callee`, whether it runs only on the cache-miss branch of
118// an `if ($[n] !== dep)` block rather than on every render.
119function compilerCachedCalls(file: string, callee: string): boolean[] {
120 const result = compileLikeMetro(file, [], { ast: true });
121 const cached: boolean[] = [];
122 if (typeof result !== "object" || result === null || !("ast" in result)) return cached;
123 walkAst(result.ast, (node, ancestors) => {
124 if (node.type !== "CallExpression" || !isAstNode(node.callee)) return;
125 if (node.callee.type !== "Identifier" || node.callee.name !== callee) return;
126 cached.push(
127 ancestors.some(
128 (ancestor, index) =>
129 ancestor.type === "IfStatement" &&
130 ancestors[index + 1] === ancestor.consequent &&
131 JSON.stringify(ancestor.test).includes('"name":"$"'),
132 ),
133 );
134 });
135 return cached;
136}
137
138describe("React Compiler coverage", () => {
139 it("Metro's transformer applies the compiler", async () => {
140 const { transformer } = await metroConfig;
141 const metroTransformer = requireFromMobile(transformer.babelTransformerPath);
142 const filename = NodePath.join(
143 mobileRoot,
144 "src/features/archive/ArchivedThreadsRouteScreen.tsx",
145 );
146 const transformed = (reactCompiler: string) => {
147 const { ast } = metroTransformer.transform({
148 filename,
149 src: NodeFS.readFileSync(filename, "utf8"),
150 plugins: [],
151 options: {
152 dev: false,
153 hot: false,
154 platform: "ios",
155 projectRoot: mobileRoot,
156 type: "module",
157 enableBabelRCLookup: false,
158 experimentalImportSupport: false,
159 customTransformOptions: { reactCompiler, routerRoot: "app" },
160 },
161 });
162 return JSON.stringify(ast.program.body);
163 };
164 expect(transformed("true")).toContain("react/compiler-runtime");
165 expect(transformed("false")).not.toContain("react/compiler-runtime");
166 });
167
168 it.each(sourceFiles)("compiles %s", (file) => {
169 const bailouts = compilerBailouts(file);
170 if (file in KNOWN_BAILOUTS) {
171 expect(bailouts, `${file} compiles now; remove it from KNOWN_BAILOUTS`).not.toEqual([]);
172 } else {
173 expect(bailouts).toEqual([]);
174 }
175 });
176
177 // The compiler once silently dropped this cache, so every
178 // ThreadFeed render rebuilt the feed and handed LegendList a new data array.
179 it("caches the thread feed derivation", () => {
180 expect(
181 compilerCachedCalls("src/features/threads/ThreadFeed.tsx", "deriveThreadFeedPresentation"),
182 ).toEqual([true]);
183 });
184
185 // The compiler once silently dropped these caches, so the new-task context
186 // value changed on every provider render and re-rendered every consumer.
187 it("caches the new-task model options and host environments", () => {
188 const file = "src/features/threads/new-task-flow-provider.tsx";
189 expect(compilerCachedCalls(file, "buildModelOptions")).toEqual([true]);
190 expect(compilerCachedCalls(file, "repositoryGroupingKeyOf")).toEqual([true, true]);
191 });
192
193 // An uncached registration would re-register the workspace inspector on every render.
194 it("registers the workspace inspector only when its renderer changes", () => {
195 expect(
196 compilerCachedCalls(
197 "src/features/layout/AdaptiveWorkspaceLayout.tsx",
198 "registerWorkspaceInspector",
199 ),
200 ).toEqual([true]);
201 });
202
203 it("lists only files that exist", () => {
204 expect(Object.keys(KNOWN_BAILOUTS).filter((file) => !sourceFiles.includes(file))).toEqual([]);
205 });
206});