Skip to content

Commit

Permalink
[compiler] Patch array and argument spread mutability
Browse files Browse the repository at this point in the history
Array and argument spreads may mutate stateful iterables. Spread sites should have `ConditionallyMutate` effects (e.g. mutate if the ValueKind is mutable, otherwise read).

See
- [ecma spec (13.2.4.1 Runtime Semantics: ArrayAccumulation. SpreadElement : ... AssignmentExpression)](https://tc39.es/ecma262/multipage/ecmascript-language-expressions.html#sec-runtime-semantics-arrayaccumulation).
- [ecma spec 13.3.8.1 Runtime Semantics: ArgumentListEvaluation](https://tc39.es/ecma262/multipage/ecmascript-language-expressions.html#sec-runtime-semantics-argumentlistevaluation)

Note that
- Object and JSX Attribute spreads do not evaluate iterables (srcs [mozilla](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#description), [ecma](https://tc39.es/ecma262/multipage/ecmascript-language-expressions.html#sec-runtime-semantics-propertydefinitionevaluation))
- An ideal mutability inference system could model known collections (i.e. Arrays or Sets) as a "mutated collection of non-mutable objects" (see `todo-granular-iterator-semantics`), but this is not what we do today. As such, an array / argument spread will always extend the range of built-in arrays, sets, etc
- Due to HIR limitations, call expressions with argument spreads may cause unnecessary bailouts and/or scope merging when we know the call itself has `freeze`, `capture`, or `read` semantics (e.g. `useHook(...mutableValue)`)
  We can deal with this by rewriting these call instructions to (1) create an intermediate array to consume the iterator and (2) capture and spread the array at the callsite
  • Loading branch information
mofeiZ committed Mar 4, 2025
1 parent 464a6c6 commit de8fb89
Show file tree
Hide file tree
Showing 12 changed files with 283 additions and 68 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -872,11 +872,31 @@ function inferBlock(
reason: new Set([ValueReason.Other]),
context: new Set(),
};

for (const element of instrValue.elements) {
if (element.kind === 'Spread') {
state.referenceAndRecordEffects(
freezeActions,
element.place,
Effect.ConditionallyMutate,
ValueReason.Other,
);
} else if (element.kind === 'Identifier') {
state.referenceAndRecordEffects(
freezeActions,
element,
Effect.Capture,
ValueReason.Other,
);
} else {
let _: 'Hole' = element.kind;
}
}
state.initialize(instrValue, valueKind);
state.define(instr.lvalue, instrValue);
instr.lvalue.effect = Effect.Store;
continuation = {
kind: 'initialize',
valueKind,
effect: {kind: Effect.Capture, reason: ValueReason.Other},
lvalueEffect: Effect.Store,
kind: 'funeffects',
};
break;
}
Expand Down Expand Up @@ -1241,21 +1261,12 @@ function inferBlock(
for (let i = 0; i < instrValue.args.length; i++) {
const arg = instrValue.args[i];
const place = arg.kind === 'Identifier' ? arg : arg.place;
if (effects !== null) {
state.referenceAndRecordEffects(
freezeActions,
place,
effects[i],
ValueReason.Other,
);
} else {
state.referenceAndRecordEffects(
freezeActions,
place,
Effect.ConditionallyMutate,
ValueReason.Other,
);
}
state.referenceAndRecordEffects(
freezeActions,
place,
getArgumentEffect(effects != null ? effects[i] : null, arg),
ValueReason.Other,
);
hasCaptureArgument ||= place.effect === Effect.Capture;
}
if (signature !== null) {
Expand Down Expand Up @@ -1307,7 +1318,10 @@ function inferBlock(
signature !== null
? {
kind: signature.returnValueKind,
reason: new Set([ValueReason.Other]),
reason: new Set([
signature.returnValueReason ??
ValueReason.KnownReturnSignature,
]),
context: new Set(),
}
: {
Expand All @@ -1330,7 +1344,8 @@ function inferBlock(
state.referenceAndRecordEffects(
freezeActions,
place,
Effect.Read,
// see call-spread-argument-mutable-iterator test fixture
arg.kind === 'Spread' ? Effect.ConditionallyMutate : Effect.Read,
ValueReason.Other,
);
}
Expand All @@ -1356,25 +1371,16 @@ function inferBlock(
for (let i = 0; i < instrValue.args.length; i++) {
const arg = instrValue.args[i];
const place = arg.kind === 'Identifier' ? arg : arg.place;
if (effects !== null) {
/*
* If effects are inferred for an argument, we should fail invalid
* mutating effects
*/
state.referenceAndRecordEffects(
freezeActions,
place,
effects[i],
ValueReason.Other,
);
} else {
state.referenceAndRecordEffects(
freezeActions,
place,
Effect.ConditionallyMutate,
ValueReason.Other,
);
}
/*
* If effects are inferred for an argument, we should fail invalid
* mutating effects
*/
state.referenceAndRecordEffects(
freezeActions,
place,
getArgumentEffect(effects != null ? effects[i] : null, arg),
ValueReason.Other,
);
hasCaptureArgument ||= place.effect === Effect.Capture;
}
if (signature !== null) {
Expand Down Expand Up @@ -2049,3 +2055,31 @@ function areArgumentsImmutableAndNonMutating(
}
return true;
}

function getArgumentEffect(
signatureEffect: Effect | null,
arg: Place | SpreadPattern,
): Effect {
if (signatureEffect != null) {
if (arg.kind === 'Identifier') {
return signatureEffect;
} else if (
signatureEffect === Effect.Mutate ||
signatureEffect === Effect.ConditionallyMutate
) {
return signatureEffect;
} else {
// see call-spread-argument-mutable-iterator test fixture
if (signatureEffect === Effect.Freeze) {
CompilerError.throwTodo({
reason: 'Support spread syntax for hook arguments',
loc: arg.place.loc,
});
}
// effects[i] is Effect.Capture | Effect.Read | Effect.Store
return Effect.ConditionallyMutate;
}
} else {
return Effect.ConditionallyMutate;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@

## Input

```javascript
function useBar({arg}) {
/**
* Note that mutableIterator is mutated by the later object spread. Therefore,
* `s.values()` should be memoized within the same block as the object spread.
* In terms of compiler internals, they should have the same reactive scope.
*/
const obj = {};
const s = new Set([obj, 5, 4]);
const mutableIterator = s.values();
const arr = [...mutableIterator];

obj.x = arg;
return arr;
}

export const FIXTURE_ENTRYPOINT = {
fn: useBar,
params: [{arg: 3}],
sequentialRenders: [{arg: 3}, {arg: 3}, {arg: 4}],
};

```

## Code

```javascript
import { c as _c } from "react/compiler-runtime";
function useBar(t0) {
const $ = _c(2);
const { arg } = t0;
let arr;
if ($[0] !== arg) {
const obj = {};
const s = new Set([obj, 5, 4]);
const mutableIterator = s.values();
arr = [...mutableIterator];

obj.x = arg;
$[0] = arg;
$[1] = arr;
} else {
arr = $[1];
}
return arr;
}

export const FIXTURE_ENTRYPOINT = {
fn: useBar,
params: [{ arg: 3 }],
sequentialRenders: [{ arg: 3 }, { arg: 3 }, { arg: 4 }],
};

```
### Eval output
(kind: ok) [{"x":3},5,4]
[{"x":3},5,4]
[{"x":4},5,4]
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
function useBar({arg}) {
/**
* Note that mutableIterator is mutated by the later object spread. Therefore,
* `s.values()` should be memoized within the same block as the object spread.
* In terms of compiler internals, they should have the same reactive scope.
*/
const obj = {};
const s = new Set([obj, 5, 4]);
const mutableIterator = s.values();
const arr = [...mutableIterator];

obj.x = arg;
return arr;
}

export const FIXTURE_ENTRYPOINT = {
fn: useBar,
params: [{arg: 3}],
sequentialRenders: [{arg: 3}, {arg: 3}, {arg: 4}],
};
Original file line number Diff line number Diff line change
Expand Up @@ -55,26 +55,20 @@ import { c as _c } from "react/compiler-runtime"; /**

function useBar(t0) {
"use memo";
const $ = _c(3);
const $ = _c(2);
const { arg } = t0;
let t1;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
if ($[0] !== arg) {
const s = new Set([1, 5, 4]);
t1 = s.values();
$[0] = t1;
} else {
t1 = $[0];
}
const mutableIterator = t1;
let t2;
if ($[1] !== arg) {
t2 = [arg, ...mutableIterator];
$[1] = arg;
$[2] = t2;
const mutableIterator = s.values();

t1 = [arg, ...mutableIterator];
$[0] = arg;
$[1] = t1;
} else {
t2 = $[2];
t1 = $[1];
}
return t2;
return t1;
}

export const FIXTURE_ENTRYPOINT = {
Expand All @@ -84,4 +78,8 @@ export const FIXTURE_ENTRYPOINT = {
};

```
### Eval output
(kind: ok) [3,1,5,4]
[3,1,5,4]
[4,1,5,4]
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@

## Input

```javascript
import {useIdentity} from 'shared-runtime';

function useFoo() {
const it = new Set([1, 2]).values();
useIdentity();
return Math.max(...it);
}

export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [{}],
sequentialRenders: [{}, {}],
};

```

## Code

```javascript
import { useIdentity } from "shared-runtime";

function useFoo() {
const it = new Set([1, 2]).values();
useIdentity();
return Math.max(...it);
}

export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [{}],
sequentialRenders: [{}, {}],
};

```
### Eval output
(kind: ok) 2
2
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {useIdentity} from 'shared-runtime';

function useFoo() {
const it = new Set([1, 2]).values();
useIdentity();
return Math.max(...it);
}

export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [{}],
sequentialRenders: [{}, {}],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@

## Input

```javascript
import {useIdentity} from 'shared-runtime';

function Component() {
const items = makeArray(0, 1, 2, null, 4, false, 6);
return useIdentity(...items.values());
}

export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [],
sequentialRenders: [{}, {}],
};

```


## Error

```
3 | function Component() {
4 | const items = makeArray(0, 1, 2, null, 4, false, 6);
> 5 | return useIdentity(...items.values());
| ^^^^^^^^^^^^^^ Todo: Support spread syntax for hook arguments (5:5)
6 | }
7 |
8 | export const FIXTURE_ENTRYPOINT = {
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {useIdentity} from 'shared-runtime';

function Component() {
const items = makeArray(0, 1, 2, null, 4, false, 6);
return useIdentity(...items.values());
}

export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [],
sequentialRenders: [{}, {}],
};
Loading

0 comments on commit de8fb89

Please sign in to comment.