main
md 143 lines 5.11 KB
Rendered Raw
1 # pruneAlwaysInvalidatingScopes
2
3 ## File
4 `src/ReactiveScopes/PruneAlwaysInvalidatingScopes.ts`
5
6 ## Purpose
7 This pass identifies and prunes reactive scopes whose dependencies will *always* invalidate on every render, making memoization pointless. Specifically, it tracks values that are guaranteed to be new allocations (arrays, objects, JSX, new expressions) and checks if those values are used outside of any memoization scope. When a downstream scope depends on such an unmemoized always-invalidating value, the scope is pruned because it would re-execute on every render anyway.
8
9 The optimization avoids wasted comparisons in the generated code. Without this pass, the compiler would emit dependency checks for scopes that will never cache-hit, adding runtime overhead with no benefit. By converting these scopes to `pruned-scope` nodes, the codegen emits the instructions inline without memoization guards.
10
11 ## Input Invariants
12 - The pass expects a `ReactiveFunction` with scopes already formed
13 - Scopes should have their `dependencies` populated with the identifiers they depend on
14 - The pass runs after `MergeReactiveScopesThatInvalidateTogether`
15 - Hook calls have already caused scope flattening via `FlattenScopesWithHooksOrUseHIR`
16
17 ## Output Guarantees
18 - Scopes that depend on unmemoized always-invalidating values are converted to `pruned-scope` nodes
19 - The `unmemoizedValues` set correctly propagates through `StoreLocal`/`LoadLocal` instructions
20 - All declarations and reassignments within pruned scopes that are themselves always-invalidating are added to `unmemoizedValues`, enabling cascading pruning of downstream scopes
21
22 ## Algorithm
23
24 The pass uses a `ReactiveFunctionTransform` visitor with two key methods:
25
26 ### 1. `transformInstruction` - Tracks always-invalidating values:
27
28 ```typescript
29 switch (value.kind) {
30 case 'ArrayExpression':
31 case 'ObjectExpression':
32 case 'JsxExpression':
33 case 'JsxFragment':
34 case 'NewExpression': {
35 if (lvalue !== null) {
36 this.alwaysInvalidatingValues.add(lvalue.identifier);
37 if (!withinScope) {
38 this.unmemoizedValues.add(lvalue.identifier); // Key: only if outside a scope
39 }
40 }
41 break;
42 }
43 // Also propagates through StoreLocal and LoadLocal
44 }
45 ```
46
47 ### 2. `transformScope` - Prunes scopes with unmemoized dependencies:
48
49 ```typescript
50 for (const dep of scopeBlock.scope.dependencies) {
51 if (this.unmemoizedValues.has(dep.identifier)) {
52 // Propagate unmemoized status to scope outputs
53 for (const [_, decl] of scopeBlock.scope.declarations) {
54 if (this.alwaysInvalidatingValues.has(decl.identifier)) {
55 this.unmemoizedValues.add(decl.identifier);
56 }
57 }
58 return {
59 kind: 'replace',
60 value: {
61 kind: 'pruned-scope',
62 scope: scopeBlock.scope,
63 instructions: scopeBlock.instructions,
64 },
65 };
66 }
67 }
68 ```
69
70 ## Edge Cases
71
72 ### Function Calls Not Considered Always-Invalidating
73 The pass optimistically assumes function calls may return primitives, so `makeArray()` doesn't trigger pruning even though it might return a new array.
74
75 ### Conditional Allocations
76 Code like `x = cond ? [] : 42` doesn't trigger pruning because the value might be a primitive.
77
78 ### Propagation Through Locals
79 The pass correctly tracks values through `StoreLocal` and `LoadLocal` to handle variable reassignments and loads.
80
81 ### Cascading Pruning
82 When a scope is pruned, its always-invalidating outputs become unmemoized, potentially causing downstream scopes to be pruned as well.
83
84 ## TODOs
85 None in the source file.
86
87 ## Example
88
89 ### Fixture: `prune-scopes-whose-deps-invalidate-array.js`
90
91 **Input:**
92 ```javascript
93 function Component(props) {
94 const x = [];
95 useHook();
96 x.push(props.value);
97 const y = [x];
98 return [y];
99 }
100 ```
101
102 **After PruneAlwaysInvalidatingScopes** (from `yarn snap -p prune-scopes-whose-deps-invalidate-array.js -d`):
103 ```
104 <pruned> scope @0 [1:14] dependencies=[] declarations=[x$21_@0] reassignments=[] {
105 [2] $20_@0 = Array []
106 [3] StoreLocal Const x$21_@0 = $20_@0
107 [4] $23 = LoadGlobal import { useHook }
108 [6] $24_@1 = Call $23() // Hook flattens scope
109 [7] break bb9 (implicit)
110 [8] $25_@0 = LoadLocal x$21_@0
111 [9] $26 = PropertyLoad $25_@0.push
112 [10] $27 = LoadLocal props$19
113 [11] $28 = PropertyLoad $27.value
114 [12] $29 = MethodCall $25_@0.$26($28)
115 }
116 [14] $30 = LoadLocal x$21_@0
117 <pruned> scope @2 [15:23] dependencies=[x$21_@0:TObject<BuiltInArray>] declarations=[$35_@3] {
118 [16] $31_@2 = Array [$30]
119 [18] StoreLocal Const y$32 = $31_@2
120 [19] $34 = LoadLocal y$32
121 [21] $35_@3 = Array [$34]
122 }
123 [23] return $35_@3
124 ```
125
126 Key observations:
127 - Scope @0 is pruned because the hook call (`useHook()`) flattens it (hook rules prevent memoization around hooks)
128 - `x` is an `ArrayExpression` created in the pruned scope @0, making it unmemoized
129 - Scope @2 depends on `x$21_@0` which is unmemoized and always-invalidating (it's an array)
130 - Therefore, scope @2 is also pruned - cascading pruning
131
132 **Generated Code:**
133 ```javascript
134 function Component(props) {
135 const x = [];
136 useHook();
137 x.push(props.value);
138 const y = [x];
139 return [y];
140 }
141 ```
142
143 The output matches the input because all memoization was pruned - the code runs unconditionally on every render.