[compiler] Fix for false positive mutation of destructured spread object (#33786)
When destructuring, spread creates a new mutable object that _captures_ part of the original rvalue. This new value is safe to modify. When making this change I realized that we weren't inferring array pattern spread as creating an array (in type inference) so I also added that here.
Joseph Savona committed
Jul 24, 2025 at 15:16 UTC
448f781a52d62042341d2411d1352d705ce2cbfe
8 files changed
+291
-23
compiler/packages/babel-plugin-react-compiler/src/HIR/visitors.ts
+45
@@ -345,6 +345,51 @@ export function* eachPatternOperand(pattern: Pattern): Iterable<Place> {
345
}
346
}
347
348
+export function* eachPatternItem(
349
+ pattern: Pattern,
350
+): Iterable<Place | SpreadPattern> {
351
+ switch (pattern.kind) {
352
+ case 'ArrayPattern': {
353
+ for (const item of pattern.items) {
354
+ if (item.kind === 'Identifier') {
355
+ yield item;
356
+ } else if (item.kind === 'Spread') {
357
+ yield item;
358
+ } else if (item.kind === 'Hole') {
359
+ continue;
360
+ } else {
361
+ assertExhaustive(
362
+ item,
363
+ `Unexpected item kind \`${(item as any).kind}\``,
364
+ );
365
+ }
366
+ }
367
+ break;
368
+ }
369
+ case 'ObjectPattern': {
370
+ for (const property of pattern.properties) {
371
+ if (property.kind === 'ObjectProperty') {
372
+ yield property.place;
373
+ } else if (property.kind === 'Spread') {
374
+ yield property;
375
+ } else {
376
+ assertExhaustive(
377
+ property,
378
+ `Unexpected item kind \`${(property as any).kind}\``,
379
+ );
380
+ }
381
+ }
382
+ break;
383
+ }
384
+ default: {
385
+ assertExhaustive(
386
+ pattern,
387
+ `Unexpected pattern kind \`${(pattern as any).kind}\``,
388
+ );
389
+ }
390
+ }
391
+}
392
+
393
export function mapInstructionLValues(
394
instr: Instruction,
395
fn: (place: Place) => Place,
compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts
+21
-6
@@ -36,8 +36,8 @@ import {
36
ValueReason,
37
} from '../HIR';
38
import {
39
- eachInstructionValueLValue,
39
eachInstructionValueOperand,
40
+ eachPatternItem,
41
eachTerminalOperand,
42
eachTerminalSuccessor,
43
} from '../HIR/visitors';
@@ -1864,19 +1864,34 @@ function computeSignatureForInstruction(
1864
break;
1865
}
1866
case 'Destructure': {
1867
- for (const patternLValue of eachInstructionValueLValue(value)) {
1868
- if (isPrimitiveType(patternLValue.identifier)) {
1867
+ for (const patternItem of eachPatternItem(value.lvalue.pattern)) {
1868
+ const place =
1869
+ patternItem.kind === 'Identifier' ? patternItem : patternItem.place;
1870
+ if (isPrimitiveType(place.identifier)) {
1871
effects.push({
1872
kind: 'Create',
1871
- into: patternLValue,
1873
+ into: place,
1874
value: ValueKind.Primitive,
1875
reason: ValueReason.Other,
1876
});
1875
- } else {
1877
+ } else if (patternItem.kind === 'Identifier') {
1878
effects.push({
1879
kind: 'CreateFrom',
1880
from: value.value,
1879
- into: patternLValue,
1881
+ into: place,
1882
+ });
1883
+ } else {
1884
+ // Spread creates a new object/array that captures from the RValue
1885
+ effects.push({
1886
+ kind: 'Create',
1887
+ into: place,
1888
+ reason: ValueReason.Other,
1889
+ value: ValueKind.Mutable,
1890
+ });
1891
+ effects.push({
1892
+ kind: 'Capture',
1893
+ from: value.value,
1894
+ into: place,
1895
});
1896
}
1897
}
compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts
+6
@@ -360,6 +360,12 @@ function* generateInstructionTypes(
360
value: makePropertyLiteral(propertyName),
361
},
362
});
363
+ } else if (item.kind === 'Spread') {
364
+ // Array pattern spread always creates an array
365
+ yield equation(item.place.identifier.type, {
366
+ kind: 'Object',
367
+ shapeId: BuiltInArrayId,
368
+ });
369
} else {
370
break;
371
}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/array-pattern-spread-creates-array.expect.md
new
+104
@@ -0,0 +1,104 @@
1
+
2
+## Input
3
+
4
+```javascript
5
+// @validatePreserveExistingMemoizationGuarantees
6
+import {useMemo} from 'react';
7
+import {makeObject_Primitives, ValidateMemoization} from 'shared-runtime';
8
+
9
+function Component(props) {
10
+ // Should memoize independently
11
+ const x = useMemo(() => makeObject_Primitives(), []);
12
+
13
+ const rest = useMemo(() => {
14
+ const [_, ...rest] = props.array;
15
+
16
+ // Should be inferred as Array.proto.push which doesn't mutate input
17
+ rest.push(x);
18
+ return rest;
19
+ });
20
+
21
+ return (
22
+ <>
23
+ <ValidateMemoization inputs={[]} output={x} />
24
+ <ValidateMemoization inputs={[props.array]} output={rest} />
25
+ </>
26
+ );
27
+}
28
+
29
+export const FIXTURE_ENTRYPOINT = {
30
+ fn: Component,
31
+ params: [{array: [0, 1, 2]}],
32
+};
33
+
34
+```
35
+
36
+## Code
37
+
38
+```javascript
39
+import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees
40
+import { useMemo } from "react";
41
+import { makeObject_Primitives, ValidateMemoization } from "shared-runtime";
42
+
43
+function Component(props) {
44
+ const $ = _c(9);
45
+ let t0;
46
+ if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
47
+ t0 = makeObject_Primitives();
48
+ $[0] = t0;
49
+ } else {
50
+ t0 = $[0];
51
+ }
52
+ const x = t0;
53
+ let rest;
54
+ if ($[1] !== props.array) {
55
+ [, ...rest] = props.array;
56
+
57
+ rest.push(x);
58
+ $[1] = props.array;
59
+ $[2] = rest;
60
+ } else {
61
+ rest = $[2];
62
+ }
63
+ const rest_0 = rest;
64
+ let t1;
65
+ if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
66
+ t1 = <ValidateMemoization inputs={[]} output={x} />;
67
+ $[3] = t1;
68
+ } else {
69
+ t1 = $[3];
70
+ }
71
+ let t2;
72
+ if ($[4] !== props.array) {
73
+ t2 = [props.array];
74
+ $[4] = props.array;
75
+ $[5] = t2;
76
+ } else {
77
+ t2 = $[5];
78
+ }
79
+ let t3;
80
+ if ($[6] !== rest_0 || $[7] !== t2) {
81
+ t3 = (
82
+ <>
83
+ {t1}
84
+ <ValidateMemoization inputs={t2} output={rest_0} />
85
+ </>
86
+ );
87
+ $[6] = rest_0;
88
+ $[7] = t2;
89
+ $[8] = t3;
90
+ } else {
91
+ t3 = $[8];
92
+ }
93
+ return t3;
94
+}
95
+
96
+export const FIXTURE_ENTRYPOINT = {
97
+ fn: Component,
98
+ params: [{ array: [0, 1, 2] }],
99
+};
100
+
101
+```
102
+
103
+### Eval output
104
+(kind: ok) <div>{"inputs":[],"output":{"a":0,"b":"value1","c":true}}</div><div>{"inputs":[[0,1,2]],"output":[1,2,{"a":0,"b":"value1","c":true}]}</div>
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/array-pattern-spread-creates-array.js
new
+28
@@ -0,0 +1,28 @@
1
+// @validatePreserveExistingMemoizationGuarantees
2
+import {useMemo} from 'react';
3
+import {makeObject_Primitives, ValidateMemoization} from 'shared-runtime';
4
+
5
+function Component(props) {
6
+ // Should memoize independently
7
+ const x = useMemo(() => makeObject_Primitives(), []);
8
+
9
+ const rest = useMemo(() => {
10
+ const [_, ...rest] = props.array;
11
+
12
+ // Should be inferred as Array.proto.push which doesn't mutate input
13
+ rest.push(x);
14
+ return rest;
15
+ });
16
+
17
+ return (
18
+ <>
19
+ <ValidateMemoization inputs={[]} output={x} />
20
+ <ValidateMemoization inputs={[props.array]} output={rest} />
21
+ </>
22
+ );
23
+}
24
+
25
+export const FIXTURE_ENTRYPOINT = {
26
+ fn: Component,
27
+ params: [{array: [0, 1, 2]}],
28
+};
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-local-mutation-of-new-object-from-destructured-prop.expect.md
new
+62
@@ -0,0 +1,62 @@
1
+
2
+## Input
3
+
4
+```javascript
5
+import {Stringify} from 'shared-runtime';
6
+
7
+function Component(props) {
8
+ const {a} = props;
9
+ const {b, ...rest} = a;
10
+ // Local mutation of `rest` is allowed since it is a newly allocated object
11
+ rest.value = props.value;
12
+ return <Stringify rest={rest} />;
13
+}
14
+
15
+export const FIXTURE_ENTRYPOINT = {
16
+ fn: Component,
17
+ params: [{a: {b: 0, other: 'other'}, value: 42}],
18
+};
19
+
20
+```
21
+
22
+## Code
23
+
24
+```javascript
25
+import { c as _c } from "react/compiler-runtime";
26
+import { Stringify } from "shared-runtime";
27
+
28
+function Component(props) {
29
+ const $ = _c(5);
30
+ const { a } = props;
31
+ let rest;
32
+ if ($[0] !== a || $[1] !== props.value) {
33
+ const { b, ...t0 } = a;
34
+ rest = t0;
35
+
36
+ rest.value = props.value;
37
+ $[0] = a;
38
+ $[1] = props.value;
39
+ $[2] = rest;
40
+ } else {
41
+ rest = $[2];
42
+ }
43
+ let t0;
44
+ if ($[3] !== rest) {
45
+ t0 = <Stringify rest={rest} />;
46
+ $[3] = rest;
47
+ $[4] = t0;
48
+ } else {
49
+ t0 = $[4];
50
+ }
51
+ return t0;
52
+}
53
+
54
+export const FIXTURE_ENTRYPOINT = {
55
+ fn: Component,
56
+ params: [{ a: { b: 0, other: "other" }, value: 42 }],
57
+};
58
+
59
+```
60
+
61
+### Eval output
62
+(kind: ok) <div>{"rest":{"other":"other","value":42}}</div>
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-local-mutation-of-new-object-from-destructured-prop.js
new
+14
@@ -0,0 +1,14 @@
1
+import {Stringify} from 'shared-runtime';
2
+
3
+function Component(props) {
4
+ const {a} = props;
5
+ const {b, ...rest} = a;
6
+ // Local mutation of `rest` is allowed since it is a newly allocated object
7
+ rest.value = props.value;
8
+ return <Stringify rest={rest} />;
9
+}
10
+
11
+export const FIXTURE_ENTRYPOINT = {
12
+ fn: Component,
13
+ params: [{a: {b: 0, other: 'other'}, value: 42}],
14
+};
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-undefined-expression-of-jsxexpressioncontainer.expect.md
+11
-17
@@ -48,32 +48,26 @@ import { c as _c } from "react/compiler-runtime";
48
import { StaticText1, Stringify, Text } from "shared-runtime";
49
50
function Component(props) {
51
- const $ = _c(6);
51
+ const $ = _c(4);
52
const { buttons } = props;
53
- let nonPrimaryButtons;
54
- if ($[0] !== buttons) {
55
- [, ...nonPrimaryButtons] = buttons;
56
- $[0] = buttons;
57
- $[1] = nonPrimaryButtons;
58
- } else {
59
- nonPrimaryButtons = $[1];
60
- }
53
let t0;
62
- if ($[2] !== nonPrimaryButtons) {
54
+ if ($[0] !== buttons) {
55
+ const [, ...nonPrimaryButtons] = buttons;
56
+
57
t0 = nonPrimaryButtons.map(_temp);
64
- $[2] = nonPrimaryButtons;
65
- $[3] = t0;
58
+ $[0] = buttons;
59
+ $[1] = t0;
60
} else {
67
- t0 = $[3];
61
+ t0 = $[1];
62
}
63
const renderedNonPrimaryButtons = t0;
64
let t1;
71
- if ($[4] !== renderedNonPrimaryButtons) {
65
+ if ($[2] !== renderedNonPrimaryButtons) {
66
t1 = <StaticText1>{renderedNonPrimaryButtons}</StaticText1>;
73
- $[4] = renderedNonPrimaryButtons;
74
- $[5] = t1;
67
+ $[2] = renderedNonPrimaryButtons;
68
+ $[3] = t1;
69
} else {
76
- t1 = $[5];
70
+ t1 = $[3];
71
}
72
return t1;
73
}