Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[compiler] Patch ValidatePreserveMemo to bailout correctly for refs #30603

Merged
merged 4 commits into from
Aug 8, 2024

Conversation

mofeiZ
Copy link
Contributor

@mofeiZ mofeiZ commented Aug 6, 2024

Stack from ghstack (oldest at bottom):


Background
ValidatePreserveExistingMemo checks that useMemos are preserved by checking that the returned result of the existing useMemo/useCallbacks either:

  1. is never assigned a reactive scope (e.g. inferred to be a global or primitive)
  2. has an unpruned reactive scope
    We prune scopes for a few reasons. This is a bit overly conservative as there are valid reasons for pruning (see special-casing for PruneAlwaysInvalidatingScopes). Note that this bugfix actually exposes some of these. We can either special-case more pruning passes to be valid (potentially introducing PrunedScope.reason) or continue to be conservative to retain source semantics.

The bug
When inlining useMemo, we convert the useMemo to an iife and inline it, synthesizing a temporary variable for recording the iife return value.

// source
useMemo(() => foo(), ...);

// hir
$0 = Decl t$0
$1 = LoadGlobal "foo"
$2 = CallExpression $1
$3 = Reassign t$0 = $2
$4 = FinishMemoize decl=t$0  // validation marker

$2 gets assigned a reactive scope. But t$0 is never mutated after the reassignment (note that this is impossible by construction -- validatePreserveExistingMemo would fail earlier in inferReferenceEffects in that case), so it would never receive a scope. We incorrectly check t$0 (instead of $2) for scope pruning

This PR
This PR fixes a bug around ValidatePreserveUseMemo + iife inlining. For useMemos that are successfully inlined, we previously always hit case 1: assuming that useMemo return values don't need reactive scopes due to type inference.

This PR patches validatePreserveExistingMemo to check through reassignments to the FinishMemoize value. Since FinishMemoize only ever records rvalues (the result of the useMemo / useCallback call itself), this should only affect inlined useMemos.

Following the same example, we previously would check that t$0 preserves existing memo semantics. Now, we check $2 instead.

Copy link

vercel bot commented Aug 6, 2024

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
react-compiler-playground ✅ Ready (Inspect) Visit Preview 💬 Add feedback Aug 7, 2024 11:23pm

… for refs"


---
**Background**
ValidatePreserveExistingMemo checks that useMemos are preserved by checking that the returned result of the existing `useMemo`/`useCallbacks` either:
1. is never assigned a reactive scope (e.g. inferred to be a global or primitive)
2. has an *unpruned* reactive scope
We prune scopes for a few reasons. This is a bit overly conservative as there are valid reasons for pruning (see special-casing for  `PruneAlwaysInvalidatingScopes`). Note that this bugfix actually exposes some of these. We can either special-case more pruning passes to be valid (potentially introducing `PrunedScope.reason`) or continue to be conservative to retain source semantics.

**This PR**
This PR fixes a bug around ValidatePreserveUseMemo + iife inlining. For useMemos that are successfully inlined, we previously always hit case 1: assuming that useMemo return values don't need reactive scopes due to type inference.

When inlining useMemo, we convert the useMemo to an iife and inline it, synthesizing a temporary variable for recording the iife return value.
```js
// source
useMemo(() => foo(), ...);

// hir
$0 = Decl t$0
$1 = LoadGlobal "foo"
$2 = CallExpression $1
$3 = Reassign t$0 = $2
$4 = FinishMemoize decl=t$0  // validation marker
```

`$2` gets assigned a reactive scope. But `t$0` is never mutated after the reassignment (note that this is impossible by construction -- `validatePreserveExistingMemo` would fail earlier in inferReferenceEffects in that case), so it does not get a scope.

This PR patches `validatePreserveExistingMemo` to check through reassignments to the `FinishMemoize` value. Since FinishMemoize only ever records rvalues (the result of the useMemo / useCallback call itself), this should only affect inlined useMemos.

Following the same example, we previously would check that `t$0` preserves existing memo semantics. Now, we check `$2` instead.

TODO: test by syncing internally

[ghstack-poisoned]
@mofeiZ mofeiZ marked this pull request as ready for review August 6, 2024 03:03
mofeiZ added a commit that referenced this pull request Aug 6, 2024
ghstack-source-id: 7013d50a4eae1fdbf85a3fd996f4daa4235374c0
Pull Request resolved: #30603
useHook();
x.push(props);

return useMemo(() => [x], [x]);
Copy link
Contributor Author

@mofeiZ mofeiZ Aug 6, 2024

Choose a reason for hiding this comment

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

Note this bails out because we don't have the same special-casing for pruning always-invalidating scopes as we do for non-escaping scope decls.

Hopefully this is a rare case, although it makes sense to add special handling for it (more so than the logic we already have for PruneNonEscapingScopes, which arguably changes program semantics)

Copy link
Contributor

Choose a reason for hiding this comment

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

I think it makes sense to add special-casing for pruning of always invalidating scopes to avoid this false positive

… for refs"


---
**Background**
ValidatePreserveExistingMemo checks that useMemos are preserved by checking that the returned result of the existing `useMemo`/`useCallbacks` either:
1. is never assigned a reactive scope (e.g. inferred to be a global or primitive)
2. has an *unpruned* reactive scope
We prune scopes for a few reasons. This is a bit overly conservative as there are valid reasons for pruning (see special-casing for  `PruneAlwaysInvalidatingScopes`). Note that this bugfix actually exposes some of these. We can either special-case more pruning passes to be valid (potentially introducing `PrunedScope.reason`) or continue to be conservative to retain source semantics.

**This PR**
This PR fixes a bug around ValidatePreserveUseMemo + iife inlining. For useMemos that are successfully inlined, we previously always hit case 1: assuming that useMemo return values don't need reactive scopes due to type inference.

When inlining useMemo, we convert the useMemo to an iife and inline it, synthesizing a temporary variable for recording the iife return value.
```js
// source
useMemo(() => foo(), ...);

// hir
$0 = Decl t$0
$1 = LoadGlobal "foo"
$2 = CallExpression $1
$3 = Reassign t$0 = $2
$4 = FinishMemoize decl=t$0  // validation marker
```

`$2` gets assigned a reactive scope. But `t$0` is never mutated after the reassignment (note that this is impossible by construction -- `validatePreserveExistingMemo` would fail earlier in inferReferenceEffects in that case), so it does not get a scope.

This PR patches `validatePreserveExistingMemo` to check through reassignments to the `FinishMemoize` value. Since FinishMemoize only ever records rvalues (the result of the useMemo / useCallback call itself), this should only affect inlined useMemos.

Following the same example, we previously would check that `t$0` preserves existing memo semantics. Now, we check `$2` instead.

TODO: test by syncing internally

[ghstack-poisoned]
mofeiZ added a commit that referenced this pull request Aug 6, 2024
ghstack-source-id: 73fc47f23488b392644ffa27f124c309dc82b69c
Pull Request resolved: #30603
Comment on lines +939 to +943
/**
* Track reassignments so we can correctly set `pruned` flags for
* inlined useMemos.
*/
reassignments: Map<DeclarationId, Set<Identifier>> = new Map();
Copy link
Contributor Author

@mofeiZ mofeiZ Aug 6, 2024

Choose a reason for hiding this comment

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

This is the same logic (and comments) as in ValidatePreserveManualMemo. This was needed to avoid a regression of 0c86667

Comment on lines +1020 to +1024
[...decls].every(
decl => decl.scope == null || this.prunedScopes.has(decl.scope.id),
)
) {
instruction.value.pruned = true;
value.pruned = true;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

See newly added test fixtures for why this is needed

… for refs"


---
**Background**
ValidatePreserveExistingMemo checks that useMemos are preserved by checking that the returned result of the existing `useMemo`/`useCallbacks` either:
1. is never assigned a reactive scope (e.g. inferred to be a global or primitive)
2. has an *unpruned* reactive scope
We prune scopes for a few reasons. This is a bit overly conservative as there are valid reasons for pruning (see special-casing for  `PruneAlwaysInvalidatingScopes`). Note that this bugfix actually exposes some of these. We can either special-case more pruning passes to be valid (potentially introducing `PrunedScope.reason`) or continue to be conservative to retain source semantics.

**The bug**
When inlining useMemo, we convert the useMemo to an iife and inline it, synthesizing a temporary variable for recording the iife return value.
```js
// source
useMemo(() => foo(), ...);

// hir
$0 = Decl t$0
$1 = LoadGlobal "foo"
$2 = CallExpression $1
$3 = Reassign t$0 = $2
$4 = FinishMemoize decl=t$0  // validation marker
```

`$2` gets assigned a reactive scope. But `t$0` is never mutated after the reassignment (note that this is impossible by construction -- `validatePreserveExistingMemo` would fail earlier in inferReferenceEffects in that case), so it would never receive a scope. We incorrectly check `t$0` (instead of `$2`) for scope pruning 

**This PR**
This PR fixes a bug around ValidatePreserveUseMemo + iife inlining. For useMemos that are successfully inlined, we previously always hit case 1: assuming that useMemo return values don't need reactive scopes due to type inference.

This PR patches `validatePreserveExistingMemo` to check through reassignments to the `FinishMemoize` value. Since FinishMemoize only ever records rvalues (the result of the useMemo / useCallback call itself), this should only affect inlined useMemos.

Following the same example, we previously would check that `t$0` preserves existing memo semantics. Now, we check `$2` instead.

TODO: test by syncing internally

[ghstack-poisoned]
if (instruction.value.kind === 'FinishMemoize') {
const identifier = instruction.value.decl.identifier;
const value = instruction.value;
if (value.kind === 'StoreLocal' && value.lvalue.kind === 'Reassign') {
Copy link
Contributor

Choose a reason for hiding this comment

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

There are other forms of reassignment, should we handle Destructure, PostfixUpdate, and PrefixUpdate too?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, this is intended to only map inlined iife return values, which we replace with a reassignment to a generated + promoted temporary.

This is why the affected test cases are all useMemo related

useHook();
x.push(props);

return useMemo(() => [x], [x]);
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it makes sense to add special-casing for pruning of always invalidating scopes to avoid this false positive

@mofeiZ mofeiZ merged commit 9a259b2 into gh/mofeiZ/16/base Aug 8, 2024
19 checks passed
mofeiZ added a commit that referenced this pull request Aug 8, 2024
ghstack-source-id: b9c13bf5f858123b68c9e89ca8c7629cf2b90a15
Pull Request resolved: #30603
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.

4 participants