Files
oxc-project__oxc/crates/oxc_react_compiler/fixtures/outlined-function-in-nested-block.js
Boshen 78bf14f4f8 fix(react_compiler): insert outlined functions as siblings at any nesting depth (#25548)
Closes #25536

## Problem

With `enableFunctionOutlining` (default on), the compiler outlines function expressions whose lowered **context** is empty. Context only counts variables captured from scopes inside the function being compiled — a free variable of the whole compiled function (e.g. a binding of an *enclosing* function) is modeled as a plain global load/store in HIR, so a callback referencing one still gets outlined. Correctness then depends entirely on where the outlined declaration is inserted: upstream uses Babel `originalFn.insertAfter(...)`, which splices into the statement list containing the original at **any nesting depth**, preserving the invariant that every name visible to the original is visible at its sibling position.

`ox_insert_outlined_after` searched only `program.body`. For a component declared inside another function, the search missed and the fallback appended the outlined declaration at module scope:

```js
export function makeProbe() {
  let mounts = 0;
  function MountCounter() {
    useEffect(() => { mounts += 1; }, []);
    return null;
  }
  return { MountCounter, read: () => mounts };
}
```

compiled to

```js
export function makeProbe() { /* ... useEffect(_temp, t0) ... */ }
function _temp() {
  mounts = mounts + 1; // ReferenceError: mounts is not defined
}
```

with no diagnostic — a silent miscompilation that only fails when the effect fires.

A second instance of the same shape: gating ran before outlined insertion. The gating rewrite replaces the declaration statement with a `const`, destroying the scope-id anchor, so gated components' outlined functions always fell to the end-of-program fallback (upstream inserts them during the compile-queue loop, before any replacement, so they land right after the gated `const`).

## Fix

- `ox_insert_outlined_after` locates the declaring statement through a new `InsertOutlinedAfter` mode on the shared `OxcVisitor`, scanning each statement list before descending into it. Top-level originals (the common case) cost the same as before — the target is found in the first list scanned with no descent — and the walk prunes itself once insertion happens. Matching by `scope_id` cell stays sound because `ox_replace_function` mutates originals in place and discovery skips compiled bodies, so the anchor statement is always original AST. The top-level append survives only as a fallback for sloppy-mode annex-B positions (`if (c) function F() {}`).
- Outlined insertion now runs before `ox_apply_gated_conditional`, restoring upstream's sequencing.

Arrow/function-expression originals still append at module scope: upstream does the same (`program.pushContainer('body', ...)`) — including for nested originals, where upstream's own output breaks the same way — and the port reproduces upstream behavior rather than diverging (now documented in the placement comment).

Placement in all three added fixtures (nested declaration = the issue repro, nested block, gating + outlining) is byte-identical to `babel-plugin-react-compiler` built from react @ 22e4f993c7 with matching options; the existing 1310 fixture snapshots are unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-12 14:31:36 +00:00

20 lines
537 B
JavaScript

// @compilationMode:"infer"
import { useEffect } from 'react';
// Like outlined-function-in-nested-declaration, but the compiled component sits
// in a block statement: the outlined callback is inserted into that block,
// mirroring Babel `insertAfter` on the enclosing statement list.
export function makeProbe(flag) {
let mounts = 0;
if (flag) {
function MountCounter() {
useEffect(() => {
mounts += 1;
}, []);
return null;
}
return { MountCounter, read: () => mounts };
}
return null;
}