Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit c871df1

Browse files
potetotejasupmanyu
andcommitted
[compiler] Don't outline functions that reference enclosing-function locals
When the compiled function is itself nested inside a non-compiled function (e.g. a factory, with @compilationMode:"infer"), HIRBuilder resolves free variables against parentFunction.scope.parent, which is the enclosing function's scope rather than the program scope, so the enclosing function's locals are classified as ModuleLocal and lowered as LoadGlobal instead of being captured in the function's context. Such functions looked outlineable (empty context), and outlining hoisted them to module scope where the referenced name is not in scope, throwing a ReferenceError at runtime (#34901). Fix in the outlining candidate check, in both compilers: reject a candidate if it (or a nested function) references a name that does not resolve to a module-scope binding. The TS pass re-resolves LoadGlobal(ModuleLocal)/StoreGlobal names against the scope HIRBuilder resolved against and requires the binding to be at program scope; the Rust lowering records the misclassified names in env.non_module_scope_names for outline_functions to consult, encoding the same decision. Adopts the approach of community PR #36539 with a stricter predicate: binding-identity instead of name lookup (catching shadowed module names), recursion into nested functions, StoreGlobal coverage, and the Rust port. Co-authored-by: tejasupmanyu <[email protected]>
1 parent ced00a8 commit c871df1

9 files changed

Lines changed: 238 additions & 10 deletions

File tree

compiler/crates/react_compiler_hir/src/environment.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ pub struct Environment {
8787
// Uses u32 to avoid depending on react_compiler_ast types.
8888
hoisted_identifiers: HashSet<u32>,
8989

90+
// Names that HIRBuilder classified as ModuleLocal but that resolve to a
91+
// binding *between* module scope and the compiled function (e.g. locals of
92+
// an enclosing factory function, when the compiled function is nested).
93+
// Mirrors what the TS compiler recovers from Babel scope data in
94+
// OutlineFunctions: such names are not in scope at module level, so
95+
// functions referencing them must not be outlined.
96+
pub non_module_scope_names: HashSet<String>,
97+
9098
// Config flags for validation passes (kept for backwards compat with existing pipeline code)
9199
pub validate_preserve_existing_memoization_guarantees: bool,
92100
pub validate_no_set_state_in_render: bool,
@@ -192,6 +200,7 @@ impl Environment {
192200
renames: Vec::new(),
193201
reference_node_ids: HashSet::new(),
194202
hoisted_identifiers: HashSet::new(),
203+
non_module_scope_names: HashSet::new(),
195204
validate_preserve_existing_memoization_guarantees: config
196205
.validate_preserve_existing_memoization_guarantees,
197206
validate_no_set_state_in_render: config.validate_no_set_state_in_render,
@@ -239,6 +248,7 @@ impl Environment {
239248
renames: Vec::new(),
240249
reference_node_ids: HashSet::new(),
241250
hoisted_identifiers: HashSet::new(),
251+
non_module_scope_names: self.non_module_scope_names.clone(),
242252
validate_preserve_existing_memoization_guarantees: self
243253
.validate_preserve_existing_memoization_guarantees,
244254
validate_no_set_state_in_render: self.validate_no_set_state_in_render,

compiler/crates/react_compiler_lowering/src/hir_builder.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,12 @@ impl<'a> HirBuilder<'a> {
10261026
},
10271027
})
10281028
} else if !self.is_scope_within_compiled_function(binding.scope) {
1029+
// The binding lives between module scope and the compiled
1030+
// function (e.g. a local of an enclosing factory function).
1031+
// It lowers like a module-level binding, but record it so
1032+
// outline_functions doesn't hoist references to it out of
1033+
// the enclosing function's scope.
1034+
self.env.non_module_scope_names.insert(name.to_string());
10291035
Ok(VariableBinding::ModuleLocal {
10301036
name: name.to_string(),
10311037
})

compiler/crates/react_compiler_optimization/src/outline_functions.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,11 @@ pub fn outline_functions(
5454
// 1. No captured context variables
5555
// 2. Anonymous (no explicit id on the inner function)
5656
// 3. Not an fbt operand
57+
// 4. Only references names that are in scope at module level
5758
if inner_func.context.is_empty()
5859
&& inner_func.id.is_none()
5960
&& !fbt_operands.contains(&lvalue_id)
61+
&& !references_non_module_scope_bindings(inner_func, env)
6062
{
6163
actions.push(Action::RecurseAndOutline {
6264
instr_idx: instr_id.0 as usize,
@@ -126,3 +128,45 @@ pub fn outline_functions(
126128
}
127129
}
128130
}
131+
132+
/// Returns true if `func` (or a function nested within it) references a
133+
/// binding that would not be in scope at module level, in which case the
134+
/// function cannot be outlined.
135+
///
136+
/// Ported from TS `referencesNonModuleScopeBindings` in
137+
/// `Optimization/OutlineFunctions.ts`. The TS implementation re-resolves
138+
/// each `LoadGlobal(ModuleLocal)`/`StoreGlobal` name against Babel scope
139+
/// data; the Rust lowering instead records the misclassified names in
140+
/// `env.non_module_scope_names` as it resolves identifiers (see
141+
/// `HirBuilder::resolve_identifier`), which encodes the same fact: the name
142+
/// resolves to a binding *between* module scope and the compiled function
143+
/// (e.g. a local of an enclosing factory function). Hoisting a function that
144+
/// references such a name would move the reference out of the enclosing
145+
/// function's scope and throw a ReferenceError at runtime.
146+
fn references_non_module_scope_bindings(func: &HirFunction, env: &Environment) -> bool {
147+
for block in func.body.blocks.values() {
148+
for &instr_id in &block.instructions {
149+
let instr = &func.instructions[instr_id.0 as usize];
150+
match &instr.value {
151+
InstructionValue::LoadGlobal {
152+
binding: NonLocalBinding::ModuleLocal { name },
153+
..
154+
}
155+
| InstructionValue::StoreGlobal { name, .. } => {
156+
if env.non_module_scope_names.contains(name) {
157+
return true;
158+
}
159+
}
160+
InstructionValue::FunctionExpression { lowered_func, .. }
161+
| InstructionValue::ObjectMethod { lowered_func, .. } => {
162+
let inner = &env.functions[lowered_func.func.0 as usize];
163+
if references_non_module_scope_bindings(inner, env) {
164+
return true;
165+
}
166+
}
167+
_ => {}
168+
}
169+
}
170+
}
171+
false
172+
}

compiler/packages/babel-plugin-react-compiler/src/Optimization/OutlineFunctions.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ export function outlineFunctions(
2727
value.loweredFunc.func.context.length === 0 &&
2828
// TODO: handle outlining named functions
2929
value.loweredFunc.func.id === null &&
30-
!fbtOperands.has(lvalue.identifier.id)
30+
!fbtOperands.has(lvalue.identifier.id) &&
31+
!referencesNonModuleScopeBindings(value.loweredFunc.func)
3132
) {
3233
const loweredFunc = value.loweredFunc.func;
3334

@@ -49,3 +50,54 @@ export function outlineFunctions(
4950
}
5051
}
5152
}
53+
54+
/**
55+
* Returns true if `fn` (or a function nested within it) references a binding
56+
* that would not be in scope at module level, in which case the function
57+
* cannot be outlined.
58+
*
59+
* When the function being compiled is itself nested inside another,
60+
* non-compiled function (eg a factory in `infer` compilation mode), HIRBuilder
61+
* classifies references to the enclosing function's locals as ModuleLocal:
62+
* it only distinguishes bindings inside the compiled function from bindings
63+
* above it, not module-scope bindings from enclosing-function locals (see
64+
* `resolveIdentifier`). Such variables don't appear in `context`, so the
65+
* function looks outlineable, but hoisting it to module scope would move the
66+
* reference out of the enclosing function's scope and throw a ReferenceError
67+
* at runtime. The same applies to StoreGlobal, which records reassignments
68+
* of any non-local name.
69+
*
70+
* Re-resolving the name against the scope enclosing the compiled function —
71+
* the same scope HIRBuilder resolved against — tells us which case we're in.
72+
* Names that don't resolve to any binding (ambient globals and references to
73+
* already-outlined functions) are fine: they mean the same thing at module
74+
* scope.
75+
*/
76+
function referencesNonModuleScopeBindings(fn: HIRFunction): boolean {
77+
const enclosingScope = fn.env.parentFunction.scope.parent;
78+
const moduleScope = fn.env.parentFunction.scope.getProgramParent();
79+
for (const [, block] of fn.body.blocks) {
80+
for (const instr of block.instructions) {
81+
const {value} = instr;
82+
let name: string | null = null;
83+
if (value.kind === 'LoadGlobal' && value.binding.kind === 'ModuleLocal') {
84+
name = value.binding.name;
85+
} else if (value.kind === 'StoreGlobal') {
86+
name = value.name;
87+
} else if (
88+
(value.kind === 'FunctionExpression' ||
89+
value.kind === 'ObjectMethod') &&
90+
referencesNonModuleScopeBindings(value.loweredFunc.func)
91+
) {
92+
return true;
93+
}
94+
if (name !== null) {
95+
const binding = enclosingScope.getBinding(name);
96+
if (binding != null && binding.scope !== moduleScope) {
97+
return true;
98+
}
99+
}
100+
}
101+
}
102+
return false;
103+
}

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-outlined-function-captures-local.expect.md renamed to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/outline-function-references-module-const.expect.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
import {useIdentity} from 'shared-runtime';
77
import {Stringify} from 'shared-runtime';
88

9+
const store = {value: 'hello'};
10+
911
function createSomething() {
10-
const store = {value: 'hello'};
1112
const Cmp = () => {
1213
const getStore = useIdentity(() => store);
1314
return <Stringify result={getStore()} />;
@@ -35,8 +36,9 @@ import { c as _c } from "react/compiler-runtime"; // @compilationMode:"infer"
3536
import { useIdentity } from "shared-runtime";
3637
import { Stringify } from "shared-runtime";
3738

39+
const store = { value: "hello" };
40+
3841
function createSomething() {
39-
const store = { value: "hello" };
4042
const Cmp = () => {
4143
const $ = _c(4);
4244
const getStore = useIdentity(_temp);
@@ -85,4 +87,6 @@ function _temp() {
8587
}
8688

8789
```
88-
90+
91+
### Eval output
92+
(kind: ok) <div>{"result":{"value":"hello"}}</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// @compilationMode:"infer"
2+
import {useIdentity} from 'shared-runtime';
3+
import {Stringify} from 'shared-runtime';
4+
5+
const store = {value: 'hello'};
6+
7+
function createSomething() {
8+
const Cmp = () => {
9+
const getStore = useIdentity(() => store);
10+
return <Stringify result={getStore()} />;
11+
};
12+
return Cmp;
13+
}
14+
15+
const Thing = createSomething();
16+
17+
function Component() {
18+
return <Thing />;
19+
}
20+
21+
export const FIXTURE_ENTRYPOINT = {
22+
fn: Component,
23+
params: [{}],
24+
};
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @compilationMode:"infer"
6+
import {useIdentity} from 'shared-runtime';
7+
import {Stringify} from 'shared-runtime';
8+
9+
function createSomething() {
10+
const store = {value: 'hello'};
11+
const Cmp = () => {
12+
const getStore = useIdentity(() => store);
13+
return <Stringify result={getStore()} />;
14+
};
15+
return Cmp;
16+
}
17+
18+
const Thing = createSomething();
19+
20+
function Component() {
21+
return <Thing />;
22+
}
23+
24+
export const FIXTURE_ENTRYPOINT = {
25+
fn: Component,
26+
params: [{}],
27+
};
28+
29+
```
30+
31+
## Code
32+
33+
```javascript
34+
import { c as _c } from "react/compiler-runtime"; // @compilationMode:"infer"
35+
import { useIdentity } from "shared-runtime";
36+
import { Stringify } from "shared-runtime";
37+
38+
function createSomething() {
39+
const store = { value: "hello" };
40+
const Cmp = () => {
41+
const $ = _c(5);
42+
let t0;
43+
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
44+
t0 = () => store;
45+
$[0] = t0;
46+
} else {
47+
t0 = $[0];
48+
}
49+
const getStore = useIdentity(t0);
50+
let t1;
51+
if ($[1] !== getStore) {
52+
t1 = getStore();
53+
$[1] = getStore;
54+
$[2] = t1;
55+
} else {
56+
t1 = $[2];
57+
}
58+
let t2;
59+
if ($[3] !== t1) {
60+
t2 = <Stringify result={t1} />;
61+
$[3] = t1;
62+
$[4] = t2;
63+
} else {
64+
t2 = $[4];
65+
}
66+
return t2;
67+
};
68+
69+
return Cmp;
70+
}
71+
72+
const Thing = createSomething();
73+
74+
function Component() {
75+
const $ = _c(1);
76+
let t0;
77+
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
78+
t0 = <Thing />;
79+
$[0] = t0;
80+
} else {
81+
t0 = $[0];
82+
}
83+
return t0;
84+
}
85+
86+
export const FIXTURE_ENTRYPOINT = {
87+
fn: Component,
88+
params: [{}],
89+
};
90+
91+
```
92+
93+
### Eval output
94+
(kind: ok) <div>{"result":{"value":"hello"}}</div>

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-outlined-function-captures-local.js renamed to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/outlined-function-captures-local.js

File renamed without changes.

compiler/packages/snap/src/SproutTodoFilter.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,6 @@
66
*/
77

88
const skipFilter = new Set([
9-
/**
10-
* Known-bug fixtures documenting wrong runtime behavior; remove each entry
11-
* when its fix lands.
12-
*/
13-
'bug-outlined-function-captures-local',
14-
159
/**
1610
* Fixtures using external modules (jest, Lexical, etc.) that can't be evaluated
1711
* in the test harness. These were previously error.todo-* but now compile in both TS and Rust.

0 commit comments

Comments
 (0)