diff --git a/compiler/packages/babel-plugin-react-compiler/src/Optimization/OutlineFunctions.ts b/compiler/packages/babel-plugin-react-compiler/src/Optimization/OutlineFunctions.ts index 6ab0a811ff5d..ea953fab5ee7 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Optimization/OutlineFunctions.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Optimization/OutlineFunctions.ts @@ -27,7 +27,8 @@ export function outlineFunctions( value.loweredFunc.func.context.length === 0 && // TODO: handle outlining named functions value.loweredFunc.func.id === null && - !fbtOperands.has(lvalue.identifier.id) + !fbtOperands.has(lvalue.identifier.id) && + !referencesUnboundModuleLocal(value.loweredFunc.func) ) { const loweredFunc = value.loweredFunc.func; @@ -49,3 +50,40 @@ export function outlineFunctions( } } } + +/** + * Returns true if `fn` contains a `LoadGlobal(ModuleLocal)` whose name does + * not actually resolve at module/program scope. + * + * This guards against an upstream misclassification: when the outermost + * function being compiled is itself nested inside another function (e.g. a + * factory or HOC, as in `infer` compilation mode), `HIRBuilder` resolves + * identifiers relative to `parentFunction.scope.parent`, which is the + * enclosing function's scope rather than the program scope. As a result, + * references to the enclosing function's locals are tagged as `ModuleLocal` + * and surface here as `LoadGlobal` instructions with `context.length === 0`, + * making the function look outlineable. Hoisting it to module scope would + * leave the referenced name undefined at runtime. + * + * A proper fix lives in `HIRBuilder`/`BuildHIR` — classifying the binding + * correctly and threading factory-scope variables through the capture + * machinery so they appear in `fn.context`. That is a substantially larger + * change that affects how nested functions are lowered in general; this pass + * keeps the function inline as a targeted, conservative workaround. + */ +function referencesUnboundModuleLocal(fn: HIRFunction): boolean { + const programScope = fn.env.parentFunction.scope.getProgramParent(); + for (const [, block] of fn.body.blocks) { + for (const instr of block.instructions) { + const {value} = instr; + if ( + value.kind === 'LoadGlobal' && + value.binding.kind === 'ModuleLocal' && + programScope.getBinding(value.binding.name) == null + ) { + return true; + } + } + } + return false; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/factory-function-component-captures-outer-scope.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/factory-function-component-captures-outer-scope.expect.md new file mode 100644 index 000000000000..571a1d2f7b4d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/factory-function-component-captures-outer-scope.expect.md @@ -0,0 +1,85 @@ + +## Input + +```javascript +// @compilationMode:"infer" +/** + * Regression test for https://github.com/facebook/react/issues/34901 + * + * When `infer` mode compiles a component defined inside an enclosing + * function (e.g. a factory or HOC), inner callbacks that capture + * variables from the enclosing scope must NOT be hoisted to module + * scope by `outlineFunctions`. The enclosing scope's locals are + * mis-tagged as `ModuleLocal` by `HIRBuilder` (it uses + * `parentFunction.scope.parent` as a proxy for the module scope), + * so `getCount`'s context appeared empty and outlining would emit a + * top-level `_temp` function referencing an undefined `counter` at + * runtime. + */ +function makeCounter(initialValue) { + const counter = {value: initialValue}; + + const Counter = () => { + const getCount = () => counter.value; + return