Skip to content

Conversation

poteto
Copy link
Member

@poteto poteto commented Aug 29, 2025

The compiler currently fails to preserve manual memoization when components use rest spread destructuring for props:

// OK
function Component(props) {
  const value = useMemo(() => compute(props.foo), [props.foo]);
}

// Manual memo could not be preserved
function Component({...props}) {
  const value = useMemo(() => compute(props.foo), [props.foo]);
}

The issue is that property accesses on rest-spread objects are treated as fully mutable.

This PR introduces a new ShallowMutable ValueKind for rest spreads that come specifically from props. When we access properties from a ShallowMutable value, they're treated as Frozen.

Closes #34313

@meta-cla meta-cla bot added the CLA Signed label Aug 29, 2025
@github-actions github-actions bot added the React Core Team Opened by a member of the React Core Team label Aug 29, 2025
@poteto poteto requested a review from josephsavona August 29, 2025 20:08
Comment on lines 1925 to 1931
let isFromProps = false;
if (context.fn.fnType === 'Component' && context.fn.params.length > 0) {
const props = context.fn.params[0];
const propsPlace = props.kind === 'Identifier' ? props : props.place;
isFromProps = propsPlace.identifier.id === value.value.identifier.id;
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this might be overly specific, but i thought i'd err on the side of specificity for now before generalizing

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to limit it to just props. If it's a rest spread of a frozen object, we can treat that as shallow mutable

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also it should work for array spreads too: const [x, ...rest] = useHook() and for object spread of hook returns, or hook arguments.

Copy link
Member

@josephsavona josephsavona left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great start! See comments for some suggestions and things to make sure we test

Comment on lines 1925 to 1931
let isFromProps = false;
if (context.fn.fnType === 'Component' && context.fn.params.length > 0) {
const props = context.fn.params[0];
const propsPlace = props.kind === 'Identifier' ? props : props.place;
isFromProps = propsPlace.identifier.id === value.value.identifier.id;
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to limit it to just props. If it's a rest spread of a frozen object, we can treat that as shallow mutable

@josephsavona
Copy link
Member

Note that object and array literals can also be treated as shallow mutable if all of their initial elements are frozen.

@poteto poteto changed the title [compiler] Preserve memoization for destructured props [compiler] Improve ShallowMutable detection for frozen sources Sep 3, 2025
The compiler currently fails to preserve manual memoization when components use rest spread destructuring for props:

```js
// OK
function Component(props) {
  const value = useMemo(() => compute(props.foo), [props.foo]);
}

// Manual memo could not be preserved
function Component({...props}) {
  const value = useMemo(() => compute(props.foo), [props.foo]);
}
```

The issue is that property accesses on rest-spread objects are treated as fully mutable.

This PR introduces a new `ShallowMutable` ValueKind for rest spreads that come specifically from props. When we access properties from a `ShallowMutable` value, they're treated as Frozen.

Closes #34313
const fromValue = state.kind(effect.from);
const fromKind = fromValue.kind;
switch (fromKind) {
case ValueKind.ShallowMutable:
Copy link
Member

@josephsavona josephsavona Sep 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm this doesn't seem right, we should keep this as an Assign. The new rule for shallow mutable should be[1]:

Given
  x is ShallowMutable
  CreateFrom y <- x
Then
  y is Frozen

Whereas this basically says

Given
  x is ShallowMutable
  Assign y = x
Then
  y is Frozen (bc immutable capture)

[1] would be nice to update MUTABILITY_ALIASING_MODEL for this, btw

Primitive = 'primitive',
Global = 'global',
Mutable = 'mutable',
ShallowMutable = 'shallowmutable',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you'll need to update mergeValueKinds() in InferMutationAliasingEffects to account for the new type

Comment on lines +1338 to +1343
if (valueInfo && valueInfo.kind === ValueKind.ShallowMutable) {
this.#values.set(value, {
kind: ValueKind.Mutable,
reason: valueInfo.reason,
});
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks right. See my comment elsewhere about updating mergeValueKinds() to account for ShallowMutability. The logic there should basically be to treat ShallowMutable like Mutable when it joins with other things (if Mutable | x => Mutable, then ShallowMutable | x => ShallowMutable). Except ShallowMutable | Mutable => Mutable.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fact that you didn't hit the invariant in mergeValueKinds() also suggests we need some more tests. Something like

function Component(props) {
  let x;
  if (props.cond) {
    x = [props.item]; // shallow mutable
  } else {
    x = []; // regular mutable
  }
  const z = x[0]; // z is mutable
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I must be overlooking things — where do we actually create new instances that are ShallowMutable?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the bug from the PR description still occurs on the latest version of the PR, and the fixtures don't seem different than before the change. We don't actually create ShallowMutable instances anywhere afaict

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe i'm looking too early, this is just exciting!!!!

josephsavona added a commit that referenced this pull request Oct 17, 2025
As part of the new inference model we updated to (correctly) treat destructuring spread as creating a new mutable object. This had the unfortunate side-effect of reducing precision on destructuring of props, though:

```js
function Component({x, ...rest}) {
  const z = rest.z;
  identity(z);
  return <Stringify x={x} z={z} />;
}
```

Memoized as the following, where we don't realize that `z` is actually frozen:

```js
function Component(t0) {
  const $ = _c(6);
  let x;
  let z;
  if ($[0] !== t0) {
    const { x: t1, ...rest } = t0;
    x = t1;
    z = rest.z;
    identity(z);
...
```

#34341 was our first thought of how to do this (thanks @poteto for exploring this idea!). But during review it became clear that it was a bit more complicated than I had thought. So this PR explores a more conservative alternative. The idea is:

* Track known sources of frozen values: component props, hook params, and hook return values.
* Find all object spreads where the rvalue is a known frozen value.
* Look at how such objects are used, and if they are only used to access properties (PropertyLoad/Destructure), pass to hooks, or pass to jsx then we can be very confident the object is not mutated. We consider any such objects to be frozen, even though technically spread creates a new object.

See new fixtures for more examples.
josephsavona added a commit that referenced this pull request Oct 17, 2025
As part of the new inference model we updated to (correctly) treat destructuring spread as creating a new mutable object. This had the unfortunate side-effect of reducing precision on destructuring of props, though:

```js
function Component({x, ...rest}) {
  const z = rest.z;
  identity(z);
  return <Stringify x={x} z={z} />;
}
```

Memoized as the following, where we don't realize that `z` is actually frozen:

```js
function Component(t0) {
  const $ = _c(6);
  let x;
  let z;
  if ($[0] !== t0) {
    const { x: t1, ...rest } = t0;
    x = t1;
    z = rest.z;
    identity(z);
...
```

#34341 was our first thought of how to do this (thanks @poteto for exploring this idea!). But during review it became clear that it was a bit more complicated than I had thought. So this PR explores a more conservative alternative. The idea is:

* Track known sources of frozen values: component props, hook params, and hook return values.
* Find all object spreads where the rvalue is a known frozen value.
* Look at how such objects are used, and if they are only used to access properties (PropertyLoad/Destructure), pass to hooks, or pass to jsx then we can be very confident the object is not mutated. We consider any such objects to be frozen, even though technically spread creates a new object.

See new fixtures for more examples.
josephsavona added a commit that referenced this pull request Oct 17, 2025
As part of the new inference model we updated to (correctly) treat destructuring spread as creating a new mutable object. This had the unfortunate side-effect of reducing precision on destructuring of props, though:

```js
function Component({x, ...rest}) {
  const z = rest.z;
  identity(z);
  return <Stringify x={x} z={z} />;
}
```

Memoized as the following, where we don't realize that `z` is actually frozen:

```js
function Component(t0) {
  const $ = _c(6);
  let x;
  let z;
  if ($[0] !== t0) {
    const { x: t1, ...rest } = t0;
    x = t1;
    z = rest.z;
    identity(z);
...
```

#34341 was our first thought of how to do this (thanks @poteto for exploring this idea!). But during review it became clear that it was a bit more complicated than I had thought. So this PR explores a more conservative alternative. The idea is:

* Track known sources of frozen values: component props, hook params, and hook return values.
* Find all object spreads where the rvalue is a known frozen value.
* Look at how such objects are used, and if they are only used to access properties (PropertyLoad/Destructure), pass to hooks, or pass to jsx then we can be very confident the object is not mutated. We consider any such objects to be frozen, even though technically spread creates a new object.

See new fixtures for more examples.
@josephsavona
Copy link
Member

I tried something simpler in #34900

josephsavona added a commit that referenced this pull request Oct 17, 2025
As part of the new inference model we updated to (correctly) treat
destructuring spread as creating a new mutable object. This had the
unfortunate side-effect of reducing precision on destructuring of props,
though:

```js
function Component({x, ...rest}) {
  const z = rest.z;
  identity(z);
  return <Stringify x={x} z={z} />;
}
```

Memoized as the following, where we don't realize that `z` is actually
frozen:

```js
function Component(t0) {
  const $ = _c(6);
  let x;
  let z;
  if ($[0] !== t0) {
    const { x: t1, ...rest } = t0;
    x = t1;
    z = rest.z;
    identity(z);
...
```

#34341 was our first thought of how to do this (thanks @poteto for
exploring this idea!). But during review it became clear that it was a
bit more complicated than I had thought. So this PR explores a more
conservative alternative. The idea is:

* Track known sources of frozen values: component props, hook params,
and hook return values.
* Find all object spreads where the rvalue is a known frozen value.
* Look at how such objects are used, and if they are only used to access
properties (PropertyLoad/Destructure), pass to hooks, or pass to jsx
then we can be very confident the object is not mutated. We consider any
such objects to be frozen, even though technically spread creates a new
object.

See new fixtures for more examples.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34900).
* __->__ #34900
* #34887
github-actions bot pushed a commit that referenced this pull request Oct 17, 2025
As part of the new inference model we updated to (correctly) treat
destructuring spread as creating a new mutable object. This had the
unfortunate side-effect of reducing precision on destructuring of props,
though:

```js
function Component({x, ...rest}) {
  const z = rest.z;
  identity(z);
  return <Stringify x={x} z={z} />;
}
```

Memoized as the following, where we don't realize that `z` is actually
frozen:

```js
function Component(t0) {
  const $ = _c(6);
  let x;
  let z;
  if ($[0] !== t0) {
    const { x: t1, ...rest } = t0;
    x = t1;
    z = rest.z;
    identity(z);
...
```

#34341 was our first thought of how to do this (thanks @poteto for
exploring this idea!). But during review it became clear that it was a
bit more complicated than I had thought. So this PR explores a more
conservative alternative. The idea is:

* Track known sources of frozen values: component props, hook params,
and hook return values.
* Find all object spreads where the rvalue is a known frozen value.
* Look at how such objects are used, and if they are only used to access
properties (PropertyLoad/Destructure), pass to hooks, or pass to jsx
then we can be very confident the object is not mutated. We consider any
such objects to be frozen, even though technically spread creates a new
object.

See new fixtures for more examples.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34900).
* __->__ #34900
* #34887

DiffTrain build for [c35f6a3](c35f6a3)
github-actions bot pushed a commit that referenced this pull request Oct 17, 2025
As part of the new inference model we updated to (correctly) treat
destructuring spread as creating a new mutable object. This had the
unfortunate side-effect of reducing precision on destructuring of props,
though:

```js
function Component({x, ...rest}) {
  const z = rest.z;
  identity(z);
  return <Stringify x={x} z={z} />;
}
```

Memoized as the following, where we don't realize that `z` is actually
frozen:

```js
function Component(t0) {
  const $ = _c(6);
  let x;
  let z;
  if ($[0] !== t0) {
    const { x: t1, ...rest } = t0;
    x = t1;
    z = rest.z;
    identity(z);
...
```

#34341 was our first thought of how to do this (thanks @poteto for
exploring this idea!). But during review it became clear that it was a
bit more complicated than I had thought. So this PR explores a more
conservative alternative. The idea is:

* Track known sources of frozen values: component props, hook params,
and hook return values.
* Find all object spreads where the rvalue is a known frozen value.
* Look at how such objects are used, and if they are only used to access
properties (PropertyLoad/Destructure), pass to hooks, or pass to jsx
then we can be very confident the object is not mutated. We consider any
such objects to be frozen, even though technically spread creates a new
object.

See new fixtures for more examples.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34900).
* __->__ #34900
* #34887

DiffTrain build for [c35f6a3](c35f6a3)
@poteto poteto closed this Oct 21, 2025
@poteto poteto deleted the pr34341 branch October 21, 2025 15:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed React Core Team Opened by a member of the React Core Team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Compiler Bug]: Destructure component props caused Compilation skipped because existing memoization could not be preserved

2 participants