Skip to content

fix(react_compiler): compile generic functions instead of over-bailing on type-param hoisting#24158

Merged
Boshen merged 1 commit into
mainfrom
fix/rc-generic-fn-hoisting
Jul 5, 2026
Merged

fix(react_compiler): compile generic functions instead of over-bailing on type-param hoisting#24158
Boshen merged 1 commit into
mainfrom
fix/rc-generic-fn-hoisting

Conversation

@Boshen

@Boshen Boshen commented Jul 4, 2026

Copy link
Copy Markdown
Member

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:

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:

// 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.

@codspeed-hq

codspeed-hq Bot commented Jul 4, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 10 untouched benchmarks
⏩ 61 skipped benchmarks1


Comparing fix/rc-generic-fn-hoisting (58ff487) with main (3205cac)

Open in CodSpeed

Footnotes

  1. 61 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@Boshen
Boshen marked this pull request as ready for review July 5, 2026 01:25
@Boshen
Boshen merged commit 854ef8d into main Jul 5, 2026
30 checks passed
@Boshen
Boshen deleted the fix/rc-generic-fn-hoisting branch July 5, 2026 01:25
Boshen added a commit that referenced this pull request Jul 6, 2026
### 🚀 Features

- 260425f semantic/examples: Include unresolved references (#24214)
(camc314)
- 2d9b0b3 minifier: Fold boolean-literal ternary branches in value
contexts (#24110) (Dunqing)
- 61fbf10 ast: Implement `ReplaceWith` on all AST types (#24013)
(overlookmotel)
- 7db7a29 allocator: Add `ReplaceWith` trait (#24012) (overlookmotel)
- 4eb074e mangler: Add `reserved` option for names that must not be
mangled (#24041) (Dunqing)
- 2e62012 data_structures: Add `StringExt` trait (#24006)
(overlookmotel)
- 60e7160 minifier: Drop side-effect-free IIFEs whose result is unused
(#23967) (Dunqing)
- 26dd9e2 ast: Add method to widen inherited enum ref to parent ref
(#23961) (overlookmotel)

### 🐛 Bug Fixes

- e8b50ee transformer: Clean up semantics for stripped TypeScript syntax
(#24180) (camc314)
- d966d0b react_compiler: Remove clippy allows (#24168) (Boshen)
- 854ef8d react_compiler: Compile generic functions instead of
over-bailing on type-param hoisting (#24158) (Boshen)
- 093586c react_compiler: Align memoization cache-slot allocation with
Babel (#24157) (Boshen)
- 09c8f59 react_compiler: Normalize snapshot fixture paths (#24142)
(camc314)
- f13df97 react_compiler: Drop stray empty statement from catch bindings
(#24133) (Boshen)
- cb2a505 react_compiler: Codegen destructuring reassignment targets
(#24131) (Boshen)
- b82c394 react_compiler: Propagate codegen invariants instead of
emitting empty bodies (#24128) (Boshen)
- 5771982 react_compiler: Render unchanged programs as source in fixture
snapshots (#24129) (Boshen)
- 4b16e1a transformer/async-to-generator: Preserve direct eval scope
flags (#24136) (camc314)
- 4e9194f react_compiler: Lower `delete obj.prop` to
Property/ComputedDelete (#24123) (Boshen)
- 0b25582 ast: Type binding node `typeAnnotation` as `TSTypeAnnotation |
null` (#23113) (Boshen)
- 018c0e5 transformer: Hoist lowered async declarations (#22770)
(camc314)
- 652fbaf mangler: Keep names of destructured exported bindings (#24036)
(Dunqing)
- e274415 minifier: Don't drop global calls that throw despite pure
arguments (#23917) (Dunqing)
- 59abb30 minifier: Only merge string literals in `try_fold_add` when
the inner operator is `+` (#23622) (Jerry Zhao)

### ⚡ Performance

- c5ca77b transformer: Avoid cloning refresh options (#24191) (camc314)
- bf1a151 react_compiler: Compile out debug printers (#24184) (Boshen)
- abb44a0 transformer: Build fixed object-rest arguments (#24190)
(camc314)
- a4db731 isolated_declarations: Use `ReplaceWith` instead of `TakeIn`
(#24016) (overlookmotel)
- ff10855 transformer: Use `ReplaceWith` instead of `TakeIn` (#24015)
(overlookmotel)
- bd49aff ecmascript: Avoid heap-allocating Math.min/max/imul operands
(#23941) (Lawrence Lin)
- e4b708b react_compiler: Skip compiled files before prefilters (#24171)
(Boshen)
- c59f2fe rust: Return impl ExactSizeIterator from slice-backed
accessors (#24144) (Boshen)
- 5d6d04a codegen: SWAR-skip boring byte runs in sourcemap line/column
scan (#24023) (Boshen)
- a55e0be traverse: Reduce string operations in `get_var_name_from_node`
(#24007) (overlookmotel)
- e6d48e1 transformer/nullish_coalescing: Move cold path into separate
function (#23989) (overlookmotel)
- c4e35b5 transformer/object_rest_spread: Pre-allocate capacity in `Vec`
(#23988) (overlookmotel)
- 527b8e5 transformer/decorators: Narrow type earlier (#23987)
(overlookmotel)

### 📚 Documentation

- 30d17f5 allocator: Clarify docs for `TakeIn::take_in_box` (#24093)
(overlookmotel)
- 675e6a8 ast: Correct doc comment for `PrivateFieldExpression` (#24008)
(overlookmotel)
- e4c30e6 minifier: Explain what `dce` mode means (#23994) (Dunqing)
- 37cbf88 ast_macros: Document fields of `StructDetails` (#23959)
(overlookmotel)
- 4de3e54 ast: Correct doc comment (#23948) (overlookmotel)

Co-authored-by: Boshen <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant