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>
This commit is contained in:
authored and GitHub committed 2026-09-25 11:58:39 +01:00
1 parent 73796df649
commit 3255b89c34
7 files changed
+194 -28

No files matched your search

+9 -4
View File
@@ -59,9 +59,11 @@ The asynchronous `transform` runs on a worker-pool thread. It is useful when
processing files concurrently, but can be slower for a single small file.
`TransformResult` contains `code`, an optional source `map`, `errors`, and a
`fatal` flag. Recoverable React Compiler bail-outs are omitted in every output
mode, matching Babel's default `logger: null`. Fatal diagnostics appear in
`errors` without producing code.
`fatal` flag. Fatal diagnostics appear in `errors` without producing code.
Recoverable React Compiler diagnostics are omitted by default;
set `reactCompiler.reportDiagnostics` to include them in
`errors` while code is still produced, and use `fatal` to determine whether the
output is usable.
## Options
@@ -105,6 +107,7 @@ omitted.
| ------------------------ | ------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `compilationMode` | `string` | `"infer"` | Select which functions to compile: `"infer"`, `"syntax"`, `"annotation"`, or `"all"`. |
| `panicThreshold` | `string` | `"none"` | Select which diagnostics abort the transform: `"none"`, `"critical_errors"`, or `"all_errors"`. |
| `reportDiagnostics` | `boolean` | `false` | Include recoverable React Compiler diagnostics (skipped functions, rule suppressions, lint findings) in `result.errors` while still emitting code. Fatal diagnostics are always reported. |
| `target` | `string` or `ReactCompilerMetaTarget` | `"19"` | Target React `"17"`, `"18"`, `"19"`, or a Meta-internal runtime. |
| `gating` | `ReactCompilerGating` | Unset | Emit compiled and original functions behind an imported feature flag. |
| `dynamicGating` | `ReactCompilerDynamicGating` | Unset | Resolve flags in `"use memo if(...)"` directives from an imported module. A directive takes precedence over `gating`. |
@@ -125,7 +128,9 @@ ESLint and Oxlint comments: `eslint-disable`, `eslint-disable-next-line`,
`outputMode: "ssr"` takes precedence over `noEmit`. Otherwise, `noEmit: true`
forces lint output, including when `outputMode: "client"` is set. Lint output
suppresses only React Compiler rewrites; the downstream Oxc transform still
removes TypeScript syntax and applies the configured JSX transform.
removes TypeScript syntax and applies the configured JSX transform. Lint
findings are recoverable diagnostics, so pair lint output with
`reportDiagnostics: true` to receive them in `errors`.
The object-valued target and gating options have these shapes:
+22 -3
View File
@@ -205,6 +205,16 @@ export interface ReactCompilerOptions {
* @default 'none'
*/
panicThreshold?: 'none' | 'critical_errors' | 'all_errors'
/**
* Include recoverable React Compiler diagnostics in `errors`.
*
* Enable this to receive recoverable diagnostics (bail-outs, rule suppressions,
* lint findings) while code is still produced. Fatal diagnostics are reported
* regardless of this option.
*
* @default false
*/
reportDiagnostics?: boolean
/**
* React runtime target. React 17 and 18 use `react-compiler-runtime`;
* React 19 uses `react/compiler-runtime`.
@@ -217,13 +227,17 @@ export interface ReactCompilerOptions {
/** Enable `"use memo if(...)"` directive-driven gating. */
dynamicGating?: ReactCompilerDynamicGating
/**
* Analyze and report diagnostics without applying compiler output.
* Analyze without applying compiler output. Pair with `reportDiagnostics`
* to receive the findings.
*
* @deprecated Prefer `outputMode: "lint"`.
* @default false
*/
noEmit?: boolean
/** Select client, SSR, or lint output. */
/**
* Select client, SSR, or lint output. Lint findings are recoverable
* diagnostics, so pair lint output with `reportDiagnostics`.
*/
outputMode?: 'client' | 'ssr' | 'lint'
/**
* ESLint rule names whose suppressions opt a function out of compilation.
@@ -334,7 +348,12 @@ export interface TransformResult {
code: string
/** Source map, populated when `sourcemap` is `true`. */
map?: SourceMap
/** Parse, semantic, downstream transform, and fatal React Compiler diagnostics. */
/**
* Parse, semantic, downstream transform, and fatal React Compiler diagnostics.
*
* Recoverable React Compiler diagnostics are included when
* `reactCompiler.reportDiagnostics` is `true`.
*/
errors: Array<OxcError>
}
+22 -13
View File
@@ -51,6 +51,9 @@ pub struct TransformResult {
pub map: Option<SourceMap>,
/// Parse, semantic, downstream transform, and fatal React Compiler diagnostics.
///
/// Recoverable React Compiler diagnostics are included when
/// `reactCompiler.reportDiagnostics` is `true`.
pub errors: Vec<OxcError>,
}
@@ -66,17 +69,18 @@ fn transform_impl(
);
let sourcemap = options.as_ref().and_then(|options| options.sourcemap).unwrap_or(false);
let (react_compiler_options, transform_options) =
match options.unwrap_or_default().resolve(filename) {
Ok(options) => options,
Err(error) => {
return TransformResult {
fatal: true,
errors: OxcError::from_diagnostics(filename, source_text, [error]),
..TransformResult::default()
};
}
};
let options = options.unwrap_or_default();
let report_diagnostics = options.report_react_compiler_diagnostics();
let (react_compiler_options, transform_options) = match options.resolve(filename) {
Ok(options) => options,
Err(error) => {
return TransformResult {
fatal: true,
errors: OxcError::from_diagnostics(filename, source_text, [error]),
..TransformResult::default()
};
}
};
let allocator = Allocator::default();
let parser_return = Parser::new(&allocator, source_text, source_type).parse();
@@ -101,8 +105,13 @@ fn transform_impl(
let react_output = match react_compiler_options {
None => None,
Some(options) => match react_compiler_compile(&program, &semantic, &allocator, options) {
// Match Babel's default `logger: null` by omitting recoverable diagnostics.
CompileResult::Success { output, .. } => output,
// Include recoverable diagnostics only when requested.
CompileResult::Success { output, diagnostics: react_diagnostics } => {
if report_diagnostics {
diagnostics.extend(react_diagnostics);
}
output
}
CompileResult::Fatal { diagnostics: react_diagnostics } => {
diagnostics.extend(react_diagnostics);
return error_result(filename, source_text, diagnostics);
+22 -2
View File
@@ -61,6 +61,15 @@ pub struct ReactCompilerOptions {
#[napi(ts_type = "'none' | 'critical_errors' | 'all_errors'")]
pub panic_threshold: Option<String>,
/// Include recoverable React Compiler diagnostics in `errors`.
///
/// Enable this to receive recoverable diagnostics (bail-outs, rule suppressions,
/// lint findings) while code is still produced. Fatal diagnostics are reported
/// regardless of this option.
///
/// @default false
pub report_diagnostics: Option<bool>,
/// React runtime target. React 17 and 18 use `react-compiler-runtime`;
/// React 19 uses `react/compiler-runtime`.
///
@@ -74,13 +83,15 @@ pub struct ReactCompilerOptions {
/// Enable `"use memo if(...)"` directive-driven gating.
pub dynamic_gating: Option<ReactCompilerDynamicGating>,
/// Analyze and report diagnostics without applying compiler output.
/// Analyze without applying compiler output. Pair with `reportDiagnostics`
/// to receive the findings.
///
/// @deprecated Prefer `outputMode: "lint"`.
/// @default false
pub no_emit: Option<bool>,
/// Select client, SSR, or lint output.
/// Select client, SSR, or lint output. Lint findings are recoverable
/// diagnostics, so pair lint output with `reportDiagnostics`.
#[napi(ts_type = "'client' | 'ssr' | 'lint'")]
pub output_mode: Option<String>,
@@ -272,6 +283,15 @@ pub struct ReactCompilerEnvironmentOptions {
}
impl TransformOptions {
/// Whether recoverable React Compiler diagnostics are forwarded to `errors`
/// (`reactCompiler.reportDiagnostics`).
pub(crate) fn report_react_compiler_diagnostics(&self) -> bool {
matches!(
&self.react_compiler,
Some(Either::B(options)) if options.report_diagnostics == Some(true)
)
}
pub(crate) fn resolve(
self,
filename: &str,
@@ -328,6 +328,81 @@ export function Component({ value }: { value: 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) => {
@@ -200,6 +200,16 @@ export interface ReactCompilerOptions {
* @default 'none'
*/
panicThreshold?: 'none' | 'critical_errors' | 'all_errors'
/**
* Include recoverable React Compiler diagnostics in `errors`.
*
* Enable this to receive recoverable diagnostics (bail-outs, rule suppressions,
* lint findings) while code is still produced. Fatal diagnostics are reported
* regardless of this option.
*
* @default false
*/
reportDiagnostics?: boolean
/**
* React runtime target. React 17 and 18 use `react-compiler-runtime`;
* React 19 uses `react/compiler-runtime`.
@@ -212,13 +222,17 @@ export interface ReactCompilerOptions {
/** Enable `"use memo if(...)"` directive-driven gating. */
dynamicGating?: ReactCompilerDynamicGating
/**
* Analyze and report diagnostics without applying compiler output.
* Analyze without applying compiler output. Pair with `reportDiagnostics`
* to receive the findings.
*
* @deprecated Prefer `outputMode: "lint"`.
* @default false
*/
noEmit?: boolean
/** Select client, SSR, or lint output. */
/**
* Select client, SSR, or lint output. Lint findings are recoverable
* diagnostics, so pair lint output with `reportDiagnostics`.
*/
outputMode?: 'client' | 'ssr' | 'lint'
/**
* ESLint rule names whose suppressions opt a function out of compilation.
@@ -329,7 +343,12 @@ export interface TransformResult {
code: string
/** Source map, populated when `sourcemap` is `true`. */
map?: SourceMap
/** Parse, semantic, downstream transform, and fatal React Compiler diagnostics. */
/**
* Parse, semantic, downstream transform, and fatal React Compiler diagnostics.
*
* Recoverable React Compiler diagnostics are included when
* `reactCompiler.reportDiagnostics` is `true`.
*/
errors: Array<OxcError>
}
@@ -200,6 +200,16 @@ export interface ReactCompilerOptions {
* @default 'none'
*/
panicThreshold?: 'none' | 'critical_errors' | 'all_errors'
/**
* Include recoverable React Compiler diagnostics in `errors`.
*
* Enable this to receive recoverable diagnostics (bail-outs, rule suppressions,
* lint findings) while code is still produced. Fatal diagnostics are reported
* regardless of this option.
*
* @default false
*/
reportDiagnostics?: boolean
/**
* React runtime target. React 17 and 18 use `react-compiler-runtime`;
* React 19 uses `react/compiler-runtime`.
@@ -212,13 +222,17 @@ export interface ReactCompilerOptions {
/** Enable `"use memo if(...)"` directive-driven gating. */
dynamicGating?: ReactCompilerDynamicGating
/**
* Analyze and report diagnostics without applying compiler output.
* Analyze without applying compiler output. Pair with `reportDiagnostics`
* to receive the findings.
*
* @deprecated Prefer `outputMode: "lint"`.
* @default false
*/
noEmit?: boolean
/** Select client, SSR, or lint output. */
/**
* Select client, SSR, or lint output. Lint findings are recoverable
* diagnostics, so pair lint output with `reportDiagnostics`.
*/
outputMode?: 'client' | 'ssr' | 'lint'
/**
* ESLint rule names whose suppressions opt a function out of compilation.
@@ -329,7 +343,12 @@ export interface TransformResult {
code: string
/** Source map, populated when `sourcemap` is `true`. */
map?: SourceMap
/** Parse, semantic, downstream transform, and fatal React Compiler diagnostics. */
/**
* Parse, semantic, downstream transform, and fatal React Compiler diagnostics.
*
* Recoverable React Compiler diagnostics are included when
* `reactCompiler.reportDiagnostics` is `true`.
*/
errors: Array<OxcError>
}