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