Skip to content

Commit fa353c9

Browse files
committed
[compiler] Fix for edge cases of mutation of potentially frozen values
Fixes two related cases of mutation of potentially frozen values. The first is method calls on frozen values. Previously, we modeled unknown function calls as potentially aliasing their receiver+args into the return value. If the receiver or argument were known to be frozen, then we would downgrade the `Alias` effect into an `ImmutableCapture`. However, within a function expression it's possible to call a function using a frozen value as an argument (that gets `Alias`-ed into the return) but where we don't have the context locally to know that the value is frozen. This results in cases like this: ```js const frozen = useContext(...); useEffect(() => { frozen.method().property = true; ^^^^^^^^^^^^^^^^^^^^^^^^ cannot mutate frozen value }, [...]); ``` Within the function we would infer: ``` t0 = MethodCall ... Create t0 = mutable Alias t0 <- frozen t1 = PropertyStore ... Mutate t0 ``` And then transitively infer the function expression as having a `Mutate 'frozen'` effect, which when evaluated against the outer context (`frozen` is frozen) is an error. The fix is to model unknown function calls as _maybe_ aliasing their receiver/args in the return, and then considering mutations of a maybe-aliased value to only be a conditional mutation of the source: ``` t0 = MethodCall ... Create t0 = mutable MaybeAlias t0 <- frozen // maybe alias now t1 = PropertyStore ... Mutate t0 ``` Then, the `Mutate t0` turns into a `MutateConditional 'frozen'`, which just gets ignored when we process the outer context. The second, related fix is for known mutation of phis that may be a frozen value. The previous inference model correctly recorded these as errors, the new model does not. We now correctly report a validation error for this case in the new model.
1 parent 4f34cc4 commit fa353c9

17 files changed

+470
-106
lines changed

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -943,7 +943,10 @@ export function printAliasingEffect(effect: AliasingEffect): string {
943943
return `Assign ${printPlaceForAliasEffect(effect.into)} = ${printPlaceForAliasEffect(effect.from)}`;
944944
}
945945
case 'Alias': {
946-
return `Alias ${printPlaceForAliasEffect(effect.into)} = ${printPlaceForAliasEffect(effect.from)}`;
946+
return `Alias ${printPlaceForAliasEffect(effect.into)} <- ${printPlaceForAliasEffect(effect.from)}`;
947+
}
948+
case 'MaybeAlias': {
949+
return `MaybeAlias ${printPlaceForAliasEffect(effect.into)} <- ${printPlaceForAliasEffect(effect.from)}`;
947950
}
948951
case 'Capture': {
949952
return `Capture ${printPlaceForAliasEffect(effect.into)} <- ${printPlaceForAliasEffect(effect.from)}`;

compiler/packages/babel-plugin-react-compiler/src/Inference/AliasingEffects.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,23 @@ export type AliasingEffect =
9090
* c could be mutating a.
9191
*/
9292
| {kind: 'Alias'; from: Place; into: Place}
93+
94+
/**
95+
* Indicates the potential for information flow from `from` to `into`. This is used for a specific
96+
* case: functions with unknown signatures. If the compiler sees a call such as `foo(x)`, it has to
97+
* consider several possibilities (which may depend on the arguments):
98+
* - foo(x) returns a new mutable value that does not capture any information from x.
99+
* - foo(x) returns a new mutable value that *does* capture information from x.
100+
* - foo(x) returns x itself, ie foo is the identity function
101+
*
102+
* The same is true of functions that take multiple arguments: `cond(a, b, c)` could conditionally
103+
* return b or c depending on the value of a.
104+
*
105+
* To represent this case, MaybeAlias represents the fact that an aliasing relationship could exist.
106+
* Any mutations that flow through this relationship automatically become conditional.
107+
*/
108+
| {kind: 'MaybeAlias'; from: Place; into: Place}
109+
93110
/**
94111
* Records direct assignment: `into = from`.
95112
*/
@@ -183,7 +200,8 @@ export function hashEffect(effect: AliasingEffect): string {
183200
case 'ImmutableCapture':
184201
case 'Assign':
185202
case 'Alias':
186-
case 'Capture': {
203+
case 'Capture':
204+
case 'MaybeAlias': {
187205
return [
188206
effect.kind,
189207
effect.from.identifier.id,

compiler/packages/babel-plugin-react-compiler/src/Inference/AnalyseFunctions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ function lowerWithMutationAliasing(fn: HIRFunction): void {
8585
case 'Assign':
8686
case 'Alias':
8787
case 'Capture':
88-
case 'CreateFrom': {
88+
case 'CreateFrom':
89+
case 'MaybeAlias': {
8990
capturedOrMutated.add(effect.from.identifier.id);
9091
break;
9192
}

compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,7 @@ function applyEffect(
687687
}
688688
break;
689689
}
690+
case 'MaybeAlias':
690691
case 'Alias':
691692
case 'Capture': {
692693
CompilerError.invariant(
@@ -951,7 +952,7 @@ function applyEffect(
951952
context,
952953
state,
953954
// OK: recording information flow
954-
{kind: 'Alias', from: operand, into: effect.into},
955+
{kind: 'MaybeAlias', from: operand, into: effect.into},
955956
initialized,
956957
effects,
957958
);
@@ -1325,7 +1326,7 @@ class InferenceState {
13251326
return 'mutate-global';
13261327
}
13271328
case ValueKind.MaybeFrozen: {
1328-
return 'none';
1329+
return 'mutate-frozen';
13291330
}
13301331
default: {
13311332
assertExhaustive(kind, `Unexpected kind ${kind}`);
@@ -2357,6 +2358,7 @@ function computeEffectsForSignature(
23572358
// Apply substitutions
23582359
for (const effect of signature.effects) {
23592360
switch (effect.kind) {
2361+
case 'MaybeAlias':
23602362
case 'Assign':
23612363
case 'ImmutableCapture':
23622364
case 'Alias':

compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingRanges.ts

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
import {assertExhaustive, getOrInsertWith} from '../Utils/utils';
2929
import {Err, Ok, Result} from '../Utils/Result';
3030
import {AliasingEffect} from './AliasingEffects';
31+
import {printIdentifier} from '../HIR/PrintHIR';
3132

3233
/**
3334
* This pass builds an abstract model of the heap and interprets the effects of the
@@ -160,6 +161,8 @@ export function inferMutationAliasingRanges(
160161
state.assign(index++, effect.from, effect.into);
161162
} else if (effect.kind === 'Alias') {
162163
state.assign(index++, effect.from, effect.into);
164+
} else if (effect.kind === 'MaybeAlias') {
165+
state.maybeAlias(index++, effect.from, effect.into);
163166
} else if (effect.kind === 'Capture') {
164167
state.capture(index++, effect.from, effect.into);
165168
} else if (
@@ -346,7 +349,8 @@ export function inferMutationAliasingRanges(
346349
case 'Assign':
347350
case 'Alias':
348351
case 'Capture':
349-
case 'CreateFrom': {
352+
case 'CreateFrom':
353+
case 'MaybeAlias': {
350354
const isMutatedOrReassigned =
351355
effect.into.identifier.mutableRange.end > instr.id;
352356
if (isMutatedOrReassigned) {
@@ -567,7 +571,12 @@ type Node = {
567571
createdFrom: Map<Identifier, number>;
568572
captures: Map<Identifier, number>;
569573
aliases: Map<Identifier, number>;
570-
edges: Array<{index: number; node: Identifier; kind: 'capture' | 'alias'}>;
574+
maybeAliases: Map<Identifier, number>;
575+
edges: Array<{
576+
index: number;
577+
node: Identifier;
578+
kind: 'capture' | 'alias' | 'maybeAlias';
579+
}>;
571580
transitive: {kind: MutationKind; loc: SourceLocation} | null;
572581
local: {kind: MutationKind; loc: SourceLocation} | null;
573582
lastMutated: number;
@@ -585,6 +594,7 @@ class AliasingState {
585594
createdFrom: new Map(),
586595
captures: new Map(),
587596
aliases: new Map(),
597+
maybeAliases: new Map(),
588598
edges: [],
589599
transitive: null,
590600
local: null,
@@ -630,6 +640,18 @@ class AliasingState {
630640
}
631641
}
632642

643+
maybeAlias(index: number, from: Place, into: Place): void {
644+
const fromNode = this.nodes.get(from.identifier);
645+
const toNode = this.nodes.get(into.identifier);
646+
if (fromNode == null || toNode == null) {
647+
return;
648+
}
649+
fromNode.edges.push({index, node: into.identifier, kind: 'maybeAlias'});
650+
if (!toNode.maybeAliases.has(from.identifier)) {
651+
toNode.maybeAliases.set(from.identifier, index);
652+
}
653+
}
654+
633655
render(index: number, start: Identifier, errors: CompilerError): void {
634656
const seen = new Set<Identifier>();
635657
const queue: Array<Identifier> = [start];
@@ -673,22 +695,24 @@ class AliasingState {
673695
// Null is used for simulated mutations
674696
end: InstructionId | null,
675697
transitive: boolean,
676-
kind: MutationKind,
698+
startKind: MutationKind,
677699
loc: SourceLocation,
678700
errors: CompilerError,
679701
): void {
680-
const seen = new Set<Identifier>();
702+
const seen = new Map<Identifier, MutationKind>();
681703
const queue: Array<{
682704
place: Identifier;
683705
transitive: boolean;
684706
direction: 'backwards' | 'forwards';
685-
}> = [{place: start, transitive, direction: 'backwards'}];
707+
kind: MutationKind;
708+
}> = [{place: start, transitive, direction: 'backwards', kind: startKind}];
686709
while (queue.length !== 0) {
687-
const {place: current, transitive, direction} = queue.pop()!;
688-
if (seen.has(current)) {
710+
const {place: current, transitive, direction, kind} = queue.pop()!;
711+
const previousKind = seen.get(current);
712+
if (previousKind != null && previousKind >= kind) {
689713
continue;
690714
}
691-
seen.add(current);
715+
seen.set(current, kind);
692716
const node = this.nodes.get(current);
693717
if (node == null) {
694718
continue;
@@ -724,13 +748,18 @@ class AliasingState {
724748
if (edge.index >= index) {
725749
break;
726750
}
727-
queue.push({place: edge.node, transitive, direction: 'forwards'});
751+
queue.push({place: edge.node, transitive, direction: 'forwards', kind});
728752
}
729753
for (const [alias, when] of node.createdFrom) {
730754
if (when >= index) {
731755
continue;
732756
}
733-
queue.push({place: alias, transitive: true, direction: 'backwards'});
757+
queue.push({
758+
place: alias,
759+
transitive: true,
760+
direction: 'backwards',
761+
kind,
762+
});
734763
}
735764
if (direction === 'backwards' || node.value.kind !== 'Phi') {
736765
/**
@@ -747,7 +776,25 @@ class AliasingState {
747776
if (when >= index) {
748777
continue;
749778
}
750-
queue.push({place: alias, transitive, direction: 'backwards'});
779+
queue.push({place: alias, transitive, direction: 'backwards', kind});
780+
}
781+
/**
782+
* MaybeAlias indicates potential data flow from unknown function calls,
783+
* so we downgrade mutations through these aliases to consider them
784+
* conditional. This means we'll consider them for mutation *range*
785+
* purposes but not report validation errors for mutations, since
786+
* we aren't sure that the `from` value could actually be aliased.
787+
*/
788+
for (const [alias, when] of node.maybeAliases) {
789+
if (when >= index) {
790+
continue;
791+
}
792+
queue.push({
793+
place: alias,
794+
transitive,
795+
direction: 'backwards',
796+
kind: MutationKind.Conditional,
797+
});
751798
}
752799
}
753800
/**
@@ -758,7 +805,12 @@ class AliasingState {
758805
if (when >= index) {
759806
continue;
760807
}
761-
queue.push({place: capture, transitive, direction: 'backwards'});
808+
queue.push({
809+
place: capture,
810+
transitive,
811+
direction: 'backwards',
812+
kind,
813+
});
762814
}
763815
}
764816
}

compiler/packages/babel-plugin-react-compiler/src/Inference/MUTABILITY_ALIASING_MODEL.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,10 @@ This is somewhat the inverse of `Capture`. The `CreateFrom` effect describes tha
153153

154154
Describes immutable data flow from one value to another. This is not currently used for anything, but is intended to eventually power a more sophisticated escape analysis.
155155

156+
### MaybeAlias
157+
158+
Describes potential data flow that the compiler knows may occur behind a function call, but cannot be sure about. For example, `foo(x)` _may_ be the identity function and return `x`, or `cond(a, b, c)` may conditionally return `b` or `c` depending on the value of `a`, but those functions could just as easily return new mutable values and not capture any information from their arguments. MaybeAlias represents that we have to consider the potential for data flow when deciding mutable ranges, but should be conservative about reporting errors. For example, `foo(someFrozenValue).property = true` should not error since we don't know for certain that foo returns its input.
159+
156160
### State-Changing Effects
157161

158162
The following effects describe state changes to specific values, not data flow. In many cases, JavaScript semantics will involve a combination of both data-flow effects *and* state-change effects. For example, `object.property = value` has data flow (`Capture object <- value`) and mutation (`Mutate object`).
@@ -347,6 +351,17 @@ a.b = b; // capture
347351
mutate(a); // can transitively mutate b
348352
```
349353

354+
### MaybeAlias makes mutation conditional
355+
356+
Because we don't know for certain that the aliasing occurs, we consider the mutation conditional against the source.
357+
358+
```
359+
MaybeAlias a <- b
360+
Mutate a
361+
=>
362+
MutateConditional b
363+
```
364+
350365
### Freeze Does Not Freeze the Value
351366

352367
Freeze does not freeze the value itself:
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
2+
## Input
3+
4+
```javascript
5+
import {useHook} from 'shared-runtime';
6+
7+
function Component(props) {
8+
const frozen = useHook();
9+
let x;
10+
if (props.cond) {
11+
x = frozen;
12+
} else {
13+
x = {};
14+
}
15+
x.property = true;
16+
}
17+
18+
```
19+
20+
21+
## Error
22+
23+
```
24+
9 | x = {};
25+
10 | }
26+
> 11 | x.property = true;
27+
| ^ InvalidReact: Updating a value returned from a hook is not allowed. Consider moving the mutation into the hook where the value is constructed (11:11)
28+
12 | }
29+
13 |
30+
```
31+
32+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import {useHook} from 'shared-runtime';
2+
3+
function Component(props) {
4+
const frozen = useHook();
5+
let x;
6+
if (props.cond) {
7+
x = frozen;
8+
} else {
9+
x = {};
10+
}
11+
x.property = true;
12+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
2+
## Input
3+
4+
```javascript
5+
import {Stringify, useIdentity} from 'shared-runtime';
6+
7+
function Component() {
8+
const data = useIdentity(
9+
new Map([
10+
[0, 'value0'],
11+
[1, 'value1'],
12+
])
13+
);
14+
const items = [];
15+
// NOTE: `i` is a context variable because it's reassigned and also referenced
16+
// within a closure, the `onClick` handler of each item
17+
// TODO: for loops create a unique environment on each iteration, which means
18+
// that if the iteration variable is only updated in the updater, the variable
19+
// is effectively const within the body and the "update" acts more like
20+
// a re-initialization than a reassignment.
21+
// Until we model this "new environment" semantic, we allow this case to error
22+
for (let i = MIN; i <= MAX; i += INCREMENT) {
23+
items.push(
24+
<Stringify key={i} onClick={() => data.get(i)} shouldInvokeFns={true} />
25+
);
26+
}
27+
return <>{items}</>;
28+
}
29+
30+
const MIN = 0;
31+
const MAX = 3;
32+
const INCREMENT = 1;
33+
34+
export const FIXTURE_ENTRYPOINT = {
35+
params: [],
36+
fn: Component,
37+
};
38+
39+
```
40+
41+
42+
## Error
43+
44+
```
45+
16 | // a re-initialization than a reassignment.
46+
17 | // Until we model this "new environment" semantic, we allow this case to error
47+
> 18 | for (let i = MIN; i <= MAX; i += INCREMENT) {
48+
| ^ InvalidReact: Updating a value used previously in JSX is not allowed. Consider moving the mutation before the JSX. Found mutation of `i` (18:18)
49+
19 | items.push(
50+
20 | <Stringify key={i} onClick={() => data.get(i)} shouldInvokeFns={true} />
51+
21 | );
52+
```
53+
54+
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ function Component() {
1010
const items = [];
1111
// NOTE: `i` is a context variable because it's reassigned and also referenced
1212
// within a closure, the `onClick` handler of each item
13+
// TODO: for loops create a unique environment on each iteration, which means
14+
// that if the iteration variable is only updated in the updater, the variable
15+
// is effectively const within the body and the "update" acts more like
16+
// a re-initialization than a reassignment.
17+
// Until we model this "new environment" semantic, we allow this case to error
1318
for (let i = MIN; i <= MAX; i += INCREMENT) {
1419
items.push(
1520
<Stringify key={i} onClick={() => data.get(i)} shouldInvokeFns={true} />

0 commit comments

Comments
 (0)