Commit Graph

192 Commits

Author SHA1 Message Date
Marius Schulz b6850625af perf(react_compiler): keep small hot-path collections inline (#24514)
## What

Two kinds of small, hot collections in the react-compiler pass get
heap-allocated on every use:

- The HIR operand/lvalue visitors (`each_instruction_value_operand`,
`each_terminal_operand`, and friends) build a fresh `Vec<Place>` for
every instruction they're called on, and nearly every pass calls them in
a loop. The lists almost always hold one to four entries.
- The aliasing fixpoint allocates a successor list per block visit
(`terminal_successors`) and an args/captures list per interned effect
(`EffectKey`).

## Change

- The visitors now return `PlaceList = SmallVec<[Place; 4]>`; element
order and contents are unchanged.
- `terminal_successors` returns a `SmallVec<[BlockId; 2]>`, and
`EffectKey::Apply` args / `CreateFunction` captures are inline small
vectors, so building a typical key no longer allocates.
- Adds `smallvec` to the crate (already a workspace dependency).

Numbers from the stock release profile (fat LTO), linting cal.com with
`react/react-compiler` as the only enabled rule, `--threads=1`:

|              |         before |          after |               delta |
| ------------ | -------------: | -------------: | ------------------: |
| instructions | 18,085,644,607 | 17,200,052,223 |               −4.9% |
| wall time    |        2.787 s |        2.754 s | −1.2% |

## Verification

- Lint output is byte-identical to main on cal.com and excalidraw, with
`reportAllBailouts` both on and off (full stdout/stderr and exit codes,
single-threaded).
- Transform output is identical on all 1,438 tsx/jsx files across the
two corpora, with no exit-code changes.
- `cargo test -p oxc_react_compiler` (including the fixture snapshot
suite), clippy, and rustfmt are all clean.

This PR was assisted by Claude Code.
2026-07-15 00:14:01 +08:00
Boshen 639665b299 refactor(react_compiler): switch ContextIdentifierVisitor to VisitJs (#24513)
## What

First consumer of the `VisitJs` trait from #24499: switches `ContextIdentifierVisitor` (`react_compiler_lowering/find_context_identifiers.rs`) from `impl Visit` to `impl VisitJs`.

## Changes

- `impl Visit` → `impl VisitJs`; direct `walk::walk_*` calls → `walk_js::walk_*`.
- Deletes the six hand-written `visit_ts_*` skip-stubs (`visit_ts_type`, `visit_ts_type_annotation`, `visit_ts_type_parameter_instantiation`/`_declaration`, `visit_ts_type_alias_declaration`, `visit_ts_interface_declaration`) — `VisitJs` prunes type-space natively, and these methods no longer exist on the trait.
- Keeps the empty `visit_ts_enum_declaration` / `visit_ts_module_declaration` overrides: enum initializers and namespace bodies are runtime JS which `VisitJs` *does* walk, while the original Babel walker treated them as opaque RawNodes — the stubs preserve exact parity.
- Drops two `visit_ts_this_parameter(...)` calls that were always no-ops (their walk only descended into the type annotation, which the deleted stub discarded).

Behavior is unchanged; the visitor just no longer monomorphizes any TS type-grammar walks. Existing `oxc_react_compiler` tests cover the pruned/kept boundary directly (`typescript_only_constructs_round_trip`, `type_query_casts_are_renamed_with_value_bindings`, `ts_wrapped_assignment_targets_do_not_panic`).

---

🤖 This PR was written with the assistance of Claude Code (Claude Fable 5).
2026-07-14 16:01:17 +00:00
Marius Schulz 7808a6e964 perf(react_compiler): make aliasing effects cheap to intern and clone (#24506)
## What

The mutation/aliasing inference interns every effect it applies. Both
the interning map and the fixpoint-stable ValueId cache were keyed by a
formatted `String`, so every lookup on the `apply_effect` hot path built
a fresh string. On top of that, `AliasingEffect::Apply` carried an owned
`FunctionSignature`, so cloning an Apply effect meant copying the whole
struct including its parameter effect list, and `apply_signature`
deep-cloned the cached effect list of every instruction it applied.

## Change

- Replace the `String` key with an `EffectKey` enum carrying exactly the
fields the string encoded. `Apply::signature`/`span` and
`Mutate::reason` still don't participate in identity, and the one
collision the string format had (an empty `Apply` argument list vs a
single hole) is reproduced on purpose. Building and hashing a key no
longer allocates or formats, except in the rare error-carrying arms,
which clone their message strings.
- Put the `FunctionSignature` in `Apply` behind `Rc`. It's still
deep-cloned out of the shape registry once per call instruction, but
every later clone of the effect is just a refcount bump. The interning
key already ignores the signature, and all readers are immutable.
- Put the cached `InstructionSignature` entries behind `Rc` too, so
`apply_signature` can hold the signature while passing the context on
mutably, instead of cloning the whole effect list each time.

Follow-up to #24117; this PR is based on current main, which includes
it.

Numbers from linting cal.com with only `react/react-compiler` enabled,
single thread, fat-LTO release build:

| Metric (cal.com, 1 thread) |     main |  this PR | delta |
| -------------------------- | -------: | -------: | ----: |
| instructions               | 19.113 G | 18.086 G | −5.4% |
| wall time                  |  2.907 s |  2.804 s | −3.5% |

## Verification

- Output of the main binary vs this PR's binary is byte-identical
(stdout, stderr, exit code) on all 4 corpus/config combinations: cal.com
and excalidraw, each with `reportAllBailouts` on and off.
- The `react_compiler` example (parse, compile, codegen) produces
identical output on all 1438 corpus files.
- `cargo test -p oxc_react_compiler` passes, including the 1,736-fixture
snapshot corpus: compiled output is unchanged on every fixture.
- `cargo clippy` and `cargo fmt` clean.

One note for future re-syncs with the vendored React Compiler sources:
`EffectKey` mirrors `AliasingEffect` variant for variant. Adding an
identity-relevant field to a variant without updating `effect_key`
silently changes interning behavior. Keep the two in sync.

This PR was assisted by Claude Code.
2026-07-14 20:43:15 +08:00
Boshen 3a36f2a67c perf(react_compiler): store AbstractValue reasons as a u16 bitmask (#24480)
Follow-up to #24476. Profiling the mutation-aliasing pass after that PR showed the `SmallVec<[ValueReason; 4]>` I introduced for `AbstractValue.reason` at **~7% of compile self-time** — it no longer allocates, but it's still copied on every `AbstractValue` clone, which happens once per entry on every `InferenceState` clone in the fixpoint.

There are only 12 `ValueReason` variants, so the whole set fits in a `u16` bitmask. `ReasonSet` — and therefore `AbstractValue` — becomes `Copy`, so copying it is a register move instead of a memcpy, and membership / union / superset collapse to single bitwise ops.

The one behavioral wrinkle is `primary_reason`, which returns the first non-`Other` reason: it now iterates in `ValueReason` declaration order rather than insertion order. The full snapshot suite (`tests/snapshot.rs`, the ~1288 upstream fixtures) is **unchanged**, confirming the ordering difference is not observable in output.

### Results

Real-world React component files (from `bluesky-social/social-app`), compile pass only:

| File | before | after |
| --- | ---: | ---: |
| Explore.tsx (35 KB) | 23.1 ms | **21.2 ms** (−8%) |
| MessageItem.tsx (30 KB) | 21.3 ms | **20.5 ms** (−4%) |
| Drawer.tsx (21 KB) | 8.6 ms | 8.7 ms (noise; few reasons) |

Allocation count is unchanged — the `SmallVec` was already inline, so this is purely a copy/`memcpy` win.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-14 07:56:22 +00:00
Boshen 1c96753d9c perf(react_compiler): use FxHashMap for the lookup-only aliasing node map (#24490)
`AliasingState.nodes` in `infer_mutation_aliasing_ranges.rs` is only accessed via `get`/`get_mut`/`insert`/`entry`/`contains_key` — never iterated — so its insertion order is never observed. Switching it from `FxIndexMap` to `FxHashMap` drops the entry `Vec` that `IndexMap` maintains, and since `IdentifierId -> Node` is unique to this pass, it also removes the `IndexMap` monomorphization (small binary-size win). The order-critical `created_from`/`captures`/`aliases`/`maybe_aliases` maps (iterated into a worklist that drives diagnostic order) stay `FxIndexMap`.

Snapshots unchanged (`cargo test -p oxc_react_compiler`).
2026-07-14 07:13:01 +00:00
Boshen 48911ecaca perf(react_compiler): use FxHashSet for membership-only block sets (#24486)
`get_reverse_postordered_blocks`'s DFS `visited`/`used`/`used_fallthroughs` sets and the three `block_ids` lookup sets in `hir_builder.rs` are used only via `.contains()`/`.insert()` — the output ordering comes from the separate `postorder` Vec, never the sets. Switching them from `FxIndexSet` to `FxHashSet` drops the entry `Vec` that `IndexSet` maintains and skips the per-insert `Vec` push (mirrors `dominator.rs`, which already uses `FxHashSet<BlockId>`). The order-critical `BasicBlock.preds` construction stays `FxIndexSet`.

Memory/perf only — binary size is unchanged (`FxIndexSet<BlockId>` stays for `preds`). Snapshots unchanged (`cargo test -p oxc_react_compiler`).
2026-07-14 06:39:31 +00:00
Boshen 0c97b34d67 refactor(react_compiler): store aliasing signature config as &'static str (#24477)
The built-in aliasing signatures in `globals.rs`/`object_shape.rs` are all compile-time string literals, so `AliasingSignatureConfig` (and the `AliasingEffectConfig`/`ApplyArgConfig` it contains) can hold `&'static str` / `&'static [...]` instead of `String`/`Vec`. This removes the 59 `.to_string()` allocations from the once-per-process built-in registry build.

Shrinks the react_compiler-enabled `oxc-transform` `.node` by **~16 KiB** (5,403,088 → 5,386,640 bytes, stripped release). Snapshots unchanged (`cargo test -p oxc_react_compiler`).
2026-07-14 03:58:26 +00:00
Boshen 1e3e626831 perf(react_compiler): reduce allocations in reactive-places and mutation-aliasing passes (#24476)
Two allocation-focused optimizations to the React Compiler's two hottest passes. Both are behavior-preserving — the full snapshot suite (`tests/snapshot.rs`, the ~1288 upstream fixtures) is unchanged, and no snapshots were regenerated.

Profiling the compiler on real component files showed ~99% of compile time is the compiler core (parse/semantic/codegen are <1% combined), and the core is allocation-bound: compiling a 35 KB file did ~1.2M heap allocations. These two changes remove most of that.

### 1. Memoize post-dominator frontiers (`infer_reactive_places`)

`is_reactive_controlled_block` recomputed each block's post-dominator frontier — a reverse-CFG walk that allocates several sets — on every call, which happens once per block and once per phi operand on every fixpoint iteration. The frontier depends only on the CFG, which is invariant across the fixpoint, so it is now computed once per block up front and looked up.

### 2. Inline `SmallVec` for `AbstractValue.reason` (`infer_mutation_aliasing_effects`)

`AbstractValue.reason` was an `FxIndexSet<ValueReason>`, which heap-allocates on every clone. During inference these values are cloned constantly (once per entry on every `InferenceState` clone), so the reason sets dominated allocation in the pass. The sets are tiny — usually a single element, never more than one per `ValueReason` variant — so they are now backed by an inline, insertion-ordered `SmallVec`: clones no longer allocate, and `primary_reason` (first non-`Other` reason) keeps its ordering.

### Results

Measured on real-world React component files (from `bluesky-social/social-app`), compile pass only, system allocator:

| File | before | after | allocations |
| --- | ---: | ---: | ---: |
| Explore.tsx (35 KB) | 49.6 ms | 23.5 ms (**2.1×**) | 1.24M → 220K (**−82%**) |
| MessageItem.tsx (30 KB) | 28.4 ms | 20.9 ms (**1.36×**) | 542K → 297K (**−45%**) |
| Drawer.tsx (21 KB) | 9.7 ms | 8.7 ms (1.11×) | 183K → 155K (−15%) |

\#2 is the larger lever — the reason-set clones dominated allocation in the pass. \#1 is a complementary ~6–10% CPU win from avoiding the redundant frontier recomputation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-14 03:53:33 +00:00
Marius Schulz e86e0d8125 perf(react_compiler): avoid copying the aliasing fixpoint's abstract state (#24117)
## What

`infer_mutation_aliasing_effects` runs a fixpoint that tracks one
`InferenceState` per block. Each state consists of two hash maps holding
every value and variable of the function, and it was deep-cloned per
block visit, per successor, per merge, and per variable set. Profiling
an Oxlint run with `react/react-compiler` as the only enabled rule
showed this state churn dominating the rule's cost.

## Change

This PR makes several optimizations, broken out into separate commits.
I'd recommend reviewing these one commit at a time:

- Avoid redundant state clones: move the incoming state into the working
state, hand the post-block state to the last successor by move (most
blocks have exactly one successor), and stop replacing a queued state
with a clone of itself when a merge is a no-op.
- Skip state snapshots for acyclic CFGs: `states_by_block` is only read
when a block is re-queued after processing, which requires a CFG cycle;
detect back edges up front and skip the per-block snapshot for
cycle-free functions (the common case for components).
- Merge queued states in place: new `merge_from` applies `merge`'s
per-entry updates directly to the queued state instead of building and
dropping a full replacement state.
- Share variable sets via `Rc`: the `variables` map's sets are never
mutated in place — every write replaces the whole entry — so cloning a
state or assigning a variable now bumps a refcount instead of copying a
hash table.

## Impact

cal.com corpus (5,054 files), `react/react-compiler` only, 1 thread,
release profile (fat LTO) with mimalloc:

| Metric | baseline | this PR | Δ |
| --- | ---: | ---: | ---: |
| instructions | 33.37 B | 19.36 B | **−42.0%** |
| wall time | 4.46 s | 3.03 s | **−32.1%** |

## Verification

- Byte-identical diagnostics: 4/4 diffs clean over cal.com and
excalidraw.
- Transform differential: 1,438/1,438 corpus tsx/jsx files produce
identical output (stdout+stderr+exit) through the `react_compiler`
example.
- `cargo test -p oxc_react_compiler` (16/16, including the fixture
snapshot corpus, whose snapshots were generated from unmodified code),
`cargo clippy -p oxc_react_compiler`, `cargo fmt -- --check`: all clean.

This PR was assisted by Claude Code.
2026-07-13 20:33:37 +08:00
Boshen b80d351d5c refactor(react_compiler): drop lint allows and fix clippy warnings (#24449)
Follow-up to #24442 / #24448 (now merged). Removes clippy `"allow"`s from `crates/oxc_react_compiler` and fixes the warnings they were hiding.

## Crate-wide allows removed (`[lints.clippy]`)

Only `result_large_err` (the whole-crate `Result<T, OxcDiagnostic>` trade-off) remains.

| lint | warnings | fix |
| --- | --- | --- |
| `collapsible_if` | 197 | `cargo clippy --fix` (nested `if` → `&&` / `if let` chains) |
| `unnecessary_unwrap` | 4 | rewritten as `if let Some(..)` |
| `disallowed_types` | 0 | allow was dead — no `std::HashMap`/`HashSet` in the crate (the `ScopeInfo`/`ScopeData` comment was stale) |
| `needless_return` | 0 | allow was dead — no trailing `return`s |

## Inline allows removed

| lint | fix |
| --- | --- |
| `only_used_in_recursion` (3) | drop the recursion-only params: `func` (`apply_effect` → `apply_signature`), `types` (`get_context_reassignment`), `is_first_pass` (`record_instruction_derivations`) |
| `large_enum_variant` (3) | box `ReactiveStatement::Terminal` and `CompileResult`'s `output` (unboxed at the `lib.rs` boundary, so the public `compile` API is unchanged); `AliasingEffect`'s allow was already dead |
| `enum_variant_names` (1) | rename `Type::TypeVar` → `Type::Var` |

## Notes

- Behavior-preserving: the insta snapshot corpus and all 15 transform tests are byte-identical; `clippy` is clean.
- Deliberately left in place (would need bigger refactors, out of scope): `result_large_err` (whole-crate), `too_many_arguments` (×15 — arg-bundling structs), and `HIR`'s `upper_case_acronyms`.
- This diverges the vendored `src/` modules a bit further from upstream `react_compiler_oxc` (the `collapsible_if` collapses especially) — worth a note for whoever re-syncs.
- One thing the lint surfaced: `is_first_pass` in `record_instruction_derivations` was passed a real value by its fixpoint-loop caller but never read, so both passes were already identical — removing it is behavior-preserving but flags a latent gap if the passes were meant to differ.
2026-07-13 08:58:25 +00:00
Boshen 8b1f6a286d refactor(react_compiler): store HIR arenas in IndexVec (#24448)
Follow-up to #24442 (the id → `oxc_index` nonmax migration, now merged). Now that the HIR ids are `oxc_index` index types, this puts them to use as indices.

## What

- Convert the four **append-only** `Environment` arenas — `identifiers`, `types`, `scopes`, `functions` — from `Vec<T>` to `IndexVec<Id, T>`. They're now indexed by their typed id, and the factories use `next_idx()` / `push`.
- Helper params that took `&[T]` / `&mut Vec<T>` of these element types become `&IndexSlice<Id, [T]>` / `&mut IndexVec<Id, T>`; callers pass `&env.<arena>`, which deref-coerces.
- Replace the global-types map key `(u32, InstructionId)` — where `u32::MAX` meant "outer function" — with `(Option<FunctionId>, InstructionId)`, deleting the `u32::MAX` sentinel and the `index()`-as-`u32` casts.
- Add a named `BlockId::ENTRY` for the "entry block is 0" construction sites.

## Why

Type-safe indexing: a `ScopeId` can no longer index the `identifiers` arena (previously `arena[id.index()]` accepted any id's `usize`). `push` returns the id, so the id factories read straight. And the `Option<FunctionId>` key makes the outer/inner-function distinction typed instead of a magic `u32::MAX`.

## Notes

- The **instruction table stays `Vec`** (`HirFunction.instructions`, `BasicBlock.instructions`): it's `drain`-ed during IIFE inlining, so it isn't a clean append-only arena. Instruction indexing keeps `.index()`.
- Behavior-preserving: the insta snapshot corpus and all 15 transform tests are byte-identical.
2026-07-13 08:25:49 +00:00
Boshen 53342c91e9 refactor(react_compiler): define IDs with oxc_index nonmax index types (#24442)
## What

Migrate the React Compiler's HIR ID newtypes from hand-rolled `pub struct X(pub u32)` / `type X = u32` to oxc's `oxc_index::define_nonmax_u32_index_type!` — the same index-type idiom used by `SymbolId`, `ScopeId`, `NodeId`, `ReferenceId`, `BasicBlockId`, etc.

Migrated: `BlockId`, `IdentifierId`, `InstructionId`, `EvaluationOrder`, `DeclarationId`, `ScopeId`, `TypeId`, `FunctionId`, `MutableRangeId`, and the validation-pass `RefId`.

## Why

`NonMaxU32` backing adds a niche, so `Option<Id>` is **4 bytes instead of 8**. The crate has 48 `Option<Id>` fields — many on hot HIR structs (`Identifier`, `Place`, `MutableRange`, `ReactiveScope`, `Instruction`) — which shrink for free, plus `RefId`'s `Option<RefId>` fields. It also aligns the crate with the rest of oxc, and the macro's `Add`/`PartialOrd<usize>` impls let the pervasive `id.0 + 1` / `a.0 > b.0 + 1` arithmetic collapse to `id + 1` / `a > b + 1`.

## Notes

- `ValueId` is intentionally **not** migrated. It overlays two id-spaces by OR-ing bit 31 (`| 0x80000000`), whose value space includes `u32::MAX` and would panic under `NonMaxU32`; it is also only ever a hashmap/set key (never a `Vec` index or inside `Option<ValueId>`), so a nonmax index buys nothing. It stays a plain `u32` newtype, with the bit-tag now behind a named `ValueId::from_identifier` constructor.
- Named sentinels replace repeated magic zeros: `EvaluationOrder::UNSET` (the "not-yet-numbered" order, ~106 sites) and `BlockId::PLACEHOLDER` (the never-read block installed by the final `terminate()`; uses `MAX_INDEX` so it can't alias the real entry block, which is `0`).
- The old `u32::MAX` sentinels: the two never-read placeholders now use the named consts above; the `infer_types` one is a plain `u32` function-key and is unchanged.
- Behavior-preserving: the insta snapshot corpus and all 15 transform tests are byte-identical.
- These `src/` modules track upstream closely; this deliberately diverges, consistent with the recent oxc-integration work on this crate.
2026-07-13 07:02:13 +00:00
Boshen 4a196dbbe7 refactor(react_compiler): reuse oxc AST operator types and simplify imports (#24438)
## Summary

Cleans up `oxc_react_compiler`'s use of oxc AST types. Everything here is internal to the crate — no public API or behavior change (the HIR operator/`Span` types are only reachable through a private `mod`, and all consumers are in-crate).

- **Reuse oxc operator enums.** The HIR defined its own `BinaryOperator`, `LogicalOperator` and `UpdateOperator` that are structurally identical to oxc's AST operators (same value sets, only differently-spelled variants). Replaced them with the oxc types and dropped the four now-identity conversion functions. `UnaryOperator` stays HIR-owned on purpose — oxc's has an extra `Delete` variant that the compiler routes as a separate instruction.

- **Glob imports for `oxc_ast::ast`.** Where the `use oxc_ast::ast as oxc;` alias was pure noise, switched to `use oxc_ast::ast::*;` and dropped the `oxc::` prefixes. The two files that bridge the AST and the HIR keep the alias, since `UnaryOperator`/`ObjectProperty` legitimately collide by name there and the prefix does real disambiguation.

- **Remove re-exports from the HIR module.** `react_compiler_hir` no longer re-exports `oxc_span::Span` or the oxc operators; consumers import them straight from their source crates.

- **`is_component_name`** inspects the first byte instead of decoding the first `char`.

Net −244 lines. Existing snapshot and transform integration tests pass unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-13 03:19:40 +00:00
Boshen d3f55c3aa2 refactor(react_compiler): replace JsString with oxc_str::Str (#24436)
## Summary

Removes `crates/oxc_react_compiler/src/react_compiler_utils/js_string.rs`.

`JsString` was a hand-rolled, WTF-16-capable arena string backing `PrimitiveValue::String`, kept so the compiler could represent JS strings containing lone surrogates that Rust's `str` cannot. Unlike the numeric/identifier helpers removed in #24430, it has no canonical oxc equivalent, so this replaces it with `oxc_str::Str<'a>` (UTF-8) — continuing the "use oxc utilities instead of reimplementations" cleanup.

## Behavior change

Lone-surrogate handling becomes lossy. For all well-formed strings — the entire realistic input space — behavior is identical:

- `from_code_units` is gone, and the `__SURROGATE_XXXX__` babel-bridge markers are no longer decoded/re-encoded (`from_marker_string`/`to_marker_string` → plain arena alloc / `as_str`).
- `+` no longer pairs surrogate halves split across operands (now plain UTF-8 concatenation via `Str::from_strs_array_in`).

## Verification

- `cargo clippy -p oxc_react_compiler` and `cargo fmt --check` — clean.
- `cargo test -p oxc_react_compiler` — snapshot conformance (fixture corpus) unchanged and all 15 transform integration tests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-13 02:41:44 +00:00
Boshen cc820cb2c7 refactor(react_compiler): use oxc utilities instead of reimplementations (#24430)
Replaces hand-rolled JavaScript utilities in `oxc_react_compiler` with oxc's canonical implementations, each of which is also strictly more spec-correct than the local version.

## Replaced

- **`is_valid_identifier`** (two copies) → `oxc_syntax::identifier::is_identifier_name` + `oxc_syntax::keyword::is_reserved_keyword`. Removes duplicated 46-word reserved-word lists and approximate `is_alphabetic`/`is_alphanumeric` char checks (oxc uses exact ECMAScript `ID_Start`/`ID_Continue`). Also fixes one copy that silently allowed `await` as an identifier.
- **`js_to_number`** → `oxc_ecmascript::StringToNumber`. The local version accepted `"inf"`/`"nan"` and used Rust's Unicode `.trim()` instead of JS whitespace.
- **`js_to_int32` / `js_to_uint32`** → `oxc_ecmascript::{ToInt32, ToUint32}`. The local `(n.trunc() as i64) & 0xFFFFFFFF` saturated for large magnitudes (e.g. `1e300` produced `-1` instead of `0`).
- **`format_js_number`** → `oxc_syntax::number::ToJsString`. Replaces a documented `Number.prototype.toString()` approximation (which fell back to Rust's `Display`) with the dragonbox-based, spec-correct formatter.
- **`ox_parse_regexp_flags`** → `oxc::RegExpFlags::try_from(char)`. Removes a duplicated 8-arm flag `match`.

Adds the `oxc_ecmascript` dependency and enables the `to_js_string` feature on `oxc_syntax`. The single-call numeric conversions are inlined at their call sites; `is_valid_identifier` (composes two calls, used as a fn-pointer), `format_js_number` (pub, cross-file `Display` helper), and `ox_parse_regexp_flags` (a per-char loop) remain thin helpers.

Snapshot conformance (over the upstream fixture corpus) and the transform integration tests pass unchanged.
2026-07-13 01:54:16 +00:00
Boshen cadbfee9a5 refactor(react_compiler): remove dead code (#24426)
Removes all compiler-flagged dead code from `oxc_react_compiler`.

## Removed

- **Superseded functions** (`propagate_scope_dependencies_hir`): `collect_hoistable_property_loads`, `collect_hoistable_property_loads_impl`, `key_by_scope_id` — the live pipeline uses the restructured `collect_hoistable_and_propagate` instead.
- **Unused methods/wrappers**: `kind_opt`, `mutate`, `define_context`.
- **Unused fields** (removed with their construction sites): `PropertyPathNode::{parent, root}`, `DependencyCollectionContext::temporaries_used_outside_scope` (cascaded cleanly through `collect_dependencies`), `JsxInstrInfo::instr_id`, `ControlFlowTarget::Loop::owns_block`, `OxcContext::fn_name`, `StartMemoInfo::span`, `Callbacks::memo_locals`.
- **Unused enum variant**: `MutationKind::None` (the `Ord` ordering of the remaining variants is preserved).
- **Dormant scaffolding**: `TransformedValue::Replace` — collapsed the base traversal's replace path down to direct `visit_value` calls (the 11 transform passes are unaffected) — and `LvalueRef::Spread` plus its codegen match arm.
- **`hmac-sha256` dependency** — orphaned once `source_file_hash` was removed; dropped from the crate and workspace manifests.

Pre-existing `#[allow(dead_code)]` markers on live-but-unread code were left intact, so the diff is purely dead-code removal. Snapshot conformance (over the upstream fixture corpus) and the transform integration tests pass unchanged.
2026-07-13 00:43:16 +00:00
Boshen 0d3f9b8b57 refactor(react_compiler): use clone_in_with_semantic_ids for all AST clones (#24406) 2026-07-12 23:55:40 +00:00
Boshen dce0f296e9 perf(react_compiler): replace all compiled functions in a single AST walk (#24403)
## What

`OxcReplaceFnVisitor` walked the entire program from the root **once per compiled function** — O(replacements × AST size). This collects all non-gated replacements into an `FxHashMap<ScopeId, &CodegenFunction>` and applies them in a **single traversal**, with a `remaining` counter that stops the walk early once every target has been applied.

## Why it's equivalent

- Non-gated replacement is a pure **in-place field edit** of a function node — it never inserts or removes `program.body` statements — so running the whole batch before the outlined-declaration insertion and gating loop (which *do* restructure `program.body`) cannot affect their results.
- The **gated** path is untouched: it stays in the loop because it replaces a function with a `gating() ? … : …` conditional and clones the *original* body. Each replacement is gated or non-gated, never both.
- A top-down walk reaches an outer function before any nested one and does not recurse into a replaced node, preserving the "outer wins, inner clobbered" behavior the N separate walks already produced — independent of list order or nesting.

## Verification

- `cargo test -p oxc_react_compiler`: the 1806-fixture snapshot corpus passes **unchanged** (byte-identical output), plus all transform integration tests.

Net: N full-program traversals → 1, scaling with the number of compiled functions per file.
2026-07-12 15:14:48 +00:00
Boshen 71945a2bfd refactor(react_compiler): reserve memo cache import name to remove rename pass (#24402)
## What

Removes `OxcRenameIdentifierVisitor` — the whole-program pass that patched a `useMemoCache` placeholder callee into the real memo-cache import local (`_c`) after codegen.

Codegen used to emit `const $ = useMemoCache(N)` with a placeholder, and `ox_transform_program` minted the real name at splice time and walked the entire program renaming it (mirroring the Babel plugin's placeholder-then-rename dance).

Instead, the name is now **reserved up front**:

- `ProgramContext::reserve_memo_cache_name` mints `_c`/`_c2`/… before compilation, next to the existing instrument/hook-guard pre-resolved names.
- It flows into each per-function `Environment::memo_cache_name`, and codegen emits it directly at the `const $ = _c(N)` preface.
- The import itself stays **deferred** — registered in `ox_transform_program` only when an applied function actually uses memo slots (unchanged gate), so no spurious imports.

## Why the collision behavior is preserved

The old late mint saw globals referenced *inside* compiled functions (via `rename_variables`' returned set), so a file referencing a global literally named `_c` correctly picked `_c2`. An up-front mint can't see those yet, so `reserve_memo_cache_name` also checks the file's `root_unresolved_references` — a superset — keeping the name choice byte-identical, and as a bonus avoiding a shadowed module-level `_c` (closer to Babel's `generateUid` `hasGlobal` check).

## Result

Byte-identical output across the full 1806-fixture snapshot corpus (`cargo test -p oxc_react_compiler`, zero `.snap` changes). Clippy (`CARGO_BUILD_WARNINGS=deny`) and rustdoc (`-D warnings`) clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-12 14:52:12 +00:00
Boshen 6427542c8d refactor(react_compiler): split public API into compile + in-place CompileOutput::transform (#24401)
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)
2026-07-12 14:35:19 +00:00
Boshen cacd667233 refactor(react_compiler): replace CommentData with oxc Comment in suppressions (#24397)
`CommentData` and `CommentLoc` were Babel-shaped vestiges: every `Option` field was always `Some`, `CommentLoc` duplicated `start`/`end` with identical values, and `value` is just a slice of source text.

Store the oxc `Comment` directly in `SuppressionRange` — now lifetime-free and `Copy` — and derive the trimmed comment text on demand via `content_span()`, both at match time and when building the suppression diagnostic. `ProgramContext` gains a `source_text` field so `suppressions_to_diagnostics` can slice it.

Fallout removed along the way:

- the dead `is_none()` guards and `and_then` chains over always-`Some` fields
- the redundant `trim_start()` fallback branches in the `matches_eslint_*` matchers (the value is pre-trimmed)
- the `.cloned().collect()` dance in `try_compile_function` (ranges are `Copy` now)

Output is byte-identical: the fixture snapshot corpus passes unchanged, including the suppression fixtures (eslint disable/unclosed-block/next-line and Flow).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-12 11:36:31 +00:00
Boshen a66f0fd99b refactor(react_compiler): remove UnsupportedNode and align TypeCastExpression with oxc AST (#24394)
Follow-up to #24393, removing preserved-AST baggage from the HIR:

## Remove `UnsupportedNode`

Its only producer was the inline TS `enum` statement — upstream's single *silent* `UnsupportedNode` case (every other upstream site records an error first, so the function fails compilation anyway; this port already expresses those without the wrapper). Instead of carrying an AST statement through the whole pipeline just to re-emit it at codegen, inline enums now take the same path as `using` declarations: lowering sets `skip_compilation`, the function is left uncompiled — silently, no diagnostic — and the rest of the file compiles as usual.

One intended snapshot change: `ts-enum-inline.tsx` is no longer memoized and round-trips unchanged. (Top-level enums are unaffected — only compiled function bodies are rebuilt.)

## Align `TypeCastExpression` with the oxc AST

The stringly `(type_annotation_kind: Option<&'static str>, type_annotation: Option<&TSType>)` pair becomes a `TypeCast<'a>` enum mirroring the oxc wrapper nodes it lowers from — `TSAsExpression`/`TSTypeAssertion` → `TypeCast::As`, `TSSatisfiesExpression` → `TypeCast::Satisfies` — removing the invalid `None`/`Some` combinations and codegen's dead fallback arm. This leaves the cast annotation as the only borrowed AST kept in the HIR (an arena clone made at lowering).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-12 09:53:42 +00:00
Boshen 7ae9257187 refactor(react_compiler): replace String with arena Ident/Str in HIR (#24393)
Converts `oxc_react_compiler` HIR strings from owned `String` to arena types from `oxc_str`, mirroring the `oxc_ast` conventions. Output is byte-identical over the 1806-fixture snapshot corpus, and the public `transform`/`lint` API is unchanged.

## Type mapping

- Identifier-ish names → `Ident<'a>` (Copy, precomputed hash): `IdentifierName`, `PropertyLiteral`, `ObjectPropertyKey`, `NonLocalBinding`/`VariableBinding` names, JSX attribute/tag names, `StoreGlobal`, `MetaProperty`, function ids/name-hints, `Type::shape_id`/`object_name`
- Text values → `Str<'a>`: directives, `JSXText`, template quasis, regex pattern/flags, import module specifiers
- `JsString` is now Copy with `Utf8(Str<'a>) | Wtf16(&'a [u16])` — string literals borrow AST arena text directly; constant-folding results allocate once into the arena
- Name-keyed maps → `IdentHashMap`/`IdentHashSet`/`IdentIndexMap` (str lookups keep working via `Equivalent` with the precomputed hash)
- `type_annotation_kind` → `Option<&'static str>` (`"as"`/`"satisfies"`)

## Where names come from

- AST names are copied out of node fields as `Ident<'a>` values (free); binding names resolved through `Scoping` are arena-copied once by `ScopeResolver::symbol_ident()` (the `Scoping` name storage lives in the `Semantic` borrow, not the arena)
- `Environment<'a>` now carries `allocator: &'a Allocator`; synthesized names (`#t{n}` temporaries, `name_{i}` collision suffixes, `_temp{i}` uids, `$` cache vars, outlined-component names) build with `format_ident!`
- `BUILT_IN_*` shape ids are `Ident<'static>` consts (`static_ident!`), so shape-id comparisons take the len+hash fast path; the static globals/shapes base tables are `IdentHashMap<'static, _>` built with zero startup `String` allocations (the handful of `<generated_N>` anon ids minted during the once-per-process static build are leaked as static data; per-compilation ids go in the arena)

## Structural payoff

Codegen previously kept the HIR at a separate lifetime `'h` and re-allocated every identifier into the output arena via `ox_str`. Input AST, HIR, and output AST share one allocator, so the lifetimes are unified: a name flows from source text through lowering, SSA versioning, and renaming into the output AST without a single copy. Per-operand `Constant` clones in constant propagation and per-version name clones in `enter_ssa` are now 16-byte copies.

To keep the public API borrow-free (`&Program<'a>`, `&Semantic<'_>`, unchanged from before), the HIR holds no references into the input `Program`: the two AST nodes it used to keep verbatim — `TypeCastExpression` type annotations and `UnsupportedNode` statements (inline TS `enum`) — are cloned into the arena at lowering with `clone_in_with_semantic_ids` (preserving the semantic id cells codegen's rename-by-reference-identity relies on). `FunctionNode` and the discovery/lowering paths carry the node borrow and the arena lifetime separately.

## Deliberately still `String`

- Config API (`options.rs`, `environment_config.rs`, `type_config.rs`) — created before the arena exists; converted at the `with_config` boundary
- `Cow<'static, str>` where values are static-or-config-owned: `FunctionSignature::canonical_name`/`known_incompatible`, `get_react_compiler_runtime_module`
- Suppression comment values are now `&'a str` source subslices; `decode_jsx_entities` returns `Cow` (borrows when no entity)
- Diagnostics/debug text (`format_place`, dependency printers, sort keys, `format_js_number`) and aliasing-config placeholder names (`"@rest"`, …)

## Verification

- `cargo test -p oxc_react_compiler`: 1806-fixture snapshot suite unchanged, 15 integration tests pass (tests/example untouched from main)
- `oxc_linter` / `oxc_transformer` (+tests) / benchmark build clean; transformer integration tests pass
- clippy (CI profile), typos, shear, fmt clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-12 08:28:02 +00:00
Boshen a09cef0362 refactor(react_compiler): use semantic IDs instead of spans to locate AST nodes (#24384)
The crate inherited Babel's `node.start`-as-node-ID convention: spans were used as lookup keys and node identity throughout. This converts every such mechanism to semantic IDs (`ReferenceId` / `SymbolId` / `ScopeId` / `NodeId`, plus node pointer identity), staged as four commits, each byte-identical over the fixture corpus.

**1. Type-annotation renames** — `Environment::renames` was `Vec<{original, renamed, declaration_start}>`, matched in codegen by name + nearest-preceding-declaration offset, filtered through `reference_node_ids` (a program-wide `FxHashSet<u32>` of reference offsets). Now `FxHashMap<ReferenceId, String>`, recorded per resolved reference at rename time and applied in `ox_reemit_ts_type` via the `reference_id` cells on a `clone_in_with_semantic_ids` clone. Deletes `reference_node_ids`, the export-default pseudo-reference block, and `ScopeResolver`'s position-set construction.

**2. Identifier-loc index + declaration identity** — `IdentifierLocIndex` was `FxHashMap<u32 /* span.start */, Entry>`; now `refs: FxHashMap<ReferenceId, Entry>` + `decl_spans: FxHashMap<SymbolId, Span>`, read off the semantic cells during the walk. `resolve_binding_identifier` compared `declaration_start(symbol) == ident.span.start`; now the declaration identifier is found as a node and compared by `ptr::eq` (`declaration_ident` replaces `declaration_start`). The hoisting analysis, `gather_captured_context`, and `find_context_identifiers` iterate `reference_ids()` instead of reference positions; identity-aliasing skips that ID iteration makes structurally impossible are dropped.

**3. Structural containment** — "is this reference inside this function" checks moved from byte-range windows to structure: `gather_captured_context` tests AST-subtree membership against `capture_root_node(function_scope)` (the function node, widened to the enclosing `ObjectProperty` for methods/accessors, mirroring Babel's `ObjectMethod` extent — this also removes the `func_start`/`func_end`/`method_span` plumbing through the lowering entry points); the `find_context_identifiers` supplement walks the reference node's containing functions; `validate_ts_this_parameters` uses scope ancestry.

**4. Function identity in the entrypoint** — `CompileSource::fn_node_id`/`already_compiled` and the whole splice machinery (`OxcReplaceFnVisitor`, gated replacement, outlined-decl insertion, original-fn cloning) matched functions by `span.start == fn_node_id`; now by the function's `ScopeId` cell. This requires `ox_splice_program` to clone the program with `clone_in_with_semantic_ids` (a plain `clone_in` resets the cells; codegen-built nodes carry no cells, so they can never be spuriously matched, whereas the old span matching could in principle re-match a span-preserving clone). `is_wrapper_callee` same-node span comparisons became pointer identity, `declarator_name_for` needs only the parent edge, and `hoisted_identifiers` is typed `FxHashSet<SymbolId>` instead of raw `u32`.

Spans remain only as **values**: diagnostic labels, output ordering (source-position sort of captures/hoists, matching Babel's position-ordered traversal), hoisting's statement-range attribution, suppression-comment overlap (comments have no IDs), and `fn_start`/`fn_end` metadata.

One behavioral note: positional-aliasing defenses that existed for Hermes-desugared component syntax (generated nodes reusing source offsets) are now mostly structural — the gather-side alias filter is kept, but the ID-based iteration no longer needs the declaration-site offset skips. The fixture corpus has no Hermes cases; the ecosystem differential is the place to watch.

Verified: all four stages individually byte-identical over the 1288-fixture snapshot corpus; clippy clean with `--all-features`.
2026-07-11 14:55:33 +00:00
Boshen b6d2a29e47 refactor(semantic, transformer, react_compiler): use if-let match guards (#24383)
Rust 1.95 stabilized if-let guards in match arms (MSRV bumped in #24359). This converts the three places which guard on `is_some()` and then re-extract the same value in the body — the guard now binds the value directly:

- `oxc_semantic` `binder.rs` — `export_decl.declaration.is_some()` guard followed by `.as_ref().unwrap()`.
- `oxc_transformer` `typescript/annotations.rs` — `decl.declaration.is_some()` guard followed by an `is_some_and` re-check.
- `oxc_react_compiler` `validate_preserved_manual_memoization.rs` — `state.manual_memo_state.is_some()` guard followed by `.as_mut().unwrap()`; the guard now binds `&mut` directly. The sibling `StoreLocal` arm used the same unwrap idiom in a body `if`, converted to a let chain for consistency.

I audited the other ~15 `is_some()` match guards in the workspace: they only test the value without extracting it in the body, so they stay as plain guards.

Verified: crate tests pass, semantic coverage suites and Babel transform conformance produce **zero snapshot diffs**, clippy clean for the changed files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-11 11:11:00 +00:00
Boshen cc2b3b6d21 refactor(react_compiler): remove redundant source_location, pass plain Span in lowering (#24377)
After #24248 replaced Babel-style `SourceLocation` with oxc `Span`, `HirBuilder::source_location` was just `Some(span)` — but its `Option` return type kept a layer of parameters and locals needlessly optional.

- Delete `source_location()` and collapse its 175 call sites.
- Remove dead `.or(span)` fallbacks (the left side was always `Some`), which made the `span` parameters of `lower_followup_target` and `lower_assignment_target_maybe_default` unused — both dropped.
- De-Option the span parameters that now only ever receive `Some(..)`: `lower_inner`, `lower_identifier`, `lower_identifier_for_assignment`, `lower_binding_assignment`, `lower_default_to_temp`, `lower_member_assignment_target`, `lower_assignment_target`, `lower_identifier_followup_store`, `lower_for_in_of_left`, plus `HirBuilder::resolve_identifier` and `set_identifier_declaration_span`. `Option<Span>` remains only at genuine boundaries where synthetic `None` spans exist (HIR struct fields, `build_temporary_place`/`make_temporary`, `resolve_binding_with_span`).
- `.with_labels(Some(x))` → `.with_label(x)` at diagnostic sites; catch-handler span simplifies from `handler_param_span.flatten().or(handler_span)` to `handler_param_span.unwrap_or(handler_span)`.
- `collect_catch_pattern_identifier_spans` loses its recursion-only `builder` parameter and collects `Vec<Span>` instead of `Vec<Option<Span>>`.

Pure type-level refactor: the 1806-fixture snapshot corpus passes unchanged (byte-identical output).
2026-07-11 10:32:18 +00:00
Boshen 4a62b32544 refactor(react_compiler): remove CompilerError, use OxcDiagnostic and Diagnostics directly (#24345)
Follow-up to #24322, which slimmed `CompilerError` to `Vec<OxcDiagnostic>` + control-flow flags. This deletes the type entirely.

### End state

- Pass functions return `Result<T, OxcDiagnostic>` — every thrown error was verifiably a single diagnostic, so the aggregate wrapper (and the lossy `From<CompilerError> for OxcDiagnostic` first-diagnostic bridge) just disappears. `env.record_error` returns `Err(OxcDiagnostic)` directly.
- Genuine aggregates use `oxc_diagnostics::Diagnostics`: `Environment::errors`, the collection-returning lint validators, suppression/gating errors, and `compile_fn`'s error channel.
- `diagnostics.rs` keeps `ErrorCategory` plus two free functions: `has_critical_errors` (the `panicThreshold: critical_errors` check, preserving the `PreserveManualMemo` displays-as-error/internally-warning exception that `Diagnostics::has_errors()` does not have) and `to_string_for_event` (single-diagnostic now).

### Where `is_thrown` went

The flag distinguished thrown pass errors (which surface `CompileUnexpectedThrow`, matching TS `tryCompileFunction`'s catch block) from accumulated/suppression errors (which must not). That bit is now encoded structurally: the pipeline body moves into `run_pipeline`, whose outer `Err(OxcDiagnostic)` is a thrown error while invariant/accumulated failures return as `Ok(Err(Diagnostics))`. `compile_fn` does the "Unexpected error" surfacing at that boundary (it takes the function span for the label); suppression errors are built as `Diagnostics` in `try_compile_function` and never pass the surfacing point. Diagnostic push ordering is unchanged (surfacing before `log_error`'s per-detail pushes).

A uniform `Result<T, Diagnostics>` everywhere would have erased thrown-ness through auto-`?` conversion (and `Result<T, Vec<_>>` trips the orphan rule), so the single-vs-collection split is deliberate.

### Output parity

Zero output change. The 1806-fixture snapshot corpus — 341 of which snapshot diagnostics across all 24 error categories, including the `Unexpected error` surfacing, the simulated-unknown `Pipeline error`, Suppression, Gating, Config, and `PreserveManualMemo` — passes unchanged, as does the oxlint `react/react-compiler` rule snapshot. `take_invariant_errors` keeps all invariants (not first-only) specifically to avoid drift.

Net −66 lines.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-10 08:47:34 +00:00
Boshen 0e8ecbe16d refactor(react_compiler): remove for_kv_map, question_mark, unneeded_wildcard_pattern clippy allows (#24342)
Remove three clippy lint allows from `oxc_react_compiler` and fix the six spots that triggered them:

- `unneeded_wildcard_pattern` (3): drop redundant `children: _` / `span: _` fields next to `..` in match patterns
- `for_kv_map` (1): iterate `temporaries.values()` instead of discarding the key from `iter()`
- `question_mark` (2): replace `match`-with-`return None` arms with `?` in `constant_propagation.rs` (same early-return semantics)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-10 07:10:31 +00:00
Boshen a3a39f98eb feat(react_compiler): implement enableEmitHookGuards codegen (#24329)
Implements the last `unimplemented`-flagged codegen path, porting TS `createHookGuard`/`createCallExpression` exactly:

- **Whole-body wrap**: with `enableEmitHookGuards` in client mode, the compiled function body becomes `try { $dispatcherGuard(0); … } finally { $dispatcherGuard(1); }`, applied before the memo-cache preface is unshifted so `const $ = _c(n)` stays outside the guard.
- **Per-hook-call wrap**: hook calls (`getHookKind != null`, for both `CallExpression` and `MethodCall`) are wrapped in `(function () { try { $dispatcherGuard(2); return useHook(…); } finally { $dispatcherGuard(3); } })()`. Non-hook calls are untouched — the previous stub errored on *every* call when guards were enabled.
- The guard import was already resolved by the existing `hook_guard_name` plumbing; codegen just references it.

Since this was the only producer of `CompilerError::unimplemented`, the flag and the empty-body fallback in `codegen_function` are removed — codegen errors now always propagate as diagnostics.

Output verified against the upstream fixture: `flag-enable-emit-hook-guards` previously fell back to an **empty function body**; it now matches `flag-enable-emit-hook-guards.expect.md` node-for-node (same 4 memo slots, same guard kinds, hooks-only wrapping including the nested `useContext` and the `ObjectWithHooks.useIdentity` method call), differing only in oxc_codegen paren style for IIFEs in expression position. The remaining 1735 fixture snapshots are unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-10 04:38:14 +00:00
Boshen 5a9721aa8f refactor(react_compiler): use oxc_diagnostics directly, delete react_compiler_diagnostics (#24322)
The crate-private diagnostics data model (`react_compiler_diagnostics`: `CompilerDiagnostic`, `CompilerDiagnosticDetail`, `CompilerErrorDetail`, `CompilerErrorOrDiagnostic`, `ErrorSeverity`) duplicated what `OxcDiagnostic` already models and was converted to it at the program boundary anyway. This deletes the module and has passes construct `OxcDiagnostic` eagerly.

### What survives, and where the category went

- `ErrorCategory` stays (it drives TS-parity semantics). The aggregate `CompilerError` recovers it from the deterministic `[ReactCompiler] <Category>: ` message prefix that `ErrorCategory::diagnostic` writes — no field on the diagnostic changes, so consumers see exactly the same `OxcDiagnostic`s as before. Category-driven control flow is preserved: `Invariant` throws from `record_error`, `Config` always trips the panic threshold, and `PreserveManualMemo` displays as an error while staying internally a warning for `panicThreshold: critical_errors`.
- `CompilerError` slims to `Vec<OxcDiagnostic>` plus the two control-flow flags (`is_thrown`, `unimplemented`); pass signatures use `Result<T, OxcDiagnostic>` with `From` conversions keeping `?` working.
- `js_string.rs` was never diagnostics code (WTF-16 string values) — moved to `react_compiler_utils`. `GENERATED_SOURCE`/`Span` moved to `react_compiler_hir`.

### Output parity

Zero output change. The two legacy carrier types rendered labels differently (plain underline vs `span.label(text)`), and thrown Todo/`enter_ssa` errors were flattened to span-only form; each converted site preserves its path's rendering. The 1736-fixture snapshot corpus and the oxlint `react/react-compiler` rule snapshot pass **unchanged** — no snapshot files are touched in this PR.

Net −784 lines. `cargo lint` needed a crate-local `result_large_err = "allow"` (the crate defines its own `[lints.clippy]` and doesn't inherit the workspace allow; same rationale — `OxcDiagnostic` is deliberately inlined).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-09 12:19:19 +00:00
Boshen 2cf659cfed refactor(react_compiler): remove the React-like-name prefilter, gate on function discovery (#24301)
## Why

`ReactLikeVisitor` (`prefilter.rs`) skipped whole files by scanning for React-like names before running the pipeline. It was a second, divergent implementation of function selection with real false negatives vs `babel-plugin-react-compiler` (which has no prefilter):

- It never descended into function bodies, so nested hook/component **declarations** were invisible — `function factory() { function useThing() { useState() } }` was never compiled.
- It checked names only, so `'use memo'` / `'use forget'` opt-ins on non-React-named functions were missed in infer mode (in `getReactFunctionType` the opt-in check precedes the mode dispatch, so these compile once reached).

Both classes now compile, matching Babel. `DiscoveryWalker` is the single source of truth for selection.

## How

`compile_program` decides emptiness before any setup:

1. **A sound pre-check from semantic data, no AST walk** (`may_have_functions_to_compile`): every discoverable function creates a function scope, so classifying the function node behind each function scope (own id for declarations, the directly-enclosing `const Foo = ...` declarator for expressions/arrows — the only name sources discovery uses outside forwardRef/memo) covers the named and directive-opt-in selection paths, nested functions included. The forwardRef/memo path needs an identifier named `memo`/`forwardRef`/`React` as (part of) a call's callee, so the reference shapes of those three names — unresolved globals and root bindings first (O(1)), non-root bindings via a symbol scan last — cover wrapped anonymous functions. Over-approximation only costs a walk that finds an empty queue; the check mirrors the walker's rules exactly (direct-declarator naming, cooked directive values, callee-name matching), so a compilable file is never skipped.
2. **Discovery runs first, early-returns on an empty queue**, before the scope-resolver and import setup that only compilation needs. The already-compiled dedup set moves from `ProgramContext` into `DiscoveryWalker` so discovery needs no context; the walker borrows function/declarator names and the `forwardRef`/`memo` callee stack as `&'ast str` instead of allocating a `String` per named function; the scope stack is only maintained in `'all'` mode, the only mode that reads it.

`has_memo_cache_function_import` (the already-compiled file check, still needed) moves to `entrypoint/imports.rs`; `is_react_like_name` is deleted with its last user.

## Behavior changes

- Files whose only compilable functions were invisible to the name prefilter (nested hook/component declarations, directive opt-ins on non-React names) now compile / lint.
- `validate_restricted_imports` now runs on files with no React-like names, matching Babel (observable only with `validateBlocklistedImports` configured).
- `all` / `annotation` modes: no change (the prefilter was already bypassed there).

## Perf

On the `react_compiler` benchmark: files that compile are unchanged; files with nothing to compile (binder.ts, react.development.js) pay the semantic-data pre-check instead of a whole-program walk and measure at main-level times locally.
2026-07-09 04:29:47 +00:00
Boshen 88179d2917 refactor(react_compiler): pass narrow data to entrypoint helpers and hoist restricted-import check (#24293)
Entrypoint helpers took broad context objects where they read one or two fields; narrowing them removes an allocation and simplifies the restricted-import bail-out.

- `find_directive_disabling_memoization` takes a `&str` iterator and `custom_opt_out_directives: Option<&[String]>` instead of `&[String]` + `&PluginOptions`, so the module-scope opt-out check reads `program.directives` directly instead of collecting a `Vec<String>`.
- `log_error`/`handle_error` take `panic_threshold` and the `Diagnostics` accumulator instead of `&mut ProgramContext`.
- `validate_restricted_imports` returns `Option<Diagnostics>` directly (converting details via `detail_to_diagnostic`, keeping the message format centralized) and now runs in `compile()` after the prefilters, before `compile_program`. The `CompilerError` round-trip through `handle_error` was unobservable on this path: both `CompileResult` variants collapse to `(None, diagnostics)` in `compile()`, and the simulated-unknown branch in `log_error` cannot trigger for `Todo`-category details.

Behavior is unchanged: the snapshot corpus (including `error.validate-blocklisted-imports.ts`) passes with no snapshot updates.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-08 10:49:06 +00:00
Boshen 7e5a94c5b5 refactor(react_compiler): fold compile_program tail into one return (#24263)
## What

Collapses the tail of `compile_program` — the `replacements.is_empty()` early return plus the splice-and-return — into a single `CompileResult::Success` whose `ast` is computed with `bool::then`:

```rust
CompileResult::Success {
    ast: (!replacements.is_empty())
        .then(|| ox_splice_program(&ast, program, &replacements, &mut context)),
    diagnostics: context.diagnostics,
}
```

The comment records the useful fact behind it: **lint mode always lands in the empty-`replacements` case** (it applies no compiled functions), so `ox_splice_program`'s whole-program clone never runs in lint — the whole-program AST is never reconstructed when linting.

## Not a behavior change

Codegen (`codegen_function`) still runs in lint mode. An earlier version of this PR skipped it for speed, but codegen also records bailout `Todo` diagnostics that `reportAllBailouts` surfaces, so skipping it dropped those (raised in review). Rather than gate the skip behind an option, this drops the skip entirely — codegen runs exactly as before, so no diagnostics change. What remains is the return-folding refactor above.
2026-07-07 15:11:54 +00:00
Boshen 26db2f152b refactor(react_compiler): prune dangling comments in place after compile (#24258)
## What

Simplify the post-compile comment cleanup in `oxc_react_compiler`.

The output program is built by cloning the source (`ox_splice_program` → `program.clone_in()`), and `CloneIn` for `Program` copies both `comments` and `source_text`. So the compiled program already carries every original comment and the correct `source_text` before the cleanup step runs.

The old `preserve_comments` re-derived that from scratch:

- allocated a second `ArenaVec` and re-filtered `source.comments` into it, then
- re-assigned an already-correct `source_text`,

which forced it to take `source: &Program` and `allocator` params it didn't need.

## Change

Filter the already-cloned `compiled.comments` in place with `retain`, dropping the extra allocation, the redundant `source_text` write, and both params. Renamed to `prune_inner_comments`, and corrected the doc comments — the old ones claimed the roundtrip *drops* comments, when it actually clones them and this step prunes only the ones anchored inside rewritten functions (whose spans no longer resolve).

## Verification

Output is unchanged: the fixture snapshot suite passes byte-identical (no `.snap` updates) and all 15 unit tests pass. `cargo clippy -p oxc_react_compiler --all-targets` is clean.
2026-07-07 09:15:35 +00:00
Boshen fde100b615 refactor(react_compiler): remove unnecessary clones (#24256)
## What

Removes unnecessary clones in `oxc_react_compiler`.

- **The program-level `env_config` clone** was a spurious borrow-checker workaround. `env_config = options.environment.clone()` was cloned "to satisfy the borrow checker", but it came from the `options` param — which is a *different* variable from `context` (context holds its own `options.clone()`). So `process_fn` can take `&options.environment` directly alongside `&mut context` with no conflict. The comment's rationale didn't hold.
- **19 genuinely-redundant clones** (value cloned, then the original dropped without further use) across `program`, `environment`, `build_hir`, `infer_mutation_aliasing_effects`, `propagate_scope_dependencies_hir`, `build_reactive_function`, `extract_scope_declarations_from_destructuring` — found with `clippy::redundant_clone`, plus a few `field: x.clone()` → `field: x` shorthands.

No behavior change: the 1806-fixture snapshot corpus and integration tests are byte-identical.

## Follow-up (not in this PR)

Two per-function clones remain that are *by design* rather than redundant — `Environment` deep-clones program-wide immutable data once per compiled function:
- `env.reference_node_ids = scope.all_reference_positions().clone()` — the whole file's `FxHashSet<u32>`, read-only, used only in codegen.
- `Environment::with_config(env_config.clone())` — the 53-field config (mostly bools), never mutated.

Fixing these means having `Environment` borrow (a lifetime — ~118 spellings) or share via `Rc` (no `Rc` in the crate today), so I left them out pending a direction.
2026-07-07 08:23:19 +00:00
Boshen 06db0c21ef refactor(react_compiler): remove no-op debug logging and HIR printers (#24252)
## What

`ProgramContext::log_debug` had an **empty body** — so every
`DebugLogEntry` built behind the ~57 `if context.debug_enabled { … }`
gates in the pipeline (and the 16 `log_reactive_debug!` calls) was
computed and immediately discarded. The `debug` cargo feature gated
~2500 lines of HIR/reactive printers (`print.rs`, `debug_print.rs`,
`print_reactive_function.rs`) that fed only those discarded entries.

`PluginOptions::debug` (public, default `false`) had no consumer other
than toggling that no-op.

## Changes

- Remove the no-op sink: `DebugLogEntry`, `log_debug`,
`ProgramContext::debug_enabled`, `PluginOptions::debug`, the ~57 gated
blocks, and the `log_reactive_debug!` macro.
- Delete the three printers and the `debug` cargo feature.
- Prune the HIR / reactive-IR metadata fields that only the printers
read — reactive terminal/value `span`s (keeping the ones codegen
actually reads, e.g. `ForOf`/`ForIn`), type-annotation metadata — plus
the helpers that became unreachable.

## Notes

- Behavior-preserving: the 1806-fixture snapshot corpus and integration
tests are **byte-identical**, and `oxc_transformer`/`oxc_linter` compile
unchanged.
- Net ~−3000 lines.

Follow-up to #24248.
2026-07-07 15:48:18 +08:00
Boshen 335506b68b refactor(react_compiler): de-monomorphize block completion and operand iterators (#24250)
## What

Two monomorphization cuts in `oxc_react_compiler` that shrink generated
code with **no behavior change**.

**1. Extract a non-generic `HirBuilder::complete_block`.**
`try_enter_reserved` is generic over its closure and was instantiated
28×; the heavy, non-generic tail (constructing a `BasicBlock` +
`FxIndexMap` insert) was inlined into every copy. Moving it into one
`#[inline(never)]` helper compiles it once, and dedups the identical
block copy-pasted in `terminate` and `terminate_with_continuation`.

**2. Erase the `for_each_*_mut` closures.** The six operand/lvalue
iterators in `react_compiler_hir::visitors` took `&mut impl FnMut(&mut
Place)`, so each walker body was monomorphized per distinct caller
closure. Switching to `&mut dyn FnMut(&mut Place)` compiles each body
once. Callers are unchanged (auto-coercion).

Both are "different-body" copies that fat-LTO cannot fold, so the
savings are real in the shipped binary.

## Size impact

Measured on a fat-LTO build (`opt-level=3`, `lto="fat"`,
`codegen-units=1`) of the `react_compiler` example, before vs after on
an identical base (diff-only):

| `.text` section | bytes |
| --- | ---: |
| before | 2,867,912 |
| after | 2,845,812 |
| **reduction** | **−22,100 (~21.6 KiB, ~1.6% of the crate's code)** |

(`cargo llvm-lines` reports −5,368 pre-opt IR lines; LTO inlining
multiplies the win across call sites, hence the larger real figure.)

## Correctness

`cargo test -p oxc_react_compiler` — the 1806-fixture snapshot corpus
and 15 transform integration tests pass **byte-identically**; `cargo
clippy` clean.

## Note on change 2

The `for_each_*_mut` iterators run on the per-instruction operand path,
so change 2 swaps an inlinable direct closure call for a `dyn FnMut`
indirect call. Correctness is verified but throughput is **not**
benchmarked. If CI benchmarks flag a regression it can be dropped
independently — change 1 is zero-risk (cold, once-per-block) and carries
the rest of the win.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-07 14:03:41 +08:00
Boshen d36ee67ade refactor(react_compiler): use oxc Span for source locations (#24248)
## What

The React Compiler HIR carried source positions as `SourceLocation` (`{ start, end }` of `Position { line, column, index }`). Line and column were redundant — only the byte offset (`index`) ever fed a diagnostic, and miette derives line/column from the byte span at render time.

This replaces `SourceLocation`/`Position` with `oxc_span::Span` (byte offsets) throughout the crate.

## Changes

- Drop the per-function `LineOffsets` table — an `O(source length)` scan built once **per compiled function** purely to compute line/column — and delete the `source_loc` module.
- `SourceLocation` → `oxc_span::Span` (used directly, no alias).
- Rename the pervasive `loc` field / locals / methods to `span`.
- Inline the now-identity `loc_to_span` diagnostic helper.

## Notes

- Behavior-preserving. The 1806-fixture snapshot corpus and integration tests pass unchanged, **except** the rare `[ReactCompiler] Unexpected error: …` message no longer embeds a redundant `(line:column)` suffix — the byte-offset label span is still attached, so miette renders the location.
- Kept as-is: the `IdentifierLocIndex` subsystem, the full-word `source_location` / `primary_location` methods, and the `openingLoc:` / `closingLoc:` debug-dump labels that mirror the upstream TS HIR printer.

Net ~−150 lines across ~70 files.
2026-07-07 05:31:34 +00:00
Boshen 769072d869 refactor(react_compiler): simplify compile_program signature and tidy imports (#24243)
Pure refactor of the react compiler entrypoint; no behavior change
(verified against the fixture snapshot corpus and the integration
tests).

- `compile_program` now takes `allocator`/`semantic` and builds the
`AstBuilder` and `ScopeResolver` itself, instead of receiving pre-built
ones from `compile`. Its `oxc_program` parameter is renamed to
`program`.
- Fully-qualified paths and function-local `use` statements are hoisted
to top-level imports. `oxc_allocator::Box`/`Vec` become the
non-colliding `ArenaBox`/`ArenaVec` aliases; the `oxc::`-alias AST types
are used consistently rather than imported bare.
- Dead code and stale `#[allow(dead_code)]` attributes are removed — the
unused `is_react_api` fn, the never-constructed
`CompileSourceKind::Outlined` variant, and the write-only
`CompileSource::fn_name` field.
- `has_memo_cache_function_import` moves into `prefilter`, next to
`has_react_like_functions` (both are "should we skip this file?"
checks).
- `lint` moves directly below `transform` so the two public entry points
sit together.
2026-07-07 09:53:03 +08:00
Boshen b61159ebe8 refactor(react_compiler): model PluginOptions string options as enums and consolidate into options.rs (#24234)
## 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)
2026-07-07 00:49:41 +00:00
Boshen 3ae8317299 refactor(react_compiler): skip only functions that use using instead of the whole file (#24217)
## Summary

`using`/`await using` disposal semantics aren't preserved by the React Compiler yet, so any file containing them was skipped entirely by a whole-program `ResourceManagementVisitor` pre-pass — a `using` *anywhere* in the file dropped *every* component in it.

This detects `using` during lowering instead. `lower_variable_declaration` already visits every variable declaration (including those in nested closures), so it sets a `skip_compilation` flag on the `Environment`; `compile_fn` checks it after lowering and returns `Ok(None)` — a silent, per-function skip with no diagnostic — so sibling functions still compile.

## Details

- Removes `ResourceManagementVisitor` and the whole-file gate in `compile()`; reuses the existing lowering traversal rather than adding a new visitor.
- The skip is silent (no diagnostic), unlike a `record_error` bail.
- Top-level `using` is untouched: the compiler only rewrites function bodies, so a module-scope `using` is preserved verbatim while the file's components still compile.
- Adds a fixture where a `using`-containing component is emitted unchanged and a structurally identical sibling component is memoized.

Before: any `using` → whole file skipped. After: only the enclosing function is skipped.

## Performance

CodSpeed reports a large regression on `react_compiler[kitchen-sink.tsx]` (19ms → 429ms) and `linter[kitchen-sink.tsx]` (166ms → 542ms). This reflects correct behavior, not a code inefficiency:

- `kitchen-sink.tsx` is a ~21k-line file with ~90 React components, and it contains `using`/`await using` in two small **non-React** helper functions (`exerciseUsing`/`exerciseAwaitUsing`).
- On `main` the whole-program pre-pass saw that `using` and skipped the **entire file**, so the benchmark was measuring parse + semantic + skip — it never actually compiled anything.
- With this PR the 90 components compile normally (the 429ms is their real cost). The two `using` helpers aren't even compile candidates in the default `infer` mode, so the per-function skip never fires for them here.

Files that do **not** contain `using` are unaffected — identical performance. The regression is confined to files that mix `using` with React components (a rare combination), which now correctly compile instead of being discarded wholesale.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-06 11:27:55 +00:00
Boshen bf1a151aae perf(react_compiler): compile out debug printers (#24184)
Compile React Compiler HIR/reactive debug printers only with the `debug` feature. This keeps default oxlint release builds from linking debug-only formatting code and reduces the stripped release binary by 49,568 bytes on aarch64-apple-darwin.

Validated with:
- `cargo check -p oxc_react_compiler`
- `cargo check -p oxc_react_compiler --features debug`
- `cargo build -p oxlint --release --features allocator`
2026-07-06 07:09:18 +00:00
Boshen e744e79d77 refactor(react_compiler): remove redundant locals and parameters (#24176)
AI-assisted.

Remove redundant local aliases and late-initialization patterns in `oxc_react_compiler`.

Validated with `just fmt`, `cargo check -p oxc_react_compiler`, `cargo test -p oxc_react_compiler --test transform`, `cargo test -p oxc_react_compiler --test snapshot`, and `cargo clippy -p oxc_react_compiler -- -W clippy::redundant_locals -W clippy::let_and_return -W clippy::needless_late_init`.
2026-07-05 12:50:33 +00:00
Boshen 409fc03737 refactor(react_compiler): remove redundant source text storage (#24175)
Removes the duplicated source text stored on `ProgramContext` and `Environment`.

The pipeline now passes `program.source_text` directly when building line offsets for lowering.

Validated with `just fmt`, `cargo test -p oxc_react_compiler --test transform`, `cargo test -p oxc_react_compiler --test snapshot`, and `cargo check -p oxc_react_compiler`.
2026-07-05 12:03:36 +00:00
Boshen e4b708b983 perf(react_compiler): skip compiled files before prefilters (#24171)
Moves the already-compiled runtime import check to the start of React Compiler compile.

Adds one fixture snapshot for a type-only runtime cache import, which must still compile and inject the value runtime import.

Validated with `just fmt`, `cargo test -p oxc_react_compiler --test snapshot`, `cargo test -p oxc_react_compiler --test transform`, and `cargo check -p oxc_react_compiler`.
2026-07-05 11:21:38 +00:00
Boshen 81bac2eb88 refactor(react-compiler): borrow strings where possible (#24170)
AI-assisted.

Borrow string slices in react compiler helper APIs and avoid temporary String allocation when checking `PropertyLiteral` values.

Validation:
- `cargo test -p oxc_react_compiler`
- `cargo clippy -p oxc_react_compiler --all-targets -- -W clippy::ptr_arg -W clippy::unnecessary_to_owned -W clippy::cmp_owned`
- `just ready` reached the full test suite; it failed on unrelated oxlint ANSI snapshot output differences in `output_formatter::test::test_output_formatter_diagnostic_formats*`.
2026-07-05 03:37:13 +00:00
Boshen c84c909402 refactor(react_compiler): remove dead private api (#24169)
Remove dead private React Compiler API after making vendored modules
private.

AI-assisted.
2026-07-05 11:01:29 +08:00
Boshen d966d0bfd3 fix(react_compiler): remove clippy allows (#24168)
Removes the blanket `clippy::all` module allowances from `oxc_react_compiler` and fixes the resulting diagnostics with mechanical cleanups, targeted item-level allowances, and normal dependency access to `cow-utils`.

Validated with `just fmt`, `cargo clippy -p oxc_react_compiler --lib --all-targets -- -D warnings`, and `cargo test -p oxc_react_compiler`.

AI-assisted.
2026-07-05 02:11:52 +00:00
Boshen 854ef8dea8 fix(react_compiler): compile generic functions instead of over-bailing on type-param hoisting (#24158)
## Problem

oxc over-bails on **generic functions** (`Component<T>`,
`useThing<T>()`) that `babel-plugin-react-compiler` compiles. It emits
`[ReactCompiler] Todo: Unsupported declaration type for hoisting` with
help `variable "T" declared with Unknown`, then passes the whole
function through unmemoized. This was the largest oxc capability gap vs
upstream Babel across the ecosystem corpus.

Example — `actual/.../autocomplete/Autocomplete.tsx`: oxc `_c` was `[5,
6, 11]`, Babel `[5, 6, 11, 26, 125]`. oxc missed the two generic
components `SingleAutocomplete<T extends AutocompleteItem>` and
`MultiAutocomplete<T extends AutocompleteItem>`. After this change oxc
emits `[5, 6, 11, 26, 125]` — identical to Babel.

## Mechanism

The referenced-before-declared hoisting scan in `build_hir.rs`
(`lower_block_statement_inner`) collects "hoistable" bindings of a
block, then hoists any that are referenced inside a nested function
before their declaration. A hoisted binding whose declaration kind is
neither `FunctionDeclaration` nor `VariableDeclarator` records the
`Unsupported declaration type for hoisting` error.

The scan mirrors Babel's `BuildHIR` `case 'BlockStatement'`. The key
upstream line is:

```js
for (const [, binding] of Object.entries(stmt.scope.bindings)) { ... }   // hoistable set
const binding = id.scope.getBinding(id.node.name);                       // per-reference lookup
if (binding != null && hoistableIdentifiers.has(binding.identifier) && (fnDepth > 0 || ...))
```

**Babel never registers a type parameter (or interface, or pure type
alias) in `scope.bindings`.** Verified directly against the fork's
Babel:

```
function C<T>(props){ const f = () => props.v as T; ... }
=> scope.bindings = ["props", "f"]        // no T
   getBinding("T") === undefined
```

So for Babel a type name has no binding, the hoist check short-circuits,
and generic functions compile.

oxc's semantic model *does* give type parameters/interfaces a symbol.
With `binding_kind`/`decl_kind` both `Unknown`, they passed the
hoistable filter, and every pure-type-position reference to `T` inside a
nested function (`(y: T)`, `x as T`, `useMemo<T>()`, `new Set<T>()`,
`<Table<T>/>`, `const s: Array<T>`, `(): T =>`) looked like a
referenced-before-declared use — bailing the whole function.

## Fix (narrow, binding-side)

Exclude **type-only symbols** from the hoistable set, matching Babel's
`scope.bindings` exactly:

```rust
// scope.rs
pub fn is_type_only_binding(&self, symbol_id: SymbolId) -> bool {
    let flags = self.scoping().symbol_flags(symbol_id);
    flags.is_type() && !flags.is_value()
}
```

`is_type() && !is_value()` selects exactly type parameters, interfaces,
pure type aliases, and type-only imports. Classes, enums, value modules,
and `interface`/`class` merged declarations keep `is_value()` and remain
hoistable — matching Babel, which *does* register those.

This is deliberately a **binding-side** filter, not a reference-side
one. It never touches how value bindings' references are counted, so it
cannot change any value binding's hoist decision — which is what makes
it safe. (Prior reference-side attempts that skipped type-position
references or erased type params regressed because they also affected
value bindings referenced in type positions.)

## Validation

**Fixture snapshot corpus** (`tests/snapshots/`, 1734 fixtures): green.
Exactly **one** snapshot changed — `error.todo-round2_identifier_diff` —
and the change *removes* the spurious `variable "OuterProps" declared
with Unknown` diagnostic (`OuterProps` is the type parameter of
`withRemountOnChange<OuterProps extends {}>`). The fixture still bails
on its genuine `Handle ClassExpression expressions` Todo, so the bail
decision and `No changes.` output are preserved; only the bogus
type-param diagnostic — which Babel never emits — is gone.

**Ecosystem** (697 generic-declaration candidate files across 44 repos,
oxc vs fork Babel):

- **122** files hit the type-param over-bail before this change; **all
122** no longer do.
- **467** candidates now produce an `_c` multiset **byte-identical to
Babel**.
- Files where oxc memoizes but Babel bails were checked against
`git`-old oxc. The handful of newly-diverging files bail in Babel for
reasons **unrelated to type parameters** (`Handle tagged template with
interpolations`, `Expected all references to a variable to be
consistently local or context references`). A minimal non-generic probe
confirms oxc already compiled tagged-template interpolations that Babel
bails on *before* this change (old `_c(4)` == new `_c(4)`, Babel
`CompileError`) — so these are pre-existing oxc/Babel capability
differences merely unmasked by removing a coincidental double-bail, not
new over-compiles from this change. In three of them oxc gained a
function whose `_c` value exactly matches a Babel slot (moving oxc
closer to Babel).

`cargo fmt` and `cargo clippy -p oxc_react_compiler --all-targets -- -D
warnings` are clean.
2026-07-05 09:25:28 +08:00
Boshen 093586c9e5 fix(react_compiler): align memoization cache-slot allocation with Babel (#24157)
## Root cause

On ~57 ecosystem files, oxc allocated **one fewer `_c()` cache slot**
than `babel-plugin-react-compiler` in a function (e.g. oxc `_c(25)`
where Babel emits `_c(26)`; oxc `[26,40]` where Babel `[27,40]`),
shifting every subsequent `$[i]` index by −1.

The construct responsible is the **regex literal**. In
`react_compiler_lowering/build_hir.rs`, `lower_expression` had no arm
for `oxc::Expression::RegExpLiteral`, so every regex literal fell
through the catch-all that "bails to undefined":

```rust
_ => {
    // not-yet-ported arms bail to undefined
    Ok(InstructionValue::Primitive { value: PrimitiveValue::Undefined, loc })
}
```

Two consequences:

1. **Correctness**: the regex was dropped from the output — e.g.
`useId().replace(/:/g, "")` compiled to `t1.replace(undefined, "")`, and
`pattern.test(value)` compiled to `undefined.test(value)`.
2. **Slot count**: an `undefined` primitive does not allocate, so
`may_allocate` never gave it a reactive scope. Babel treats a regex
literal as an allocation and memoizes it (a nested
`Symbol.for("react.memo_cache_sentinel")` scope when it has no deps),
reserving one extra slot. That missing slot was the −1.

All the downstream machinery for regex was **already implemented** — the
HIR variant `InstructionValue::RegExpLiteral`, its codegen
(`codegen_reactive_function.rs`), `may_allocate` (returns `true`),
aliasing/type inference, and every visitor. Only the lowering arm was
missing.

## The fix

Add the single missing arm in `lower_expression`, mirroring TS
`BuildHIR`'s `case 'RegExpLiteral'`:

```rust
oxc::Expression::RegExpLiteral(regexp) => Ok(InstructionValue::RegExpLiteral {
    pattern: regexp.regex.pattern.text.as_str().to_string(),
    flags: regexp.regex.flags.to_string(),
    loc: builder.source_location(regexp.span),
}),
```

The regex now lowers to a real value and receives its own reactive
scope, matching Babel's sentinel-memoized nested scope. Minimal repro
output now equals Babel byte-for-byte:

```js
// before: t1 = t0.replace(undefined, "");   _c(4)
// after (matches Babel):                     _c(5)
if ($[0] !== t0) {
  let t2;
  if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
    t2 = /:/g;
    $[2] = t2;
  } else { t2 = $[2]; }
  t1 = t0.replace(t2, "");
  ...
}
```

## Before/after `_c(N)` on ecosystem files

| file | before | after / Babel |
| --- | --- | --- |
| `create-better-t-stack/.../charts/dash-tail-stroke.tsx` | `_c(25)` |
`_c(26)` |
| `create-better-t-stack/.../charts/area-chart.tsx` | `_c(26)`,`_c(40)`
| `_c(27)`,`_c(40)` |
| `.../day-picker.tsx` | `_c(77)` | `_c(78)` |
| `.../DynamicLink.tsx` | `_c(15)` | `_c(16)` |
| `.../Image.jsx` | `_c(23)` | `_c(24)` |

Across the 420 regex-containing ecosystem source files: **48 went from
mismatch → matching Babel's slot multiset, with 0 regressions** (files
that matched Babel before still match). The handful still differing do
so for unrelated pre-existing reasons (whole extra/missing scopes), not
the regex slot.

## Snapshot note

Exactly one fixture snapshot changes: `regexp-literal.snap` — the
dedicated regex fixture, whose old golden captured the
`undefined.test(value)` bug. The new snapshot is **byte-identical to the
upstream Babel `regexp-literal.expect.md` golden** (`const pattern =
/foo/g; … pattern.test(value)`). `CI=1 cargo test -p oxc_react_compiler`
is green; `cargo fmt` / `cargo clippy --all-targets -D warnings` clean.
2026-07-05 09:25:00 +08:00