Skip to content

Commit 294fa2c

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 129aa85 commit 294fa2c

17 files changed

+483
-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
@@ -691,6 +691,7 @@ function applyEffect(
691691
}
692692
break;
693693
}
694+
case 'MaybeAlias':
694695
case 'Alias':
695696
case 'Capture': {
696697
CompilerError.invariant(
@@ -955,7 +956,7 @@ function applyEffect(
955956
context,
956957
state,
957958
// OK: recording information flow
958-
{kind: 'Alias', from: operand, into: effect.into},
959+
{kind: 'MaybeAlias', from: operand, into: effect.into},
959960
initialized,
960961
effects,
961962
);
@@ -1323,7 +1324,7 @@ class InferenceState {
13231324
return 'mutate-global';
13241325
}
13251326
case ValueKind.MaybeFrozen: {
1326-
return 'none';
1327+
return 'mutate-frozen';
13271328
}
13281329
default: {
13291330
assertExhaustive(kind, `Unexpected kind ${kind}`);
@@ -2376,6 +2377,7 @@ function computeEffectsForSignature(
23762377
// Apply substitutions
23772378
for (const effect of signature.effects) {
23782379
switch (effect.kind) {
2380+
case 'MaybeAlias':
23792381
case 'Assign':
23802382
case 'ImmutableCapture':
23812383
case 'Alias':

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

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ export function inferMutationAliasingRanges(
160160
state.assign(index++, effect.from, effect.into);
161161
} else if (effect.kind === 'Alias') {
162162
state.assign(index++, effect.from, effect.into);
163+
} else if (effect.kind === 'MaybeAlias') {
164+
state.maybeAlias(index++, effect.from, effect.into);
163165
} else if (effect.kind === 'Capture') {
164166
state.capture(index++, effect.from, effect.into);
165167
} else if (
@@ -346,7 +348,8 @@ export function inferMutationAliasingRanges(
346348
case 'Assign':
347349
case 'Alias':
348350
case 'Capture':
349-
case 'CreateFrom': {
351+
case 'CreateFrom':
352+
case 'MaybeAlias': {
350353
const isMutatedOrReassigned =
351354
effect.into.identifier.mutableRange.end > instr.id;
352355
if (isMutatedOrReassigned) {
@@ -567,7 +570,12 @@ type Node = {
567570
createdFrom: Map<Identifier, number>;
568571
captures: Map<Identifier, number>;
569572
aliases: Map<Identifier, number>;
570-
edges: Array<{index: number; node: Identifier; kind: 'capture' | 'alias'}>;
573+
maybeAliases: Map<Identifier, number>;
574+
edges: Array<{
575+
index: number;
576+
node: Identifier;
577+
kind: 'capture' | 'alias' | 'maybeAlias';
578+
}>;
571579
transitive: {kind: MutationKind; loc: SourceLocation} | null;
572580
local: {kind: MutationKind; loc: SourceLocation} | null;
573581
lastMutated: number;
@@ -585,6 +593,7 @@ class AliasingState {
585593
createdFrom: new Map(),
586594
captures: new Map(),
587595
aliases: new Map(),
596+
maybeAliases: new Map(),
588597
edges: [],
589598
transitive: null,
590599
local: null,
@@ -630,6 +639,18 @@ class AliasingState {
630639
}
631640
}
632641

642+
maybeAlias(index: number, from: Place, into: Place): void {
643+
const fromNode = this.nodes.get(from.identifier);
644+
const toNode = this.nodes.get(into.identifier);
645+
if (fromNode == null || toNode == null) {
646+
return;
647+
}
648+
fromNode.edges.push({index, node: into.identifier, kind: 'maybeAlias'});
649+
if (!toNode.maybeAliases.has(from.identifier)) {
650+
toNode.maybeAliases.set(from.identifier, index);
651+
}
652+
}
653+
633654
render(index: number, start: Identifier, errors: CompilerError): void {
634655
const seen = new Set<Identifier>();
635656
const queue: Array<Identifier> = [start];
@@ -673,22 +694,24 @@ class AliasingState {
673694
// Null is used for simulated mutations
674695
end: InstructionId | null,
675696
transitive: boolean,
676-
kind: MutationKind,
697+
startKind: MutationKind,
677698
loc: SourceLocation,
678699
errors: CompilerError,
679700
): void {
680-
const seen = new Set<Identifier>();
701+
const seen = new Map<Identifier, MutationKind>();
681702
const queue: Array<{
682703
place: Identifier;
683704
transitive: boolean;
684705
direction: 'backwards' | 'forwards';
685-
}> = [{place: start, transitive, direction: 'backwards'}];
706+
kind: MutationKind;
707+
}> = [{place: start, transitive, direction: 'backwards', kind: startKind}];
686708
while (queue.length !== 0) {
687-
const {place: current, transitive, direction} = queue.pop()!;
688-
if (seen.has(current)) {
709+
const {place: current, transitive, direction, kind} = queue.pop()!;
710+
const previousKind = seen.get(current);
711+
if (previousKind != null && previousKind >= kind) {
689712
continue;
690713
}
691-
seen.add(current);
714+
seen.set(current, kind);
692715
const node = this.nodes.get(current);
693716
if (node == null) {
694717
continue;
@@ -724,13 +747,18 @@ class AliasingState {
724747
if (edge.index >= index) {
725748
break;
726749
}
727-
queue.push({place: edge.node, transitive, direction: 'forwards'});
750+
queue.push({place: edge.node, transitive, direction: 'forwards', kind});
728751
}
729752
for (const [alias, when] of node.createdFrom) {
730753
if (when >= index) {
731754
continue;
732755
}
733-
queue.push({place: alias, transitive: true, direction: 'backwards'});
756+
queue.push({
757+
place: alias,
758+
transitive: true,
759+
direction: 'backwards',
760+
kind,
761+
});
734762
}
735763
if (direction === 'backwards' || node.value.kind !== 'Phi') {
736764
/**
@@ -747,7 +775,25 @@ class AliasingState {
747775
if (when >= index) {
748776
continue;
749777
}
750-
queue.push({place: alias, transitive, direction: 'backwards'});
778+
queue.push({place: alias, transitive, direction: 'backwards', kind});
779+
}
780+
/**
781+
* MaybeAlias indicates potential data flow from unknown function calls,
782+
* so we downgrade mutations through these aliases to consider them
783+
* conditional. This means we'll consider them for mutation *range*
784+
* purposes but not report validation errors for mutations, since
785+
* we aren't sure that the `from` value could actually be aliased.
786+
*/
787+
for (const [alias, when] of node.maybeAliases) {
788+
if (when >= index) {
789+
continue;
790+
}
791+
queue.push({
792+
place: alias,
793+
transitive,
794+
direction: 'backwards',
795+
kind: MutationKind.Conditional,
796+
});
751797
}
752798
}
753799
/**
@@ -758,7 +804,12 @@ class AliasingState {
758804
if (when >= index) {
759805
continue;
760806
}
761-
queue.push({place: capture, transitive, direction: 'backwards'});
807+
queue.push({
808+
place: capture,
809+
transitive,
810+
direction: 'backwards',
811+
kind,
812+
});
762813
}
763814
}
764815
}

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:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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+
Found 1 error:
25+
26+
Error: This value cannot be modified
27+
28+
Modifying a value returned from a hook is not allowed. Consider moving the modification into the hook where the value is constructed.
29+
30+
error.invalid-mutate-phi-which-could-be-frozen.ts:11:2
31+
9 | x = {};
32+
10 | }
33+
> 11 | x.property = true;
34+
| ^ value cannot be modified
35+
12 | }
36+
13 |
37+
```
38+
39+
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+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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+
Found 1 error:
46+
47+
Error: This value cannot be modified
48+
49+
Modifying a value used previously in JSX is not allowed. Consider moving the modification before the JSX.
50+
51+
error.todo-for-loop-with-context-variable-iterator.ts:18:30
52+
16 | // a re-initialization than a reassignment.
53+
17 | // Until we model this "new environment" semantic, we allow this case to error
54+
> 18 | for (let i = MIN; i <= MAX; i += INCREMENT) {
55+
| ^ `i` cannot be modified
56+
19 | items.push(
57+
20 | <Stringify key={i} onClick={() => data.get(i)} shouldInvokeFns={true} />
58+
21 | );
59+
```
60+
61+

0 commit comments

Comments
 (0)