Files
5a90a5a7a5 [compiler] Bail out with Todo on using and await using declarations (#36946)
## Summary

The compiler silently rewrote functions containing `using` and `await
using` declarations (explicit resource management). BuildHIR's
`VariableDeclaration` case only special-cases `var`; every other kind
falls through to `InstructionKind.Const`. A `using` declaration
therefore compiled as a plain memoized `const`, and the implicit dispose
call at scope exit was dropped from the compiled output. No diagnostic
was emitted. The Rust port mirrored the same bug in `build_hir.rs`.

The Rust port had a second, harder failure: its
`VariableDeclarationKind` enum had no `await using` variant, so a Babel
AST containing one failed serde deserialization at the NAPI boundary
("unknown variant `await using`") and threw for the whole file.

The fix records the same per-function Todo bail as the existing `var`
case, in both implementations, then continues lowering the declaration
as `const` so references do not break while the error unwinds (mirroring
how `var` continues as `let`). This matches the compiler's established
Todo-bail precedent for unsupported syntax: the function is skipped with
a logged `CompileError` diagnostic, `panicThreshold` is respected, and
sibling functions in the same file still compile. This deliberately
contrasts with oxc-project/oxc#24217, which handles the same syntax with
a silent per-function skip; nothing in this compiler is skipped without
a surfaced diagnostic.

Changes:

- TS: `BuildHIR.ts` records a Todo `CompilerErrorDetail` for `using` and
`await using` kinds, in the style of the existing `var` case.
- Rust: same Todo `record_error` in `build_hir.rs`. New `AwaitUsing`
variant (serde name `"await using"`) in `react_compiler_ast`, so `await
using` survives the NAPI boundary, covered by a serde round-trip unit
test.
- Test harness: enables the `explicitResourceManagement` parser plugin
in snap and in the e2e script's Babel baseline so fixtures can use the
syntax.

Fixtures (identical snapshots for the TS and Rust pipelines):

- `error.todo-using-declaration` and
`error.todo-await-using-declaration` show the Todo diagnostic with code
frames.
- `using-declaration-bailout-sibling-compiles` shows the bailing
function left untouched (disposal preserved) while a sibling component
in the same file is memoized.

The first commit snapshots the previous broken behavior (`using`
memoized as `const` with disposal dropped); the second commit lands the
fix and updates the snapshots.

## How did you test this change?

All results below are from the branch rebased onto current main.

TS implementation:

- `yarn snap`: 1809 tests, 1809 passed, 0 failed.
- `yarn workspace babel-plugin-react-compiler lint`: clean. `yarn
prettier-check` at the repo root: clean.
- Manually verified pre-fix output: a component with `using resource =
getResource(props.id)` compiled to a memoized `const` with no disposal.

Rust implementation:

- `cargo test --workspace`: 42 passed, 0 failed (includes the new serde
round-trip unit test for both `using` kinds).
- `bash scripts/test-babel-ast.sh`: passed.
- `bash scripts/test-rust-port.sh`: 1808 passed, 0 failed.
- `yarn snap --rust`: 1809 tests, 1809 passed, 0 failed, against the
same `.expect.md` snapshots as the TS run.
- `scripts/test-e2e.ts` (babel variant): the three using fixtures pass
code and events parity.
- Manually verified `await using` now deserializes across the NAPI
boundary and records the Todo instead of throwing.


<div><a
href="https://cursor.com/agents/bc-5fe4350d-5d42-4c0e-9001-429ed0b5e5b7"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-5fe4350d-5d42-4c0e-9001-429ed0b5e5b7"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-07 20:46:37 -07:00

559 lines
16 KiB
TypeScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* End-to-end test script comparing the Rust compiler against the TS reference.
*
* Runs fixtures through:
* - TS baseline (Babel plugin, in-process) — the reference output
* - babel variant: Rust via Babel plugin (in-process via NAPI) — the
* production path
*
* This complements `yarn snap --rust` by independently comparing the NAPI
* bridge's code output AND its logged events against the TS plugin.
*
* Usage: npx tsx compiler/scripts/test-e2e.ts [fixtures-path] [--variant babel] [--limit N] [--no-color]
*/
import * as babel from '@babel/core';
import generate from '@babel/generator';
import {execSync} from 'child_process';
import fs from 'fs';
import path from 'path';
import prettier from 'prettier';
import {parseConfigPragmaForTests} from '../packages/babel-plugin-react-compiler/src/Utils/TestUtils';
const REPO_ROOT = path.resolve(__dirname, '../..');
// --- Parse flags ---
const rawArgs = process.argv.slice(2);
const noColor = rawArgs.includes('--no-color') || !!process.env.NO_COLOR;
const variantIdx = rawArgs.indexOf('--variant');
const variantArg =
variantIdx >= 0 ? (rawArgs[variantIdx + 1] as 'babel') : null;
const limitIdx = rawArgs.indexOf('--limit');
const limitArg = limitIdx >= 0 ? parseInt(rawArgs[limitIdx + 1], 10) : 50;
// Extract positional args (strip flags and flag values)
const skipIndices = new Set<number>();
for (const flag of ['--no-color']) {
const idx = rawArgs.indexOf(flag);
if (idx >= 0) skipIndices.add(idx);
}
for (const flag of ['--variant', '--limit']) {
const idx = rawArgs.indexOf(flag);
if (idx >= 0) {
skipIndices.add(idx);
skipIndices.add(idx + 1);
}
}
const positional = rawArgs.filter((_a, i) => !skipIndices.has(i));
// --- ANSI colors ---
const useColor = !noColor;
const RED = useColor ? '\x1b[0;31m' : '';
const GREEN = useColor ? '\x1b[0;32m' : '';
const YELLOW = useColor ? '\x1b[0;33m' : '';
const BOLD = useColor ? '\x1b[1m' : '';
const DIM = useColor ? '\x1b[2m' : '';
const RESET = useColor ? '\x1b[0m' : '';
// --- Fixtures ---
const DEFAULT_FIXTURES_DIR = path.join(
REPO_ROOT,
'compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler',
);
const fixturesPath = positional[0]
? path.resolve(positional[0])
: DEFAULT_FIXTURES_DIR;
function discoverFixtures(rootPath: string): string[] {
const stat = fs.statSync(rootPath);
if (stat.isFile()) {
return [rootPath];
}
const results: string[] = [];
function walk(dir: string): void {
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (
/\.(js|jsx|ts|tsx)$/.test(entry.name) &&
!entry.name.endsWith('.expect.md')
) {
results.push(fullPath);
}
}
}
walk(rootPath);
results.sort();
return results;
}
// --- Build ---
console.log('Building Rust native module...');
try {
execSync('~/.cargo/bin/cargo build -p react_compiler_napi', {
cwd: path.join(REPO_ROOT, 'compiler/crates'),
stdio: ['inherit', 'pipe', 'pipe'],
shell: true,
});
} catch (e: any) {
// Show stderr on build failure (includes errors + warnings)
if (e.stderr) {
process.stderr.write(e.stderr);
}
console.error(`${RED}ERROR: Failed to build Rust crates.${RESET}`);
process.exit(1);
}
// Copy the built dylib as index.node
const NATIVE_DIR = path.join(
REPO_ROOT,
'compiler/packages/babel-plugin-react-compiler-rust/native',
);
const NATIVE_NODE_PATH = path.join(NATIVE_DIR, 'index.node');
const TARGET_DIR = path.join(REPO_ROOT, 'compiler/target/debug');
const dylib = fs.existsSync(
path.join(TARGET_DIR, 'libreact_compiler_napi.dylib'),
)
? path.join(TARGET_DIR, 'libreact_compiler_napi.dylib')
: path.join(TARGET_DIR, 'libreact_compiler_napi.so');
if (!fs.existsSync(dylib)) {
console.error(
`${RED}ERROR: Could not find built native module in ${TARGET_DIR}${RESET}`,
);
process.exit(1);
}
fs.copyFileSync(dylib, NATIVE_NODE_PATH);
// --- Load plugins ---
const tsPlugin = require('../packages/babel-plugin-react-compiler/src').default;
const rustPlugin =
require('../packages/babel-plugin-react-compiler-rust/src').default;
// --- Normalize code for comparison ---
// Reparse with Babel and regenerate with compact:true to erase all
// whitespace/formatting differences, then Prettier for readable output.
async function formatCode(code: string, isFlow: boolean): Promise<string> {
try {
const parserPlugins: string[] = isFlow
? ['flow', 'jsx']
: ['typescript', 'jsx', 'explicitResourceManagement'];
const ast = babel.parseSync(code, {
sourceType: 'module',
parserOpts: {plugins: parserPlugins},
configFile: false,
babelrc: false,
});
if (!ast) return code;
const compact = generate(ast, {compact: true}).code;
return await prettier.format(compact, {
semi: true,
parser: isFlow ? 'flow' : 'babel-ts',
});
} catch {
return code;
}
}
// --- Compile via Babel plugin ---
type CompileResult = {
code: string | null;
error: string | null;
events: Array<Record<string, unknown>>;
};
function compileBabel(
plugin: any,
fixturePath: string,
source: string,
firstLine: string,
): CompileResult {
const isFlow = firstLine.includes('@flow');
const isScript = firstLine.includes('@script');
const parserPlugins: string[] = isFlow
? ['flow', 'jsx']
: ['typescript', 'jsx', 'explicitResourceManagement'];
const pragmaOpts = parseConfigPragmaForTests(firstLine, {
compilationMode: 'all',
});
const events: Array<Record<string, unknown>> = [];
const pluginOptions = {
...pragmaOpts,
compilationMode: 'all' as const,
panicThreshold: 'all_errors' as const,
logger: {
logEvent(_filename: string | null, event: Record<string, unknown>): void {
events.push(event);
},
debugLogIRs(): void {},
},
};
try {
const result = babel.transformSync(source, {
filename: fixturePath,
sourceType: isScript ? 'script' : 'module',
parserOpts: {plugins: parserPlugins},
plugins: [[plugin, pluginOptions]],
configFile: false,
babelrc: false,
});
return {code: result?.code ?? null, error: null, events};
} catch (e) {
return {
code: null,
error: e instanceof Error ? e.message : String(e),
events,
};
}
}
// --- Event normalization ---
// Strip identifierName (Babel-specific SourceLocation property), sort
// keys for stable comparison, then JSON.stringify. Both the TS plugin and
// the Rust NAPI bridge emit 0-based columns/indices, so no positional
// adjustment is needed.
const STRIP_KEYS = new Set(['identifierName', 'fnLoc']);
function sortAndStrip(obj: unknown): unknown {
if (obj === null || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map(sortAndStrip);
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(obj as Record<string, unknown>).sort()) {
if (STRIP_KEYS.has(key)) continue;
sorted[key] = sortAndStrip((obj as Record<string, unknown>)[key]);
}
return sorted;
}
function stripPipelineErrorStack(
events: Array<Record<string, unknown>>,
): Array<Record<string, unknown>> {
return events.map(event => {
if (event.kind !== 'PipelineError') return event;
const data = event.data;
if (typeof data !== 'string') return event;
// Strip JS stack trace: keep only the message (before first "\n at ")
const idx = data.indexOf('\n at ');
return {...event, data: idx >= 0 ? data.substring(0, idx) : data};
});
}
function normalizeEvents(events: Array<Record<string, unknown>>): string {
return JSON.stringify(sortAndStrip(stripPipelineErrorStack(events)), null, 2);
}
// --- Simple unified diff ---
function unifiedDiff(
expected: string,
actual: string,
leftLabel: string,
rightLabel: string,
): string {
const expectedLines = expected.split('\n');
const actualLines = actual.split('\n');
const lines: string[] = [];
lines.push(`${RED}--- ${leftLabel}${RESET}`);
lines.push(`${GREEN}+++ ${rightLabel}${RESET}`);
const maxLen = Math.max(expectedLines.length, actualLines.length);
let contextStart = -1;
for (let i = 0; i < maxLen; i++) {
const eLine = i < expectedLines.length ? expectedLines[i] : undefined;
const aLine = i < actualLines.length ? actualLines[i] : undefined;
if (eLine === aLine) continue;
if (contextStart !== i) {
lines.push(`${YELLOW}@@ line ${i + 1} @@${RESET}`);
}
contextStart = i + 1;
if (eLine !== undefined && aLine !== undefined) {
lines.push(`${RED}-${eLine}${RESET}`);
lines.push(`${GREEN}+${aLine}${RESET}`);
} else if (eLine !== undefined) {
lines.push(`${RED}-${eLine}${RESET}`);
} else if (aLine !== undefined) {
lines.push(`${GREEN}+${aLine}${RESET}`);
}
}
return lines.join('\n');
}
// --- Main ---
type Variant = 'babel';
const ALL_VARIANTS: Variant[] = ['babel'];
const variants: Variant[] = variantArg ? [variantArg] : ALL_VARIANTS;
const fixtures = discoverFixtures(fixturesPath);
if (fixtures.length === 0) {
console.error('No fixtures found at', fixturesPath);
process.exit(1);
}
interface VariantStats {
passed: number;
failed: number;
codePassed: number;
codeFailed: number;
eventsPassed: number;
eventsFailed: number;
failures: Array<{fixture: string; detail: string}>;
failedFixtures: string[];
}
function makeStats(): VariantStats {
return {
passed: 0,
failed: 0,
codePassed: 0,
codeFailed: 0,
eventsPassed: 0,
eventsFailed: 0,
failures: [],
failedFixtures: [],
};
}
// --- Progress helper ---
function writeProgress(msg: string): void {
if (process.stderr.isTTY) {
process.stderr.write(`\r\x1b[K${msg}`);
}
}
function clearProgress(): void {
if (process.stderr.isTTY) {
process.stderr.write('\r\x1b[K');
}
}
// --- Pre-compute TS baselines (shared across variants) ---
interface FixtureInfo {
fixturePath: string;
relPath: string;
source: string;
firstLine: string;
isFlow: boolean;
}
async function runVariant(
variant: Variant,
fixtureInfos: FixtureInfo[],
tsBaselines: Map<string, string>,
tsRawEvents: Map<string, Array<Record<string, unknown>>>,
s: VariantStats,
): Promise<void> {
for (let i = 0; i < fixtureInfos.length; i++) {
const {fixturePath, relPath, source, firstLine, isFlow} = fixtureInfos[i];
const tsCode = tsBaselines.get(fixturePath)!;
const tsEvents = normalizeEvents(tsRawEvents.get(fixturePath)!);
writeProgress(
` ${variant}: ${i + 1}/${fixtureInfos.length} (${s.passed} passed, ${
s.failed
} failed)`,
);
const variantResult = compileBabel(
rustPlugin,
fixturePath,
source,
firstLine,
);
const variantCode = await formatCode(variantResult.code ?? '', isFlow);
const variantEvents = normalizeEvents(variantResult.events);
// When both TS and the variant error (produce empty/no output), count as pass.
const tsErrored = tsCode.trim() === '';
const variantErrored =
variantCode.trim() === '' || variantResult.error != null;
const codeMatch = tsCode === variantCode || (tsErrored && variantErrored);
const eventsMatch = tsEvents === variantEvents;
// When code doesn't match due to TS error + variant passthrough, check
// if the variant output is just uncompiled source (no memoization).
let codePassthrough = false;
if (!codeMatch && tsErrored && variantCode.trim() !== '') {
const variantHasMemoization =
variantCode.includes('_c(') || variantCode.includes('useMemoCache');
if (!variantHasMemoization) {
codePassthrough = true;
}
}
const codeOk = codeMatch || codePassthrough;
if (codeOk) {
s.codePassed++;
} else {
s.codeFailed++;
}
if (eventsMatch) {
s.eventsPassed++;
} else {
s.eventsFailed++;
}
if (codeOk && eventsMatch) {
s.passed++;
} else {
s.failed++;
s.failedFixtures.push(relPath);
if (limitArg === 0 || s.failures.length < limitArg) {
const details: string[] = [];
if (!codeOk) {
details.push(unifiedDiff(tsCode, variantCode, 'TypeScript', variant));
}
if (!eventsMatch) {
details.push(
unifiedDiff(
tsEvents,
variantEvents,
'TS events',
variant + ' events',
),
);
}
s.failures.push({
fixture: relPath,
detail: details.join('\n\n'),
});
}
}
}
clearProgress();
}
(async () => {
const stats = new Map<Variant, VariantStats>();
for (const v of variants) {
stats.set(v, makeStats());
}
if (variantArg) {
console.log(
`Testing ${BOLD}${fixtures.length}${RESET} fixtures: TS baseline vs ${BOLD}${variantArg}${RESET}`,
);
} else {
console.log(
`Testing ${BOLD}${fixtures.length}${RESET} fixtures across all variants`,
);
}
console.log('');
// Pre-compute fixture info and TS baselines
const fixtureInfos: FixtureInfo[] = [];
const tsBaselines = new Map<string, string>();
const tsRawEvents = new Map<string, Array<Record<string, unknown>>>();
console.log('Computing TS baselines...');
for (let i = 0; i < fixtures.length; i++) {
const fixturePath = fixtures[i];
const relPath = path.relative(REPO_ROOT, fixturePath);
const source = fs.readFileSync(fixturePath, 'utf8');
const firstLine = source.substring(0, source.indexOf('\n'));
const isFlow = firstLine.includes('@flow');
writeProgress(` baseline: ${i + 1}/${fixtures.length}`);
const tsResult = compileBabel(tsPlugin, fixturePath, source, firstLine);
const tsCode = await formatCode(tsResult.code ?? '', isFlow);
fixtureInfos.push({fixturePath, relPath, source, firstLine, isFlow});
tsBaselines.set(fixturePath, tsCode);
tsRawEvents.set(fixturePath, tsResult.events);
}
clearProgress();
console.log(`Computed ${fixtures.length} baselines.`);
console.log('');
// Run each variant
for (const variant of variants) {
console.log(`Running ${BOLD}${variant}${RESET} variant...`);
await runVariant(
variant,
fixtureInfos,
tsBaselines,
tsRawEvents,
stats.get(variant)!,
);
const s = stats.get(variant)!;
console.log(` ${s.passed} passed, ${s.failed} failed`);
}
console.log('');
// --- Output ---
if (variantArg) {
// Single variant mode: show diffs
const s = stats.get(variantArg)!;
const total = fixtures.length;
const summaryColor = s.failed === 0 ? GREEN : RED;
const summary =
`Code: ${s.codePassed}/${total} passed ` +
`Events: ${s.eventsPassed}/${total} passed ` +
`Total: ${s.passed}/${total} passed`;
console.log(`${summaryColor}${summary}${RESET}`);
console.log('');
for (const failure of s.failures) {
console.log(`${RED}FAIL${RESET} ${failure.fixture}`);
console.log(failure.detail);
console.log('');
}
if (s.failures.length < s.failed) {
console.log(
`${DIM} (showing first ${s.failures.length} of ${s.failed} failures)${RESET}`,
);
}
console.log('---');
console.log(`${summaryColor}${summary}${RESET}`);
} else {
// Summary table mode
const total = fixtures.length;
function fmtCell(passed: number, total: number): string {
const pct = ((passed / total) * 100).toFixed(1);
return `${passed}/${total} (${pct}%)`;
}
// Table header
const colW = 22;
const hdr =
`${'Variant'.padEnd(10)} ` +
`${'Code'.padEnd(colW)} ` +
`${'Events'.padEnd(colW)} ` +
`${'Total'.padEnd(colW)}`;
console.log(`${BOLD}${hdr}${RESET}`);
for (const variant of ALL_VARIANTS) {
const s = stats.get(variant)!;
const line =
`${variant.padEnd(10)} ` +
`${fmtCell(s.codePassed, total).padEnd(colW)} ` +
`${fmtCell(s.eventsPassed, total).padEnd(colW)} ` +
`${fmtCell(s.passed, total)}`;
const color = s.failed === 0 ? GREEN : s.passed === 0 ? RED : YELLOW;
console.log(`${color}${line}${RESET}`);
}
}
// Exit with failure if any variant has failures
const anyFailed = [...stats.values()].some(s => s.failed > 0);
process.exit(anyFailed ? 1 : 0);
})();