From 2a0042cdc755a52cf20b6fc466644cb1af3b465f Mon Sep 17 00:00:00 2001 From: Boshen Date: Wed, 15 Jul 2026 16:59:50 +0800 Subject: [PATCH 1/3] feat(napi/transform, oxc): add React Compiler behind a Cargo feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes the React Compiler as a Cargo feature on the `oxc` crate and the `oxc-transform` napi binding. `oxc` already had a `react_compiler` feature, but nothing re-exported `oxc_react_compiler`, so enabling it produced a `TransformOptions::react_compiler` field whose `PluginOptions` type callers could not name. Add `oxc::react_compiler`, which also lets the binding build `PluginOptions` through `oxc` instead of depending on the crate directly. Restore the napi `reactCompiler` option, removed in #23590. That change rejected feature-gating because both options it saw were unworkable: unconditional types mean a feature-off build silently ignores the option, and gated types mean `build-test` regenerates a `.d.ts` that no longer matches the checked-in one. Gating below the type surface avoids both -- the JS-facing option types are plain strings and bools, so they stay unconditional and only the conversion into `PluginOptions` is gated. A `--no-default-features` build regenerates a byte-identical `index.d.ts`, and passing `reactCompiler` to such a build is a hard error rather than a silent no-op. The feature is on by default so the published binary supports what the types advertise. It is a default rather than `--features react_compiler` in the build scripts because `build` already passes `--features allocator`, and the napi CLI forwards repeated flags as `--features react_compiler allocator`, which cargo misreads. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Cargo.lock | 1 + crates/oxc/Cargo.toml | 6 +- crates/oxc/src/lib.rs | 13 ++ napi/transform/Cargo.toml | 10 +- napi/transform/index.d.ts | 90 +++++++++- napi/transform/src/lib.rs | 3 + napi/transform/src/react_compiler.rs | 184 ++++++++++++++++++++ napi/transform/src/transformer.rs | 35 +++- napi/transform/test/reactCompiler.test.ts | 201 ++++++++++++++++++++++ 9 files changed, 534 insertions(+), 9 deletions(-) create mode 100644 napi/transform/src/react_compiler.rs create mode 100644 napi/transform/test/reactCompiler.test.ts diff --git a/Cargo.lock b/Cargo.lock index 7368aaadbc333..a78b9baf53314 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1646,6 +1646,7 @@ dependencies = [ "oxc_mangler", "oxc_minifier", "oxc_parser", + "oxc_react_compiler", "oxc_regular_expression", "oxc_semantic", "oxc_span", diff --git a/crates/oxc/Cargo.toml b/crates/oxc/Cargo.toml index 2a22fa1e32a60..1488a4cca9717 100644 --- a/crates/oxc/Cargo.toml +++ b/crates/oxc/Cargo.toml @@ -36,6 +36,7 @@ oxc_isolated_declarations = { workspace = true, optional = true } oxc_mangler = { workspace = true, optional = true } oxc_minifier = { workspace = true, optional = true } oxc_parser = { workspace = true, features = [] } +oxc_react_compiler = { workspace = true, optional = true } oxc_regular_expression = { workspace = true, optional = true } oxc_semantic = { workspace = true, optional = true } oxc_span = { workspace = true } @@ -66,7 +67,10 @@ full = [ semantic = ["oxc_semantic"] transformer = ["oxc_transformer", "oxc_transformer_plugins"] # Experimental React Compiler pass; off by default (pulls the heavy `oxc_react_compiler` dep). -react_compiler = ["transformer", "oxc_transformer/react_compiler"] +# `dep:oxc_react_compiler` backs the `oxc::react_compiler` re-export, without which +# `TransformOptions::react_compiler` would be a field whose `PluginOptions` type callers +# cannot name. +react_compiler = ["transformer", "oxc_transformer/react_compiler", "dep:oxc_react_compiler"] minifier = ["oxc_mangler", "oxc_minifier"] codegen = ["oxc_codegen", "oxc_codegen/sourcemap"] mangler = ["oxc_mangler"] diff --git a/crates/oxc/src/lib.rs b/crates/oxc/src/lib.rs index 986039ee47e82..8f92d2d997abd 100644 --- a/crates/oxc/src/lib.rs +++ b/crates/oxc/src/lib.rs @@ -70,6 +70,19 @@ pub mod semantic { pub use oxc_semantic::*; } +#[cfg(feature = "react_compiler")] +pub mod react_compiler { + //! Experimental React Compiler, run as the first transform pass. + //! + //! Provides the `PluginOptions` that + //! [`crate::transformer::TransformOptions::react_compiler`] takes. + //! + //! See the [`oxc_react_compiler` module-level documentation](oxc_react_compiler) for more + //! information. + #[doc(inline)] + pub use oxc_react_compiler::*; +} + #[cfg(feature = "transformer")] pub mod transformer { //! Transformer/Transpiler diff --git a/napi/transform/Cargo.toml b/napi/transform/Cargo.toml index 458b3bdcdf009..e2f56496def13 100644 --- a/napi/transform/Cargo.toml +++ b/napi/transform/Cargo.toml @@ -51,5 +51,13 @@ mimalloc-safe = { workspace = true, optional = true, features = [ napi-build = { workspace = true } [features] -default = [] +# On by default so the published binary supports the `reactCompiler` option that +# `index.d.ts` advertises. The generated `.d.ts` does not depend on this feature — the +# option types are unconditional (see `src/react_compiler.rs`), so a +# `--no-default-features` build regenerates a byte-identical one and CI's +# `git diff --exit-code` holds either way. Opting out only drops the (heavy) compiler +# from the binary; passing `reactCompiler` to such a build is then a hard error rather +# than a silent no-op. +default = ["react_compiler"] allocator = ["dep:mimalloc-safe"] +react_compiler = ["oxc/react_compiler"] diff --git a/napi/transform/index.d.ts b/napi/transform/index.d.ts index de149d5c5986e..2384c9866d215 100644 --- a/napi/transform/index.d.ts +++ b/napi/transform/index.d.ts @@ -329,6 +329,83 @@ export interface PluginsOptions { taggedTemplateEscape?: boolean } +/** Dynamic gating for {@link ReactCompilerOptions#dynamicGating}. */ +export interface ReactCompilerDynamicGating { + /** Module the gating import comes from. */ + source: string +} + +/** Static gating for {@link ReactCompilerOptions#gating}. */ +export interface ReactCompilerGating { + /** Module the gating import comes from. */ + source: string + /** Imported specifier used as the gate. */ + importSpecifierName: string +} + +/** + * Options for the experimental [React Compiler](https://github.com/react/react/tree/main/compiler). + * + * Mirrors the compiler's `PluginOptions`. The deep `environment` configuration + * (inference / validation flags) is not surfaced here. + * + * @see {@link TransformOptions#reactCompiler} + */ +export interface ReactCompilerOptions { + /** + * Which functions to compile. + * + * @default 'infer' + */ + compilationMode?: 'infer' | 'syntax' | 'annotation' | 'all' + /** + * What to do when a function cannot be compiled. + * + * @default 'none' + */ + panicThreshold?: 'none' | 'critical_errors' | 'all_errors' + /** + * React runtime version target. `17` and `18` require the + * `react-compiler-runtime` package; `19` ships the runtime in `react`. + * + * @default '19' + */ + target?: '17' | '18' | '19' + /** + * Analyze and report diagnostics only; emit no transformed code. + * + * @default false + */ + noEmit?: boolean + /** + * Compiler output mode. + * + * @default undefined + */ + outputMode?: 'client' | 'ssr' | 'lint' + /** + * Compile even functions marked with the `"use no memo"` / `"use no forget"` + * opt-out directives. + * + * @default false + */ + ignoreUseNoForget?: boolean + /** + * Treat Flow suppression comments as opt-outs. + * + * @default true + */ + flowSuppressions?: boolean + /** ESLint rules whose suppressions opt a function out of compilation. */ + eslintSuppressionRules?: Array + /** Extra directives that opt a function out of compilation. */ + customOptOutDirectives?: Array + /** Also emit a gated (feature-flagged) version of each compiled function. */ + gating?: ReactCompilerGating + /** Dynamically-gated compilation. */ + dynamicGating?: ReactCompilerDynamicGating +} + export interface ReactRefreshOptions { /** * Specify the identifier of the refresh registration variable. @@ -448,7 +525,7 @@ export declare function transform(filename: string, sourceText: string, options? * * Options are listed in evaluation order: the source is parsed (`lang`, * `sourceType`), declarations are emitted (`typescript.declaration`), then - * transforms run (`typescript`, `decorator`, `plugins`, + * transforms run (`reactCompiler`, `typescript`, `decorator`, `plugins`, * `jsx`, `target`), followed by the `inject` and `define` plugins, and * finally codegen (`sourcemap`). `helpers` configures the runtime helpers * the transforms emit. @@ -467,6 +544,17 @@ export interface TransformOptions { cwd?: string /** Set assumptions in order to produce smaller output. */ assumptions?: CompilerAssumptions + /** + * Enable the experimental [React Compiler](https://github.com/react/react/tree/main/compiler). + * + * `true` enables it with default options; an object enables it with the + * given options; `false` or omitted disables it. When enabled, the compiler + * runs as the first transform and memoizes React components and hooks. + * + * Requires a build with the `react_compiler` Cargo feature, which is on by + * default; enabling this option against a build without it is an error. + */ + reactCompiler?: boolean | ReactCompilerOptions /** * Configure how TypeScript is transformed. * diff --git a/napi/transform/src/lib.rs b/napi/transform/src/lib.rs index 950724e42a558..e5b91e9ab16db 100644 --- a/napi/transform/src/lib.rs +++ b/napi/transform/src/lib.rs @@ -14,5 +14,8 @@ static ALLOC: mimalloc_safe::MiMalloc = mimalloc_safe::MiMalloc; mod isolated_declaration; pub use isolated_declaration::*; +mod react_compiler; +pub use react_compiler::*; + mod transformer; pub use transformer::*; diff --git a/napi/transform/src/react_compiler.rs b/napi/transform/src/react_compiler.rs new file mode 100644 index 0000000000000..1d7556f68afd5 --- /dev/null +++ b/napi/transform/src/react_compiler.rs @@ -0,0 +1,184 @@ +//! React Compiler options for the transform binding. +//! +//! A curated, JS-facing mirror of the React Compiler `PluginOptions` (the deep +//! `environment` config is not surfaced), resolved into the concrete options the +//! transformer consumes. +//! +//! The option types below are deliberately **not** gated on the `react_compiler` +//! feature, for two reasons: `#[napi(object)]` ignores `#[cfg]` on fields (it emits +//! them into the generated `FromNapiValue`/`ToNapiValue` regardless), and keeping the +//! types unconditional makes the generated `index.d.ts` identical in every feature +//! config, so a lean build can never drift from the checked-in one. Only the +//! conversion into the compiler's `PluginOptions` is gated; without the feature, +//! `is_enabled` lets the caller reject the option rather than silently ignore it. + +use napi::Either; +use napi_derive::napi; + +#[cfg(feature = "react_compiler")] +use std::str::FromStr; + +#[cfg(feature = "react_compiler")] +use oxc::react_compiler::{CompilerTarget, DynamicGatingConfig, GatingConfig, PluginOptions}; + +/// Options for the experimental [React Compiler](https://github.com/react/react/tree/main/compiler). +/// +/// Mirrors the compiler's `PluginOptions`. The deep `environment` configuration +/// (inference / validation flags) is not surfaced here. +/// +/// @see {@link TransformOptions#reactCompiler} +#[napi(object)] +#[derive(Default, Debug)] +pub struct ReactCompilerOptions { + /// Which functions to compile. + /// + /// @default 'infer' + #[napi(ts_type = "'infer' | 'syntax' | 'annotation' | 'all'")] + pub compilation_mode: Option, + + /// What to do when a function cannot be compiled. + /// + /// @default 'none' + #[napi(ts_type = "'none' | 'critical_errors' | 'all_errors'")] + pub panic_threshold: Option, + + /// React runtime version target. `17` and `18` require the + /// `react-compiler-runtime` package; `19` ships the runtime in `react`. + /// + /// @default '19' + #[napi(ts_type = "'17' | '18' | '19'")] + pub target: Option, + + /// Analyze and report diagnostics only; emit no transformed code. + /// + /// @default false + pub no_emit: Option, + + /// Compiler output mode. + /// + /// @default undefined + #[napi(ts_type = "'client' | 'ssr' | 'lint'")] + pub output_mode: Option, + + /// Compile even functions marked with the `"use no memo"` / `"use no forget"` + /// opt-out directives. + /// + /// @default false + pub ignore_use_no_forget: Option, + + /// Treat Flow suppression comments as opt-outs. + /// + /// @default true + pub flow_suppressions: Option, + + /// ESLint rules whose suppressions opt a function out of compilation. + pub eslint_suppression_rules: Option>, + + /// Extra directives that opt a function out of compilation. + pub custom_opt_out_directives: Option>, + + /// Also emit a gated (feature-flagged) version of each compiled function. + pub gating: Option, + + /// Dynamically-gated compilation. + pub dynamic_gating: Option, +} + +/// Static gating for {@link ReactCompilerOptions#gating}. +#[napi(object)] +#[derive(Debug)] +pub struct ReactCompilerGating { + /// Module the gating import comes from. + pub source: String, + /// Imported specifier used as the gate. + pub import_specifier_name: String, +} + +/// Dynamic gating for {@link ReactCompilerOptions#dynamicGating}. +#[napi(object)] +#[derive(Debug)] +pub struct ReactCompilerDynamicGating { + /// Module the gating import comes from. + pub source: String, +} + +/// Whether the `reactCompiler` option asks for the compiler to run. `false` and an +/// absent option both mean "disabled", and must not be treated as a request. +#[cfg(not(feature = "react_compiler"))] +pub fn is_enabled(option: Option<&Either>) -> bool { + matches!(option, Some(Either::A(true) | Either::B(_))) +} + +/// Resolve the JS `reactCompiler` option into the compiler's `PluginOptions`: +/// `true` → default options, an object → those options, `false`/absent → disabled. +/// +/// # Errors +/// +/// Returns an error if a string-valued option is not one of its documented variants. +#[cfg(feature = "react_compiler")] +pub fn resolve( + option: Option>, +) -> Result, String> { + match option { + Some(Either::A(true)) => Ok(Some(PluginOptions::default())), + Some(Either::B(options)) => options.into_plugin_options().map(Some), + Some(Either::A(false)) | None => Ok(None), + } +} + +/// Parse a string-valued option into the enum the compiler takes. The `ts_type` +/// annotations constrain these at the type level only, so a plain-JS caller can still +/// reach this with anything. +#[cfg(feature = "react_compiler")] +fn parse(value: &str, option: &str) -> Result { + T::from_str(value).map_err(|_| format!("Invalid reactCompiler.{option} option: `{value}`.")) +} + +#[cfg(feature = "react_compiler")] +impl ReactCompilerOptions { + fn into_plugin_options(self) -> Result { + let mut options = PluginOptions::default(); + if let Some(compilation_mode) = self.compilation_mode { + options.compilation_mode = parse(&compilation_mode, "compilationMode")?; + } + if let Some(panic_threshold) = self.panic_threshold { + options.panic_threshold = parse(&panic_threshold, "panicThreshold")?; + } + if let Some(target) = self.target { + // `CompilerTarget::Version` takes any string and silently falls back to the + // React 19 runtime for unrecognized ones, so reject typos here instead. + if !matches!(target.as_str(), "17" | "18" | "19") { + return Err(format!("Invalid reactCompiler.target option: `{target}`.")); + } + options.target = CompilerTarget::Version(target); + } + if let Some(no_emit) = self.no_emit { + options.no_emit = no_emit; + } + if let Some(output_mode) = self.output_mode { + options.output_mode = Some(parse(&output_mode, "outputMode")?); + } + if let Some(ignore_use_no_forget) = self.ignore_use_no_forget { + options.ignore_use_no_forget = ignore_use_no_forget; + } + if let Some(flow_suppressions) = self.flow_suppressions { + options.flow_suppressions = flow_suppressions; + } + if self.eslint_suppression_rules.is_some() { + options.eslint_suppression_rules = self.eslint_suppression_rules; + } + if self.custom_opt_out_directives.is_some() { + options.custom_opt_out_directives = self.custom_opt_out_directives; + } + if let Some(gating) = self.gating { + options.gating = Some(GatingConfig { + source: gating.source, + import_specifier_name: gating.import_specifier_name, + }); + } + if let Some(dynamic_gating) = self.dynamic_gating { + options.dynamic_gating = Some(DynamicGatingConfig { source: dynamic_gating.source }); + } + Ok(options) + } +} diff --git a/napi/transform/src/transformer.rs b/napi/transform/src/transformer.rs index 7eabd38be3d0e..8eacd20295f1d 100644 --- a/napi/transform/src/transformer.rs +++ b/napi/transform/src/transformer.rs @@ -30,6 +30,7 @@ use oxc_napi::{OxcError, get_source_type}; use oxc_sourcemap::napi::SourceMap; use crate::IsolatedDeclarationsOptions; +use crate::react_compiler::ReactCompilerOptions; #[derive(Default)] #[napi(object)] @@ -83,7 +84,7 @@ pub struct TransformResult { /// /// Options are listed in evaluation order: the source is parsed (`lang`, /// `sourceType`), declarations are emitted (`typescript.declaration`), then -/// transforms run (`typescript`, `decorator`, `plugins`, +/// transforms run (`reactCompiler`, `typescript`, `decorator`, `plugins`, /// `jsx`, `target`), followed by the `inject` and `define` plugins, and /// finally codegen (`sourcemap`). `helpers` configures the runtime helpers /// the transforms emit. @@ -107,6 +108,17 @@ pub struct TransformOptions { /// Set assumptions in order to produce smaller output. pub assumptions: Option, + /// Enable the experimental [React Compiler](https://github.com/react/react/tree/main/compiler). + /// + /// `true` enables it with default options; an object enables it with the + /// given options; `false` or omitted disables it. When enabled, the compiler + /// runs as the first transform and memoizes React components and hooks. + /// + /// Requires a build with the `react_compiler` Cargo feature, which is on by + /// default; enabling this option against a build without it is an error. + #[napi(ts_type = "boolean | ReactCompilerOptions")] + pub react_compiler: Option>, + /// Configure how TypeScript is transformed. /// /// `typescript.declaration` is evaluated before all transforms. @@ -178,9 +190,21 @@ impl TryFrom for oxc::transformer::TransformOptions { Some(Either::B(list)) => EnvOptions::from_target_list(&list)?, _ => EnvOptions::default(), }; + // The option is always part of the type surface (so `index.d.ts` doesn't vary + // with features), which means a build without the compiler has to reject it + // here rather than accept it and quietly skip the pass. + #[cfg(not(feature = "react_compiler"))] + if crate::react_compiler::is_enabled(options.react_compiler.as_ref()) { + return Err( + "`reactCompiler` requires a build with the `react_compiler` Cargo feature." + .to_string(), + ); + } Ok(Self { cwd: options.cwd.map(PathBuf::from).unwrap_or_default(), assumptions: options.assumptions.map(Into::into).unwrap_or_default(), + #[cfg(feature = "react_compiler")] + react_compiler: crate::react_compiler::resolve(options.react_compiler)?, typescript: options .typescript .map(oxc::transformer::TypeScriptOptions::from) @@ -208,11 +232,10 @@ impl TryFrom for oxc::transformer::TransformOptions { helper_loader: options .helpers .map_or_else(HelperLoaderOptions::default, HelperLoaderOptions::from), - // `..Default` supplies `proposals` (TC39, none implemented) and, when a - // workspace build enables it via Cargo feature unification, the gated - // `oxc_transformer` `react_compiler` field this binding no longer exposes. - // Keeps the literal valid in every feature config (and avoids - // `clippy::needless_update`). + // `..Default` supplies `proposals` (TC39, none implemented) and, when this + // binding's `react_compiler` feature is off while feature unification still + // enables `oxc_transformer`'s, that gated field. Keeps the literal valid in + // every feature config (and avoids `clippy::needless_update`). ..Default::default() }) } diff --git a/napi/transform/test/reactCompiler.test.ts b/napi/transform/test/reactCompiler.test.ts new file mode 100644 index 0000000000000..922af7e6ea78e --- /dev/null +++ b/napi/transform/test/reactCompiler.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; + +import { transformSync } from "../index"; + +// A single fixture exercising every concern the React Compiler integration has to +// handle together: a memoizable component using a hook, TypeScript types, JSX, ES +// module syntax, and top-level comments. The compiler runs first on the pristine +// AST, then the rest of the transform pipeline (TypeScript, JSX) runs on its +// output, and codegen emits the result. +const fixture = `// @license MIT +import { useState } from "react"; + +interface Props { + text: string; + onClick: () => void; +} + +// Memoized component: exercises hooks, TS types, JSX and comments. +export function Component(props: Props) { + const [count, setCount] = useState(0); + return ( +
props.onClick()}> + {props.text}: {count} +
+ ); +} +`; + +describe("reactCompiler", () => { + it("memoizes, composes with the TS + JSX transforms, and preserves comments", () => { + const { code, errors } = transformSync("Component.tsx", fixture, { + reactCompiler: true, + jsx: { runtime: "automatic" }, + }); + + expect(errors).toEqual([]); + + // React Compiler memoized the component. + expect(code).toContain("react/compiler-runtime"); + expect(code).toContain("_c("); + + // JSX was lowered via the automatic runtime — no raw JSX remains. + expect(code).toContain("jsx"); + expect(code).not.toContain(""); + + // The hook call and ES module syntax survive. + expect(code).toContain("useState("); + expect(code).toContain("export function Component"); + + // Top-level comments survive react_compiler -> transformer -> codegen. + expect(code).toContain("@license MIT"); + expect(code).toContain("Memoized component"); + }); + + it("accepts a ReactCompilerOptions object", () => { + const { code } = transformSync("Component.tsx", fixture, { + reactCompiler: { compilationMode: "all" }, + }); + expect(code).toContain("react/compiler-runtime"); + expect(code).toContain("_c("); + }); + + // The `ts_type` annotations constrain the string options at the type level only, so + // a plain-JS caller can still reach the binding with an unknown value. + it.each([ + ["compilationMode", { compilationMode: "bogus" }], + ["panicThreshold", { panicThreshold: "bogus" }], + ["outputMode", { outputMode: "bogus" }], + // An unrecognized target would otherwise fall back to the React 19 runtime silently. + ["target", { target: "20" }], + ])("rejects an unknown `%s` value rather than ignoring it", (option, reactCompiler) => { + const { code, errors } = transformSync("Component.tsx", fixture, { + reactCompiler: reactCompiler as never, + }); + + expect(code).toBe(""); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain(`Invalid reactCompiler.${option} option:`); + }); + + // Each option below changes observable output, proving it is forwarded to the compiler. + + it("forwards `target` — 17/18 import the standalone runtime package", () => { + const { code } = transformSync("Component.tsx", fixture, { + reactCompiler: { target: "18" }, + jsx: { runtime: "automatic" }, + }); + expect(code).toContain("react-compiler-runtime"); + expect(code).not.toContain("react/compiler-runtime"); + }); + + it("forwards `gating` — emits a feature-gated component", () => { + const { code } = transformSync("Component.tsx", fixture, { + reactCompiler: { + gating: { source: "my-gating-module", importSpecifierName: "isForgetEnabled" }, + }, + jsx: { runtime: "automatic" }, + }); + expect(code).toContain("my-gating-module"); + expect(code).toContain("isForgetEnabled"); + }); + + it("forwards `ignoreUseNoForget` — compiles a `use no memo` function", () => { + const source = `function Component(props) { + "use no memo"; + return
{props.text}
; +} +`; + const optedOut = transformSync("Component.jsx", source, { + reactCompiler: true, + jsx: { runtime: "automatic" }, + }); + expect(optedOut.code).not.toContain("_c("); + + const overridden = transformSync("Component.jsx", source, { + reactCompiler: { ignoreUseNoForget: true }, + jsx: { runtime: "automatic" }, + }); + expect(overridden.code).toContain("_c("); + }); + + it("emits code when React Compiler reports warnings", () => { + const { code, errors } = transformSync( + "Component.jsx", + ` +function Component() { + const fbt = "span"; + return Hello; +} +`, + { + reactCompiler: true, + jsx: { runtime: "automatic" }, + }, + ); + + expect(code).not.toBe(""); + expect(code).toContain("function Component"); + expect(errors.some((error) => error.severity === "Warning")).toBe(true); + expect(errors.some((error) => error.severity === "Error")).toBe(false); + }); + + it("aborts the transform when React Compiler reports an error", () => { + const { code, errors } = transformSync( + "Component.jsx", + ` +function Component(props) { + if (props.cond) { + useState(0); + } + return
{props.text}
; +} +`, + { + reactCompiler: true, + jsx: { runtime: "automatic" }, + }, + ); + + // A React Compiler error (Rules of Hooks violation) is fatal: it is surfaced + // at error severity and the transform stops, emitting no code. + expect(errors.some((error) => error.severity === "Error")).toBe(true); + expect(code).toBe(""); + }); + + it("keeps enum values available for the downstream TypeScript transform", () => { + const { code, errors } = transformSync( + "Component.tsx", + ` +enum E { + A = 1, + B = A + 1, +} + +function Component() { + return
{E.B}
; +} +`, + { + reactCompiler: true, + jsx: { runtime: "automatic" }, + }, + ); + + expect(errors).toEqual([]); + expect(code).toContain('E[E["B"] = 2] = "B"'); + }); + + it("does nothing when `reactCompiler` is omitted (the default) or `false`", () => { + for (const options of [{}, { reactCompiler: false }]) { + const { code } = transformSync("Component.tsx", fixture, options); + expect(code).not.toContain("react/compiler-runtime"); + expect(code).not.toContain("_c("); + } + }); +}); From 96f88c9aa12b5c45d43c02e156ad49e2092da7b2 Mon Sep 17 00:00:00 2001 From: Boshen Date: Wed, 15 Jul 2026 17:10:38 +0800 Subject: [PATCH 2/3] refactor(napi/transform): move `reactCompiler` under `plugins` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group the option with the other third-party plugin configs as `plugins.reactCompiler`, rather than a top-level `reactCompiler`. The nesting is a JS-API grouping only. `oxc::transformer::PluginsOptions` has no such field -- the transformer runs the compiler as its own pass before the main traversal, off `TransformOptions::react_compiler` -- so `TryFrom` lifts the option out of `plugins` before converting the rest. The evaluation-order doc keeps listing it first for that reason. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- napi/transform/index.d.ts | 31 ++++++------ napi/transform/src/react_compiler.rs | 7 +-- napi/transform/src/transformer.rs | 45 ++++++++++------- napi/transform/test/reactCompiler.test.ts | 59 +++++++++++++++++------ 4 files changed, 91 insertions(+), 51 deletions(-) diff --git a/napi/transform/index.d.ts b/napi/transform/index.d.ts index 2384c9866d215..0a93dbe454c59 100644 --- a/napi/transform/index.d.ts +++ b/napi/transform/index.d.ts @@ -325,6 +325,18 @@ export interface ModuleRunnerTransformResult { export declare function moduleRunnerTransformSync(filename: string, sourceText: string, options?: ModuleRunnerTransformOptions | undefined | null): ModuleRunnerTransformResult export interface PluginsOptions { + /** + * Enable the experimental [React Compiler](https://github.com/react/react/tree/main/compiler). + * + * `true` enables it with default options; an object enables it with the + * given options; `false` or omitted disables it. When enabled, the compiler + * runs in its own pass before every other transform, memoizing React + * components and hooks. + * + * Requires a build with the `react_compiler` Cargo feature, which is on by + * default; enabling this option against a build without it is an error. + */ + reactCompiler?: boolean | ReactCompilerOptions styledComponents?: StyledComponentsOptions taggedTemplateEscape?: boolean } @@ -349,7 +361,7 @@ export interface ReactCompilerGating { * Mirrors the compiler's `PluginOptions`. The deep `environment` configuration * (inference / validation flags) is not surfaced here. * - * @see {@link TransformOptions#reactCompiler} + * @see {@link PluginsOptions#reactCompiler} */ export interface ReactCompilerOptions { /** @@ -525,9 +537,9 @@ export declare function transform(filename: string, sourceText: string, options? * * Options are listed in evaluation order: the source is parsed (`lang`, * `sourceType`), declarations are emitted (`typescript.declaration`), then - * transforms run (`reactCompiler`, `typescript`, `decorator`, `plugins`, - * `jsx`, `target`), followed by the `inject` and `define` plugins, and - * finally codegen (`sourcemap`). `helpers` configures the runtime helpers + * transforms run (`plugins.reactCompiler`, `typescript`, `decorator`, + * `plugins`, `jsx`, `target`), followed by the `inject` and `define` plugins, + * and finally codegen (`sourcemap`). `helpers` configures the runtime helpers * the transforms emit. * * @see {@link transform} @@ -544,17 +556,6 @@ export interface TransformOptions { cwd?: string /** Set assumptions in order to produce smaller output. */ assumptions?: CompilerAssumptions - /** - * Enable the experimental [React Compiler](https://github.com/react/react/tree/main/compiler). - * - * `true` enables it with default options; an object enables it with the - * given options; `false` or omitted disables it. When enabled, the compiler - * runs as the first transform and memoizes React components and hooks. - * - * Requires a build with the `react_compiler` Cargo feature, which is on by - * default; enabling this option against a build without it is an error. - */ - reactCompiler?: boolean | ReactCompilerOptions /** * Configure how TypeScript is transformed. * diff --git a/napi/transform/src/react_compiler.rs b/napi/transform/src/react_compiler.rs index 1d7556f68afd5..d6b0adbe424ee 100644 --- a/napi/transform/src/react_compiler.rs +++ b/napi/transform/src/react_compiler.rs @@ -26,7 +26,7 @@ use oxc::react_compiler::{CompilerTarget, DynamicGatingConfig, GatingConfig, Plu /// Mirrors the compiler's `PluginOptions`. The deep `environment` configuration /// (inference / validation flags) is not surfaced here. /// -/// @see {@link TransformOptions#reactCompiler} +/// @see {@link PluginsOptions#reactCompiler} #[napi(object)] #[derive(Default, Debug)] pub struct ReactCompilerOptions { @@ -131,7 +131,8 @@ pub fn resolve( /// reach this with anything. #[cfg(feature = "react_compiler")] fn parse(value: &str, option: &str) -> Result { - T::from_str(value).map_err(|_| format!("Invalid reactCompiler.{option} option: `{value}`.")) + T::from_str(value) + .map_err(|_| format!("Invalid plugins.reactCompiler.{option} option: `{value}`.")) } #[cfg(feature = "react_compiler")] @@ -148,7 +149,7 @@ impl ReactCompilerOptions { // `CompilerTarget::Version` takes any string and silently falls back to the // React 19 runtime for unrecognized ones, so reject typos here instead. if !matches!(target.as_str(), "17" | "18" | "19") { - return Err(format!("Invalid reactCompiler.target option: `{target}`.")); + return Err(format!("Invalid plugins.reactCompiler.target option: `{target}`.")); } options.target = CompilerTarget::Version(target); } diff --git a/napi/transform/src/transformer.rs b/napi/transform/src/transformer.rs index 8eacd20295f1d..cc184b2ce113e 100644 --- a/napi/transform/src/transformer.rs +++ b/napi/transform/src/transformer.rs @@ -84,9 +84,9 @@ pub struct TransformResult { /// /// Options are listed in evaluation order: the source is parsed (`lang`, /// `sourceType`), declarations are emitted (`typescript.declaration`), then -/// transforms run (`reactCompiler`, `typescript`, `decorator`, `plugins`, -/// `jsx`, `target`), followed by the `inject` and `define` plugins, and -/// finally codegen (`sourcemap`). `helpers` configures the runtime helpers +/// transforms run (`plugins.reactCompiler`, `typescript`, `decorator`, +/// `plugins`, `jsx`, `target`), followed by the `inject` and `define` plugins, +/// and finally codegen (`sourcemap`). `helpers` configures the runtime helpers /// the transforms emit. /// /// @see {@link transform} @@ -108,17 +108,6 @@ pub struct TransformOptions { /// Set assumptions in order to produce smaller output. pub assumptions: Option, - /// Enable the experimental [React Compiler](https://github.com/react/react/tree/main/compiler). - /// - /// `true` enables it with default options; an object enables it with the - /// given options; `false` or omitted disables it. When enabled, the compiler - /// runs as the first transform and memoizes React components and hooks. - /// - /// Requires a build with the `react_compiler` Cargo feature, which is on by - /// default; enabling this option against a build without it is an error. - #[napi(ts_type = "boolean | ReactCompilerOptions")] - pub react_compiler: Option>, - /// Configure how TypeScript is transformed. /// /// `typescript.declaration` is evaluated before all transforms. @@ -184,19 +173,23 @@ pub struct TransformOptions { impl TryFrom for oxc::transformer::TransformOptions { type Error = String; - fn try_from(options: TransformOptions) -> Result { + fn try_from(mut options: TransformOptions) -> Result { let env = match options.target { Some(Either::A(s)) => EnvOptions::from_target(&s)?, Some(Either::B(list)) => EnvOptions::from_target_list(&list)?, _ => EnvOptions::default(), }; + // Grouped under `plugins` for JS callers, but the transformer runs it as its own + // pass, so lift it out before `plugins` is converted. + let react_compiler = + options.plugins.as_mut().and_then(|plugins| plugins.react_compiler.take()); // The option is always part of the type surface (so `index.d.ts` doesn't vary // with features), which means a build without the compiler has to reject it // here rather than accept it and quietly skip the pass. #[cfg(not(feature = "react_compiler"))] - if crate::react_compiler::is_enabled(options.react_compiler.as_ref()) { + if crate::react_compiler::is_enabled(react_compiler.as_ref()) { return Err( - "`reactCompiler` requires a build with the `react_compiler` Cargo feature." + "`plugins.reactCompiler` requires a build with the `react_compiler` Cargo feature." .to_string(), ); } @@ -204,7 +197,7 @@ impl TryFrom for oxc::transformer::TransformOptions { cwd: options.cwd.map(PathBuf::from).unwrap_or_default(), assumptions: options.assumptions.map(Into::into).unwrap_or_default(), #[cfg(feature = "react_compiler")] - react_compiler: crate::react_compiler::resolve(options.react_compiler)?, + react_compiler: crate::react_compiler::resolve(react_compiler)?, typescript: options .typescript .map(oxc::transformer::TypeScriptOptions::from) @@ -544,11 +537,27 @@ pub struct StyledComponentsOptions { #[napi(object)] #[derive(Default)] pub struct PluginsOptions { + /// Enable the experimental [React Compiler](https://github.com/react/react/tree/main/compiler). + /// + /// `true` enables it with default options; an object enables it with the + /// given options; `false` or omitted disables it. When enabled, the compiler + /// runs in its own pass before every other transform, memoizing React + /// components and hooks. + /// + /// Requires a build with the `react_compiler` Cargo feature, which is on by + /// default; enabling this option against a build without it is an error. + #[napi(ts_type = "boolean | ReactCompilerOptions")] + pub react_compiler: Option>, + pub styled_components: Option, pub tagged_template_escape: Option, } impl From for oxc::transformer::PluginsOptions { + /// `react_compiler` is deliberately not mapped here: it is grouped under `plugins` + /// for JS callers, but the transformer runs it as its own pass off + /// `TransformOptions::react_compiler`, so `TryFrom` takes it out + /// of `plugins` before this conversion. fn from(options: PluginsOptions) -> Self { oxc::transformer::PluginsOptions { styled_components: options diff --git a/napi/transform/test/reactCompiler.test.ts b/napi/transform/test/reactCompiler.test.ts index 922af7e6ea78e..a0f304920863c 100644 --- a/napi/transform/test/reactCompiler.test.ts +++ b/napi/transform/test/reactCompiler.test.ts @@ -26,10 +26,10 @@ export function Component(props: Props) { } `; -describe("reactCompiler", () => { +describe("plugins.reactCompiler", () => { it("memoizes, composes with the TS + JSX transforms, and preserves comments", () => { const { code, errors } = transformSync("Component.tsx", fixture, { - reactCompiler: true, + plugins: { reactCompiler: true }, jsx: { runtime: "automatic" }, }); @@ -59,12 +59,39 @@ describe("reactCompiler", () => { it("accepts a ReactCompilerOptions object", () => { const { code } = transformSync("Component.tsx", fixture, { - reactCompiler: { compilationMode: "all" }, + plugins: { reactCompiler: { compilationMode: "all" } }, }); expect(code).toContain("react/compiler-runtime"); expect(code).toContain("_c("); }); + // It sits under `plugins` for JS callers, but the compiler still runs as its own pass + // before the rest of `plugins` — so it composes with its neighbours there. + it("composes with the other plugins it is grouped with", () => { + const { code, errors } = transformSync( + "Component.tsx", + `import styled from "styled-components"; +const Box = styled.div\`color: red;\`; +export function Component() { + const [n] = useState(0); + return {n}; +} +`, + { + plugins: { + reactCompiler: true, + styledComponents: { displayName: true }, + }, + jsx: { runtime: "automatic" }, + }, + ); + + expect(errors).toEqual([]); + // React Compiler memoized, and styled-components still got its displayName. + expect(code).toContain("_c("); + expect(code).toContain("displayName"); + }); + // The `ts_type` annotations constrain the string options at the type level only, so // a plain-JS caller can still reach the binding with an unknown value. it.each([ @@ -75,19 +102,19 @@ describe("reactCompiler", () => { ["target", { target: "20" }], ])("rejects an unknown `%s` value rather than ignoring it", (option, reactCompiler) => { const { code, errors } = transformSync("Component.tsx", fixture, { - reactCompiler: reactCompiler as never, + plugins: { reactCompiler: reactCompiler as never }, }); expect(code).toBe(""); expect(errors).toHaveLength(1); - expect(errors[0].message).toContain(`Invalid reactCompiler.${option} option:`); + expect(errors[0].message).toContain(`Invalid plugins.reactCompiler.${option} option:`); }); // Each option below changes observable output, proving it is forwarded to the compiler. it("forwards `target` — 17/18 import the standalone runtime package", () => { const { code } = transformSync("Component.tsx", fixture, { - reactCompiler: { target: "18" }, + plugins: { reactCompiler: { target: "18" } }, jsx: { runtime: "automatic" }, }); expect(code).toContain("react-compiler-runtime"); @@ -96,8 +123,10 @@ describe("reactCompiler", () => { it("forwards `gating` — emits a feature-gated component", () => { const { code } = transformSync("Component.tsx", fixture, { - reactCompiler: { - gating: { source: "my-gating-module", importSpecifierName: "isForgetEnabled" }, + plugins: { + reactCompiler: { + gating: { source: "my-gating-module", importSpecifierName: "isForgetEnabled" }, + }, }, jsx: { runtime: "automatic" }, }); @@ -112,13 +141,13 @@ describe("reactCompiler", () => { } `; const optedOut = transformSync("Component.jsx", source, { - reactCompiler: true, + plugins: { reactCompiler: true }, jsx: { runtime: "automatic" }, }); expect(optedOut.code).not.toContain("_c("); const overridden = transformSync("Component.jsx", source, { - reactCompiler: { ignoreUseNoForget: true }, + plugins: { reactCompiler: { ignoreUseNoForget: true } }, jsx: { runtime: "automatic" }, }); expect(overridden.code).toContain("_c("); @@ -134,7 +163,7 @@ function Component() { } `, { - reactCompiler: true, + plugins: { reactCompiler: true }, jsx: { runtime: "automatic" }, }, ); @@ -157,7 +186,7 @@ function Component(props) { } `, { - reactCompiler: true, + plugins: { reactCompiler: true }, jsx: { runtime: "automatic" }, }, ); @@ -182,7 +211,7 @@ function Component() { } `, { - reactCompiler: true, + plugins: { reactCompiler: true }, jsx: { runtime: "automatic" }, }, ); @@ -191,8 +220,8 @@ function Component() { expect(code).toContain('E[E["B"] = 2] = "B"'); }); - it("does nothing when `reactCompiler` is omitted (the default) or `false`", () => { - for (const options of [{}, { reactCompiler: false }]) { + it("does nothing when omitted (the default), or when `plugins` or the option is absent/false", () => { + for (const options of [{}, { plugins: {} }, { plugins: { reactCompiler: false } }]) { const { code } = transformSync("Component.tsx", fixture, options); expect(code).not.toContain("react/compiler-runtime"); expect(code).not.toContain("_c("); From 62d07315e5ec2d3d438dcb57145e2cc276a5a1bf Mon Sep 17 00:00:00 2001 From: Boshen Date: Thu, 16 Jul 2026 14:49:00 +0800 Subject: [PATCH 3/3] feat(napi/transform): surface the React Compiler `environment` option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `environment` is the option upstream's Babel plugin leads with (`environment: Partial`), and it was the one part of `PluginOptions` this binding did not mirror. Without it a JS caller cannot reach the passes that are off by default -- `validateNoSetStateInEffects`, `validateNoDerivedComputationsInEffects`, `enableJsxOutlining` and others -- so the compiler carried roughly 85 KiB of code no caller could execute. Field names mirror `EnvironmentConfig` one-for-one, so an `environment` block that works in a Babel config works here unchanged. Every field is optional; unset fields keep the compiler's default. Not surfaced: the five composite entries (`customHooks`, `moduleTypeProvider`, `enableEmitHookGuards`, `enableEmitInstrumentForget`, `validateExhaustiveEffectDependencies`), and `throwUnknownExceptionTestonly`, which exists to make the compiler panic in upstream's own tests. Upstream defaults were checked against `babel-plugin-react-compiler/src/HIR/Environment.ts`: the flags this port defaults to `false` default to `false` there too. Pragmas remain test-only, matching upstream, where the parser is `parseConfigPragmaForTests` in `Utils/TestUtils.ts`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- napi/transform/index.d.ts | 56 ++++++- napi/transform/src/react_compiler.rs | 183 +++++++++++++++++++++- napi/transform/test/reactCompiler.test.ts | 44 ++++++ 3 files changed, 275 insertions(+), 8 deletions(-) diff --git a/napi/transform/index.d.ts b/napi/transform/index.d.ts index 0a93dbe454c59..fd4be6de462e3 100644 --- a/napi/transform/index.d.ts +++ b/napi/transform/index.d.ts @@ -347,6 +347,52 @@ export interface ReactCompilerDynamicGating { source: string } +/** + * Feature flags and validation toggles for the React Compiler's passes. + * + * Field names mirror the upstream `EnvironmentConfig` one-for-one, so a value that + * works in a Babel `environment` config works here unchanged. Every field is optional; + * omitting one keeps the compiler's default. Several of these gate passes that are off + * by default, so setting them is the only way to reach those diagnostics. + * + * @see {@link ReactCompilerOptions#environment} + */ +export interface ReactCompilerEnvironmentOptions { + customMacros?: Array + enableResetCacheOnSourceFileChanges?: boolean + enablePreserveExistingMemoizationGuarantees?: boolean + validatePreserveExistingMemoizationGuarantees?: boolean + validateExhaustiveMemoizationDependencies?: boolean + enableOptionalDependencies?: boolean + enableNameAnonymousFunctions?: boolean + validateHooksUsage?: boolean + validateRefAccessDuringRender?: boolean + validateNoSetStateInRender?: boolean + enableUseKeyedState?: boolean + validateNoSetStateInEffects?: boolean + validateNoDerivedComputationsInEffects?: boolean + validateNoDerivedComputationsInEffectsExp?: boolean + validateNoJsxInTryStatements?: boolean + validateStaticComponents?: boolean + validateNoCapitalizedCalls?: Array + validateBlocklistedImports?: Array + validateSourceLocations?: boolean + validateNoImpureFunctionsInRender?: boolean + validateNoFreezingKnownMutableFunctions?: boolean + enableAssumeHooksFollowRulesOfReact?: boolean + enableTransitivelyFreezeFunctionExpressions?: boolean + enableFunctionOutlining?: boolean + enableJsxOutlining?: boolean + assertValidMutableRanges?: boolean + enableCustomTypeDefinitionForReanimated?: boolean + enableTreatRefLikeIdentifiersAsRefs?: boolean + enableTreatSetIdentifiersAsStateSetters?: boolean + validateNoVoidUseMemo?: boolean + enableAllowSetStateFromRefsInEffects?: boolean + enableVerboseNoSetStateInEffect?: boolean + enableForest?: boolean +} + /** Static gating for {@link ReactCompilerOptions#gating}. */ export interface ReactCompilerGating { /** Module the gating import comes from. */ @@ -358,8 +404,7 @@ export interface ReactCompilerGating { /** * Options for the experimental [React Compiler](https://github.com/react/react/tree/main/compiler). * - * Mirrors the compiler's `PluginOptions`. The deep `environment` configuration - * (inference / validation flags) is not surfaced here. + * Mirrors the compiler's `PluginOptions`. * * @see {@link PluginsOptions#reactCompiler} */ @@ -416,6 +461,13 @@ export interface ReactCompilerOptions { gating?: ReactCompilerGating /** Dynamically-gated compilation. */ dynamicGating?: ReactCompilerDynamicGating + /** + * Feature flags and validation toggles for the compilation passes. + * + * Each field left unset keeps the compiler's own default, matching the + * `Partial` shape of the upstream Babel plugin's option. + */ + environment?: ReactCompilerEnvironmentOptions } export interface ReactRefreshOptions { diff --git a/napi/transform/src/react_compiler.rs b/napi/transform/src/react_compiler.rs index d6b0adbe424ee..79dfc38c94c12 100644 --- a/napi/transform/src/react_compiler.rs +++ b/napi/transform/src/react_compiler.rs @@ -1,8 +1,16 @@ //! React Compiler options for the transform binding. //! -//! A curated, JS-facing mirror of the React Compiler `PluginOptions` (the deep -//! `environment` config is not surfaced), resolved into the concrete options the -//! transformer consumes. +//! A JS-facing mirror of the React Compiler `PluginOptions`, resolved into the concrete +//! options the transformer consumes. +//! +//! `environment` mirrors the option upstream's Babel plugin leads with +//! (`environment: Partial`); without it a caller cannot reach the +//! validation and inference passes that are off by default, which the compiler would +//! then carry as unreachable code. The flags there are plain toggles; the five +//! composite entries (`customHooks`, `moduleTypeProvider`, `enableEmitHookGuards`, +//! `enableEmitInstrumentForget`, `validateExhaustiveEffectDependencies`) are not +//! surfaced, nor is `throwUnknownExceptionTestonly`, which exists to make the compiler +//! panic in upstream's own tests. //! //! The option types below are deliberately **not** gated on the `react_compiler` //! feature, for two reasons: `#[napi(object)]` ignores `#[cfg]` on fields (it emits @@ -19,12 +27,13 @@ use napi_derive::napi; use std::str::FromStr; #[cfg(feature = "react_compiler")] -use oxc::react_compiler::{CompilerTarget, DynamicGatingConfig, GatingConfig, PluginOptions}; +use oxc::react_compiler::{ + CompilerTarget, DynamicGatingConfig, EnvironmentConfig, GatingConfig, PluginOptions, +}; /// Options for the experimental [React Compiler](https://github.com/react/react/tree/main/compiler). /// -/// Mirrors the compiler's `PluginOptions`. The deep `environment` configuration -/// (inference / validation flags) is not surfaced here. +/// Mirrors the compiler's `PluginOptions`. /// /// @see {@link PluginsOptions#reactCompiler} #[napi(object)] @@ -82,6 +91,12 @@ pub struct ReactCompilerOptions { /// Dynamically-gated compilation. pub dynamic_gating: Option, + + /// Feature flags and validation toggles for the compilation passes. + /// + /// Each field left unset keeps the compiler's own default, matching the + /// `Partial` shape of the upstream Babel plugin's option. + pub environment: Option, } /// Static gating for {@link ReactCompilerOptions#gating}. @@ -102,6 +117,159 @@ pub struct ReactCompilerDynamicGating { pub source: String, } +/// Feature flags and validation toggles for the React Compiler's passes. +/// +/// Field names mirror the upstream `EnvironmentConfig` one-for-one, so a value that +/// works in a Babel `environment` config works here unchanged. Every field is optional; +/// omitting one keeps the compiler's default. Several of these gate passes that are off +/// by default, so setting them is the only way to reach those diagnostics. +/// +/// @see {@link ReactCompilerOptions#environment} +#[napi(object)] +#[derive(Default, Debug)] +pub struct ReactCompilerEnvironmentOptions { + pub custom_macros: Option>, + pub enable_reset_cache_on_source_file_changes: Option, + pub enable_preserve_existing_memoization_guarantees: Option, + pub validate_preserve_existing_memoization_guarantees: Option, + pub validate_exhaustive_memoization_dependencies: Option, + pub enable_optional_dependencies: Option, + pub enable_name_anonymous_functions: Option, + pub validate_hooks_usage: Option, + pub validate_ref_access_during_render: Option, + pub validate_no_set_state_in_render: Option, + pub enable_use_keyed_state: Option, + pub validate_no_set_state_in_effects: Option, + pub validate_no_derived_computations_in_effects: Option, + pub validate_no_derived_computations_in_effects_exp: Option, + pub validate_no_jsx_in_try_statements: Option, + pub validate_static_components: Option, + pub validate_no_capitalized_calls: Option>, + pub validate_blocklisted_imports: Option>, + pub validate_source_locations: Option, + pub validate_no_impure_functions_in_render: Option, + pub validate_no_freezing_known_mutable_functions: Option, + pub enable_assume_hooks_follow_rules_of_react: Option, + pub enable_transitively_freeze_function_expressions: Option, + pub enable_function_outlining: Option, + pub enable_jsx_outlining: Option, + pub assert_valid_mutable_ranges: Option, + pub enable_custom_type_definition_for_reanimated: Option, + pub enable_treat_ref_like_identifiers_as_refs: Option, + pub enable_treat_set_identifiers_as_state_setters: Option, + pub validate_no_void_use_memo: Option, + pub enable_allow_set_state_from_refs_in_effects: Option, + pub enable_verbose_no_set_state_in_effect: Option, + pub enable_forest: Option, +} + +#[cfg(feature = "react_compiler")] +impl ReactCompilerEnvironmentOptions { + /// Overlay the caller's flags onto the compiler's defaults, leaving unset fields alone. + fn apply_to(self, env: &mut EnvironmentConfig) { + if self.custom_macros.is_some() { + env.custom_macros = self.custom_macros; + } + if self.enable_reset_cache_on_source_file_changes.is_some() { + env.enable_reset_cache_on_source_file_changes = + self.enable_reset_cache_on_source_file_changes; + } + if let Some(v) = self.enable_preserve_existing_memoization_guarantees { + env.enable_preserve_existing_memoization_guarantees = v; + } + if let Some(v) = self.validate_preserve_existing_memoization_guarantees { + env.validate_preserve_existing_memoization_guarantees = v; + } + if let Some(v) = self.validate_exhaustive_memoization_dependencies { + env.validate_exhaustive_memoization_dependencies = v; + } + if let Some(v) = self.enable_optional_dependencies { + env.enable_optional_dependencies = v; + } + if let Some(v) = self.enable_name_anonymous_functions { + env.enable_name_anonymous_functions = v; + } + if let Some(v) = self.validate_hooks_usage { + env.validate_hooks_usage = v; + } + if let Some(v) = self.validate_ref_access_during_render { + env.validate_ref_access_during_render = v; + } + if let Some(v) = self.validate_no_set_state_in_render { + env.validate_no_set_state_in_render = v; + } + if let Some(v) = self.enable_use_keyed_state { + env.enable_use_keyed_state = v; + } + if let Some(v) = self.validate_no_set_state_in_effects { + env.validate_no_set_state_in_effects = v; + } + if let Some(v) = self.validate_no_derived_computations_in_effects { + env.validate_no_derived_computations_in_effects = v; + } + if let Some(v) = self.validate_no_derived_computations_in_effects_exp { + env.validate_no_derived_computations_in_effects_exp = v; + } + if let Some(v) = self.validate_no_jsx_in_try_statements { + env.validate_no_jsx_in_try_statements = v; + } + if let Some(v) = self.validate_static_components { + env.validate_static_components = v; + } + if self.validate_no_capitalized_calls.is_some() { + env.validate_no_capitalized_calls = self.validate_no_capitalized_calls; + } + if self.validate_blocklisted_imports.is_some() { + env.validate_blocklisted_imports = self.validate_blocklisted_imports; + } + if let Some(v) = self.validate_source_locations { + env.validate_source_locations = v; + } + if let Some(v) = self.validate_no_impure_functions_in_render { + env.validate_no_impure_functions_in_render = v; + } + if let Some(v) = self.validate_no_freezing_known_mutable_functions { + env.validate_no_freezing_known_mutable_functions = v; + } + if let Some(v) = self.enable_assume_hooks_follow_rules_of_react { + env.enable_assume_hooks_follow_rules_of_react = v; + } + if let Some(v) = self.enable_transitively_freeze_function_expressions { + env.enable_transitively_freeze_function_expressions = v; + } + if let Some(v) = self.enable_function_outlining { + env.enable_function_outlining = v; + } + if let Some(v) = self.enable_jsx_outlining { + env.enable_jsx_outlining = v; + } + if let Some(v) = self.assert_valid_mutable_ranges { + env.assert_valid_mutable_ranges = v; + } + if let Some(v) = self.enable_custom_type_definition_for_reanimated { + env.enable_custom_type_definition_for_reanimated = v; + } + if let Some(v) = self.enable_treat_ref_like_identifiers_as_refs { + env.enable_treat_ref_like_identifiers_as_refs = v; + } + if let Some(v) = self.enable_treat_set_identifiers_as_state_setters { + env.enable_treat_set_identifiers_as_state_setters = v; + } + if let Some(v) = self.validate_no_void_use_memo { + env.validate_no_void_use_memo = v; + } + if let Some(v) = self.enable_allow_set_state_from_refs_in_effects { + env.enable_allow_set_state_from_refs_in_effects = v; + } + if let Some(v) = self.enable_verbose_no_set_state_in_effect { + env.enable_verbose_no_set_state_in_effect = v; + } + if let Some(v) = self.enable_forest { + env.enable_forest = v; + } + } +} + /// Whether the `reactCompiler` option asks for the compiler to run. `false` and an /// absent option both mean "disabled", and must not be treated as a request. #[cfg(not(feature = "react_compiler"))] @@ -180,6 +348,9 @@ impl ReactCompilerOptions { if let Some(dynamic_gating) = self.dynamic_gating { options.dynamic_gating = Some(DynamicGatingConfig { source: dynamic_gating.source }); } + if let Some(environment) = self.environment { + environment.apply_to(&mut options.environment); + } Ok(options) } } diff --git a/napi/transform/test/reactCompiler.test.ts b/napi/transform/test/reactCompiler.test.ts index a0f304920863c..b63f4563e00d0 100644 --- a/napi/transform/test/reactCompiler.test.ts +++ b/napi/transform/test/reactCompiler.test.ts @@ -228,3 +228,47 @@ function Component() { } }); }); + +// `environment` mirrors the option upstream's Babel plugin leads with. Several passes are +// off by default, so without it these diagnostics are unreachable from JS. +describe("plugins.reactCompiler.environment", () => { + const setStateInEffect = `import { useEffect, useState } from "react"; +export function Component() { + const [x, setX] = useState(0); + useEffect(() => { setX(1); }, []); + return
{x}
; +} +`; + + // This pass needs both the flag and `outputMode: 'lint'` — the pipeline gates it on + // `env.config.validate_no_set_state_in_effects && env.output_mode == Lint`. + it("reaches a validation that is off by default", () => { + const off = transformSync("Component.tsx", setStateInEffect, { + plugins: { reactCompiler: { outputMode: "lint" } }, + jsx: { runtime: "automatic" }, + }); + // Flag defaults to false, so the pass never runs and reports nothing. + expect(off.errors.some((e) => /setState|effect/i.test(e.message))).toBe(false); + + const on = transformSync("Component.tsx", setStateInEffect, { + plugins: { + reactCompiler: { + outputMode: "lint", + environment: { validateNoSetStateInEffects: true }, + }, + }, + jsx: { runtime: "automatic" }, + }); + expect(on.errors.length).toBeGreaterThan(0); + expect(on.errors.some((e) => /setState|effect/i.test(e.message))).toBe(true); + }); + + it("leaves unset flags at the compiler's defaults", () => { + const { code, errors } = transformSync("Component.tsx", fixture, { + plugins: { reactCompiler: { environment: { enableForest: false } } }, + jsx: { runtime: "automatic" }, + }); + expect(errors).toEqual([]); + expect(code).toContain("_c("); + }); +});