mirror of
https://github.com/oxc-project/oxc.git
synced 2026-09-14 19:36:11 +08:00
78bf14f4f8
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)
18 lines
548 B
JavaScript
18 lines
548 B
JavaScript
// @compilationMode:"infer"
|
|
import { useEffect } from 'react';
|
|
|
|
// The outlined effect callback references `mounts`, a binding of the function
|
|
// enclosing the compiled component. It must be inserted as a sibling of the
|
|
// component (where `mounts` is still in scope), not hoisted to module scope.
|
|
// https://github.com/oxc-project/oxc/issues/25536
|
|
export function makeProbe() {
|
|
let mounts = 0;
|
|
function MountCounter() {
|
|
useEffect(() => {
|
|
mounts += 1;
|
|
}, []);
|
|
return null;
|
|
}
|
|
return { MountCounter, read: () => mounts };
|
|
}
|