mirror of
https://github.com/aphrody-labs/oxc.git
synced 2026-10-15 06:39:17 +00:00
## 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>
516 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|