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