@samitouri / QOS-React-2 / commits / af1aa8d0d3

Validation that useMemo/useCallback is preserved in the output

Extends `@enablePreserveExistingMemoization` to validate that all of the original values were actually memoized. This works nearly identically to how we validate effect deps are memoized. We look for Memoize instructions whose values need memoization but whose range extends past the memoize instruction, or where the value isn't memoized at all.

Joe Savona committed Dec 15, 2023 at 16:22 UTC af1aa8d0d31ffac773dd6d4081fcd64beac22919
7 files changed +140 -77
compiler/packages/babel-plugin-react-forget/src/Entrypoint/Pipeline.ts
+5
@@ -74,6 +74,7 @@ import {
74 validateMemoizedEffectDependencies,
75 validateNoRefAccessInRender,
76 validateNoSetStateInRender,
77 + validatePreservedManualMemoization,
78 validateUseMemo,
79 } from "../Validation";
80
@@ -348,6 +349,10 @@ function* runWithEnvironment(
349 validateMemoizedEffectDependencies(reactiveFunction);
350 }
351
352 + if (env.config.enablePreserveExistingMemoizationGuarantees) {
353 + validatePreservedManualMemoization(reactiveFunction);
354 + }
355 +
356 if (env.config.enableForest) {
357 yield* lowerToForest(reactiveFunction);
358 }
compiler/packages/babel-plugin-react-forget/src/Validation/ValidatePreservedManualMemoization.ts new
+95
@@ -0,0 +1,95 @@
1 +/*
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + */
7 +
8 +import { CompilerError, ErrorSeverity } from "..";
9 +import {
10 + Identifier,
11 + Instruction,
12 + ReactiveFunction,
13 + ReactiveInstruction,
14 + ReactiveScopeBlock,
15 + ScopeId,
16 +} from "../HIR";
17 +import { isMutable } from "../ReactiveScopes/InferReactiveScopeVariables";
18 +import {
19 + ReactiveFunctionVisitor,
20 + visitReactiveFunction,
21 +} from "../ReactiveScopes/visitors";
22 +
23 +/**
24 + * Validates that all explicit manual memoization (useMemo/useCallback) was accurately
25 + * preserved, and that no originally memoized values became unmemoized in the output.
26 + *
27 + * This can occur if a value's mutable range somehow extended to include a hook and
28 + * was pruned.
29 + */
30 +export function validatePreservedManualMemoization(fn: ReactiveFunction): void {
31 + const errors = new CompilerError();
32 + visitReactiveFunction(fn, new Visitor(), errors);
33 + if (errors.hasErrors()) {
34 + throw errors;
35 + }
36 +}
37 +
38 +class Visitor extends ReactiveFunctionVisitor<CompilerError> {
39 + scopes: Set<ScopeId> = new Set();
40 +
41 + override visitScope(
42 + scopeBlock: ReactiveScopeBlock,
43 + state: CompilerError
44 + ): void {
45 + this.traverseScope(scopeBlock, state);
46 +
47 + /*
48 + * Record scopes that exist in the AST so we can later check to see if
49 + * effect dependencies which should be memoized (have a scope assigned)
50 + * actually are memoized (that scope exists).
51 + * However, we only record scopes if *their* dependencies are also
52 + * memoized, allowing a transitive memoization check.
53 + */
54 + let areDependenciesMemoized = true;
55 + for (const dep of scopeBlock.scope.dependencies) {
56 + if (isUnmemoized(dep.identifier, this.scopes)) {
57 + areDependenciesMemoized = false;
58 + break;
59 + }
60 + }
61 + if (areDependenciesMemoized) {
62 + this.scopes.add(scopeBlock.scope.id);
63 + for (const id of scopeBlock.scope.merged) {
64 + this.scopes.add(id);
65 + }
66 + }
67 + }
68 +
69 + override visitInstruction(
70 + instruction: ReactiveInstruction,
71 + state: CompilerError
72 + ): void {
73 + this.traverseInstruction(instruction, state);
74 + if (instruction.value.kind === "Memoize") {
75 + const value = instruction.value.value;
76 + if (
77 + isMutable(instruction as Instruction, value) ||
78 + isUnmemoized(value.identifier, this.scopes)
79 + ) {
80 + state.push({
81 + reason:
82 + "This value was manually memoized, but cannot be memoized under Forget because it may be mutated after it is memoized",
83 + description: null,
84 + severity: ErrorSeverity.InvalidReact,
85 + loc: typeof instruction.loc !== "symbol" ? instruction.loc : null,
86 + suggestions: null,
87 + });
88 + }
89 + }
90 + }
91 +}
92 +
93 +function isUnmemoized(operand: Identifier, scopes: Set<ScopeId>): boolean {
94 + return operand.scope != null && !scopes.has(operand.scope.id);
95 +}
compiler/packages/babel-plugin-react-forget/src/Validation/index.ts
+1
@@ -10,4 +10,5 @@ export { validateHooksUsage } from "./ValidateHooksUsage";
10 export { validateMemoizedEffectDependencies } from "./ValidateMemoizedEffectDependencies";
11 export { validateNoRefAccessInRender } from "./ValidateNoRefAccesInRender";
12 export { validateNoSetStateInRender } from "./ValidateNoSetStateInRender";
13 +export { validatePreservedManualMemoization } from "./ValidatePreservedManualMemoization";
14 export { validateUseMemo } from "./ValidateUseMemo";
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.todo-useCallback-set-ref-nested-property-preserve-memoization.expect.md new
+38
@@ -0,0 +1,38 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @enablePreserveExistingMemoizationGuarantees
6 +import { useCallback, useRef } from "react";
7 +
8 +function Component(props) {
9 + const ref = useRef({ inner: null });
10 +
11 + const onChange = useCallback((event) => {
12 + // The ref should still be mutable here even though function deps are frozen in
13 + // @enablePreserveExistingMemoizationGuarantees mode
14 + ref.current.inner = event.target.value;
15 + });
16 +
17 + ref.current.inner = null;
18 +
19 + return <input onChange={onChange} />;
20 +}
21 +
22 +export const FIXTURE_ENTRYPOINT = {
23 + fn: Component,
24 + params: [{}],
25 +};
26 +
27 +```
28 +
29 +
30 +## Error
31 +
32 +```
33 +[ReactForget] InvalidReact: This value was manually memoized, but cannot be memoized under Forget because it may be mutated after it is memoized (7:11)
34 +
35 +[ReactForget] InvalidReact: This value was manually memoized, but cannot be memoized under Forget because it may be mutated after it is memoized (7:11)
36 +```
37 +
38 +
\ No newline at end of file
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.todo-useCallback-set-ref-nested-property-preserve-memoization.js renamed
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/useCallback-set-ref-nested-property-dont-preserve-memoization.expect.md
+1 -3
@@ -62,6 +62,4 @@ export const FIXTURE_ENTRYPOINT = {
62 };
63
64 ```
65 -
66 -### Eval output
67 -(kind: ok) <input>
\ No newline at end of file
65 +
\ No newline at end of file
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/useCallback-set-ref-nested-property-preserve-memoization.expect.md deleted
-74
@@ -1,74 +0,0 @@
1 -
2 -## Input
3 -
4 -```javascript
5 -// @enablePreserveExistingMemoizationGuarantees
6 -import { useCallback, useRef } from "react";
7 -
8 -function Component(props) {
9 - const ref = useRef({ inner: null });
10 -
11 - const onChange = useCallback((event) => {
12 - // The ref should still be mutable here even though function deps are frozen in
13 - // @enablePreserveExistingMemoizationGuarantees mode
14 - ref.current.inner = event.target.value;
15 - });
16 -
17 - ref.current.inner = null;
18 -
19 - return <input onChange={onChange} />;
20 -}
21 -
22 -export const FIXTURE_ENTRYPOINT = {
23 - fn: Component,
24 - params: [{}],
25 -};
26 -
27 -```
28 -
29 -## Code
30 -
31 -```javascript
32 -// @enablePreserveExistingMemoizationGuarantees
33 -import {
34 - useCallback,
35 - useRef,
36 - unstable_useMemoCache as useMemoCache,
37 -} from "react";
38 -
39 -function Component(props) {
40 - const $ = useMemoCache(3);
41 - let t0;
42 - if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
43 - t0 = { inner: null };
44 - $[0] = t0;
45 - } else {
46 - t0 = $[0];
47 - }
48 - const ref = useRef(t0);
49 -
50 - const onChange = (event) => {
51 - ref.current.inner = event.target.value;
52 - };
53 -
54 - ref.current.inner = null;
55 - let t1;
56 - if ($[1] !== onChange) {
57 - t1 = <input onChange={onChange} />;
58 - $[1] = onChange;
59 - $[2] = t1;
60 - } else {
61 - t1 = $[2];
62 - }
63 - return t1;
64 -}
65 -
66 -export const FIXTURE_ENTRYPOINT = {
67 - fn: Component,
68 - params: [{}],
69 -};
70 -
71 -```
72 -
73 -### Eval output
74 -(kind: ok) <input>
\ No newline at end of file