main
md 285 lines 9.26 KB
Rendered Raw
1 # validateNoFreezingKnownMutableFunctions
2
3 ## File
4 `src/Validation/ValidateNoFreezingKnownMutableFunctions.ts`
5
6 ## Purpose
7 This validation pass ensures that functions with known mutations (functions that mutate captured local variables) are not passed where a frozen value is expected. Frozen contexts include JSX props, hook arguments, and return values from hooks.
8
9 The key insight is that a function which mutates captured variables is effectively a mutable value itself. Unlike a mutable array (which a receiver can choose not to mutate), there is no way for the receiver of a function to prevent the mutation from happening when the function is called. Therefore, passing such functions to props or hooks violates React's expectation that rendered values are immutable.
10
11 ## Input Invariants
12 - The function has been through aliasing effect inference
13 - `aliasingEffects` on FunctionExpression values have been computed
14 - `Mutate` and `MutateTransitive` effects identify definite mutations to captured variables
15
16 ## Validation Rules
17 The pass produces errors when:
18
19 1. **Mutable function passed as JSX prop**: A function that mutates a captured variable is passed as a prop to a JSX element
20 2. **Mutable function passed to hook**: A function that mutates a captured variable is passed as an argument to a hook
21 3. **Mutable function returned from hook**: A function that mutates a captured variable is returned from a hook
22
23 **Exception - Ref mutations**: Functions that mutate refs (`isRefOrRefLikeMutableType`) are allowed, since refs are mutable by design and not tracked for rendering purposes.
24
25 Error messages produced:
26 - Category: `Immutability`
27 - Reason: "Cannot modify local variables after render completes"
28 - Description: "This argument is a function which may reassign or mutate [variable] after render, which can cause inconsistent behavior on subsequent renders. Consider using state instead"
29 - Messages:
30 - "This function may (indirectly) reassign or modify [variable] after render"
31 - "This modifies [variable]"
32
33 ## Algorithm
34
35 ### Phase 1: Track Context Mutation Effects
36 The pass maintains a map from identifier IDs to their associated mutation effects:
37
38 ```typescript
39 const contextMutationEffects: Map<
40 IdentifierId,
41 Extract<AliasingEffect, {kind: 'Mutate'} | {kind: 'MutateTransitive'}>
42 > = new Map();
43 ```
44
45 ### Phase 2: Single Forward Pass
46 Process all blocks in order, handling specific instruction types:
47
48 ```typescript
49 for (const block of fn.body.blocks.values()) {
50 for (const instr of block.instructions) {
51 switch (value.kind) {
52 case 'LoadLocal': {
53 // Propagate mutation effect from source to loaded value
54 const effect = contextMutationEffects.get(value.place.identifier.id);
55 if (effect != null) {
56 contextMutationEffects.set(lvalue.identifier.id, effect);
57 }
58 break;
59 }
60 case 'StoreLocal': {
61 // Propagate mutation effect to both lvalue and stored variable
62 const effect = contextMutationEffects.get(value.value.identifier.id);
63 if (effect != null) {
64 contextMutationEffects.set(lvalue.identifier.id, effect);
65 contextMutationEffects.set(value.lvalue.place.identifier.id, effect);
66 }
67 break;
68 }
69 case 'FunctionExpression': {
70 // Check function's aliasing effects for context mutations
71 if (value.loweredFunc.func.aliasingEffects != null) {
72 const context = new Set(
73 value.loweredFunc.func.context.map(p => p.identifier.id)
74 );
75 for (const effect of value.loweredFunc.func.aliasingEffects) {
76 if (effect.kind === 'Mutate' || effect.kind === 'MutateTransitive') {
77 // Mark function as mutable if it mutates a context variable
78 if (context.has(effect.value.identifier.id) &&
79 !isRefOrRefLikeMutableType(effect.value.identifier.type)) {
80 contextMutationEffects.set(lvalue.identifier.id, effect);
81 }
82 }
83 }
84 }
85 break;
86 }
87 default: {
88 // Check all operands for freeze effect violations
89 for (const operand of eachInstructionValueOperand(value)) {
90 visitOperand(operand); // Check if mutable function is being frozen
91 }
92 }
93 }
94 }
95 }
96 ```
97
98 ### Phase 3: Validate Freeze Effects
99 When an operand has a `Freeze` effect, check if it's a known mutable function:
100
101 ```typescript
102 function visitOperand(operand: Place): void {
103 if (operand.effect === Effect.Freeze) {
104 const effect = contextMutationEffects.get(operand.identifier.id);
105 if (effect != null) {
106 // Emit error with both usage location and mutation location
107 errors.pushDiagnostic(
108 CompilerDiagnostic.create({
109 category: ErrorCategory.Immutability,
110 reason: 'Cannot modify local variables after render completes',
111 description: `This argument is a function which may reassign or mutate ${variable} after render...`,
112 })
113 .withDetails({loc: operand.loc, message: 'This function may...'})
114 .withDetails({loc: effect.value.loc, message: 'This modifies...'})
115 );
116 }
117 }
118 }
119 ```
120
121 ## Edge Cases
122
123 ### Function Passed as JSX Prop (Error)
124 ```javascript
125 function Component() {
126 const cache = new Map();
127 const fn = () => {
128 cache.set('key', 'value'); // Mutates captured variable
129 };
130 return <Foo fn={fn} />; // Error: fn is frozen but mutates cache
131 }
132 ```
133
134 ### Function Passed to Hook (Error)
135 ```javascript
136 function useFoo() {
137 const cache = new Map();
138 useHook(() => {
139 cache.set('key', 'value'); // Error: function mutates cache
140 });
141 }
142 ```
143
144 ### Function Returned from Hook (Error)
145 ```javascript
146 function useFoo() {
147 useHook(); // For hook inference
148 const cache = new Map();
149 return () => {
150 cache.set('key', 'value'); // Error: returned function mutates cache
151 };
152 }
153 ```
154
155 ### Ref Mutation (Allowed)
156 ```javascript
157 function Component() {
158 const ref = useRef(null);
159 const fn = () => {
160 ref.current = value; // OK: refs are mutable by design
161 };
162 return <Foo fn={fn} />; // Allowed
163 }
164 ```
165
166 ### Conditional Mutations
167 The pass only errors on definite mutations (`Mutate`, `MutateTransitive`), not conditional mutations (`MutateConditionally`, `MutateTransitiveConditionally`). However, if a function already has a known mutation effect, conditional mutations will propagate that effect:
168
169 ```javascript
170 function Component(cond) {
171 const cache = new Map();
172 const fn = () => {
173 cache.set('a', 1); // Definite mutation
174 };
175 const fn2 = fn; // fn2 inherits mutation effect
176 return <Foo fn={fn2} />; // Error
177 }
178 ```
179
180 ### Nested Function Expressions
181 Mutation effects propagate through assignments:
182
183 ```javascript
184 function Component() {
185 const cache = new Map();
186 const inner = () => cache.set('key', 'value');
187 const outer = inner; // outer inherits mutation effect
188 return <Foo fn={outer} />; // Error
189 }
190 ```
191
192 ## TODOs
193 None in the source file.
194
195 ## Example
196
197 ### Fixture: `error.invalid-pass-mutable-function-as-prop.js`
198
199 **Input:**
200 ```javascript
201 // @validateNoFreezingKnownMutableFunctions
202 function Component() {
203 const cache = new Map();
204 const fn = () => {
205 cache.set('key', 'value');
206 };
207 return <Foo fn={fn} />;
208 }
209 ```
210
211 **Error:**
212 ```
213 Found 1 error:
214
215 Error: Cannot modify local variables after render completes
216
217 This argument is a function which may reassign or mutate `cache` after render, which can cause inconsistent behavior on subsequent renders. Consider using state instead.
218
219 error.invalid-pass-mutable-function-as-prop.ts:7:18
220 5 | cache.set('key', 'value');
221 6 | };
222 > 7 | return <Foo fn={fn} />;
223 | ^^ This function may (indirectly) reassign or modify `cache` after render
224 8 | }
225 9 |
226
227 error.invalid-pass-mutable-function-as-prop.ts:5:4
228 3 | const cache = new Map();
229 4 | const fn = () => {
230 > 5 | cache.set('key', 'value');
231 | ^^^^^ This modifies `cache`
232 6 | };
233 7 | return <Foo fn={fn} />;
234 8 | }
235 ```
236
237 ### Fixture: `error.invalid-hook-function-argument-mutates-local-variable.js`
238
239 **Input:**
240 ```javascript
241 // @validateNoFreezingKnownMutableFunctions
242
243 function useFoo() {
244 const cache = new Map();
245 useHook(() => {
246 cache.set('key', 'value');
247 });
248 }
249 ```
250
251 **Error:**
252 ```
253 Found 1 error:
254
255 Error: Cannot modify local variables after render completes
256
257 This argument is a function which may reassign or mutate `cache` after render, which can cause inconsistent behavior on subsequent renders. Consider using state instead.
258
259 error.invalid-hook-function-argument-mutates-local-variable.ts:5:10
260 3 | function useFoo() {
261 4 | const cache = new Map();
262 > 5 | useHook(() => {
263 | ^^^^^^^
264 > 6 | cache.set('key', 'value');
265 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
266 > 7 | });
267 | ^^^^ This function may (indirectly) reassign or modify `cache` after render
268 8 | }
269 9 |
270
271 error.invalid-hook-function-argument-mutates-local-variable.ts:6:4
272 4 | const cache = new Map();
273 5 | useHook(() => {
274 > 6 | cache.set('key', 'value');
275 | ^^^^^ This modifies `cache`
276 7 | });
277 8 | }
278 9 |
279 ```
280
281 Key observations:
282 - The pass detects functions that mutate captured local variables (not refs)
283 - Errors show both where the function is used (frozen) and where the mutation occurs
284 - The validation prevents inconsistent re-render behavior by catching mutations that happen after render
285 - The suggestion to "use state instead" guides users toward the correct React pattern