Files
oxc/napi/transform-react/test/transform.test.ts
3255b89c34 feat(transform-react): add reportDiagnostics option to expose recoverable React Compiler diagnostics (#26624)
## What

Adds an opt-in `reactCompiler.reportDiagnostics` option to
`oxc-transform-react`. When `true`, the recoverable diagnostics the
React Compiler produced for a successful compile
(`CompileResult::Success { diagnostics }`) are appended to
`result.errors` alongside code, keeping the category severity the
compiler assigned (`Error` for `Suppression`, `Warning` for
`IncompatibleLibrary`, ...). `fatal` keeps its meaning and tells whether
`code` is usable.

The default is unchanged: recoverable diagnostics stay omitted, matching
Babel's default `logger: null` as decided in #26128.

## Why

Since #26128 (0.148.0) there is no way to learn *why* a function was
skipped: `errors` is fatal-only, and `panicThreshold` is not a
substitute because it aborts at the first diagnostic without producing
code. Consumers that need the reasons — a burndown inventory when
adopting the compiler, or checking which `eslint-disable` suppressions
actually took effect — lost their only channel. Details and a repro are
in #26318.

Two concrete downstream effects on 0.148+:

- `@vitejs/plugin-react` added `logDiagnostics`
(vitejs/vite-plugin-react#1431) one day after #26128, reading
recoverable diagnostics from `result.errors`; with the current binding
that option is a no-op. With this PR it only needs to forward
`reportDiagnostics: logDiagnostics`.
- `outputMode: 'lint'` / `noEmit` return `errors: []`, so lint mode has
no observable output at the Node boundary.

Babel's `logger` is the knob that turns these reports on; this option is
the non-callback equivalent. Upstream's own native bridge
(`babel-plugin-react-compiler-rust`) likewise returns every event on the
native result and lets the JS shim apply `logger: null`.

## Design notes

- **Opt-in rather than a new always-populated field**, so the default
result is byte-identical to today and nobody pays for rendering
codeframes they never read.
- **Reuses `errors`** instead of adding a second array: `errors` already
carries non-fatal parser and transformer warnings, and the 0.147 shape
(recoverable compiler diagnostics in `errors`, `fatal` to tell if output
is usable) is what existing consumers were written against.
- The severities are forwarded as the compiler assigned them (no `Error
→ Warning` rewrite as in 0.147); `fatal` is the fatality signal, as
documented on `oxc_react_compiler::compile`.

## Verification

- `napi/transform-react`: `pnpm run build-dev && pnpm test` — 33 tests
pass (3 new: opt-in vs default, multiple diagnostics with their own
severities, lint mode).
- `cargo fmt --check`, `cargo clippy -p oxc_transform_react_napi
--all-targets --all-features -- -D warnings`, `typos` on the touched
files.
- Repro from #26318 against this build (`memoized` = `_c(` count, shows
the suppression is still honored):

```
                    default        reportDiagnostics: true
default          errors: 0      errors: 1 (Suppression, label = the eslint comment)
no suppressions  errors: 0      errors: 0, memoized: 2
lint             errors: 0      errors: 1, memoized: 0
panic            fatal, 1       unchanged
```

- Ran the real consumer from #26318 (a suppression gate that maps
`react-compiler(Suppression)` labels back to the component they cover)
against this build: with the option it recovers all 5 expected sites
across the codebase; against published 0.149.0 it finds none.

`index.d.ts` is regenerated by `napi build`;
`transform-react.wasi.d.cts` mirrors it by hand, as in #26128.

closes #26318

---

AI usage disclosure (per AGENTS.md): this PR was drafted with Claude
Fable 5.1. I reviewed the diff; the verification steps above were run on
my machine against this branch.

---------

Co-authored-by: JacktheRanger <219502738+JacktheRanger@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Cameron Clark <cameron.clark@hey.com>
2026-09-25 11:58:39 +01:00

516 lines
17 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { transform, transformSync } from "../index";
const fixture = `// @license MIT
import { useState } from "react";
interface Props {
text: string;
}
export function Component(props: Props) {
const [count, setCount] = useState<number>(0);
return <button onClick={() => setCount(count + 1)}>{props.text}: {count}</button>;
}
`;
describe("transformSync", () => {
it("runs React Compiler before TypeScript and JSX transforms", () => {
const result = transformSync("Component.tsx", fixture);
expect(result.fatal).toBe(false);
expect(result.errors).toEqual([]);
expect(result.code).toContain("react/compiler-runtime");
expect(result.code).toContain("_c(");
expect(result.code).not.toContain("interface Props");
expect(result.code).not.toContain("<button");
expect(result.code).toContain("@license MIT");
});
it("honors JSX pragmas after React Compiler adds imports", () => {
const result = transformSync(
"Component.tsx",
`/** @jsxRuntime automatic */
/** @jsxImportSource custom-runtime */
export function Component({ value }: { value: string }) {
return <div>{value}</div>;
}
`,
);
expect(result.fatal).toBe(false);
expect(result.errors).toEqual([]);
expect(result.code).toContain("react/compiler-runtime");
expect(result.code).toContain('from "custom-runtime/jsx-runtime"');
expect(result.code).not.toContain('from "react/jsx-runtime"');
});
it("forwards React Compiler options", () => {
const target = transformSync("Component.tsx", fixture, {
reactCompiler: { target: "18" },
});
expect(target.errors).toEqual([]);
expect(target.code).toContain("react-compiler-runtime");
const gated = transformSync("Component.tsx", fixture, {
reactCompiler: {
gating: {
source: "feature-flags",
importSpecifierName: "isCompilerEnabled",
},
},
});
expect(gated.errors).toEqual([]);
expect(gated.code).toContain("feature-flags");
expect(gated.code).toContain("isCompilerEnabled");
});
it("supports dynamic gating and Meta-internal targets", () => {
const dynamic = transformSync(
"Component.jsx",
`export function Component(props) {
"use memo if(isCompilerEnabled)";
return <div>{props.text}</div>;
}`,
{
reactCompiler: {
dynamicGating: { source: "dynamic-feature-flags" },
},
},
);
expect(dynamic.errors).toEqual([]);
expect(dynamic.code).toContain("dynamic-feature-flags");
expect(dynamic.code).toContain("isCompilerEnabled");
const meta = transformSync("Component.tsx", fixture, {
reactCompiler: {
target: {
kind: "donotuse_meta_internal",
runtimeModule: "custom-react-runtime",
},
},
});
expect(meta.errors).toEqual([]);
expect(meta.code).toContain("custom-react-runtime");
});
it("can override opt-out directives", () => {
const source = `function Component(props) {
"use no memo";
return <div>{props.text}</div>;
}`;
const optedOut = transformSync("Component.jsx", source);
expect(optedOut.errors).toEqual([]);
expect(optedOut.code).not.toContain("_c(");
const compiled = transformSync("Component.jsx", source, {
reactCompiler: { ignoreUseNoForget: true },
});
expect(compiled.errors).toEqual([]);
expect(compiled.code).toContain("_c(");
});
it.each([
["compilationMode", { compilationMode: "bogus" }],
["panicThreshold", { panicThreshold: "bogus" }],
["outputMode", { outputMode: "bogus" }],
["target", { target: "20" }],
[
"environment.validateExhaustiveEffectDependencies",
{
environment: {
validateExhaustiveEffectDependencies: "bogus",
},
},
],
])("reports an invalid %s option without emitting code", (option, options) => {
const result = transformSync("Component.tsx", fixture, {
reactCompiler: options,
} as never);
expect(result.fatal).toBe(true);
expect(result.code).toBe("");
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain(`Invalid React Compiler \`${option}\` option`);
});
it("marks parse errors as fatal", () => {
const result = transformSync("Component.tsx", "function Component(");
expect(result.fatal).toBe(true);
expect(result.code).toBe("");
expect(result.errors.length).toBeGreaterThan(0);
});
it("supports source maps and language overrides", () => {
const result = transformSync("Component.vue", fixture, {
lang: "tsx",
sourcemap: true,
});
expect(result.errors).toEqual([]);
expect(result.map).toMatchObject({
sources: ["Component.vue"],
sourcesContent: [fixture],
version: 3,
});
});
it("can filter files with sources", () => {
const result = transformSync("vendor/Component.tsx", fixture, {
reactCompiler: { sources: ["src/"] },
});
expect(result.errors).toEqual([]);
expect(result.code).not.toContain("react/compiler-runtime");
expect(result.code).not.toContain("interface Props");
expect(result.code).not.toContain("<button");
});
it("skips node_modules by default", () => {
for (const options of [undefined, { reactCompiler: {} }]) {
const result = transformSync("node_modules/package/Component.tsx", fixture, options);
expect(result.errors).toEqual([]);
expect(result.code).not.toContain("react/compiler-runtime");
expect(result.code).not.toContain("_c(");
}
});
it("allows sources to include node_modules", () => {
const result = transformSync("node_modules/package/Component.tsx", fixture, {
reactCompiler: { sources: ["node_modules/package"] },
});
expect(result.errors).toEqual([]);
expect(result.code).toContain("react/compiler-runtime");
expect(result.code).toContain("_c(");
});
it("keeps imports used by compiled computed keys", () => {
const result = transformSync(
"Box.tsx",
`import { CSS_VAR } from "./styles.css";
export function Box({ size }) {
const style = { [CSS_VAR]: size + "px" };
return <div style={style} />;
}`,
);
expect(result.errors).toEqual([]);
expect(result.code).toContain("_c(");
expect(result.code).toContain("[CSS_VAR]");
expect(result.code).toContain("import { CSS_VAR }");
});
it("honors ESLint suppressions when internal validations are enabled", () => {
const result = transformSync(
"Counter.jsx",
`import { useEffect, useRef, useState } from "react";
export function Counter({ step }) {
const [count, setCount] = useState(0);
const ref = useRef(step);
useEffect(() => {
setCount((value) => value + ref.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <div>{count}</div>;
}`,
{
reactCompiler: {
environment: {
validateExhaustiveMemoizationDependencies: true,
},
},
},
);
expect(result.fatal).toBe(false);
expect(result.errors).toEqual([]);
expect(result.code).not.toContain("react/compiler-runtime");
expect(result.code).not.toContain("_c(");
});
it("honors ESLint suppressions by default", () => {
const result = transformSync(
"Component.tsx",
`function Component({ value }: { value: number }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
const doubled = value * 2;
return <div>{doubled}</div>;
}`,
);
expect(result.fatal).toBe(false);
expect(result.errors).toEqual([]);
expect(result.code).not.toContain("react/compiler-runtime");
});
it("allows ESLint suppression bailouts to be disabled", () => {
const result = transformSync(
"Component.tsx",
`function Component({ value }: { value: number }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
const doubled: number = value * 2;
return <div>{doubled}</div>;
}`,
{
reactCompiler: {
eslintSuppressionRules: [],
},
},
);
expect(result.fatal).toBe(false);
expect(result.errors).toEqual([]);
expect(result.code).toContain("react/compiler-runtime");
expect(result.code).toContain("_c(");
expect(result.code).not.toContain(": number");
expect(result.code).not.toContain("<div");
});
it("continues after an incompatible library bailout", () => {
const result = transformSync(
"Components.tsx",
`import { useReactTable } from "@tanstack/react-table";
function Table() {
const table = useReactTable({});
return <div>{table}</div>;
}
export function Component(props: { text: string }) {
return <span>{props.text}</span>;
}`,
);
expect(result.fatal).toBe(false);
expect(result.errors).toEqual([]);
expect(result.code).toContain("react/compiler-runtime");
expect(result.code).not.toContain("props: { text: string }");
expect(result.code).not.toContain("<span");
});
it("does not enable manual memo dependency validation by default", () => {
const result = transformSync(
"Component.tsx",
`import { useMemo } from "react";
import { typedCapture, typedCreateFrom, typedMutate, ValidateMemoization } from "shared-runtime";
function Component({ a, b }: { a: number; b: number }) {
const x = useMemo(() => ({ a }), [a, b]);
const y = typedCapture(x);
const z = typedCreateFrom(y);
typedMutate(z, b);
return <ValidateMemoization inputs={[a, b]} output={x} />;
}`,
);
expect(result.errors).toEqual([]);
expect(result.code).toContain("react/compiler-runtime");
});
it("compiles an unsuppressed sibling after a suppression bailout", () => {
const result = transformSync(
"Components.tsx",
`function Suppressed({ value }: { value: number }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
const doubled = value * 2;
return <div>{doubled}</div>;
}
export function Component(props: { text: string }) {
return <span>{props.text}</span>;
}`,
);
expect(result.fatal).toBe(false);
expect(result.errors).toEqual([]);
expect(result.code).toContain("react/compiler-runtime");
expect(result.code).not.toContain("props: { text: string }");
expect(result.code).not.toContain("<span");
});
it("reports recoverable diagnostics only when reportDiagnostics is enabled", () => {
const source = `function Suppressed({ value }: { value: number }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
const doubled = value * 2;
return <div>{doubled}</div>;
}
export function Component(props: { text: string }) {
return <span>{props.text}</span>;
}`;
const silent = transformSync("Components.tsx", source);
expect(silent.fatal).toBe(false);
expect(silent.errors).toEqual([]);
const reported = transformSync("Components.tsx", source, {
reactCompiler: { reportDiagnostics: true },
});
expect(reported.fatal).toBe(false);
expect(reported.code).toBe(silent.code);
expect(reported.errors).toHaveLength(1);
expect(reported.errors[0]).toMatchObject({
severity: "Error",
message: "React rule suppression prevents optimization",
});
expect(reported.errors[0].codeframe).toContain("react-compiler(Suppression)");
const [label] = reported.errors[0].labels;
expect(source.slice(label.start, label.end)).toBe(
"// eslint-disable-next-line react-hooks/exhaustive-deps",
);
});
it("reports every recoverable diagnostic with its own severity", () => {
const result = transformSync(
"Components.tsx",
`import { useReactTable } from "@tanstack/react-table";
function Table() {
const table = useReactTable({});
return <div>{table}</div>;
}
function Suppressed({ value }: { value: number }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
return <div>{value}</div>;
}
export function Component(props: { text: string }) {
return <span>{props.text}</span>;
}`,
{ reactCompiler: { reportDiagnostics: true } },
);
expect(result.fatal).toBe(false);
expect(result.code).toContain("_c(");
expect(result.errors.map((error) => [error.severity, error.message])).toEqual([
["Warning", "Use of incompatible library"],
["Error", "React rule suppression prevents optimization"],
]);
});
it("reports lint findings with reportDiagnostics", () => {
const result = transformSync(
"Component.tsx",
`function Component({ value }: { value: number }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
const doubled = value * 2;
return <div>{doubled}</div>;
}`,
{ reactCompiler: { outputMode: "lint", reportDiagnostics: true } },
);
expect(result.fatal).toBe(false);
expect(result.code).not.toContain("_c(");
expect(result.code).not.toContain(": number");
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toBe("React rule suppression prevents optimization");
});
it.each(["critical_errors", "all_errors"] as const)(
"makes suppression bailouts fatal at panicThreshold %s",
(panicThreshold) => {
const result = transformSync(
"Component.tsx",
`function Component({ value }: { value: number }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
const doubled = value * 2;
return <div>{doubled}</div>;
}`,
{
reactCompiler: {
panicThreshold,
},
},
);
expect(result.fatal).toBe(true);
expect(result.code).toBe("");
expect(result.errors).toHaveLength(1);
expect(result.errors[0].severity).toBe("Error");
expect(result.errors[0].message).toBe("React rule suppression prevents optimization");
},
);
it("makes warning bailouts fatal at panicThreshold all_errors", () => {
const result = transformSync(
"Table.tsx",
`import { useReactTable } from "@tanstack/react-table";
function Table() {
const table = useReactTable({});
return <div>{table}</div>;
}`,
{ reactCompiler: { panicThreshold: "all_errors" } },
);
expect(result.fatal).toBe(true);
expect(result.code).toBe("");
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toMatchObject({
severity: "Warning",
message: "Use of incompatible library",
});
});
it("can disable and explicitly enable React Compiler", () => {
const disabled = transformSync("Component.tsx", fixture, {
reactCompiler: false,
});
expect(disabled.errors).toEqual([]);
expect(disabled.code).not.toContain("react/compiler-runtime");
expect(disabled.code).not.toContain("interface Props");
expect(disabled.code).not.toContain("<button");
const enabled = transformSync("Component.tsx", fixture, {
reactCompiler: true,
});
expect(enabled.errors).toEqual([]);
expect(enabled.code).toContain("react/compiler-runtime");
});
it("configures and preserves JSX independently of React Compiler", () => {
const configured = transformSync("Component.tsx", fixture, {
reactCompiler: false,
jsx: { importSource: "custom-jsx" },
});
expect(configured.errors).toEqual([]);
expect(configured.code).toContain('from "custom-jsx/jsx-runtime"');
expect(configured.code).not.toContain("<button");
const preserved = transformSync("Component.tsx", fixture, {
jsx: "preserve",
});
expect(preserved.errors).toEqual([]);
expect(preserved.code).toContain("react/compiler-runtime");
expect(preserved.code).not.toContain("interface Props");
expect(preserved.code).toContain("<button");
});
it("reports invalid JSX modes without emitting code", () => {
const result = transformSync("Component.tsx", fixture, {
reactCompiler: false,
jsx: "invalid",
} as never);
expect(result.fatal).toBe(true);
expect(result.code).toBe("");
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain("Invalid `jsx` option");
});
it("supports React Fast Refresh through JSX options", () => {
const result = transformSync("Component.tsx", fixture, {
jsx: { refresh: { emitFullSignatures: true } },
});
expect(result.errors).toEqual([]);
expect(result.code).toContain("react/compiler-runtime");
expect(result.code).toContain("$RefreshSig$");
expect(result.code).toContain("$RefreshReg$");
expect(result.code).toContain("useState{[count, setCount](0)}");
});
});
describe("transform", () => {
it("matches the synchronous transform", async () => {
const sync = transformSync("Component.tsx", fixture);
const asyncResult = await transform("Component.tsx", fixture);
expect(asyncResult).toEqual(sync);
});
});