Skip to content

Commit 8ef9d1f

Browse files
committed
[compiler][bugfix] Bail out when a memo block declares hoisted fns
(needs cleanup + more test fixtures) Note that bailing out adds false positives for hoisted functions whose only references are within other functions. For example, this rewrite would be safe. ```js // source program function foo() { return bar(); } function bar() { return 42; } // compiler output let bar; if (/* deps changed */) { function foo() { return bar(); } bar = function bar() { return 42; } } ``` These false positives are difficult to detect because any maybe-call of foo before the definition of bar would be invalid. Instead of bailing out, we should rewrite hoisted function declarations to the following form. ```js let bar$0; if (/* deps changed */) { // All references within the declaring memo block // or before the function declaration should use // the original identifier `bar` function foo() { return bar(); } function bar() { return 42; } bar$0 = bar; } // All references after the declaring memo block // or after the function declaration should use // the rewritten declaration `bar$0` ```
1 parent c919590 commit 8ef9d1f

File tree

4 files changed

+135
-83
lines changed

4 files changed

+135
-83
lines changed

compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneHoistedContexts.ts

+92-4
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8+
import {CompilerError} from '..';
89
import {
910
convertHoistedLValueKind,
1011
IdentifierId,
12+
InstructionId,
1113
InstructionKind,
14+
Place,
1215
ReactiveFunction,
1316
ReactiveInstruction,
1417
ReactiveScopeBlock,
@@ -24,31 +27,78 @@ import {
2427
/*
2528
* Prunes DeclareContexts lowered for HoistedConsts, and transforms any references back to its
2629
* original instruction kind.
30+
*
31+
* Also detects and bails out on context variables which are:
32+
* - function declarations, which are hoisted by JS engines to the nearest block scope
33+
* - referenced before they are defined (i.e. having a `DeclareContext HoistedConst`)
34+
* - declared
35+
*
36+
* This is because React Compiler converts a `function foo()` function declaration to
37+
* 1. a `let foo;` declaration before reactive memo blocks
38+
* 2. a `foo = function foo() {}` assignment within the block
39+
*
40+
* This means references before the assignment are invalid (see fixture
41+
* error.todo-functiondecl-hoisting)
2742
*/
2843
export function pruneHoistedContexts(fn: ReactiveFunction): void {
2944
visitReactiveFunction(fn, new Visitor(), {
3045
activeScopes: empty(),
46+
uninitialized: new Map(),
3147
});
3248
}
3349

3450
type VisitorState = {
3551
activeScopes: Stack<Set<IdentifierId>>;
52+
uninitialized: Map<
53+
IdentifierId,
54+
| {
55+
kind: 'unknown-kind';
56+
}
57+
| {
58+
kind: 'func';
59+
definition: Place | null;
60+
}
61+
>;
3662
};
3763

3864
class Visitor extends ReactiveFunctionTransform<VisitorState> {
3965
override visitScope(scope: ReactiveScopeBlock, state: VisitorState): void {
4066
state.activeScopes = state.activeScopes.push(
4167
new Set(scope.scope.declarations.keys()),
4268
);
69+
/**
70+
* Add declared but not initialized / assigned variables. This may include
71+
* function declarations that escape the memo block.
72+
*/
73+
for (const decl of scope.scope.declarations.values()) {
74+
state.uninitialized.set(decl.identifier.id, {kind: 'unknown-kind'});
75+
}
4376
this.traverseScope(scope, state);
4477
state.activeScopes.pop();
78+
for (const decl of scope.scope.declarations.values()) {
79+
state.uninitialized.delete(decl.identifier.id);
80+
}
81+
}
82+
override visitPlace(
83+
_id: InstructionId,
84+
place: Place,
85+
state: VisitorState,
86+
): void {
87+
const maybeHoistedFn = state.uninitialized.get(place.identifier.id);
88+
if (
89+
maybeHoistedFn?.kind === 'func' &&
90+
maybeHoistedFn.definition !== place
91+
) {
92+
CompilerError.throwTodo({
93+
reason: '[PruneHoistedContexts] Rewrite hoisted function references',
94+
loc: place.loc,
95+
});
96+
}
4597
}
4698
override transformInstruction(
4799
instruction: ReactiveInstruction,
48100
state: VisitorState,
49101
): Transformed<ReactiveStatement> {
50-
this.visitInstruction(instruction, state);
51-
52102
/**
53103
* Remove hoisted declarations to preserve TDZ
54104
*/
@@ -57,6 +107,18 @@ class Visitor extends ReactiveFunctionTransform<VisitorState> {
57107
instruction.value.lvalue.kind,
58108
);
59109
if (maybeNonHoisted != null) {
110+
if (
111+
maybeNonHoisted === InstructionKind.Function &&
112+
state.uninitialized.has(instruction.value.lvalue.place.identifier.id)
113+
) {
114+
state.uninitialized.set(
115+
instruction.value.lvalue.place.identifier.id,
116+
{
117+
kind: 'func',
118+
definition: null,
119+
},
120+
);
121+
}
60122
return {kind: 'remove'};
61123
}
62124
}
@@ -65,18 +127,44 @@ class Visitor extends ReactiveFunctionTransform<VisitorState> {
65127
instruction.value.lvalue.kind !== InstructionKind.Reassign
66128
) {
67129
/**
68-
* Rewrite StoreContexts let/const/functions that will be pre-declared in
130+
* Rewrite StoreContexts let/const that will be pre-declared in
69131
* codegen to reassignments.
70132
*/
71133
const lvalueId = instruction.value.lvalue.place.identifier.id;
72134
const isDeclaredByScope = state.activeScopes.find(scope =>
73135
scope.has(lvalueId),
74136
);
75137
if (isDeclaredByScope) {
76-
instruction.value.lvalue.kind = InstructionKind.Reassign;
138+
if (
139+
instruction.value.lvalue.kind === InstructionKind.Let ||
140+
instruction.value.lvalue.kind === InstructionKind.Const
141+
) {
142+
instruction.value.lvalue.kind = InstructionKind.Reassign;
143+
} else if (instruction.value.lvalue.kind === InstructionKind.Function) {
144+
const maybeHoistedFn = state.uninitialized.get(lvalueId);
145+
if (maybeHoistedFn != null) {
146+
CompilerError.invariant(maybeHoistedFn.kind === 'func', {
147+
reason: '[PruneHoistedContexts] Unexpected hoisted function',
148+
loc: instruction.loc,
149+
});
150+
maybeHoistedFn.definition = instruction.value.lvalue.place;
151+
/**
152+
* References to hoisted functions are now "safe" as variable assignments
153+
* have finished.
154+
*/
155+
state.uninitialized.delete(lvalueId);
156+
}
157+
} else {
158+
CompilerError.throwTodo({
159+
reason: '[PruneHoistedContexts] Unexpected kind',
160+
description: `(${instruction.value.lvalue.kind})`,
161+
loc: instruction.loc,
162+
});
163+
}
77164
}
78165
}
79166

167+
this.visitInstruction(instruction, state);
80168
return {kind: 'keep'};
81169
}
82170
}

compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-functiondecl-hoisting.expect.md

-79
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
2+
## Input
3+
4+
```javascript
5+
import {Stringify} from 'shared-runtime';
6+
7+
/**
8+
* Fixture currently fails with
9+
* Found differences in evaluator results
10+
* Non-forget (expected):
11+
* (kind: ok) <div>{"result":{"value":2},"fn":{"kind":"Function","result":{"value":2}},"shouldInvokeFns":true}</div>
12+
* Forget:
13+
* (kind: exception) bar is not a function
14+
*/
15+
function Foo({value}) {
16+
const result = bar();
17+
function bar() {
18+
return {value};
19+
}
20+
return <Stringify result={result} fn={bar} shouldInvokeFns={true} />;
21+
}
22+
23+
export const FIXTURE_ENTRYPOINT = {
24+
fn: Foo,
25+
params: [{value: 2}],
26+
};
27+
28+
```
29+
30+
31+
## Error
32+
33+
```
34+
10 | */
35+
11 | function Foo({value}) {
36+
> 12 | const result = bar();
37+
| ^^^ Todo: [PruneHoistedContexts] Rewrite hoisted function references (12:12)
38+
13 | function bar() {
39+
14 | return {value};
40+
15 | }
41+
```
42+
43+

0 commit comments

Comments
 (0)