1
+import {
2
+ ScopeId,
3
+ HIRFunction,
4
+ Place,
5
+ Instruction,
6
+ ReactiveScopeDependency,
7
+ Identifier,
8
+ ReactiveScope,
9
+ isObjectMethodType,
10
+ isRefValueType,
11
+ isUseRefType,
12
+ makeInstructionId,
13
+ InstructionId,
14
+ InstructionKind,
15
+ GeneratedSource,
16
+ DeclarationId,
17
+ areEqualPaths,
18
+ IdentifierId,
19
+} from './HIR';
20
+import {
21
+ BlockInfo,
22
+ collectHoistablePropertyLoads,
23
+ getProperty,
24
+} from './CollectHoistablePropertyLoads';
25
+import {
26
+ ScopeBlockTraversal,
27
+ eachInstructionOperand,
28
+ eachInstructionValueOperand,
29
+ eachPatternOperand,
30
+ eachTerminalOperand,
31
+} from './visitors';
32
+import {Stack, empty} from '../Utils/Stack';
33
+import {CompilerError} from '../CompilerError';
34
+import {Iterable_some} from '../Utils/utils';
35
+import {ReactiveScopeDependencyTreeHIR} from './DeriveMinimalDependenciesHIR';
36
+
37
+export function propagateScopeDependenciesHIR(fn: HIRFunction): void {
38
+ const usedOutsideDeclaringScope =
39
+ findTemporariesUsedOutsideDeclaringScope(fn);
40
+ const temporaries = collectTemporariesSidemap(fn, usedOutsideDeclaringScope);
41
+
42
+ const hoistablePropertyLoads = collectHoistablePropertyLoads(fn, temporaries);
43
+
44
+ const scopeDeps = collectDependencies(
45
+ fn,
46
+ usedOutsideDeclaringScope,
47
+ temporaries,
48
+ );
49
+
50
+ /**
51
+ * Derive the minimal set of hoistable dependencies for each scope.
52
+ */
53
+ for (const [scope, deps] of scopeDeps) {
54
+ const tree = new ReactiveScopeDependencyTreeHIR();
55
+
56
+ /**
57
+ * Step 1: Add every dependency used by this scope (e.g. `a.b.c`)
58
+ */
59
+ for (const dep of deps) {
60
+ tree.addDependency({...dep});
61
+ }
62
+ /**
63
+ * Step 2: Mark hoistable dependencies, given the basic block in
64
+ * which the scope begins.
65
+ */
66
+ recordHoistablePropertyReads(hoistablePropertyLoads, scope.id, tree);
67
+ const candidates = tree.deriveMinimalDependencies();
68
+ for (const candidateDep of candidates) {
69
+ if (
70
+ !Iterable_some(
71
+ scope.dependencies,
72
+ existingDep =>
73
+ existingDep.identifier.declarationId ===
74
+ candidateDep.identifier.declarationId &&
75
+ areEqualPaths(existingDep.path, candidateDep.path),
76
+ )
77
+ )
78
+ scope.dependencies.add(candidateDep);
79
+ }
80
+ }
81
+}
82
+
83
+function findTemporariesUsedOutsideDeclaringScope(
84
+ fn: HIRFunction,
85
+): ReadonlySet<DeclarationId> {
86
+ /*
87
+ * tracks all relevant LoadLocal and PropertyLoad lvalues
88
+ * and the scope where they are defined
89
+ */
90
+ const declarations = new Map<DeclarationId, ScopeId>();
91
+ const prunedScopes = new Set<ScopeId>();
92
+ const scopeTraversal = new ScopeBlockTraversal();
93
+ const usedOutsideDeclaringScope = new Set<DeclarationId>();
94
+
95
+ function handlePlace(place: Place): void {
96
+ const declaringScope = declarations.get(place.identifier.declarationId);
97
+ if (
98
+ declaringScope != null &&
99
+ !scopeTraversal.isScopeActive(declaringScope) &&
100
+ !prunedScopes.has(declaringScope)
101
+ ) {
102
+ // Declaring scope is not active === used outside declaring scope
103
+ usedOutsideDeclaringScope.add(place.identifier.declarationId);
104
+ }
105
+ }
106
+
107
+ function handleInstruction(instr: Instruction): void {
108
+ const scope = scopeTraversal.currentScope;
109
+ if (scope == null || prunedScopes.has(scope)) {
110
+ return;
111
+ }
112
+ switch (instr.value.kind) {
113
+ case 'LoadLocal':
114
+ case 'LoadContext':
115
+ case 'PropertyLoad': {
116
+ declarations.set(instr.lvalue.identifier.declarationId, scope);
117
+ break;
118
+ }
119
+ default: {
120
+ break;
121
+ }
122
+ }
123
+ }
124
+
125
+ for (const [blockId, block] of fn.body.blocks) {
126
+ scopeTraversal.recordScopes(block);
127
+ const scopeStartInfo = scopeTraversal.blockInfos.get(blockId);
128
+ if (scopeStartInfo?.kind === 'begin' && scopeStartInfo.pruned) {
129
+ prunedScopes.add(scopeStartInfo.scope.id);
130
+ }
131
+ for (const instr of block.instructions) {
132
+ for (const place of eachInstructionOperand(instr)) {
133
+ handlePlace(place);
134
+ }
135
+ handleInstruction(instr);
136
+ }
137
+
138
+ for (const place of eachTerminalOperand(block.terminal)) {
139
+ handlePlace(place);
140
+ }
141
+ }
142
+ return usedOutsideDeclaringScope;
143
+}
144
+
145
+/**
146
+ * @returns mapping of LoadLocal and PropertyLoad to the source of the load.
147
+ * ```js
148
+ * // source
149
+ * foo(a.b);
150
+ *
151
+ * // HIR: a potential sidemap is {0: a, 1: a.b, 2: foo}
152
+ * $0 = LoadLocal 'a'
153
+ * $1 = PropertyLoad $0, 'b'
154
+ * $2 = LoadLocal 'foo'
155
+ * $3 = CallExpression $2($1)
156
+ * ```
157
+ * Only map LoadLocal and PropertyLoad lvalues to their source if we know that
158
+ * reordering the read (from the time-of-load to time-of-use) is valid.
159
+ *
160
+ * If a LoadLocal or PropertyLoad instruction is within the reactive scope range
161
+ * (a proxy for mutable range) of the load source, later instructions may
162
+ * reassign / mutate the source value. Since it's incorrect to reorder these
163
+ * load instructions to after their scope ranges, we also do not store them in
164
+ * identifier sidemaps.
165
+ *
166
+ * Take this example (from fixture
167
+ * `evaluation-order-mutate-call-after-dependency-load`)
168
+ * ```js
169
+ * // source
170
+ * function useFoo(arg) {
171
+ * const arr = [1, 2, 3, ...arg];
172
+ * return [
173
+ * arr.length,
174
+ * arr.push(0)
175
+ * ];
176
+ * }
177
+ *
178
+ * // IR pseudocode
179
+ * scope @0 {
180
+ * $0 = arr = ArrayExpression [1, 2, 3, ...arg]
181
+ * $1 = arr.length
182
+ * $2 = arr.push(0)
183
+ * }
184
+ * scope @1 {
185
+ * $3 = ArrayExpression [$1, $2]
186
+ * }
187
+ * ```
188
+ * Here, it's invalid for scope@1 to take `arr.length` as a dependency instead
189
+ * of $1, as the evaluation of `arr.length` changes between instructions $1 and
190
+ * $3. We do not track $1 -> arr.length in this case.
191
+ */
192
+function collectTemporariesSidemap(
193
+ fn: HIRFunction,
194
+ usedOutsideDeclaringScope: ReadonlySet<DeclarationId>,
195
+): ReadonlyMap<IdentifierId, ReactiveScopeDependency> {
196
+ const temporaries = new Map<IdentifierId, ReactiveScopeDependency>();
197
+ for (const [_, block] of fn.body.blocks) {
198
+ for (const instr of block.instructions) {
199
+ const {value, lvalue} = instr;
200
+ const usedOutside = usedOutsideDeclaringScope.has(
201
+ lvalue.identifier.declarationId,
202
+ );
203
+
204
+ if (value.kind === 'PropertyLoad' && !usedOutside) {
205
+ const property = getProperty(value.object, value.property, temporaries);
206
+ temporaries.set(lvalue.identifier.id, property);
207
+ } else if (
208
+ value.kind === 'LoadLocal' &&
209
+ lvalue.identifier.name == null &&
210
+ value.place.identifier.name !== null &&
211
+ !usedOutside
212
+ ) {
213
+ temporaries.set(lvalue.identifier.id, {
214
+ identifier: value.place.identifier,
215
+ path: [],
216
+ });
217
+ }
218
+ }
219
+ }
220
+ return temporaries;
221
+}
222
+
223
+type Decl = {
224
+ id: InstructionId;
225
+ scope: Stack<ReactiveScope>;
226
+};
227
+
228
+class Context {
229
+ #declarations: Map<DeclarationId, Decl> = new Map();
230
+ #reassignments: Map<Identifier, Decl> = new Map();
231
+
232
+ #scopes: Stack<ReactiveScope> = empty();
233
+ // Reactive dependencies used in the current reactive scope.
234
+ #dependencies: Stack<Array<ReactiveScopeDependency>> = empty();
235
+ deps: Map<ReactiveScope, Array<ReactiveScopeDependency>> = new Map();
236
+
237
+ #temporaries: ReadonlyMap<IdentifierId, ReactiveScopeDependency>;
238
+ #temporariesUsedOutsideScope: ReadonlySet<DeclarationId>;
239
+
240
+ constructor(
241
+ temporariesUsedOutsideScope: ReadonlySet<DeclarationId>,
242
+ temporaries: ReadonlyMap<IdentifierId, ReactiveScopeDependency>,
243
+ ) {
244
+ this.#temporariesUsedOutsideScope = temporariesUsedOutsideScope;
245
+ this.#temporaries = temporaries;
246
+ }
247
+
248
+ enterScope(scope: ReactiveScope): void {
249
+ // Set context for new scope
250
+ this.#dependencies = this.#dependencies.push([]);
251
+ this.#scopes = this.#scopes.push(scope);
252
+ }
253
+
254
+ exitScope(scope: ReactiveScope, pruned: boolean): void {
255
+ // Save dependencies we collected from the exiting scope
256
+ const scopedDependencies = this.#dependencies.value;
257
+ CompilerError.invariant(scopedDependencies != null, {
258
+ reason: '[PropagateScopeDeps]: Unexpected scope mismatch',
259
+ loc: scope.loc,
260
+ });
261
+
262
+ // Restore context of previous scope
263
+ this.#scopes = this.#scopes.pop();
264
+ this.#dependencies = this.#dependencies.pop();
265
+
266
+ /*
267
+ * Collect dependencies we recorded for the exiting scope and propagate
268
+ * them upward using the same rules as normal dependency collection.
269
+ * Child scopes may have dependencies on values created within the outer
270
+ * scope, which necessarily cannot be dependencies of the outer scope.
271
+ */
272
+ for (const dep of scopedDependencies) {
273
+ if (this.#checkValidDependency(dep)) {
274
+ this.#dependencies.value?.push(dep);
275
+ }
276
+ }
277
+
278
+ if (!pruned) {
279
+ this.deps.set(scope, scopedDependencies);
280
+ }
281
+ }
282
+
283
+ isUsedOutsideDeclaringScope(place: Place): boolean {
284
+ return this.#temporariesUsedOutsideScope.has(
285
+ place.identifier.declarationId,
286
+ );
287
+ }
288
+
289
+ /*
290
+ * Records where a value was declared, and optionally, the scope where the value originated from.
291
+ * This is later used to determine if a dependency should be added to a scope; if the current
292
+ * scope we are visiting is the same scope where the value originates, it can't be a dependency
293
+ * on itself.
294
+ */
295
+ declare(identifier: Identifier, decl: Decl): void {
296
+ if (!this.#declarations.has(identifier.declarationId)) {
297
+ this.#declarations.set(identifier.declarationId, decl);
298
+ }
299
+ this.#reassignments.set(identifier, decl);
300
+ }
301
+
302
+ // Checks if identifier is a valid dependency in the current scope
303
+ #checkValidDependency(maybeDependency: ReactiveScopeDependency): boolean {
304
+ // ref.current access is not a valid dep
305
+ if (
306
+ isUseRefType(maybeDependency.identifier) &&
307
+ maybeDependency.path.at(0)?.property === 'current'
308
+ ) {
309
+ return false;
310
+ }
311
+
312
+ // ref value is not a valid dep
313
+ if (isRefValueType(maybeDependency.identifier)) {
314
+ return false;
315
+ }
316
+
317
+ /*
318
+ * object methods are not deps because they will be codegen'ed back in to
319
+ * the object literal.
320
+ */
321
+ if (isObjectMethodType(maybeDependency.identifier)) {
322
+ return false;
323
+ }
324
+
325
+ const identifier = maybeDependency.identifier;
326
+ /*
327
+ * If this operand is used in a scope, has a dynamic value, and was defined
328
+ * before this scope, then its a dependency of the scope.
329
+ */
330
+ const currentDeclaration =
331
+ this.#reassignments.get(identifier) ??
332
+ this.#declarations.get(identifier.declarationId);
333
+ const currentScope = this.currentScope.value;
334
+ return (
335
+ currentScope != null &&
336
+ currentDeclaration !== undefined &&
337
+ currentDeclaration.id < currentScope.range.start
338
+ );
339
+ }
340
+
341
+ #isScopeActive(scope: ReactiveScope): boolean {
342
+ if (this.#scopes === null) {
343
+ return false;
344
+ }
345
+ return this.#scopes.find(state => state === scope);
346
+ }
347
+
348
+ get currentScope(): Stack<ReactiveScope> {
349
+ return this.#scopes;
350
+ }
351
+
352
+ visitOperand(place: Place): void {
353
+ /*
354
+ * if this operand is a temporary created for a property load, try to resolve it to
355
+ * the expanded Place. Fall back to using the operand as-is.
356
+ */
357
+ this.visitDependency(
358
+ this.#temporaries.get(place.identifier.id) ?? {
359
+ identifier: place.identifier,
360
+ path: [],
361
+ },
362
+ );
363
+ }
364
+
365
+ visitProperty(object: Place, property: string): void {
366
+ const nextDependency = getProperty(object, property, this.#temporaries);
367
+ this.visitDependency(nextDependency);
368
+ }
369
+
370
+ visitDependency(maybeDependency: ReactiveScopeDependency): void {
371
+ /*
372
+ * Any value used after its originally defining scope has concluded must be added as an
373
+ * output of its defining scope. Regardless of whether its a const or not,
374
+ * some later code needs access to the value. If the current
375
+ * scope we are visiting is the same scope where the value originates, it can't be a dependency
376
+ * on itself.
377
+ */
378
+
379
+ /*
380
+ * if originalDeclaration is undefined here, then this is not a local var
381
+ * (all decls e.g. `let x;` should be initialized in BuildHIR)
382
+ */
383
+ const originalDeclaration = this.#declarations.get(
384
+ maybeDependency.identifier.declarationId,
385
+ );
386
+ if (
387
+ originalDeclaration !== undefined &&
388
+ originalDeclaration.scope.value !== null
389
+ ) {
390
+ originalDeclaration.scope.each(scope => {
391
+ if (
392
+ !this.#isScopeActive(scope) &&
393
+ !Iterable_some(
394
+ scope.declarations.values(),
395
+ decl =>
396
+ decl.identifier.declarationId ===
397
+ maybeDependency.identifier.declarationId,
398
+ )
399
+ ) {
400
+ scope.declarations.set(maybeDependency.identifier.id, {
401
+ identifier: maybeDependency.identifier,
402
+ scope: originalDeclaration.scope.value!,
403
+ });
404
+ }
405
+ });
406
+ }
407
+
408
+ if (this.#checkValidDependency(maybeDependency)) {
409
+ this.#dependencies.value!.push(maybeDependency);
410
+ }
411
+ }
412
+
413
+ /*
414
+ * Record a variable that is declared in some other scope and that is being reassigned in the
415
+ * current one as a {@link ReactiveScope.reassignments}
416
+ */
417
+ visitReassignment(place: Place): void {
418
+ const currentScope = this.currentScope.value;
419
+ if (
420
+ currentScope != null &&
421
+ !Iterable_some(
422
+ currentScope.reassignments,
423
+ identifier =>
424
+ identifier.declarationId === place.identifier.declarationId,
425
+ ) &&
426
+ this.#checkValidDependency({identifier: place.identifier, path: []})
427
+ ) {
428
+ currentScope.reassignments.add(place.identifier);
429
+ }
430
+ }
431
+}
432
+
433
+function handleInstruction(instr: Instruction, context: Context): void {
434
+ const {id, value, lvalue} = instr;
435
+ if (value.kind === 'LoadLocal') {
436
+ if (
437
+ value.place.identifier.name === null ||
438
+ lvalue.identifier.name !== null ||
439
+ context.isUsedOutsideDeclaringScope(lvalue)
440
+ ) {
441
+ context.visitOperand(value.place);
442
+ }
443
+ } else if (value.kind === 'PropertyLoad') {
444
+ if (context.isUsedOutsideDeclaringScope(lvalue)) {
445
+ context.visitProperty(value.object, value.property);
446
+ }
447
+ } else if (value.kind === 'StoreLocal') {
448
+ context.visitOperand(value.value);
449
+ if (value.lvalue.kind === InstructionKind.Reassign) {
450
+ context.visitReassignment(value.lvalue.place);
451
+ }
452
+ context.declare(value.lvalue.place.identifier, {
453
+ id,
454
+ scope: context.currentScope,
455
+ });
456
+ } else if (value.kind === 'DeclareLocal' || value.kind === 'DeclareContext') {
457
+ /*
458
+ * Some variables may be declared and never initialized. We need
459
+ * to retain (and hoist) these declarations if they are included
460
+ * in a reactive scope. One approach is to simply add all `DeclareLocal`s
461
+ * as scope declarations.
462
+ */
463
+
464
+ /*
465
+ * We add context variable declarations here, not at `StoreContext`, since
466
+ * context Store / Loads are modeled as reads and mutates to the underlying
467
+ * variable reference (instead of through intermediate / inlined temporaries)
468
+ */
469
+ context.declare(value.lvalue.place.identifier, {
470
+ id,
471
+ scope: context.currentScope,
472
+ });
473
+ } else if (value.kind === 'Destructure') {
474
+ context.visitOperand(value.value);
475
+ for (const place of eachPatternOperand(value.lvalue.pattern)) {
476
+ if (value.lvalue.kind === InstructionKind.Reassign) {
477
+ context.visitReassignment(place);
478
+ }
479
+ context.declare(place.identifier, {
480
+ id,
481
+ scope: context.currentScope,
482
+ });
483
+ }
484
+ } else {
485
+ for (const operand of eachInstructionValueOperand(value)) {
486
+ context.visitOperand(operand);
487
+ }
488
+ }
489
+
490
+ context.declare(lvalue.identifier, {
491
+ id,
492
+ scope: context.currentScope,
493
+ });
494
+}
495
+
496
+function collectDependencies(
497
+ fn: HIRFunction,
498
+ usedOutsideDeclaringScope: ReadonlySet<DeclarationId>,
499
+ temporaries: ReadonlyMap<IdentifierId, ReactiveScopeDependency>,
500
+): Map<ReactiveScope, Array<ReactiveScopeDependency>> {
501
+ const context = new Context(usedOutsideDeclaringScope, temporaries);
502
+
503
+ for (const param of fn.params) {
504
+ if (param.kind === 'Identifier') {
505
+ context.declare(param.identifier, {
506
+ id: makeInstructionId(0),
507
+ scope: empty(),
508
+ });
509
+ } else {
510
+ context.declare(param.place.identifier, {
511
+ id: makeInstructionId(0),
512
+ scope: empty(),
513
+ });
514
+ }
515
+ }
516
+
517
+ const scopeTraversal = new ScopeBlockTraversal();
518
+
519
+ for (const [blockId, block] of fn.body.blocks) {
520
+ scopeTraversal.recordScopes(block);
521
+ const scopeBlockInfo = scopeTraversal.blockInfos.get(blockId);
522
+ if (scopeBlockInfo?.kind === 'begin') {
523
+ context.enterScope(scopeBlockInfo.scope);
524
+ } else if (scopeBlockInfo?.kind === 'end') {
525
+ context.exitScope(scopeBlockInfo.scope, scopeBlockInfo?.pruned);
526
+ }
527
+
528
+ for (const instr of block.instructions) {
529
+ handleInstruction(instr, context);
530
+ }
531
+ for (const place of eachTerminalOperand(block.terminal)) {
532
+ context.visitOperand(place);
533
+ }
534
+ }
535
+ return context.deps;
536
+}
537
+
538
+/**
539
+ * Compute the set of hoistable property reads.
540
+ */
541
+function recordHoistablePropertyReads(
542
+ nodes: ReadonlyMap<ScopeId, BlockInfo>,
543
+ scopeId: ScopeId,
544
+ tree: ReactiveScopeDependencyTreeHIR,
545
+): void {
546
+ const node = nodes.get(scopeId);
547
+ CompilerError.invariant(node != null, {
548
+ reason: '[PropagateScopeDependencies] Scope not found in tracked blocks',
549
+ loc: GeneratedSource,
550
+ });
551
+
552
+ for (const item of node.assumedNonNullObjects) {
553
+ tree.markNodesNonNull({
554
+ ...item.fullPath,
555
+ });
556
+ }
557
+}