Skip to content

Commit 957537e

Browse files
committed
[compiler] Fix false positive memo validation
Partial fix for #34262. Consider this example: ```js function useInputValue(input) { const object = React.useMemo(() => { const {value} = transform(input); return {value}; }, [input]); return object; } ``` React Compiler breaks this code into two reactive scopes: * One for `transform(input)` * One for `{value}` When we run ValidatePreserveExistingMemo, we see that the scope for `{value}` has the dependency `value`, whereas the original memoization had the dependency `input`, and throw an error that the dependencies didn't match. In other words, we're flagging the fact that memoized _better than the user_ as a problem. The more complete solution would be to validate that there is a subgraph of reactive scopes with a single input and output node, where the input node has the same dependencies as the original useMemo, and the output has the same outputs. That is true in this case, with the subgraph being the two consecutive scopes mentioned above. But that's complicated. As a shortcut, this PR checks for any dependencies that are defined after the start of the original useMemo. If we find one, we know that it's a case where we were able to memoize more precisely than the original, and we don't report an error on the dependency. We still check that the original _output_ value is able to be memoized, though. So if the scope of `object` were extended, eg with a call to `mutate(object)`, then we'd still correctly report an error that we couldn't preserve memoization.
1 parent d260b0d commit 957537e

7 files changed

+338
-0
lines changed

compiler/packages/babel-plugin-react-compiler/src/Validation/ValidatePreservedManualMemoization.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
GeneratedSource,
1818
Identifier,
1919
IdentifierId,
20+
InstructionId,
2021
InstructionValue,
2122
ManualMemoDependency,
2223
Place,
@@ -109,6 +110,7 @@ type ManualMemoBlockState = {
109110
*/
110111
depsFromSource: Array<ManualMemoDependency> | null;
111112
manualMemoId: number;
113+
start: InstructionId;
112114
};
113115

114116
type VisitorState = {
@@ -234,6 +236,8 @@ function validateInferredDep(
234236
validDepsInMemoBlock: Array<ManualMemoDependency>,
235237
errorState: CompilerError,
236238
memoLocation: SourceLocation,
239+
memoStartInstruction: InstructionId,
240+
scopes: Set<ScopeId>,
237241
): void {
238242
let normalizedDep: ManualMemoDependency;
239243
const maybeNormalizedRoot = temporaries.get(dep.identifier.id);
@@ -271,6 +275,13 @@ function validateInferredDep(
271275
return;
272276
}
273277
}
278+
if (
279+
dep.identifier.mutableRange.start > memoStartInstruction &&
280+
!isUnmemoized(dep.identifier, scopes)
281+
) {
282+
return;
283+
}
284+
274285
let errorDiagnostic: CompareDependencyResult | null = null;
275286
for (const originalDep of validDepsInMemoBlock) {
276287
const compareResult = compareDeps(normalizedDep, originalDep);
@@ -433,6 +444,8 @@ class Visitor extends ReactiveFunctionVisitor<VisitorState> {
433444
state.manualMemoState.depsFromSource,
434445
state.errors,
435446
state.manualMemoState.loc,
447+
state.manualMemoState.start,
448+
this.scopes,
436449
);
437450
}
438451
}
@@ -508,6 +521,7 @@ class Visitor extends ReactiveFunctionVisitor<VisitorState> {
508521
depsFromSource,
509522
manualMemoId: value.manualMemoId,
510523
reassignments: new Map(),
524+
start: instruction.id,
511525
};
512526

513527
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @validatePreserveExistingMemoizationGuarantees
6+
7+
/**
8+
* Repro from https://github.com/facebook/react/issues/34262
9+
*
10+
* The compiler memoizes more precisely than the original code, with two reactive scopes:
11+
* - One for `transform(input)` with `input` as dep
12+
* - One for `{value}` with `value` as dep
13+
*
14+
* When we validate preserving manual memoization we incorrectly reject this, because
15+
* the original memoization had `object` depending on `input` but our scope depends on
16+
* `value`.
17+
*
18+
* This fixture adds a later potential mutation, which extends the scope and should
19+
* fail validation. This confirms that even though we allow the dependency to diverge,
20+
* we still check that the output value is memoized.
21+
*/
22+
function useInputValue(input) {
23+
const object = React.useMemo(() => {
24+
const {value} = transform(input);
25+
return {value};
26+
}, [input]);
27+
mutate(object);
28+
return object;
29+
}
30+
31+
```
32+
33+
34+
## Error
35+
36+
```
37+
Found 1 error:
38+
39+
Memoization: Compilation skipped because existing memoization could not be preserved
40+
41+
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.
42+
43+
error.repro-preserve-memoization-inner-destructured-value-mistaken-as-dependency-later-mutation.ts:19:17
44+
17 | */
45+
18 | function useInputValue(input) {
46+
> 19 | const object = React.useMemo(() => {
47+
| ^^^^^^^^^^^^^^^^^^^^^
48+
> 20 | const {value} = transform(input);
49+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
50+
> 21 | return {value};
51+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
52+
> 22 | }, [input]);
53+
| ^^^^^^^^^^^^^^ Could not preserve existing memoization
54+
23 | mutate(object);
55+
24 | return object;
56+
25 | }
57+
```
58+
59+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// @validatePreserveExistingMemoizationGuarantees
2+
3+
/**
4+
* Repro from https://github.com/facebook/react/issues/34262
5+
*
6+
* The compiler memoizes more precisely than the original code, with two reactive scopes:
7+
* - One for `transform(input)` with `input` as dep
8+
* - One for `{value}` with `value` as dep
9+
*
10+
* When we validate preserving manual memoization we incorrectly reject this, because
11+
* the original memoization had `object` depending on `input` but our scope depends on
12+
* `value`.
13+
*
14+
* This fixture adds a later potential mutation, which extends the scope and should
15+
* fail validation. This confirms that even though we allow the dependency to diverge,
16+
* we still check that the output value is memoized.
17+
*/
18+
function useInputValue(input) {
19+
const object = React.useMemo(() => {
20+
const {value} = transform(input);
21+
return {value};
22+
}, [input]);
23+
mutate(object);
24+
return object;
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @validatePreserveExistingMemoizationGuarantees
6+
7+
import {identity, Stringify, useHook} from 'shared-runtime';
8+
9+
/**
10+
* Repro from https://github.com/facebook/react/issues/34262
11+
*
12+
* The compiler memoizes more precisely than the original code, with two reactive scopes:
13+
* - One for `transform(input)` with `input` as dep
14+
* - One for `{value}` with `value` as dep
15+
*
16+
* When we validate preserving manual memoization we incorrectly reject this, because
17+
* the original memoization had `object` depending on `input` but our scope depends on
18+
* `value`.
19+
*/
20+
function useInputValue(input) {
21+
// Conflate the `identity(input, x)` call with something outside the useMemo,
22+
// to try and break memoization of `value`. This gets correctly flagged since
23+
// the dependency is being mutated
24+
let x = {};
25+
useHook();
26+
const object = React.useMemo(() => {
27+
const {value} = identity(input, x);
28+
return {value};
29+
}, [input, x]);
30+
return object;
31+
}
32+
33+
function Component() {
34+
return <Stringify value={useInputValue({value: 42}).value} />;
35+
}
36+
37+
export const FIXTURE_ENTRYPOINT = {
38+
fn: Component,
39+
params: [{}],
40+
};
41+
42+
```
43+
44+
45+
## Error
46+
47+
```
48+
Found 1 error:
49+
50+
Memoization: Compilation skipped because existing memoization could not be preserved
51+
52+
React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. This dependency may be mutated later, which could cause the value to change unexpectedly.
53+
54+
error.repro-preserve-memoization-inner-destructured-value-mistaken-as-dependency-mutated-dep.ts:25:13
55+
23 | const {value} = identity(input, x);
56+
24 | return {value};
57+
> 25 | }, [input, x]);
58+
| ^ This dependency may be modified later
59+
26 | return object;
60+
27 | }
61+
28 |
62+
```
63+
64+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// @validatePreserveExistingMemoizationGuarantees
2+
3+
import {identity, Stringify, useHook} from 'shared-runtime';
4+
5+
/**
6+
* Repro from https://github.com/facebook/react/issues/34262
7+
*
8+
* The compiler memoizes more precisely than the original code, with two reactive scopes:
9+
* - One for `transform(input)` with `input` as dep
10+
* - One for `{value}` with `value` as dep
11+
*
12+
* When we validate preserving manual memoization we incorrectly reject this, because
13+
* the original memoization had `object` depending on `input` but our scope depends on
14+
* `value`.
15+
*/
16+
function useInputValue(input) {
17+
// Conflate the `identity(input, x)` call with something outside the useMemo,
18+
// to try and break memoization of `value`. This gets correctly flagged since
19+
// the dependency is being mutated
20+
let x = {};
21+
useHook();
22+
const object = React.useMemo(() => {
23+
const {value} = identity(input, x);
24+
return {value};
25+
}, [input, x]);
26+
return object;
27+
}
28+
29+
function Component() {
30+
return <Stringify value={useInputValue({value: 42}).value} />;
31+
}
32+
33+
export const FIXTURE_ENTRYPOINT = {
34+
fn: Component,
35+
params: [{}],
36+
};
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
2+
## Input
3+
4+
```javascript
5+
// @validatePreserveExistingMemoizationGuarantees
6+
7+
import {identity, Stringify} from 'shared-runtime';
8+
9+
/**
10+
* Repro from https://github.com/facebook/react/issues/34262
11+
*
12+
* The compiler memoizes more precisely than the original code, with two reactive scopes:
13+
* - One for `transform(input)` with `input` as dep
14+
* - One for `{value}` with `value` as dep
15+
*
16+
* Previously ValidatePreservedManualMemoization rejected this input, because
17+
* the original memoization had `object` depending on `input` but we split the scope per above,
18+
* and the scope for the FinishMemoize instruction is the second scope which depends on `value`
19+
*/
20+
function useInputValue(input) {
21+
const object = React.useMemo(() => {
22+
const {value} = identity(input);
23+
return {value};
24+
}, [input]);
25+
return object;
26+
}
27+
28+
function Component() {
29+
return <Stringify value={useInputValue({value: 42}).value} />;
30+
}
31+
32+
export const FIXTURE_ENTRYPOINT = {
33+
fn: Component,
34+
params: [{}],
35+
};
36+
37+
```
38+
39+
## Code
40+
41+
```javascript
42+
import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees
43+
44+
import { identity, Stringify } from "shared-runtime";
45+
46+
/**
47+
* Repro from https://github.com/facebook/react/issues/34262
48+
*
49+
* The compiler memoizes more precisely than the original code, with two reactive scopes:
50+
* - One for `transform(input)` with `input` as dep
51+
* - One for `{value}` with `value` as dep
52+
*
53+
* Previously ValidatePreservedManualMemoization rejected this input, because
54+
* the original memoization had `object` depending on `input` but we split the scope per above,
55+
* and the scope for the FinishMemoize instruction is the second scope which depends on `value`
56+
*/
57+
function useInputValue(input) {
58+
const $ = _c(4);
59+
let t0;
60+
if ($[0] !== input) {
61+
t0 = identity(input);
62+
$[0] = input;
63+
$[1] = t0;
64+
} else {
65+
t0 = $[1];
66+
}
67+
const { value } = t0;
68+
let t1;
69+
if ($[2] !== value) {
70+
t1 = { value };
71+
$[2] = value;
72+
$[3] = t1;
73+
} else {
74+
t1 = $[3];
75+
}
76+
const object = t1;
77+
return object;
78+
}
79+
80+
function Component() {
81+
const $ = _c(3);
82+
let t0;
83+
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
84+
t0 = { value: 42 };
85+
$[0] = t0;
86+
} else {
87+
t0 = $[0];
88+
}
89+
const t1 = useInputValue(t0);
90+
let t2;
91+
if ($[1] !== t1.value) {
92+
t2 = <Stringify value={t1.value} />;
93+
$[1] = t1.value;
94+
$[2] = t2;
95+
} else {
96+
t2 = $[2];
97+
}
98+
return t2;
99+
}
100+
101+
export const FIXTURE_ENTRYPOINT = {
102+
fn: Component,
103+
params: [{}],
104+
};
105+
106+
```
107+
108+
### Eval output
109+
(kind: ok) <div>{"value":42}</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// @validatePreserveExistingMemoizationGuarantees
2+
3+
import {identity, Stringify} from 'shared-runtime';
4+
5+
/**
6+
* Repro from https://github.com/facebook/react/issues/34262
7+
*
8+
* The compiler memoizes more precisely than the original code, with two reactive scopes:
9+
* - One for `transform(input)` with `input` as dep
10+
* - One for `{value}` with `value` as dep
11+
*
12+
* Previously ValidatePreservedManualMemoization rejected this input, because
13+
* the original memoization had `object` depending on `input` but we split the scope per above,
14+
* and the scope for the FinishMemoize instruction is the second scope which depends on `value`
15+
*/
16+
function useInputValue(input) {
17+
const object = React.useMemo(() => {
18+
const {value} = identity(input);
19+
return {value};
20+
}, [input]);
21+
return object;
22+
}
23+
24+
function Component() {
25+
return <Stringify value={useInputValue({value: 42}).value} />;
26+
}
27+
28+
export const FIXTURE_ENTRYPOINT = {
29+
fn: Component,
30+
params: [{}],
31+
};

0 commit comments

Comments
 (0)