Skip to content

Commit 78856ef

Browse files
committed
[compiler][bugfix] Avoid inserting duplicate context variable declarations
(note: also see alternative implementation in #32747) `PruneHoistedContexts` currently strips hoisted declarations and rewrites the first `StoreContext` reassignment to a declaration. For example, in the following example, instruction 0 is removed while a synthetic `DeclareContext let` is inserted before instruction 1. ```js // source const cb = () => x; // reference that causes x to be hoisted let x = 4; x = 5; // React Compiler IR [0] DeclareContext HoistedLet 'x' ... [1] StoreContext reassign 'x' = 4 [2] StoreContext reassign 'x' = 5 ``` Currently, we don't account for `DeclareContext let`. As a result, we're rewriting to insert duplicate declarations. For the below example, we should only remove instruction 0 (no need to insert a DeclareContext `let` since one is already present). ```js // source const cb = () => x; // reference that causes x to be hoisted let x; x = 5; // React Compiler IR [0] DeclareContext HoistedLet 'x' ... [1] DeclareContext Let 'x' [2] StoreContext reassign 'x' = 5 ```
1 parent 254dc4d commit 78856ef

File tree

7 files changed

+264
-44
lines changed

7 files changed

+264
-44
lines changed

compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,27 @@ export enum InstructionKind {
746746
Function = 'Function',
747747
}
748748

749+
export function convertHoistedLValueKind(
750+
kind: InstructionKind,
751+
): InstructionKind | null {
752+
switch (kind) {
753+
case InstructionKind.HoistedLet:
754+
return InstructionKind.Let;
755+
case InstructionKind.HoistedConst:
756+
return InstructionKind.Const;
757+
case InstructionKind.HoistedFunction:
758+
return InstructionKind.Function;
759+
case InstructionKind.Let:
760+
case InstructionKind.Const:
761+
case InstructionKind.Function:
762+
case InstructionKind.Reassign:
763+
case InstructionKind.Catch:
764+
return null;
765+
default:
766+
assertExhaustive(kind, 'Unexpected lvalue kind');
767+
}
768+
}
769+
749770
function _staticInvariantInstructionValueHasLocation(
750771
value: InstructionValue,
751772
): SourceLocation {

compiler/packages/babel-plugin-react-compiler/src/HIR/PropagateScopeDependenciesHIR.ts

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ import {
2323
FunctionExpression,
2424
ObjectMethod,
2525
PropertyLiteral,
26+
convertHoistedLValueKind,
2627
} from './HIR';
28+
2729
import {
2830
collectHoistablePropertyLoads,
2931
keyByScopeId,
@@ -464,6 +466,9 @@ class Context {
464466
}
465467
this.#reassignments.set(identifier, decl);
466468
}
469+
hasDeclared(identifier: Identifier): boolean {
470+
return this.#declarations.has(identifier.declarationId);
471+
}
467472

468473
// Checks if identifier is a valid dependency in the current scope
469474
#checkValidDependency(maybeDependency: ReactiveScopeDependency): boolean {
@@ -662,21 +667,21 @@ function handleInstruction(instr: Instruction, context: Context): void {
662667
});
663668
} else if (value.kind === 'DeclareLocal' || value.kind === 'DeclareContext') {
664669
/*
665-
* Some variables may be declared and never initialized. We need
666-
* to retain (and hoist) these declarations if they are included
667-
* in a reactive scope. One approach is to simply add all `DeclareLocal`s
668-
* as scope declarations.
669-
*/
670-
671-
/*
672-
* We add context variable declarations here, not at `StoreContext`, since
673-
* context Store / Loads are modeled as reads and mutates to the underlying
674-
* variable reference (instead of through intermediate / inlined temporaries)
670+
* Some variables may be declared and never initialized. We need to retain
671+
* (and hoist) these declarations if they are included in a reactive scope.
672+
* One approach is to simply add all `DeclareLocal`s as scope declarations.
673+
*
674+
* Context variables with hoisted declarations only become live after their
675+
* first assignment. We only declare real DeclareLocal / DeclareContext
676+
* instructions (not hoisted ones) to avoid generating dependencies on
677+
* hoisted declarations.
675678
*/
676-
context.declare(value.lvalue.place.identifier, {
677-
id,
678-
scope: context.currentScope,
679-
});
679+
if (convertHoistedLValueKind(value.lvalue.kind) === null) {
680+
context.declare(value.lvalue.place.identifier, {
681+
id,
682+
scope: context.currentScope,
683+
});
684+
}
680685
} else if (value.kind === 'Destructure') {
681686
context.visitOperand(value.value);
682687
for (const place of eachPatternOperand(value.lvalue.pattern)) {
@@ -688,6 +693,23 @@ function handleInstruction(instr: Instruction, context: Context): void {
688693
scope: context.currentScope,
689694
});
690695
}
696+
} else if (value.kind === 'StoreContext') {
697+
/**
698+
* Some StoreContext variables have hoisted declarations. If we're storing
699+
* to a context variable that hasn't yet been declared, the StoreContext is
700+
* the declaration.
701+
* (see corresponding logic in PruneHoistedContext)
702+
*/
703+
if (!context.hasDeclared(value.lvalue.place.identifier)) {
704+
context.declare(value.lvalue.place.identifier, {
705+
id,
706+
scope: context.currentScope,
707+
});
708+
}
709+
710+
for (const operand of eachInstructionValueOperand(value)) {
711+
context.visitOperand(operand);
712+
}
691713
} else {
692714
for (const operand of eachInstructionValueOperand(value)) {
693715
context.visitOperand(operand);

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

Lines changed: 20 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import {CompilerError} from '..';
99
import {
10+
convertHoistedLValueKind,
1011
DeclarationId,
1112
InstructionKind,
1213
ReactiveFunction,
@@ -50,37 +51,26 @@ class Visitor extends ReactiveFunctionTransform<HoistedIdentifiers> {
5051
/**
5152
* Remove hoisted declarations to preserve TDZ
5253
*/
53-
if (
54-
instruction.value.kind === 'DeclareContext' &&
55-
instruction.value.lvalue.kind === 'HoistedConst'
56-
) {
57-
state.set(
58-
instruction.value.lvalue.place.identifier.declarationId,
59-
InstructionKind.Const,
60-
);
61-
return {kind: 'remove'};
62-
}
63-
64-
if (
65-
instruction.value.kind === 'DeclareContext' &&
66-
instruction.value.lvalue.kind === 'HoistedLet'
67-
) {
68-
state.set(
69-
instruction.value.lvalue.place.identifier.declarationId,
70-
InstructionKind.Let,
54+
if (instruction.value.kind === 'DeclareContext') {
55+
const maybeNonHoisted = convertHoistedLValueKind(
56+
instruction.value.lvalue.kind,
7157
);
72-
return {kind: 'remove'};
73-
}
74-
75-
if (
76-
instruction.value.kind === 'DeclareContext' &&
77-
instruction.value.lvalue.kind === 'HoistedFunction'
78-
) {
79-
state.set(
80-
instruction.value.lvalue.place.identifier.declarationId,
81-
InstructionKind.Function,
82-
);
83-
return {kind: 'remove'};
58+
if (maybeNonHoisted != null) {
59+
state.set(
60+
instruction.value.lvalue.place.identifier.declarationId,
61+
maybeNonHoisted,
62+
);
63+
return {kind: 'remove'};
64+
}
65+
if (instruction.value.lvalue.kind === 'Let') {
66+
/**
67+
* We don't expect const context variables to be hoisted
68+
*/
69+
state.set(
70+
instruction.value.lvalue.place.identifier.declarationId,
71+
REWRITTEN_HOISTED_LET,
72+
);
73+
}
8474
}
8575

8676
if (instruction.value.kind === 'StoreContext') {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
2+
## Input
3+
4+
```javascript
5+
import {CONST_TRUE, useIdentity} from 'shared-runtime';
6+
7+
const hidden = CONST_TRUE;
8+
function useFoo() {
9+
const makeCb = useIdentity(() => {
10+
const logIntervalId = () => {
11+
log(intervalId);
12+
};
13+
14+
let intervalId;
15+
if (!hidden) {
16+
intervalId = 2;
17+
}
18+
return () => {
19+
logIntervalId();
20+
};
21+
});
22+
23+
return <Stringify fn={makeCb()} shouldInvokeFns={true} />;
24+
}
25+
26+
export const FIXTURE_ENTRYPOINT = {
27+
fn: useFoo,
28+
params: [],
29+
};
30+
31+
```
32+
33+
## Code
34+
35+
```javascript
36+
import { c as _c } from "react/compiler-runtime";
37+
import { CONST_TRUE, useIdentity } from "shared-runtime";
38+
39+
const hidden = CONST_TRUE;
40+
function useFoo() {
41+
const $ = _c(4);
42+
const makeCb = useIdentity(_temp);
43+
let t0;
44+
if ($[0] !== makeCb) {
45+
t0 = makeCb();
46+
$[0] = makeCb;
47+
$[1] = t0;
48+
} else {
49+
t0 = $[1];
50+
}
51+
let t1;
52+
if ($[2] !== t0) {
53+
t1 = <Stringify fn={t0} shouldInvokeFns={true} />;
54+
$[2] = t0;
55+
$[3] = t1;
56+
} else {
57+
t1 = $[3];
58+
}
59+
return t1;
60+
}
61+
function _temp() {
62+
const logIntervalId = () => {
63+
log(intervalId);
64+
};
65+
let intervalId;
66+
if (!hidden) {
67+
intervalId = 2;
68+
}
69+
return () => {
70+
logIntervalId();
71+
};
72+
}
73+
74+
export const FIXTURE_ENTRYPOINT = {
75+
fn: useFoo,
76+
params: [],
77+
};
78+
79+
```
80+
81+
### Eval output
82+
(kind: exception) Stringify is not defined
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import {CONST_TRUE, useIdentity} from 'shared-runtime';
2+
3+
const hidden = CONST_TRUE;
4+
function useFoo() {
5+
const makeCb = useIdentity(() => {
6+
const logIntervalId = () => {
7+
log(intervalId);
8+
};
9+
10+
let intervalId;
11+
if (!hidden) {
12+
intervalId = 2;
13+
}
14+
return () => {
15+
logIntervalId();
16+
};
17+
});
18+
19+
return <Stringify fn={makeCb()} shouldInvokeFns={true} />;
20+
}
21+
22+
export const FIXTURE_ENTRYPOINT = {
23+
fn: useFoo,
24+
params: [],
25+
};
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
2+
## Input
3+
4+
```javascript
5+
import {CONST_NUMBER1, Stringify} from 'shared-runtime';
6+
7+
function useHook({cond}) {
8+
const getX = () => x;
9+
10+
let x;
11+
if (cond) {
12+
x = CONST_NUMBER1;
13+
}
14+
return <Stringify getX={getX} shouldInvokeFns={true} />;
15+
}
16+
17+
export const FIXTURE_ENTRYPOINT = {
18+
fn: useHook,
19+
params: [{cond: true}],
20+
sequentialRenders: [{cond: true}, {cond: true}, {cond: false}],
21+
};
22+
23+
```
24+
25+
## Code
26+
27+
```javascript
28+
import { c as _c } from "react/compiler-runtime";
29+
import { CONST_NUMBER1, Stringify } from "shared-runtime";
30+
31+
function useHook(t0) {
32+
const $ = _c(2);
33+
const { cond } = t0;
34+
let t1;
35+
if ($[0] !== cond) {
36+
const getX = () => x;
37+
38+
let x;
39+
if (cond) {
40+
x = CONST_NUMBER1;
41+
}
42+
43+
t1 = <Stringify getX={getX} shouldInvokeFns={true} />;
44+
$[0] = cond;
45+
$[1] = t1;
46+
} else {
47+
t1 = $[1];
48+
}
49+
return t1;
50+
}
51+
52+
export const FIXTURE_ENTRYPOINT = {
53+
fn: useHook,
54+
params: [{ cond: true }],
55+
sequentialRenders: [{ cond: true }, { cond: true }, { cond: false }],
56+
};
57+
58+
```
59+
60+
### Eval output
61+
(kind: ok) <div>{"getX":{"kind":"Function","result":1},"shouldInvokeFns":true}</div>
62+
<div>{"getX":{"kind":"Function","result":1},"shouldInvokeFns":true}</div>
63+
<div>{"getX":{"kind":"Function"},"shouldInvokeFns":true}</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {CONST_NUMBER1, Stringify} from 'shared-runtime';
2+
3+
function useHook({cond}) {
4+
const getX = () => x;
5+
6+
let x;
7+
if (cond) {
8+
x = CONST_NUMBER1;
9+
}
10+
return <Stringify getX={getX} shouldInvokeFns={true} />;
11+
}
12+
13+
export const FIXTURE_ENTRYPOINT = {
14+
fn: useHook,
15+
params: [{cond: true}],
16+
sequentialRenders: [{cond: true}, {cond: true}, {cond: false}],
17+
};

0 commit comments

Comments
 (0)