Files

537 lines
23 KiB
TypeScript
Raw Permalink Normal View History

2026-08-27 21:09:14 +00:00
// Runs the upstream React Compiler test fixtures through `Bun.build({ reactCompiler: true })`
// and checks Bun's output against the upstream `.expect.md` snapshot.
//
// Upstream's expected output is produced by Babel's printer, so byte-for-byte
// comparison is meaningless. Instead we check the one observable invariant the
// compiler is responsible for: the memo cache slot count (`_c(N)`). For
// expected-error fixtures we only check that Bun did not emit a memo cache.
//
// Fixtures are synced from facebook/react by `scripts/sync-react-compiler.sh`;
// the SHA they were synced from is in `src/react_compiler/UPSTREAM_PORTED`.
//
// All fixtures are compiled in a SINGLE `Bun.build()` call (one bundler
// invocation, ~1.8k entrypoints) instead of spawning a `bun build` subprocess
// per fixture. The bundler aborts the print stage if any entrypoint fails to
// parse, so we retry with the failing files removed (their build error is
// recorded and asserted on in that fixture's test case).
//
// ## Pragma handling
//
// Upstream's snap harness parses `// @key value` directives from each fixture
// and feeds them into `EnvironmentConfig` / `PluginOptions` before compiling
// (see `compiler/packages/snap/src/compiler.ts` `parseConfigPragma`). Bun does
// not yet expose a per-file hook to set these, so this runner parses the same
// pragmas and:
// - skips fixtures whose pragmas Bun cannot honour yet,
// - relaxes the slot-count check when the effective `compilationMode` differs
// from Bun's hardcoded `infer` default (upstream's snap default is `all`).
import { describe, test, expect, beforeAll } from "bun:test";
import { readFileSync, existsSync } from "node:fs";
import { join, basename } from "node:path";
import { isDebug, isASAN } from "harness";
const FIXTURE_ROOT = join(import.meta.dir, "react-compiler-fixtures");
// `parse_fixture_pragmas` and the lint-mode validation passes are
// `#[cfg(any(debug_assertions, bun_asan, feature = "fixtures"))]` — release
// builds without ASAN compile them out, so pragma-gated fixtures cannot be
// validated there.
const HAS_FIXTURE_PRAGMA_SUPPORT = isDebug || isASAN;
const SNAPSHOT_BUN_OUTPUT = !!process.env.REACT_COMPILER_FIXTURE_SNAPSHOT;
const FILTER = process.env.REACT_COMPILER_FIXTURE_FILTER;
const INPUT_EXTS = [".js", ".jsx", ".ts", ".tsx", ".mjs"];
// ---------------------------------------------------------------------------
// Pragma parsing (mirrors upstream parseConfigPragmaForTests)
// ---------------------------------------------------------------------------
type Pragmas = Map<string, string>;
// `// @key`, `// @key value`, `// @key:"value"`, `// @key(value)`. A single
// comment line may carry several pragmas (e.g. `// @flow @compilationMode:"infer"`),
// so scan each `@token` on the line independently rather than anchoring to ^...$.
const PRAGMA_TOKEN_RE = /@(\w+)(?:[:(\s]+["']?([\w.$-]+)["']?\)?)?/g;
function parsePragmas(source: string): Pragmas {
const out: Pragmas = new Map();
// Upstream (and `leading_comment_pragma` in program.rs) only scans the leading
// run of `//` lines and stops at the first non-comment, non-blank line — so a
// mid-body `// @ts-ignore` is never mistaken for a config pragma.
for (const line of source.split("\n", 40)) {
const t = line.trim();
if (t === "") continue;
if (!t.startsWith("//")) break;
for (const m of t.matchAll(PRAGMA_TOKEN_RE)) {
out.set(m[1], (m[2] ?? "true").trim());
}
}
return out;
}
// Pragmas that only affect upstream's test harness output (logging / eval),
// not the compiler's memoization behaviour. Safe to ignore.
const IGNORED_PRAGMAS = new Set([
"loggerTestOnly",
"evaluator",
"enablePropagateDepsInHIR",
"enableNewMutationAliasingModel",
// sprout (eval harness) directive — controls whether the non-Forget baseline
// is evaluated, not how the compiler runs.
"disableNonForgetInSprout",
// legacy snap markers from before parseConfigPragma existed; upstream's
// current harness ignores them.
"xonly",
"Pass",
]);
// Pragmas Bun's `parse_fixture_pragmas` (src/react_compiler/program.rs) reads
// from the leading comment and applies to ReactCompilerOptions / EnvironmentConfig
// before compiling. shouldSkip returns null for these — the compiler honours them.
//
// `parse_fixture_pragmas` is `#[cfg(any(debug_assertions, feature = "fixtures"))]`:
// release builds compile it out, so a fixture whose only observable behaviour
// comes from a pragma-gated EnvironmentConfig flag cannot be checked there.
const HANDLED_PRAGMAS = new Set([
"flow",
"script",
"skip",
// ReactCompilerOptions
"compilationMode",
"panicThreshold",
"target",
"ignoreUseNoForget",
"expectNothingCompiled",
"gating",
// EnvironmentConfig
"enablePreserveExistingMemoizationGuarantees",
"validatePreserveExistingMemoizationGuarantees",
"validateExhaustiveMemoizationDependencies",
"enableOptionalDependencies",
"enableNameAnonymousFunctions",
"validateHooksUsage",
"validateRefAccessDuringRender",
"validateNoSetStateInRender",
"enableUseKeyedState",
"validateNoSetStateInEffects",
"validateNoDerivedComputationsInEffects",
"validateNoDerivedComputationsInEffectsExp",
"validateNoDerivedComputationsInEffects_exp",
"validateNoJsxInTryStatements",
"validateNoJSXInTryStatements",
"validateStaticComponents",
"validateNoImpureFunctionsInRender",
"validateNoFreezingKnownMutableFunctions",
"enableAssumeHooksFollowRulesOfReact",
"enableTransitivelyFreezeFunctionExpressions",
"enableFunctionOutlining",
"enableJsxOutlining",
"assertValidMutableRanges",
"throwUnknownExceptionTestonly",
"throwUnknownException__testonly",
"enableCustomTypeDefinitionForReanimated",
"enableTreatRefLikeIdentifiersAsRefs",
"enableTreatSetIdentifiersAsStateSetters",
"validateNoVoidUseMemo",
"enableAllowSetStateFromRefsInEffects",
"enableVerboseNoSetStateInEffect",
"enableForest",
"validateExhaustiveEffectDependencies",
"validateNoCapitalizedCalls",
"customMacros",
"enableEmitHookGuards",
"enableEmitInstrumentForget",
"instrumentForget",
"outputMode",
"dynamicGating",
]);
// Pragmas `parse_fixture_pragmas` recognises but cannot honour (returns
// `skip: Some(reason)`). The expected output for these depends on config Bun
// can't represent, so the comparison would be meaningless.
const UNSUPPORTED_PRAGMAS = new Set([
"debug", // snap IR-dump mode; .expect.md is not JS
"hookPattern",
"customHooks",
"moduleTypeProvider",
"eslintSuppressionRules",
"validateBlocklistedImports",
// Bun handles HMR via its own React Refresh transform and never populates
// `env.code`, so the extra source-hash slot is never emitted (codegen.rs).
"enableResetCacheOnSourceFileChanges",
// Upstream test-only pass that walks the generated *Babel* AST post-codegen.
// Bun emits bun_ast, so the pass is structurally unportable (DESIGN.md).
"validateSourceLocations",
// Bails on `$FlowFixMe[react-rule-*]` suppressions. Bun has no Flow comment
// scanner, so the bailout never fires and error fixtures would mis-compare.
"enableFlowSuppressions",
// Selects upstream's pre-HIR reactive-scope fork that Bun never ported.
"enableReactiveScopesInHIR",
]);
// Fixtures Bun's React Compiler integration cannot run yet.
function shouldSkip(relPath: string, pragmas: Pragmas): string | null {
// Flow syntax: Bun has no Flow parser. `relPath` is the fixture stem (no ext).
if (relPath.endsWith(".flow") || pragmas.has("flow")) return "flow";
// fbt: Meta-internal i18n JSX, unsupported.
if (relPath.startsWith("fbt/") || /\bfbt\b/.test(basename(relPath))) return "fbt";
// meta-isms: Meta-internal type providers.
if (relPath.startsWith("meta-isms/")) return "meta-isms";
// Upstream's own skip pragma.
if (pragmas.has("skip")) return "@skip";
// Any remaining pragma maps to an EnvironmentConfig / PluginOptions field
// Bun cannot set per-invocation yet. Running with the wrong config produces
// a meaningless diff, so skip until the option lands.
let needsPragmaSupport = false;
for (const key of pragmas.keys()) {
if (IGNORED_PRAGMAS.has(key)) continue;
if (HANDLED_PRAGMAS.has(key)) {
needsPragmaSupport = true;
continue;
}
if (UNSUPPORTED_PRAGMAS.has(key)) return `pragma:@${key} (unsupported)`;
return `pragma:@${key}`;
}
if (needsPragmaSupport && !HAS_FIXTURE_PRAGMA_SUPPORT) {
return "release build (pragma parsing compiled out)";
}
return null;
}
// Known divergences from upstream — Bun produces a different (or no) result.
// Grow this from CI; each entry must say why.
// `__proto__: null`: a fixture named "constructor" exists.
const TODO: Record<string, string> = Object.assign(Object.create(null), {});
// `minify: { syntax: true }` runs the parser's visit-phase folding and the
// nested-block statement mangler before the React Compiler sees the function
// body, so the compiler is handed a different (but equivalent) AST. Per-function
// memo SLOT counts are allowed to differ between the two passes: the different
// shape can merge or split reactive scopes. What must never change is the NUMBER
// of memoized functions — a function the compiler memoizes without minify but
// bails out of with minify has silently lost memoization.
//
// Fixtures where minify legitimately (or known-divergently) changes the number
// of compiled functions are recorded here with the expected minified count and
// the reason; a compiler change that alters one of these fails the test and
// forces this record to be re-derived.
// `__proto__: null`: a fixture named "constructor" exists.
const MINIFY_SYNTAX_DIVERGENCE: Record<string, { compiledFunctions: number; buildError?: true; reason: string }> =
Object.assign(Object.create(null), {
// minify.syntax drops the unreferenced name from `function formatWithUnit() {}`,
// making it eligible for the compiler's function-outlining pass (upstream only
// outlines anonymous function expressions). The outlined module-level function
// is referentially stable with no memo slot, so `_c(1)` is legitimately gone.
// This is better output, not a bailout.
"new-mutability/repro-destructure-from-prop-with-default-value": {
compiledFunctions: 0,
reason: "anonymized function expression becomes outlineable; 0 slots needed",
},
// minify.syntax constant-folds `const x = 0` into the `useMemo` dependency
// array, turning `[x]` into `[0]`; the manual-memoization parser only accepts
// identifier / member expressions as dependencies, so it errors. This is a
// real minify-vs-compiler ordering gap, but it lives in the parser's
// visit-phase constant folding, not the compiler.
"exhaustive-deps/exhaustive-deps-allow-constant-folded-values": {
compiledFunctions: 0,
buildError: true,
reason: "constant folding rewrites the useMemo deps array entry `x` to the literal `0`",
},
});
type Fixture = {
name: string;
inputPath: string;
expectPath: string;
source: string;
pragmas: Pragmas;
skip: string | null;
todo: string | undefined;
};
function discover(): Fixture[] {
const out: Fixture[] = [];
for (const md of new Bun.Glob("**/*.expect.md").scanSync(FIXTURE_ROOT)) {
// Bun.build output paths are POSIX; normalize so `compiled.get(name)` matches on Windows.
const stem = md.slice(0, -".expect.md".length).replaceAll("\\", "/");
let inputPath: string | undefined;
for (const ext of INPUT_EXTS) {
const p = join(FIXTURE_ROOT, stem + ext);
if (existsSync(p)) {
inputPath = p;
break;
}
}
if (!inputPath) continue;
const source = readFileSync(inputPath, "utf8");
const pragmas = parsePragmas(source);
out.push({
name: stem,
inputPath,
expectPath: join(FIXTURE_ROOT, md),
source,
pragmas,
skip: shouldSkip(stem, pragmas),
todo: TODO[stem],
});
}
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
// `.expect.md` layout: `## Input` fence, then either `## Code` fence (success)
// or `## Error` fence (compile error). Some have a trailing `### Eval output`.
function parseExpect(md: string): { code: string | null; error: string | null } {
const sections: Record<string, string> = {};
// Match "## Heading" followed by a fenced block.
const re = /^## (\w+)\s*\n+```[a-z]*\n([\s\S]*?)\n```/gm;
let m: RegExpExecArray | null;
while ((m = re.exec(md))) sections[m[1]] = m[2];
return { code: sections.Code ?? null, error: sections.Error ?? null };
}
// `sub` appears in `full` in order (not necessarily contiguous).
function isSubsequence(sub: readonly number[], full: readonly number[]): boolean {
let i = 0;
for (const v of full) if (i < sub.length && v === sub[i]) i++;
return i === sub.length;
}
// All `_c(N)` / `useMemoCache(N)` slot counts in source order (the emitted
// callee depends on `@target`).
function slotCounts(src: string): number[] {
const out: number[] = [];
for (const m of src.matchAll(/\b(?:_c|useMemoCache)\((\d+)\)/g)) out.push(Number(m[1]));
return out;
}
const fixtures = discover();
const filtered = FILTER ? fixtures.filter(f => f.name.includes(FILTER)) : fixtures;
// Only fixtures whose assertions actually run get compiled. `todo` fixtures are
// included so a fix flips them to "passing" without a harness change.
const compilable = filtered.filter(f => f.skip == null);
type Compiled = Map<string, { output: string } | { error: string }>;
// fixture name -> compiled JS text, or a build-error message.
const compiled: Compiled = new Map();
// Same corpus, built a second time with `minify: { syntax: true }`.
const compiledMinify: Compiled = new Map();
// Bun.build aborts the print stage when ANY entrypoint errors, so we can't get
// per-file output from a partially-failing batch. Instead: build, record the
// erroring files from `result.logs`, drop them, and rebuild until the batch is
// clean. In practice this converges in 1-2 rounds (only a handful of fixtures
// are genuine parse errors).
async function compileInto(into: Compiled, minify: false | { syntax: true }): Promise<void> {
const byInput = new Map<string, Fixture>();
for (const f of compilable) byInput.set(f.inputPath, f);
let pending = new Set(byInput.keys());
for (let round = 0; pending.size > 0; round++) {
if (round > 20) throw new Error(`Bun.build did not converge after ${round} rounds (${pending.size} left)`);
const result = await Bun.build({
entrypoints: [...pending],
root: FIXTURE_ROOT,
target: "browser",
format: "esm",
splitting: false,
external: ["*"],
treeShaking: false,
minify,
// @ts-expect-error — wired in JSBundler.rs but not yet in bun-types
reactCompiler: true,
reactCompilerParseTestPragmas: true,
// Upstream's Babel harness enables the TS plugin unconditionally, so
// many `.js` fixtures contain TS syntax (casts, type params).
loader: { ".js": "tsx", ".mjs": "tsx" },
throw: false,
});
if (result.success) {
for (const artifact of result.outputs) {
if (artifact.kind !== "entry-point") continue;
// Output path is relative to `root` with a `.js` extension; strip both
// to recover the fixture stem.
let stem = artifact.path.replace(/^\.\//, "").replace(/\.js$/, "");
into.set(stem, { output: await artifact.text() });
}
return;
}
// Attribute every error log to an input file and drop it from the next round.
let removed = 0;
for (const log of result.logs) {
if (log.level !== "error") continue;
const file = (log as any).position?.file as string | undefined;
if (!file || !pending.has(file)) continue;
const f = byInput.get(file)!;
const prev = into.get(f.name);
const msg = String(log.message ?? log);
into.set(f.name, { error: prev && "error" in prev ? prev.error + "\n" + msg : msg });
pending.delete(file);
removed++;
}
if (removed === 0) {
// Unattributable failure — surface the raw logs so the test points at it.
throw new AggregateError(result.logs, "Bun.build failed with no per-file error attribution");
}
}
}
async function compileAll(): Promise<void> {
await compileInto(compiled, false);
await compileInto(compiledMinify, { syntax: true });
}
describe("react-compiler upstream fixtures", () => {
// Two full-corpus `Bun.build()` passes; the default 5s hook timeout is tight
// on a cold debug binary.
beforeAll(compileAll, { timeout: 120_000 });
test("fixture corpus is present", () => {
// Guards against an accidentally-empty sync.
expect(fixtures.length).toBeGreaterThan(1000);
});
describe.each(filtered.map(f => [f.name, f] as const))("%s", (name, f) => {
const { pragmas, skip, todo } = f;
const fn = skip ? test.skip : todo ? test.todo : test.concurrent;
// Upstream snap harness defaults to `all`; Bun defaults to `infer`. When
// the effective mode is not `infer` we cannot force Bun to compile, so we
// only assert when Bun *did* compile.
const effectiveMode = pragmas.get("compilationMode") ?? "all";
const modeMatchesBun = effectiveMode === "infer";
fn(skip ? `SKIP (${skip})` : todo ? `TODO (${todo})` : "compile", async () => {
const expected = parseExpect(await Bun.file(f.expectPath).text());
const isErrorFixture =
expected.error != null ||
pragmas.has("expectNothingCompiled") ||
/(^|\/)(error|todo\.error|bail)/.test(basename(name));
// `@panicThreshold:"none"` on an error-named fixture means upstream
// expects the compiler to *bail* (skip the function) rather than throw,
// and the `.expect.md` contains the unmodified input under `## Code`.
// Either way Bun must not emit a memo cache.
const isBailFixture =
pragmas.get("panicThreshold") === "none" && /(^|\/)(error|todo\.error)/.test(basename(name));
const result = compiled.get(name);
if (result == null) throw new Error(`no Bun.build result recorded for fixture "${name}"`);
const output = "output" in result ? result.output : "";
const buildError = "error" in result ? result.error : null;
if (SNAPSHOT_BUN_OUTPUT) {
expect({ buildError, output }).toMatchSnapshot();
}
if (isErrorFixture || isBailFixture) {
// Upstream expects a compile error / bailout. Bun may surface this as a
// build error or as an unmodified passthrough; either is acceptable, but
// it must NOT have produced memoized output.
expect({
fixture: name,
slotCounts: slotCounts(output),
note: "error fixture should not be memoized",
}).toEqual({ fixture: name, slotCounts: [], note: "error fixture should not be memoized" });
return;
}
// Success fixture.
expect({ fixture: name, buildError }).toEqual({ fixture: name, buildError: null });
const want = slotCounts(expected.code ?? "");
const got = slotCounts(output);
const wantRuntime = (expected.code ?? "").includes("react/compiler-runtime");
const gotRuntime = output.includes("react/compiler-runtime");
// compilationMode mismatch: upstream compiled under `all`, Bun ran under
// `infer`. Bun may legitimately compile NONE of the functions, or only a
// SUBSET (e.g. fixture has both an uppercase JSX component `infer` picks
// up and a lowercase helper only `all` would compile). Accept when Bun's
// slot list is a subsequence of upstream's; only fall through to strict
// equality when Bun compiled every function upstream did.
if (!modeMatchesBun) {
// A surviving `react/compiler-runtime` import is not evidence a compiled
// function survived: when the fixture has no `export`, `bun build`
// tree-shakes the compiled body but the injected import remains. Only
// `_c(N)` calls prove a memoized function reached the output.
if (got.length === 0 && (want.length > 0 || wantRuntime)) {
return; // Bun compiled nothing
}
if (got.length > 0 && got.length < want.length && isSubsequence(got, want)) {
expect({ fixture: name, importsCompilerRuntime: gotRuntime }).toEqual({
fixture: name,
importsCompilerRuntime: wantRuntime,
});
return; // Bun compiled a strict subset
}
}
expect({
fixture: name,
slotCounts: got,
importsCompilerRuntime: gotRuntime,
}).toEqual({
fixture: name,
slotCounts: want,
importsCompilerRuntime: wantRuntime,
});
});
// Running the same corpus with `minify: { syntax: true }` must not change
// which functions the React Compiler accepts. Each `_c(N)` call in the
// output is one memoized function; that count must match the non-minified
// pass. A function that is memoized without minify but not with it has
// silently lost memoization — exactly the failure mode `minify.syntax` is
// never allowed to introduce.
//
// `todo` marks a Bun-vs-upstream output divergence; this test is
// Bun-vs-Bun, so a fixture with a `todo` reason is still self-consistent
// here and its body would pass. Wrapping it in `test.todo` like the
// `compile` test would then report that passing body as a failure
// (`FailBecauseTodoPassed`), so only the `skip` state is honoured. Known
// minify divergences have their own map (`MINIFY_SYNTAX_DIVERGENCE`).
const fnMin = skip ? test.skip : test.concurrent;
fnMin(skip ? `minify.syntax SKIP (${skip})` : "minify.syntax", async () => {
const plain = compiled.get(name);
if (plain == null) throw new Error(`no Bun.build result recorded for fixture "${name}"`);
// The non-minified build already errored: the `compile` test owns that
// assertion and there is no baseline to compare the minified pass to.
if ("error" in plain) return;
const min = compiledMinify.get(name);
if (min == null) throw new Error(`no minify Bun.build result recorded for fixture "${name}"`);
const minOutput = "output" in min ? min.output : "";
const minBuildError = "error" in min ? min.error : null;
const divergence = MINIFY_SYNTAX_DIVERGENCE[name];
if (divergence?.buildError) {
// A known case where minify.syntax turns a clean build into a build
// error. Assert it still errors so the entry is removed once fixed.
expect({ fixture: name, reason: divergence.reason, minifyBuildErrored: minBuildError != null }).toEqual({
fixture: name,
reason: divergence.reason,
minifyBuildErrored: true,
});
return;
}
expect({
fixture: name,
minifyBuildError: minBuildError,
minifyCompiledFunctions: slotCounts(minOutput).length,
}).toEqual({
fixture: name,
minifyBuildError: null,
minifyCompiledFunctions: divergence ? divergence.compiledFunctions : slotCounts(plain.output).length,
});
});
});
});