Separate React Compiler emission from lint analysis so oxlint can dead-strip compiler codegen.
This reduces the stripped oxlint binary by 145 KiB (1.21%).
AI-assisted with Codex.
`known_incompatible` is already checked before both aliasing and legacy
signature handling, making the legacy-path TODO stale. Add
aliasing-signature coverage to guard the shared behavior.
Validated with `just ready`.
AI-assisted.
<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
Splits the experimental `react/react-compiler` rule into 24 category-specific rules matching `eslint-plugin-react-hooks` v7.
- Runs React Compiler once per file and routes structured diagnostics to each enabled rule.
- Removes `react/react-compiler` and `reportAllBailouts`, with rules categorized by user-facing lint intent.
- Adds per-rule documentation and deduplicates shared React lint URLs.
- Preserves standardized compiler diagnostics while reporting each finding under its Oxlint rule name.
Breaking change: configurations using `react/react-compiler` must migrate to the category-specific rules.
Written with Claude Code and Codex.
Preserve tagged-template call, aliasing, and mutation effects instead of assuming no-substitution tags are primitive or pure.
Execute opaque or effectful tags unconditionally while retaining memoization only when a configured signature proves a read-only pure call and safe return.
AI-assisted investigation and implementation; reviewed and understood by the contributor.
Splits `oxc_react_compiler`'s public API into two phases so the transform rewrites the caller's program in place instead of returning a whole-program clone:
- `compile(&Program, &Semantic, &Allocator, PluginOptions) -> (Option<CompileOutput>, Diagnostics)` — read-only; `None` means no changes (or error / lint output mode).
- `CompileOutput::transform(self, &mut Program, &Allocator)` — substitutes the compiled functions into the program in place. `CompileOutput` owns only arena data (replacements + `ProgramContext`), so the caller drops its `Semantic` between the two calls, releasing the shared borrow that previously forced `ox_splice_program` to `clone_in_with_semantic_ids` the entire program.
- `lint` is unchanged and structurally never runs the transform phase; the oxlint rule needed no changes.
The old `transform` free function and `TransformResult` are removed; `oxc_transformer` no longer does the `*program = result.program.take()` swap and just applies the output to its `&mut Program`.
The 1806-fixture snapshot corpus is byte-identical, and the transform path drops one whole-program clone per compiled file.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What
The React Compiler fixture snapshot harness (`crates/oxc_react_compiler/tests/snapshot.rs`) only ran `transform`. It now also runs the read-only `lint` entry point on every fixture and cross-checks its diagnostics against `transform`'s, surfacing any divergence directly in the snapshot.
## Why not a hard assert
`lint` sets `no_emit = true`, which resolves to `CompilerOutputMode::Lint`. The pipeline deliberately gates several validations on Lint mode — `validate_no_set_state_in_effects`, `validate_no_jsx_in_try_statements`, `validate_static_components`, `validate_no_derived_computations_in_effects_exp` — and conversely `@enableEmitHookGuards` errors only while emitting (Client). So the two entry points are *expected* to diverge for a handful of pragma-gated fixtures. Surfacing the difference in the snapshot (rather than asserting strict equality) documents those cases and still catches any unexpected drift.
## Result
`transform` runs first on the untouched allocator, so its output stays byte-identical to a transform-only run. Only **2** fixtures diverge — both `@validateNoSetStateInEffects` cases where `lint` reports the `EffectSetState` finding that `transform` silently compiles past. Their snapshots gained a `Lint-mode diagnostics (differ from transform)` section (additive: 12 insertions, 0 deletions). The test is green.
## Summary
Model the `String`-typed `PluginOptions` fields as enums, and consolidate the options code into a single crate-root `options.rs`.
| field | before | after |
| --- | --- | --- |
| `compilation_mode` | `String` | `CompilationMode` (`Infer` / `Syntax` / `Annotation` / `All`) |
| `panic_threshold` | `String` | `PanicThreshold` (`None` / `CriticalErrors` / `AllErrors`) |
| `output_mode` | `Option<String>` | `Option<CompilerOutputMode>` (reuses the existing enum) |
All the string comparisons become exhaustive enum matches. `FromStr` impls carry the upstream string spellings for the snapshot-test pragma parser; these options aren't deserialized from JS, so no serde is involved. The two `Vec<String>` fields (`eslint_suppression_rules`, `custom_opt_out_directives`) are arbitrary user lists and stay as-is; `target` was already an enum.
## Also in this PR
- Moved `PluginOptions`, its helper types (`CompilerTarget`, `GatingConfig`, `DynamicGatingConfig`, `CompilerOutputMode`), and its `Default` impl out of `react_compiler/entrypoint/plugin_options.rs` and `lib.rs` into the new `options.rs`.
- Documented every `PluginOptions` field and the option enums/variants.
- Added `Default` to all option enums (`CompilationMode` → `Infer`, `PanicThreshold` → `None`, `CompilerOutputMode` → `Client`, `CompilerTarget` → React 19).
Behavior is unchanged — the react_compiler snapshot corpus passes with zero churn.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
- Normalize React compiler snapshot names using path components instead of splitting on `/fixtures/`.
- Normalize fixture source newlines before parsing so diagnostic byte offsets are stable across Windows and Unix checkouts.
- Allow manually dispatched CI runs to execute the Windows cargo test job, without enabling it for normal pull requests.
## Root Cause
Windows `insta::glob!` passes fixture paths like `\?\E:\oxc\crates\oxc_react_compiler\fixtures\...`. The old string split only handled Unix `/fixtures/`, so Windows generated absolute-path snapshot names and treated every fixture as a new snapshot. After fixing that, Windows still differed because CRLF fixture checkout shifted diagnostic source offsets relative to LF snapshots.
## Root cause
The `new-mutability/` fixtures (and several others across the corpus)
that set
`// @enablePreserveExistingMemoizationGuarantees:false` bailed with a
spurious
```
[ReactCompiler] PreserveManualMemo: Existing memoization could not be preserved
```
diagnostic and emitted `No changes.`, whereas the fork compiles them.
This is **not** a compiler bug — it is a test-harness config mismatch.
The
upstream `snap` runner (`packages/snap/src/compiler.ts`,
`makePluginOptions`)
runs the entire fixture corpus with
`validatePreserveExistingMemoizationGuarantees` **off**, enabling it
only when a
fixture's first line contains
`@validatePreserveExistingMemoizationGuarantees`
(it keys off substring presence, ignoring any `:value`):
```ts
let validatePreserveExistingMemoizationGuarantees = false;
if (firstLine.includes('@validatePreserveExistingMemoizationGuarantees')) {
validatePreserveExistingMemoizationGuarantees = true;
}
```
oxc's `snapshot.rs` instead used the `EnvironmentConfig` schema default
(`true`). The `ValidatePreservedManualMemoization` pass is gated on
`enable_preserve || validate_preserve`, so with `enable_preserve:false`
but
`validate_preserve` left at its default `true`, the pass ran and fired
the error
where the fork skips validation entirely.
I confirmed with a `debugLogIRs` dump of the fork under the *real* snap
config
that oxc's `InferMutationAliasingEffects` /
`InferMutationAliasingRanges` /
reactive-scope output is byte-identical to the fork; the only divergence
was
whether the validation pass runs. (The fork also errors on these
fixtures when
`validatePreserveExistingMemoizationGuarantees` is forced on.)
## Fix
Replicate the snap runner exactly in `parse_pragma` — after the
directive loop,
unconditionally set `validate_preserve_existing_memoization_guarantees`
to
whether the first line contains the
`@validatePreserveExistingMemoizationGuarantees`
substring. This is the same class of harness alignment as #24118.
## Fixtures changed
26 snapshots, **all** moving from a spurious `PreserveManualMemo` bail
(or, for
`useMemo-named-function`, an over-eager diagnostic) to compiled code
that
matches the fork:
- 11 in `new-mutability/` (the batch): `mutate-through-identity`,
`mutate-through-identity-function-expression`,
`todo-transitivity-createfrom-capture-lambda`,
`transitivity-capture-createfrom`,
`transitivity-capture-createfrom-lambda`,
`transitivity-add-captured-array-to-itself`,
`transitivity-phi-assign-or-capture`,
`todo-control-flow-sensitive-mutation`,
`useCallback-reordering-deplist-controlflow`,
`useCallback-reordering-depslist-assignment`,
`useMemo-reordering-depslist-assignment`
- 15 elsewhere (`preserve-memo-validation/*`,
`useMemo-*`/`useCallback-*-dont-preserve-*`,
`repro-*`, `allow-modify-global-in-callback-jsx`,
`context-variable-as-jsx-element-tag`,
`existing-variables-with-c-name`).
Every changed snapshot was verified to have the **same `_c(N)` cache
size and
memoization structure as the fork** (differences reduce to codegen
whitespace:
redundant parens around assignments, JSX/array line-wrapping, trailing
commas).
No snapshot regressed (none went code→error/bail).
### new-mutability batch outcome (54 fixtures)
- **35** exact structural matches with the fork.
- **6** codegen-formatting-only diffs (identical cache size /
memoization):
`array-filter`, `array-map-named-callback-cross-context`,
`todo-control-flow-sensitive-mutation`,
`useCallback-reordering-deplist-controlflow`,
`useCallback-reordering-depslist-assignment`,
`useMemo-reordering-depslist-assignment`.
- **9** `error.*` fixtures already emit diagnostics matching the fork's
error intent
(unaffected by this change).
- **0** fixtures where oxc errors but the fork compiles (was 11).
## Remaining work (out of scope, distinct root causes)
- `transitivity-add-captured-array-to-itself` (`_c(18)` vs `_c(19)`) and
`transitivity-phi-assign-or-capture` (`_c(9)` vs `_c(11)`) now compile
but with
coarser memoization. Root cause: the harness does not supply the
shared-runtime
`typedCapture` / `typedCreateFrom` / `typedMutate` **aliasing**
signatures that
`makeSharedRuntimeTypeProvider` gives the fork, so oxc falls back to
conservative
call effects. Fixing this needs a broader `moduleTypeProvider` addition
and its own
corpus-wide verification.
- `ssa-renaming-ternary-destruction` emits an empty `function useFoo()
{}` — a
pre-existing SSA/phi/alias-group bug (the fixture's own comment flags
the case as
unresolved), unrelated to memoization validation.
- `error.todo-repro-named-function-with-shadowed-local-same-name` raises
an internal
`Invariant` instead of a clean error — a separate pre-existing issue.
## Summary
Resolves the `effect-derived-computations/` fidelity batch (21 fixtures)
where oxc emitted `No changes.` while the fork produces `## Code`.
## Root cause
The batch header is `// @validateNoDerivedComputationsInEffects_exp
@loggerTestOnly @outputMode:"lint"`. The original hypothesis was that
oxc *aborts* on the `EffectDerivationsOfState` Error-severity diagnostic
(`panic_threshold: "all_errors"`). That is **not** what happens —
instrumenting `compile()` shows all 21 fixtures return
`CompileResult::Success { ast: None }`, never `Error`:
- `validate_no_derived_computations_in_effects_exp` returns its
findings, which the pipeline hands to `log_errors_as_events`
(non-fatal), not `handle_error`.
- In lint output mode `process_fn` returns `Ok(None)` (lint never
rewrites), so `transform` returns `program: None` / `changed: false`.
The real divergence is in the **snapshot harness**: it rendered any
no-rewrite result as `No changes.`, so *every* `@outputMode:"lint"`
fixture looked like a bail-out even though the compiler ran to
completion and reported its findings. Upstream's `snap` runner instead
emits the (unmodified) code in lint mode alongside the findings.
## Fix
- `tests/snapshot.rs`: in lint output mode, render the validated
program's code instead of `No changes.` (the program is unchanged,
matching the fork's lint `## Code`).
- `src/lib.rs`: re-export `CompilerOutputMode` so the harness detects
lint mode canonically via `CompilerOutputMode::from_opts`.
No compiler logic changes — hence the `test(...)` scope; the compiler
was already correct, the harness was misrepresenting lint output.
## Fixtures changed (46 lint-mode snapshots)
Verified each changed snapshot's code against the fork's `.expect.md`
with a whitespace/trailing-comma-insensitive comparison.
**effect-derived-computations batch (21/21 — this PR's target):** all
now match the fork's `## Code` and still emit the
`EffectDerivationsOfState` diagnostic. 18 match modulo whitespace; 3
differ only by JSX-return parenthesization (`return <div>` vs `return
(<div>)`), a pure oxc-codegen-vs-babel formatting difference.
**Other `@outputMode:"lint"` fixtures (25) — all improvements toward the
fork:**
- 21 (setState, static-components, jsx-in-try-with-catch, `valid-*`,
`use-memo-noemit`, `dynamic-gating-noemit`, `fn-name-no-leak`): now
match the fork's `## Code`.
- 2 (`valid-setState-in-effect-from-ref-function-call`,
`valid-setState-in-useEffect-controlled-by-ref-value`): match the fork's
`## Code` except for compiler variable-rename suffixes (`ref_0`,
`data_0`) that the fork applies to its lint output but oxc's harness
does not — still a clear improvement over `No changes.`.
- 2 (`error.todo-invalid-jsx-in-try-with-finally`,
`error.todo-invalid-jsx-in-catch-in-outer-try-with-finally`): the fork
throws (`## Error`) on `try`/`finally` without a `catch`; oxc handles
it, emitting a `Todo` **warning** and now showing the code. Pre-existing
behavior divergence (oxc is more lenient here), surfaced by this change.
The `git diff --stat` is confined to these 46 lint-mode snapshots plus
the two source files.
## Notes / follow-ups (out of this batch)
- `invalid-jsx-in-try-with-catch` and
`invalid-jsx-in-catch-in-outer-try-with-catch` internally still
`CompileResult::Error` (a separate fatal-in-lint-mode path in the
JSX-in-try lowering). Because lint mode never rewrites, their emitted
code + diagnostic still match the fork's `## Code`, but the latent abort
is worth fixing in the JSX-in-try batch.
- Applying lint-mode variable renames in the harness (the
`ref_0`/`data_0` gap) and oxc's `try`/`finally` handling vs the fork are
separate items.
## Root cause
The `oxc_react_compiler` snapshot harness (`tests/snapshot.rs`) rendered a clean no-op compile as the literal `No changes.` marker:
```rust
if result.changed {
out.push_str(&Codegen::new().build(&program).code);
} else {
out.push_str("No changes.");
}
```
Upstream's `snap` runner instead **always** re-emits the program as `## Code`, and only replaces it with `## Error` on a hard error. So for every fixture where oxc "bails" with `No changes.`, the fork's golden shows the reprinted source — a permanent, opaque diff.
Investigating the **rules-of-hooks** batch showed this is purely a harness-representation gap, not a compiler bug:
- All 17 "bailing" rules-of-hooks fixtures carry `@expectNothingCompiled` (e.g. top-level statements, classes, or `@compilationMode:"infer"` functions that aren't components/hooks). oxc correctly compiles nothing — matching the fork, which also compiles nothing and just reprints the source.
- Every rules-of-hooks **error** fixture already matches the fork on category, message text, and source location (Rules-of-Hooks validation is a complete port).
- Every rules-of-hooks **compiling** fixture already matches the fork structurally; residual diffs are pure codegen formatting (trailing commas, JSX paren-wrapping) which are out of scope.
## Fix
Test-only change. On a clean no-op (no parse errors **and** no compile diagnostics), codegen the unchanged program instead of the marker, mirroring the upstream runner. The marker is kept when an error was reported, since upstream emits `## Error` with no code there — and reprinting a parse-recovered AST would be misleading.
```rust
let clean = parsed.diagnostics.is_empty() && result.diagnostics.as_slice().is_empty();
if result.changed || clean {
out.push_str(&Codegen::new().build(&program).code);
} else {
out.push_str("No changes.");
}
```
No compiler source is touched, so there is no risk to the compiler, napi bindings, or other consumers. The snapshot test still catches any future behavior change.
## Fixtures changed
54 snapshots that previously said `No changes.` now render the reprinted source:
- **17 rules-of-hooks** (this batch) — all `@expectNothingCompiled`; now match the fork's `## Code` modulo whitespace.
- **37 elsewhere** (guarded corpus-wide no-ops: `infer-*`, `use-no-forget-*`, `use-no-memo-*`, `gating/*`, `effect-derived-computations/*`, `valid-setState-*`, etc.).
52 of the 54 match the fork's `## Code` modulo formatting. The 11 parse-error fixtures (`error.todo-*`) keep the marker as intended.
## Remaining work
Two of the changed snapshots move **closer** to the fork but retain **pre-existing, out-of-batch** compiler gaps (unchanged by this PR, now just more visible):
- `ignore-use-no-forget.js` — oxc doesn't implement the `@ignoreUseNoForget` plugin option, so it honors the `'use no forget'` opt-out and skips; the fork ignores the opt-out and fully compiles.
- `valid-setState-in-effect-from-ref-function-call.js` — in `@outputMode:"lint"` the fork reprints with an SSA-renamed shadowed param (`ref` → `ref_0`); oxc leaves the source unchanged.
Both are separate from Rules-of-Hooks and left to their respective batches.
Closes 7 of the 17 fixtures where oxc compiled but the upstream fork errors.
The `error.invalid-type-provider-*` (4) and `error.invalid-known-incompatible-*` (3) fixtures import magic modules — `ReactCompilerTest`, `ReactCompilerKnownIncompatibleTest`, `useDefaultExportNotTypedAsHook` — that the upstream snap runner maps to intentionally-invalid / incompatible type configs via its test `moduleTypeProvider` (`snap/src/sprout/shared-runtime-type-provider.ts`). oxc's snapshot harness installed no override, so these compiled silently instead of erroring.
This installs the same test configs through the existing `EnvironmentConfig::module_type_provider` field. Unlisted modules fall back to oxc's default provider, so **only these 7 fixtures change** — nothing else in the 1,734-fixture corpus is touched. The type-config and known-incompatible-library validations already exist in oxc (`infer_mutation_aliasing_effects.rs`); they simply never saw the configs.
All 7 now emit the fork's diagnostics and decline:
- 4 × `Config: Invalid type configuration for module`
- 3 × `Compilation Skipped: Use of incompatible library`
Verified: `CI=1 cargo test` green, `cargo clippy --all-features -D warnings` and `cargo fmt --check` clean, exactly 7 snapshots changed.
Behaviour-preserving cleanup of `tests/snapshot.rs` (no snapshot changes).
- Collapse the ~30 boolean `EnvironmentConfig` arms into a `fields!` macro over a plain identifier list (`set_environment_bool`); only the non-boolean fields (enum, allow/block lists, gating/instrumentation configs) stay spelled out.
- Extract the duplicated parse-error / diagnostics printing into a single `push_diagnostics` helper.
- Fold the redundant `string_array` alias into one `json_strings` helper (shared with `json_source`).
`CI=1 cargo test` (which fails on any golden mismatch) passes with zero snapshot changes, and `cargo clippy --all-features -D warnings` + `cargo fmt --check` are clean.
> Stacked on #24109 — the simplified pragma parser it builds on only works once the fixture headers are normalised. Rebases onto `main` automatically once that merges.
The fixture corpus mixed the canonical `@key:value` pragma form with three legacy forms — `@key(value)`, `@key="value"`, and `@key value` — that upstream's `splitPragma` silently ignores or mis-reads. This rewrites the 9 affected fixtures to the canonical `@key:value` form so there is only one shape to parse, and drops the space-form tolerance from `parse_pragma` accordingly.
## Normalized headers
| fixture | before | after |
|---|---|---|
| `no-cache-slots-no-import` | `@compilationMode(all)` | `@compilationMode:"all"` |
| `repro-bailout-nopanic-shouldnt-outline` | `@panicThreshold(none)` | `@panicThreshold:"none"` |
| `fbt/error.todo-fbt-plural-in-expression-container` | `@compilationMode(infer)` | `@compilationMode:"infer"` |
| `meta-isms/repro-cx-namespace-assigned-to-temporary` | `@customMacros(cx)` | `@customMacros:"cx"` |
| `target-flag` | `@target="18"` | `@target:"18"` |
| `target-flag-meta-internal` | `@target="donotuse_meta_internal"` | `@target:"donotuse_meta_internal"` |
| `immutable-hooks` | `@enableAssumeHooksFollowRulesOfReact true` | `@enableAssumeHooksFollowRulesOfReact:true` |
| `ref-current-aliased-no-added-to-dep` | `@validateRefAccessDuringRender false` | `@validateRefAccessDuringRender:false` |
| `ref-current-field-not-added-to-dep` | `@validateRefAccessDuringRender false` | `@validateRefAccessDuringRender:false` |
## Behavior-preserving
Every regenerated snapshot differs only in the echoed header comment (codegen preserves the leading comment), plus one +1 byte-offset shift in the `fbt` diagnostic from the comment being one character longer. No compiled output changed, and no other fixture is affected by the parser simplification.
## Summary
`oxc_react_compiler` had no committed test over the upstream React
Compiler fixture corpus — the fixtures lived only in the
`oxc-project/forked-react-compiler` checkout, exercised out-of-tree.
That gap let real regressions slip past the 15 in-crate integration
tests. This PR closes it.
- **`crates/oxc_react_compiler/fixtures/`** — 1734 fixture *input* files
(`.js`/`.ts`/`.tsx`/`.jsx`/`.mjs`), copied from the fork's
`babel-plugin-react-compiler/src/__tests__/fixtures/compiler` tree with
subdirectories preserved. Flow fixtures (the `@flow`-pragma ones) are
excluded — oxc has no Flow parser. The upstream `.expect.md` goldens are
also not copied; insta is the golden here.
- **`crates/oxc_react_compiler/tests/snapshot.rs`** — an `insta::glob!`
test that, per fixture, derives `PluginOptions`/`EnvironmentConfig` from
the first-line `// @directive` pragmas (base `compilationMode=all` /
`panicThreshold=all_errors`, matching the upstream `snap` runner),
parses → builds semantic → runs `transform`, and snapshots the compiled
output plus diagnostics. Unrecognised directives are ignored, as
upstream does; camelCase flag directives map to `EnvironmentConfig`
fields via `convert_case`.
- **`crates/oxc_react_compiler/tests/snapshots/`** — 1734 committed
`.snap` goldens (oxc's own output).
- **`.typos.toml`** — excludes the fixtures dir (the upstream fixtures
contain intentional typos; `**/*.snap` was already excluded).
These snapshots are oxc's own golden output, so `cargo test` (and CI)
now catches any change in compiler behaviour across the corpus.
Regenerate after an intended change with `cargo insta accept`.
## Verification
- `cargo test -p oxc_react_compiler` passes — the 1734-fixture snapshot
test plus the existing 15 integration tests; also green under
`--all-features` (matches the cross-target CI job).
- clippy `-D warnings`, `cargo fmt`, and `typos` clean.
- No fixture panics; 1336 compiled / 398 unchanged / 335 with
diagnostics.
- The `error.*` fixtures capture the exact diagnostics — including the
"Cannot reassign variable after render" error that a recent scoping
change had silently suppressed, confirming the suite catches real
regressions.
---
AI usage disclosure: authored with Claude Code and reviewed before
submission.
🤖 Generated with [Claude Code](https://claude.com/claude-code)