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
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {CompilerError} from '../CompilerError';
import {
DeclarationId,
Environment,
Identifier,
InstructionId,
Pattern,
Place,
Expand All @@ -24,7 +25,7 @@ import {
isMutableEffect,
} from '../HIR';
import {getFunctionCallSignature} from '../Inference/InferReferenceEffects';
import {assertExhaustive} from '../Utils/utils';
import {assertExhaustive, getOrInsertDefault} from '../Utils/utils';
import {getPlaceScope} from './BuildReactiveBlocks';
import {
ReactiveFunctionTransform,
Expand Down Expand Up @@ -935,6 +936,11 @@ class PruneScopesTransform extends ReactiveFunctionTransform<
Set<DeclarationId>
> {
prunedScopes: Set<ScopeId> = new Set();
/**
* Track reassignments so we can correctly set `pruned` flags for
* inlined useMemos.
*/
reassignments: Map<DeclarationId, Set<Identifier>> = new Map();
Comment on lines +939 to +943
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


override transformScope(
scopeBlock: ReactiveScopeBlock,
Expand Down Expand Up @@ -977,24 +983,45 @@ class PruneScopesTransform extends ReactiveFunctionTransform<
}
}

/**
* If we pruned the scope for a non-escaping value, we know it doesn't
* need to be memoized. Remove associated `Memoize` instructions so that
* we don't report false positives on "missing" memoization of these values.
*/
override transformInstruction(
instruction: ReactiveInstruction,
state: Set<DeclarationId>,
): Transformed<ReactiveStatement> {
this.traverseInstruction(instruction, state);

/**
* If we pruned the scope for a non-escaping value, we know it doesn't
* need to be memoized. Remove associated `Memoize` instructions so that
* we don't report false positives on "missing" memoization of these values.
*/
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

const ids = getOrInsertDefault(
this.reassignments,
value.lvalue.place.identifier.declarationId,
new Set(),
);
ids.add(value.value.identifier);
} else if (value.kind === 'FinishMemoize') {
let decls;
if (value.decl.identifier.scope == null) {
/**
* If the manual memo was a useMemo that got inlined, iterate through
* all reassignments to the iife temporary to ensure they're memoized.
*/
decls = this.reassignments.get(value.decl.identifier.declarationId) ?? [
value.decl.identifier,
];
} else {
decls = [value.decl.identifier];
}

if (
identifier.scope !== null &&
this.prunedScopes.has(identifier.scope.id)
[...decls].every(
decl => decl.scope == null || this.prunedScopes.has(decl.scope.id),
)
) {
instruction.value.pruned = true;
value.pruned = true;
Comment on lines +1020 to +1024
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

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
ReactiveFunctionVisitor,
visitReactiveFunction,
} from '../ReactiveScopes/visitors';
import {getOrInsertDefault} from '../Utils/utils';

/**
* Validates that all explicit manual memoization (useMemo/useCallback) was accurately
Expand All @@ -52,6 +53,16 @@ export function validatePreservedManualMemoization(fn: ReactiveFunction): void {
const DEBUG = false;

type ManualMemoBlockState = {
/**
* Tracks reassigned temporaries.
* This is necessary because useMemo calls are usually inlined.
* Inlining produces a `let` declaration, followed by reassignments
* to the newly declared variable (one per return statement).
* Since InferReactiveScopes does not merge scopes across reassigned
* variables (except in the case of a mutate-after-phi), we need to
* track reassignments to validate we're retaining manual memo.
*/
reassignments: Map<DeclarationId, Set<Identifier>>;
// The source of the original memoization, used when reporting errors
loc: SourceLocation;

Expand Down Expand Up @@ -434,6 +445,18 @@ class Visitor extends ReactiveFunctionVisitor<VisitorState> {
*/
this.recordTemporaries(instruction, state);
const value = instruction.value;
if (
value.kind === 'StoreLocal' &&
value.lvalue.kind === 'Reassign' &&
state.manualMemoState != null
) {
const ids = getOrInsertDefault(
state.manualMemoState.reassignments,
value.lvalue.place.identifier.declarationId,
new Set(),
);
ids.add(value.value.identifier);
}
if (value.kind === 'StartMemoize') {
let depsFromSource: Array<ManualMemoDependency> | null = null;
if (value.deps != null) {
Expand All @@ -451,6 +474,7 @@ class Visitor extends ReactiveFunctionVisitor<VisitorState> {
decls: new Set(),
depsFromSource,
manualMemoId: value.manualMemoId,
reassignments: new Map(),
};

for (const {identifier, loc} of eachInstructionValueOperand(
Expand Down Expand Up @@ -483,20 +507,34 @@ class Visitor extends ReactiveFunctionVisitor<VisitorState> {
suggestions: null,
},
);
const reassignments = state.manualMemoState.reassignments;
state.manualMemoState = null;
if (!value.pruned) {
for (const {identifier, loc} of eachInstructionValueOperand(
value as InstructionValue,
)) {
if (isUnmemoized(identifier, this.scopes)) {
state.errors.push({
reason:
'React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. This value was memoized in source but not in compilation output.',
description: null,
severity: ErrorSeverity.CannotPreserveMemoization,
loc,
suggestions: null,
});
let decls;
if (identifier.scope == null) {
/**
* If the manual memo was a useMemo that got inlined, iterate through
* all reassignments to the iife temporary to ensure they're memoized.
*/
decls = reassignments.get(identifier.declarationId) ?? [identifier];
} else {
decls = [identifier];
}

for (const identifier of decls) {
if (isUnmemoized(identifier, this.scopes)) {
state.errors.push({
reason:
'React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. This value was memoized in source but not in compilation output.',
description: null,
severity: ErrorSeverity.CannotPreserveMemoization,
loc,
suggestions: null,
});
}
}
}
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@

## Input

```javascript
// @validatePreserveExistingMemoizationGuarantees

import {useMemo} from 'react';
import {useHook} from 'shared-runtime';

// useMemo values may not be memoized in Forget output if we
// infer that their deps always invalidate.
// This is technically a false positive as the useMemo in source
// was effectively a no-op
function useFoo(props) {
const x = [];
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

}

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

```


## Error

```
13 | x.push(props);
14 |
> 15 | return useMemo(() => [x], [x]);
| ^^^^^^^^^^^^^^^^^^^^^^^ CannotPreserveMemoization: React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. This value was memoized in source but not in compilation output. (15:15)
16 | }
17 |
18 | export const FIXTURE_ENTRYPOINT = {
```


Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import {useHook} from 'shared-runtime';

// useMemo values may not be memoized in Forget output if we
// infer that their deps always invalidate.
// This is still correct as the useMemo in source was effectively
// a no-op already.
// This is technically a false positive as the useMemo in source
// was effectively a no-op
function useFoo(props) {
const x = [];
useHook();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@

## Input

```javascript
// @validatePreserveExistingMemoizationGuarantees:true

import {useRef, useMemo} from 'react';
import {makeArray} from 'shared-runtime';

function useFoo() {
const r = useRef();
return useMemo(() => makeArray(r), []);
}

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

```


## Error

```
6 | function useFoo() {
7 | const r = useRef();
> 8 | return useMemo(() => makeArray(r), []);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ CannotPreserveMemoization: React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. This value was memoized in source but not in compilation output. (8:8)
9 | }
10 |
11 | export const FIXTURE_ENTRYPOINT = {
```


Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@

## Input

```javascript
// @validatePreserveExistingMemoizationGuarantees

import {useMemo} from 'react';
import {identity} from 'shared-runtime';

function useFoo(cond) {
useMemo(() => {
if (cond) {
return 2;
} else {
return identity(5);
}
}, [cond]);
}

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

```

## Code

```javascript
// @validatePreserveExistingMemoizationGuarantees

import { useMemo } from "react";
import { identity } from "shared-runtime";

function useFoo(cond) {
let t0;
if (cond) {
t0 = 2;
} else {
t0 = identity(5);
}
}

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

```

### Eval output
(kind: ok)
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// @validatePreserveExistingMemoizationGuarantees

import {useMemo} from 'react';
import {identity} from 'shared-runtime';

function useFoo(cond) {
useMemo(() => {
if (cond) {
return 2;
} else {
return identity(5);
}
}, [cond]);
}

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