main
ts 313 lines 9.78 KB
Raw
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 HIRFunction,
10 InstructionId,
11 Place,
12 ReactiveScope,
13 makeInstructionId,
14 } from '.';
15 import {getPlaceScope} from '../HIR/HIR';
16 import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables';
17 import DisjointSet from '../Utils/DisjointSet';
18 import {getOrInsertDefault} from '../Utils/utils';
19 import {
20 eachInstructionLValue,
21 eachInstructionOperand,
22 eachTerminalOperand,
23 } from './visitors';
24
25 /**
26 * While previous passes ensure that reactive scopes span valid sets of program
27 * blocks, pairs of reactive scopes may still be inconsistent with respect to
28 * each other.
29 *
30 * (a) Reactive scopes ranges must form valid blocks in the resulting javascript
31 * program. Any two scopes must either be entirely disjoint or one scope must be
32 * nested within the other.
33 * ```js
34 * // Scopes 1:3 and 3:5 are valid because they contain no common instructions
35 * [1] ⌝
36 * [2] ⌟
37 * [3] ⌝
38 * [4] ⌟
39 * // Scopes 1:3 and 1:5 are valid because the former is nested within the other
40 * [1] ⌝ ⌝
41 * [2] ⌟ |
42 * [3] |
43 * [4] ⌟
44 * // Scopes 1:4 and 2:5 are invalid because we cannot produce if-else memo
45 * // blocks representing these scopes in the output program.
46 * [1] ⌝
47 * [2] | ⌝
48 * [3] ⌟ |
49 * [4] ⌟
50 * ```
51 *
52 * (b) A scope's own instructions may only mutate that scope.
53 * For each reactive scope, we currently produce exactly one if-block which
54 * spans the instruction range of the scope. In this simple example, instr [2]
55 * does not mutate any values but is included within scope @0.
56 * ```js
57 * // IR instructions
58 * [1] (writes to scope @0's values)
59 * [2] (does not mutate anything)
60 * [3] (writes to scope @0's values)
61 *
62 * // javascript output
63 * if (( scope @0's dependencies changed )) {
64 * [1]
65 * [2]
66 * [3]
67 * }
68 * ```
69 * Nested scopes may be modeled as a tree in which child scopes are contained
70 * within parent scopes. This corresponds to nested if-else memo blocks in the
71 * output program). An instruction may only mutate its own "active" scope.
72 * ```js
73 * // Active scopes for a simple program
74 * scope @0 {
75 * [0] (active scope=@0)
76 * scope @1 {
77 * [1] (active scope=@1)
78 * [2] (active scope=@1)
79 * }
80 * [3] (active scope=@0)
81 * }
82 * [4] (no active scope)
83 *
84 * // In this example, scopes @0 and @1 must be merged because instr [2]'s
85 * // active scope is scope@1 but it mutates scope@0.
86 * scope @0, produces x {
87 * [0] x = []
88 * scope @1, produces y {
89 * [1] y = []
90 * [2] x.push(2)
91 * [3] y.push(3)
92 * }
93 * [3] x.push(1)
94 * }
95 * ```
96 *
97 * As mentioned, these constraints arise entirely from the current design of
98 * compiler output.
99 * - instruction ordering is preserved (otherwise, disjoint ranges for scopes
100 * may be produced by reordering their mutating instructions)
101 * - exactly one if-else block per scope, which does not allow the composition
102 * of a reactive scope from disconnected instruction ranges.
103 */
104
105 export function mergeOverlappingReactiveScopesHIR(fn: HIRFunction): void {
106 /**
107 * Collect all scopes eagerly because some scopes begin before the first
108 * instruction that references them (due to alignReactiveScopesToBlocks)
109 */
110 const scopesInfo = collectScopeInfo(fn);
111
112 /**
113 * Iterate through scopes and instructions to find which should be merged
114 */
115 const joinedScopes = getOverlappingReactiveScopes(fn, scopesInfo);
116
117 /**
118 * Merge scopes and rewrite all references
119 */
120 joinedScopes.forEach((scope, groupScope) => {
121 if (scope !== groupScope) {
122 groupScope.range.start = makeInstructionId(
123 Math.min(groupScope.range.start, scope.range.start),
124 );
125 groupScope.range.end = makeInstructionId(
126 Math.max(groupScope.range.end, scope.range.end),
127 );
128 }
129 });
130 for (const [place, originalScope] of scopesInfo.placeScopes) {
131 const nextScope = joinedScopes.find(originalScope);
132 if (nextScope !== null && nextScope !== originalScope) {
133 place.identifier.scope = nextScope;
134 }
135 }
136 }
137
138 type ScopeInfo = {
139 scopeStarts: Array<{id: InstructionId; scopes: Set<ReactiveScope>}>;
140 scopeEnds: Array<{id: InstructionId; scopes: Set<ReactiveScope>}>;
141 placeScopes: Map<Place, ReactiveScope>;
142 };
143
144 type TraversalState = {
145 joined: DisjointSet<ReactiveScope>;
146 activeScopes: Array<ReactiveScope>;
147 };
148
149 function collectScopeInfo(fn: HIRFunction): ScopeInfo {
150 const scopeStarts: Map<InstructionId, Set<ReactiveScope>> = new Map();
151 const scopeEnds: Map<InstructionId, Set<ReactiveScope>> = new Map();
152 const placeScopes: Map<Place, ReactiveScope> = new Map();
153
154 function collectPlaceScope(place: Place): void {
155 const scope = place.identifier.scope;
156 if (scope != null) {
157 placeScopes.set(place, scope);
158 /**
159 * Record both mutating and non-mutating scopes to merge scopes with
160 * still-mutating values with inner scopes that alias those values
161 * (see `nonmutating-capture-in-unsplittable-memo-block`)
162 *
163 * Note that this isn't perfect, as it also leads to merging of mutating
164 * scopes with JSX single-instruction scopes (see `mutation-within-jsx`)
165 */
166 if (scope.range.start !== scope.range.end) {
167 getOrInsertDefault(scopeStarts, scope.range.start, new Set()).add(
168 scope,
169 );
170 getOrInsertDefault(scopeEnds, scope.range.end, new Set()).add(scope);
171 }
172 }
173 }
174
175 for (const [, block] of fn.body.blocks) {
176 for (const instr of block.instructions) {
177 for (const operand of eachInstructionLValue(instr)) {
178 collectPlaceScope(operand);
179 }
180 for (const operand of eachInstructionOperand(instr)) {
181 collectPlaceScope(operand);
182 }
183 }
184 for (const operand of eachTerminalOperand(block.terminal)) {
185 collectPlaceScope(operand);
186 }
187 }
188
189 return {
190 scopeStarts: [...scopeStarts.entries()]
191 .map(([id, scopes]) => ({id, scopes}))
192 .sort((a, b) => b.id - a.id),
193 scopeEnds: [...scopeEnds.entries()]
194 .map(([id, scopes]) => ({id, scopes}))
195 .sort((a, b) => b.id - a.id),
196 placeScopes,
197 };
198 }
199
200 function visitInstructionId(
201 id: InstructionId,
202 {scopeEnds, scopeStarts}: ScopeInfo,
203 {activeScopes, joined}: TraversalState,
204 ): void {
205 /**
206 * Handle all scopes that end at this instruction.
207 */
208 const scopeEndTop = scopeEnds.at(-1);
209 if (scopeEndTop != null && scopeEndTop.id <= id) {
210 scopeEnds.pop();
211
212 /**
213 * Match scopes that end at this instruction with our stack of active
214 * scopes (from traversal state). We need to sort these in descending
215 * order of start IDs because the scopes stack is ordered as such
216 */
217 const scopesSortedStartDescending = [...scopeEndTop.scopes].sort(
218 (a, b) => b.range.start - a.range.start,
219 );
220 for (const scope of scopesSortedStartDescending) {
221 const idx = activeScopes.indexOf(scope);
222 if (idx !== -1) {
223 /**
224 * Detect and merge all overlapping scopes. `activeScopes` is ordered
225 * by scope start, so every active scope between a completed scope s
226 * and the top of the stack (1) started later than s and (2) completes after s.
227 */
228 if (idx !== activeScopes.length - 1) {
229 joined.union([scope, ...activeScopes.slice(idx + 1)]);
230 }
231 activeScopes.splice(idx, 1);
232 }
233 }
234 }
235
236 /**
237 * Handle all scopes that begin at this instruction by adding them
238 * to the scopes stack
239 */
240 const scopeStartTop = scopeStarts.at(-1);
241 if (scopeStartTop != null && scopeStartTop.id <= id) {
242 scopeStarts.pop();
243
244 const scopesSortedEndDescending = [...scopeStartTop.scopes].sort(
245 (a, b) => b.range.end - a.range.end,
246 );
247 activeScopes.push(...scopesSortedEndDescending);
248 /**
249 * Merge all identical scopes (ones with the same start and end),
250 * as they end up with the same reactive block
251 */
252 for (let i = 1; i < scopesSortedEndDescending.length; i++) {
253 const prev = scopesSortedEndDescending[i - 1];
254 const curr = scopesSortedEndDescending[i];
255 if (prev.range.end === curr.range.end) {
256 joined.union([prev, curr]);
257 }
258 }
259 }
260 }
261
262 function visitPlace(
263 id: InstructionId,
264 place: Place,
265 {activeScopes, joined}: TraversalState,
266 ): void {
267 /**
268 * If an instruction mutates an outer scope, flatten all scopes from the top
269 * of the stack to the mutated outer scope.
270 */
271 const placeScope = getPlaceScope(id, place);
272 if (placeScope != null && isMutable({id}, place)) {
273 const placeScopeIdx = activeScopes.indexOf(placeScope);
274 if (placeScopeIdx !== -1 && placeScopeIdx !== activeScopes.length - 1) {
275 joined.union([placeScope, ...activeScopes.slice(placeScopeIdx + 1)]);
276 }
277 }
278 }
279
280 function getOverlappingReactiveScopes(
281 fn: HIRFunction,
282 context: ScopeInfo,
283 ): DisjointSet<ReactiveScope> {
284 const state: TraversalState = {
285 joined: new DisjointSet<ReactiveScope>(),
286 activeScopes: [],
287 };
288
289 for (const [, block] of fn.body.blocks) {
290 for (const instr of block.instructions) {
291 visitInstructionId(instr.id, context, state);
292 for (const place of eachInstructionOperand(instr)) {
293 if (
294 (instr.value.kind === 'FunctionExpression' ||
295 instr.value.kind === 'ObjectMethod') &&
296 place.identifier.type.kind === 'Primitive'
297 ) {
298 continue;
299 }
300 visitPlace(instr.id, place, state);
301 }
302 for (const place of eachInstructionLValue(instr)) {
303 visitPlace(instr.id, place, state);
304 }
305 }
306 visitInstructionId(block.terminal.id, context, state);
307 for (const place of eachTerminalOperand(block.terminal)) {
308 visitPlace(block.terminal.id, place, state);
309 }
310 }
311
312 return state.joined;
313 }