@samitouri / QOS-React-2 / commits / 46417a4ed7

Fix bug with partially memoized destructuring

Joe Savona committed May 18, 2023 at 09:25 UTC 46417a4ed77513d8e385ccd32e2103d5504bce4e
14 files changed +474 -64
compiler/forget/src/CompilerPipeline.ts
+8
@@ -32,6 +32,7 @@ import {
32 buildReactiveBlocks,
33 buildReactiveFunction,
34 codegenReactiveFunction,
35 + extractScopeDeclarationsFromDestructuring,
36 flattenReactiveLoops,
37 flattenScopesWithHooks,
38 inferReactiveScopeVariables,
@@ -223,6 +224,13 @@ export function* run(
224 value: reactiveFunction,
225 });
226
227 + extractScopeDeclarationsFromDestructuring(reactiveFunction);
228 + yield log({
229 + kind: "reactive",
230 + name: "ExtractScopeDeclarationsFromDestructuring",
231 + value: reactiveFunction,
232 + });
233 +
234 renameVariables(reactiveFunction);
235 yield log({
236 kind: "reactive",
compiler/forget/src/HIR/HIR.ts
+9 -6
@@ -585,12 +585,7 @@ export type InstructionValue =
585 value: Place;
586 loc: SourceLocation;
587 }
588 - | {
589 - kind: "Destructure";
590 - lvalue: LValuePattern;
591 - value: Place;
592 - loc: SourceLocation;
593 - }
588 + | Destructure
589 | {
590 kind: "Primitive";
591 value: number | boolean | string | null | undefined;
@@ -756,6 +751,14 @@ export type FunctionExpression = {
751 expr: t.ArrowFunctionExpression | t.FunctionExpression;
752 loc: SourceLocation;
753 };
754 +
755 +export type Destructure = {
756 + kind: "Destructure";
757 + lvalue: LValuePattern;
758 + value: Place;
759 + loc: SourceLocation;
760 +};
761 +
762 /**
763 * A place where data may be read from / written to:
764 * - a variable (identifier)
compiler/forget/src/ReactiveScopes/ExtractScopeDeclarationsFromDestructuring.ts new
+184
@@ -0,0 +1,184 @@
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 {
9 + Destructure,
10 + Environment,
11 + IdentifierId,
12 + InstructionKind,
13 + Place,
14 + ReactiveBlock,
15 + ReactiveFunction,
16 + ReactiveInstruction,
17 + ReactiveScopeBlock,
18 +} from "../HIR";
19 +import { eachPatternOperand, mapPatternOperands } from "../HIR/visitors";
20 +import { ReactiveFunctionTransform, visitReactiveFunction } from "./visitors";
21 +
22 +/**
23 + * Destructuring statements may sometimes define some variables which are declared by the scope,
24 + * and others that are only used locally within the scope, for example:
25 + *
26 + * ```
27 + * const {x, ...rest} = value;
28 + * return rest;
29 + * ```
30 + *
31 + * Here the scope structure turns into:
32 + *
33 + * ```
34 + * let c_0 = $[0] !== value;
35 + * let rest;
36 + * if (c_0) {
37 + * // OOPS! we want to reassign `rest` here, but
38 + * // `x` isn't declared anywhere!
39 + * {x, ...rest} = value;
40 + * $[0] = value;
41 + * $[1] = rest;
42 + * } else {
43 + * rest = $[1];
44 + * }
45 + * return rest;
46 + * ```
47 + *
48 + * Note that because `rest` is declared by the scope, we can't redeclare it in the
49 + * destructuring statement. But we have to declare `x`!
50 + *
51 + * This pass finds destructuring instructions that contain mixed values such as this,
52 + * and rewrites them to ensure that any scope variable assignments are extracted first
53 + * to a temporary and reassigned in a separate instruction. For example, the output
54 + * for the above would be along the lines of:
55 + *
56 + * ```
57 + * let c_0 = $[0] !== value;
58 + * let rest;
59 + * if (c_0) {
60 + * const {x, ...t0} = value; <-- replace `rest` with a temporary
61 + * rest = t0; // <-- and create a separate instruction to assign that to `rest`
62 + * $[0] = value;
63 + * $[1] = rest;
64 + * } else {
65 + * rest = $[1];
66 + * }
67 + * return rest;
68 + * ```
69 + *
70 + */
71 +export function extractScopeDeclarationsFromDestructuring(
72 + fn: ReactiveFunction
73 +): void {
74 + const state = new State(fn.env);
75 + visitReactiveFunction(fn, new Visitor(), state);
76 +}
77 +
78 +class State {
79 + env: Environment;
80 + declared: Set<IdentifierId> = new Set();
81 +
82 + constructor(env: Environment) {
83 + this.env = env;
84 + }
85 +}
86 +
87 +class Visitor extends ReactiveFunctionTransform<State> {
88 + override visitScope(scope: ReactiveScopeBlock, state: State): void {
89 + for (const [, declaration] of scope.scope.declarations) {
90 + state.declared.add(declaration.identifier.id);
91 + }
92 + this.traverseScope(scope, state);
93 + }
94 +
95 + override visitBlock(block: ReactiveBlock, state: State): void {
96 + // Traverse first to transform inner items
97 + this.traverseBlock(block, state);
98 +
99 + // Then transform any mixed destructuring instructions
100 + let nextBlock: ReactiveBlock | null = null;
101 + for (let i = 0; i < block.length; i++) {
102 + const instr = block[i];
103 + if (
104 + instr.kind === "instruction" &&
105 + instr.instruction.value.kind === "Destructure"
106 + ) {
107 + const transformed = transformDestructuring(
108 + state,
109 + instr.instruction,
110 + instr.instruction.value
111 + );
112 + if (transformed) {
113 + nextBlock ??= block.slice(0, i);
114 + transformed.forEach((instruction) => {
115 + nextBlock?.push({
116 + kind: "instruction",
117 + instruction,
118 + });
119 + });
120 + continue;
121 + }
122 + } else if (nextBlock !== null) {
123 + nextBlock.push(instr);
124 + }
125 + }
126 + if (nextBlock !== null) {
127 + block.length = 0;
128 + block.push(...nextBlock);
129 + }
130 + }
131 +}
132 +
133 +function transformDestructuring(
134 + state: State,
135 + instr: ReactiveInstruction,
136 + destructure: Destructure
137 +): null | Array<ReactiveInstruction> {
138 + let reassigned: Set<IdentifierId> = new Set();
139 + let hasDeclaration = false;
140 + for (const place of eachPatternOperand(destructure.lvalue.pattern)) {
141 + const isDeclared = state.declared.has(place.identifier.id);
142 + if (isDeclared) {
143 + reassigned.add(place.identifier.id);
144 + }
145 + hasDeclaration ||= !isDeclared;
146 + }
147 + if (reassigned.size === 0 || !hasDeclaration) {
148 + return null;
149 + }
150 + // Else it's a mix, replace the reassigned items in the destructuring with temporary
151 + // variables and emit separate assignment statements for them
152 + const instructions: Array<ReactiveInstruction> = [];
153 + const renamed: Map<Place, Place> = new Map();
154 + mapPatternOperands(destructure.lvalue.pattern, (place) => {
155 + if (!reassigned.has(place.identifier.id)) {
156 + return place;
157 + }
158 + const tempId = state.env.nextIdentifierId;
159 + const temporary = {
160 + ...place,
161 + identifier: { ...place.identifier, id: tempId, name: `t${tempId}` },
162 + };
163 + renamed.set(place, temporary);
164 + return temporary;
165 + });
166 + instructions.push(instr);
167 + for (const [original, temporary] of renamed) {
168 + instructions.push({
169 + id: instr.id,
170 + lvalue: null,
171 + value: {
172 + kind: "StoreLocal",
173 + lvalue: {
174 + kind: InstructionKind.Reassign,
175 + place: original,
176 + },
177 + value: temporary,
178 + loc: destructure.loc,
179 + },
180 + loc: instr.loc,
181 + });
182 + }
183 + return instructions;
184 +}
compiler/forget/src/ReactiveScopes/index.ts
+1
@@ -9,6 +9,7 @@ export { alignReactiveScopesToBlockScopes } from "./AlignReactiveScopesToBlockSc
9 export { buildReactiveBlocks } from "./BuildReactiveBlocks";
10 export { buildReactiveFunction } from "./BuildReactiveFunction";
11 export { codegenReactiveFunction } from "./CodegenReactiveFunction";
12 +export { extractScopeDeclarationsFromDestructuring } from "./ExtractScopeDeclarationsFromDestructuring";
13 export { flattenReactiveLoops } from "./FlattenReactiveLoops";
14 export { flattenScopesWithHooks } from "./FlattenScopesWithHooks";
15 export { inferReactiveScopeVariables } from "./InferReactiveScopeVariables";
compiler/forget/src/__tests__/fixtures/compiler/destructuring-mixed-scope-and-local-variables-with-default.expect.md new
+101
@@ -0,0 +1,101 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +function Component(props) {
6 + const post = useFragment(graphql`...`, props.post);
7 + const allUrls = [];
8 + // `media` and `urls` are exported from the scope that will wrap this code,
9 + // but `comments` is not (it doesn't need to be memoized, bc the callback
10 + // only checks `comments.length`)
11 + // because of the scope, the let declaration for media and urls are lifted
12 + // out of the scope, and the destructure statement ends up turning into
13 + // a reassignment, instead of a const declaration. this means we try to
14 + // reassign `comments` when there's no declaration for it.
15 + const { media = null, comments = [], urls = [] } = post;
16 + const onClick = (e) => {
17 + if (!comments.length) {
18 + return;
19 + }
20 + log(comments.length);
21 + };
22 + allUrls.push(...urls);
23 + return <Media media={media} onClick={onClick} />;
24 +}
25 +
26 +```
27 +
28 +## Code
29 +
30 +```javascript
31 +import { unstable_useMemoCache as useMemoCache } from "react";
32 +function Component(props) {
33 + const $ = useMemoCache(12);
34 + const post = useFragment(graphql`...`, props.post);
35 + const c_0 = $[0] !== post;
36 + let media;
37 + let onClick;
38 + if (c_0) {
39 + const allUrls = [];
40 +
41 + const { media: t0, comments: t2, urls: t81 } = post;
42 + const c_3 = $[3] !== t0;
43 + let t1;
44 + if (c_3) {
45 + t1 = t0 === undefined ? null : t0;
46 + $[3] = t0;
47 + $[4] = t1;
48 + } else {
49 + t1 = $[4];
50 + }
51 + media = t1;
52 + const c_5 = $[5] !== t2;
53 + let t3;
54 + if (c_5) {
55 + t3 = t2 === undefined ? [] : t2;
56 + $[5] = t2;
57 + $[6] = t3;
58 + } else {
59 + t3 = $[6];
60 + }
61 + const comments = t3;
62 + const urls = t81 === undefined ? [] : t81;
63 + const c_7 = $[7] !== comments.length;
64 + let t4;
65 + if (c_7) {
66 + t4 = (e) => {
67 + if (!comments.length) {
68 + return;
69 + }
70 + log(comments.length);
71 + };
72 + $[7] = comments.length;
73 + $[8] = t4;
74 + } else {
75 + t4 = $[8];
76 + }
77 + onClick = t4;
78 + allUrls.push(...urls);
79 + $[0] = post;
80 + $[1] = media;
81 + $[2] = onClick;
82 + } else {
83 + media = $[1];
84 + onClick = $[2];
85 + }
86 + const c_9 = $[9] !== media;
87 + const c_10 = $[10] !== onClick;
88 + let t5;
89 + if (c_9 || c_10) {
90 + t5 = <Media media={media} onClick={onClick} />;
91 + $[9] = media;
92 + $[10] = onClick;
93 + $[11] = t5;
94 + } else {
95 + t5 = $[11];
96 + }
97 + return t5;
98 +}
99 +
100 +```
101 +
\ No newline at end of file
compiler/forget/src/__tests__/fixtures/compiler/destructuring-mixed-scope-and-local-variables-with-default.js renamed
+1 -16
@@ -1,7 +1,3 @@
1 -
2 -## Input
3 -
4 -```javascript
1 function Component(props) {
2 const post = useFragment(graphql`...`, props.post);
3 const allUrls = [];
@@ -12,7 +8,7 @@ function Component(props) {
8 // out of the scope, and the destructure statement ends up turning into
9 // a reassignment, instead of a const declaration. this means we try to
10 // reassign `comments` when there's no declaration for it.
15 - const { media, comments, urls } = post;
11 + const { media = null, comments = [], urls = [] } = post;
12 const onClick = (e) => {
13 if (!comments.length) {
14 return;
@@ -22,14 +18,3 @@ function Component(props) {
18 allUrls.push(...urls);
19 return <Media media={media} onClick={onClick} />;
20 }
25 -
26 -```
27 -
28 -
29 -## Error
30 -
31 -```
32 -[ReactForget] Invariant: Encountered a destructuring operation where some identifiers are already declared (reassignments) but others are not (declarations) (11:11)
33 -```
34 -
35 -
\ No newline at end of file
compiler/forget/src/__tests__/fixtures/compiler/destructuring-mixed-scope-declarations-and-locals.expect.md new
+81
@@ -0,0 +1,81 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +function Component(props) {
6 + const post = useFragment(graphql`...`, props.post);
7 + const allUrls = [];
8 + // `media` and `urls` are exported from the scope that will wrap this code,
9 + // but `comments` is not (it doesn't need to be memoized, bc the callback
10 + // only checks `comments.length`)
11 + // because of the scope, the let declaration for media and urls are lifted
12 + // out of the scope, and the destructure statement ends up turning into
13 + // a reassignment, instead of a const declaration. this means we try to
14 + // reassign `comments` when there's no declaration for it.
15 + const { media, comments, urls } = post;
16 + const onClick = (e) => {
17 + if (!comments.length) {
18 + return;
19 + }
20 + log(comments.length);
21 + };
22 + allUrls.push(...urls);
23 + return <Media media={media} onClick={onClick} />;
24 +}
25 +
26 +```
27 +
28 +## Code
29 +
30 +```javascript
31 +import { unstable_useMemoCache as useMemoCache } from "react";
32 +function Component(props) {
33 + const $ = useMemoCache(8);
34 + const post = useFragment(graphql`...`, props.post);
35 + const c_0 = $[0] !== post;
36 + let media;
37 + let onClick;
38 + if (c_0) {
39 + const allUrls = [];
40 +
41 + const { media: t83, comments, urls } = post;
42 + media = t83;
43 + const c_3 = $[3] !== comments.length;
44 + let t0;
45 + if (c_3) {
46 + t0 = (e) => {
47 + if (!comments.length) {
48 + return;
49 + }
50 + log(comments.length);
51 + };
52 + $[3] = comments.length;
53 + $[4] = t0;
54 + } else {
55 + t0 = $[4];
56 + }
57 + onClick = t0;
58 + allUrls.push(...urls);
59 + $[0] = post;
60 + $[1] = media;
61 + $[2] = onClick;
62 + } else {
63 + media = $[1];
64 + onClick = $[2];
65 + }
66 + const c_5 = $[5] !== media;
67 + const c_6 = $[6] !== onClick;
68 + let t1;
69 + if (c_5 || c_6) {
70 + t1 = <Media media={media} onClick={onClick} />;
71 + $[5] = media;
72 + $[6] = onClick;
73 + $[7] = t1;
74 + } else {
75 + t1 = $[7];
76 + }
77 + return t1;
78 +}
79 +
80 +```
81 +
\ No newline at end of file
compiler/forget/src/__tests__/fixtures/compiler/destructuring-mixed-scope-declarations-and-locals.js renamed
compiler/forget/src/__tests__/fixtures/compiler/error.escape-analysis-destructured-rest-element.expect.md deleted
-22
@@ -1,22 +0,0 @@
1 -
2 -## Input
3 -
4 -```javascript
5 -function Component(props) {
6 - // b is an object, must be memoized even though the input is not memoized
7 - const { a, ...b } = props.a;
8 - // d is an array, mut be memoized even though the input is not memoized
9 - const [c, ...d] = props.c;
10 - return <div b={b} d={d}></div>;
11 -}
12 -
13 -```
14 -
15 -
16 -## Error
17 -
18 -```
19 -[ReactForget] Invariant: Encountered a destructuring operation where some identifiers are already declared (reassignments) but others are not (declarations) (3:3)
20 -```
21 -
22 -
\ No newline at end of file
compiler/forget/src/__tests__/fixtures/compiler/error.unused-object-element-with-rest.expect.md deleted
-20
@@ -1,20 +0,0 @@
1 -
2 -## Input
3 -
4 -```javascript
5 -function Foo(props) {
6 - // can't remove `unused` since it affects which properties are copied into `rest`
7 - const { unused, ...rest } = props.a;
8 - return rest;
9 -}
10 -
11 -```
12 -
13 -
14 -## Error
15 -
16 -```
17 -[ReactForget] Invariant: Encountered a destructuring operation where some identifiers are already declared (reassignments) but others are not (declarations) (3:3)
18 -```
19 -
20 -
\ No newline at end of file
compiler/forget/src/__tests__/fixtures/compiler/escape-analysis-destructured-rest-element.expect.md new
+56
@@ -0,0 +1,56 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +function Component(props) {
6 + // b is an object, must be memoized even though the input is not memoized
7 + const { a, ...b } = props.a;
8 + // d is an array, mut be memoized even though the input is not memoized
9 + const [c, ...d] = props.c;
10 + return <div b={b} d={d}></div>;
11 +}
12 +
13 +```
14 +
15 +## Code
16 +
17 +```javascript
18 +import { unstable_useMemoCache as useMemoCache } from "react";
19 +function Component(props) {
20 + const $ = useMemoCache(7);
21 + const c_0 = $[0] !== props.a;
22 + let b;
23 + if (c_0) {
24 + const { a, ...t30 } = props.a;
25 + b = t30;
26 + $[0] = props.a;
27 + $[1] = b;
28 + } else {
29 + b = $[1];
30 + }
31 + const c_2 = $[2] !== props.c;
32 + let d;
33 + if (c_2) {
34 + const [c, ...t31] = props.c;
35 + d = t31;
36 + $[2] = props.c;
37 + $[3] = d;
38 + } else {
39 + d = $[3];
40 + }
41 + const c_4 = $[4] !== b;
42 + const c_5 = $[5] !== d;
43 + let t0;
44 + if (c_4 || c_5) {
45 + t0 = <div b={b} d={d} />;
46 + $[4] = b;
47 + $[5] = d;
48 + $[6] = t0;
49 + } else {
50 + t0 = $[6];
51 + }
52 + return t0;
53 +}
54 +
55 +```
56 +
\ No newline at end of file
compiler/forget/src/__tests__/fixtures/compiler/escape-analysis-destructured-rest-element.js renamed
compiler/forget/src/__tests__/fixtures/compiler/unused-object-element-with-rest.expect.md new
+33
@@ -0,0 +1,33 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +function Foo(props) {
6 + // can't remove `unused` since it affects which properties are copied into `rest`
7 + const { unused, ...rest } = props.a;
8 + return rest;
9 +}
10 +
11 +```
12 +
13 +## Code
14 +
15 +```javascript
16 +import { unstable_useMemoCache as useMemoCache } from "react";
17 +function Foo(props) {
18 + const $ = useMemoCache(2);
19 + const c_0 = $[0] !== props.a;
20 + let rest;
21 + if (c_0) {
22 + const { unused, ...t16 } = props.a;
23 + rest = t16;
24 + $[0] = props.a;
25 + $[1] = rest;
26 + } else {
27 + rest = $[1];
28 + }
29 + return rest;
30 +}
31 +
32 +```
33 +
\ No newline at end of file
compiler/forget/src/__tests__/fixtures/compiler/unused-object-element-with-rest.js renamed