main
md 321 lines 9.45 KB
Rendered Raw
1 # validateLocalsNotReassignedAfterRender
2
3 ## File
4 `src/Validation/ValidateLocalsNotReassignedAfterRender.ts`
5
6 ## Purpose
7 This validation pass prevents a category of bugs where a closure captures a binding from one render but does not update when the binding is reassigned in a later render.
8
9 When the React Compiler memoizes a function, that function captures bindings at the time of creation. If the function is reused across renders (because its dependencies haven't changed), any reassignments to captured variables will affect the wrong binding version. This can cause inconsistent behavior that's difficult to debug.
10
11 The pass detects when:
12 1. A local variable is reassigned within a function expression
13 2. That function expression escapes (e.g., passed to useEffect, used as event handler)
14 3. The reassignment would occur after render completes (in effects or async callbacks)
15
16 ## Input Invariants
17 - The function has been lowered to HIR
18 - Effects have been inferred for all operands (`operand.effect !== Effect.Unknown`)
19 - Function signatures have been analyzed for `noAlias` properties
20
21 ## Validation Rules
22
23 ### Rule 1: No Reassignment After Render
24 Variables cannot be reassigned in functions that escape to be called after render.
25
26 **Error:**
27 ```
28 Error: Cannot reassign variable after render completes
29
30 Reassigning `[variable]` after render has completed can cause inconsistent behavior on subsequent renders. Consider using state instead.
31 ```
32
33 ### Rule 2: No Reassignment in Async Functions
34 Variables cannot be reassigned within async functions (async functions always execute after render).
35
36 **Error:**
37 ```
38 Error: Cannot reassign variable in async function
39
40 Reassigning a variable in an async function can cause inconsistent behavior on subsequent renders. Consider using state instead.
41 ```
42
43 ## Algorithm
44
45 ### Phase 1: Track Context Variables
46 Context variables are variables declared in the outer component/hook that are captured by inner functions:
47
48 ```typescript
49 const contextVariables = new Set<IdentifierId>();
50
51 // For DeclareContext in the main function, add to tracking
52 case 'DeclareContext':
53 if (!isFunctionExpression) {
54 contextVariables.add(value.lvalue.place.identifier.id);
55 }
56 break;
57 ```
58
59 ### Phase 2: Detect Reassigning Functions
60 The pass tracks which functions contain reassignments to context variables:
61
62 ```typescript
63 const reassigningFunctions = new Map<IdentifierId, Place>();
64
65 case 'FunctionExpression':
66 case 'ObjectMethod':
67 // Recursively check if the function reassigns context variables
68 let reassignment = getContextReassignment(
69 value.loweredFunc.func,
70 contextVariables,
71 true, // isFunctionExpression
72 isAsync || value.loweredFunc.func.async
73 );
74
75 // Also check if any captured functions reassign
76 if (reassignment === null) {
77 for (const operand of eachInstructionValueOperand(value)) {
78 const fromOperand = reassigningFunctions.get(operand.identifier.id);
79 if (fromOperand !== undefined) {
80 reassignment = fromOperand;
81 break;
82 }
83 }
84 }
85
86 if (reassignment !== null) {
87 // If async, error immediately
88 if (isAsync || value.loweredFunc.func.async) {
89 throw new CompilerError("Cannot reassign variable in async function");
90 }
91 // Otherwise, track this function as reassigning
92 reassigningFunctions.set(lvalue.identifier.id, reassignment);
93 }
94 break;
95 ```
96
97 ### Phase 3: Detect Reassignment in Function Expression
98 Within a function expression, a `StoreContext` to a context variable is a reassignment:
99
100 ```typescript
101 case 'StoreContext':
102 if (isFunctionExpression) {
103 if (contextVariables.has(value.lvalue.place.identifier.id)) {
104 return value.lvalue.place; // Found a reassignment
105 }
106 } else {
107 // In main function, just track the context variable
108 contextVariables.add(value.lvalue.place.identifier.id);
109 }
110 break;
111 ```
112
113 ### Phase 4: Propagate Reassignment Through Data Flow
114 Reassigning functions flow through local/context stores:
115
116 ```typescript
117 case 'StoreLocal':
118 case 'StoreContext':
119 const reassignment = reassigningFunctions.get(value.value.identifier.id);
120 if (reassignment !== undefined) {
121 reassigningFunctions.set(value.lvalue.place.identifier.id, reassignment);
122 reassigningFunctions.set(lvalue.identifier.id, reassignment);
123 }
124 break;
125
126 case 'LoadLocal':
127 const reassignment = reassigningFunctions.get(value.place.identifier.id);
128 if (reassignment !== undefined) {
129 reassigningFunctions.set(lvalue.identifier.id, reassignment);
130 }
131 break;
132 ```
133
134 ### Phase 5: Check Escape Points
135 When a reassigning function is used as an operand with `Effect.Freeze`, it means the function escapes (e.g., passed to a hook, used as a prop):
136
137 ```typescript
138 for (const operand of operands) {
139 const reassignment = reassigningFunctions.get(operand.identifier.id);
140 if (reassignment !== undefined) {
141 if (operand.effect === Effect.Freeze) {
142 // Function escapes - this is an error
143 return reassignment;
144 } else {
145 // Function doesn't escape yet, propagate to lvalues
146 for (const lval of eachInstructionLValue(instr)) {
147 reassigningFunctions.set(lval.identifier.id, reassignment);
148 }
149 }
150 }
151 }
152 ```
153
154 ### Phase 6: Check Terminal Operands
155 Reassigning functions used in terminal operands (like return) also escape:
156
157 ```typescript
158 for (const operand of eachTerminalOperand(block.terminal)) {
159 const reassignment = reassigningFunctions.get(operand.identifier.id);
160 if (reassignment !== undefined) {
161 return reassignment;
162 }
163 }
164 ```
165
166 ### NoAlias Optimization
167 For function calls with `noAlias` signatures, only the callee needs to be checked (not all arguments):
168
169 ```typescript
170 if (value.kind === 'CallExpression') {
171 const signature = getFunctionCallSignature(fn.env, value.callee.identifier.type);
172 if (signature?.noAlias) {
173 operands = [value.callee]; // Only check the callee
174 }
175 }
176 ```
177
178 ## Edge Cases
179
180 ### Nested Async Functions
181 Async functions are always detected as problematic, regardless of nesting level:
182 ```javascript
183 function Component() {
184 let x = 0;
185 const f = async () => {
186 const g = () => {
187 x = 1; // Error: in async context
188 };
189 };
190 }
191 ```
192
193 ### Function Composition
194 If a reassigning function is captured by another function, that outer function is also marked as reassigning:
195 ```javascript
196 function Component() {
197 let x = 0;
198 const reassign = () => { x = 1; };
199 const wrapper = () => { reassign(); };
200 useEffect(wrapper); // Error: wrapper contains reassign
201 }
202 ```
203
204 ### NoAlias Functions
205 Functions with `noAlias` signatures don't let their arguments escape, so passing a reassigning function to them is safe:
206 ```javascript
207 function Component() {
208 let x = 0;
209 const f = () => { x = 1; };
210 console.log(f); // OK: console.log has noAlias, f doesn't escape
211 }
212 ```
213
214 ### Direct Effect Usage
215 The most common case is passing a reassigning function to useEffect:
216 ```javascript
217 function Component() {
218 let local;
219 const reassign = () => { local = 'new value'; };
220 useEffect(() => { reassign(); }, []); // Error
221 }
222 ```
223
224 ## TODOs
225 None in the source file.
226
227 ## Example
228
229 ### Fixture: `error.invalid-reassign-local-variable-in-effect.js`
230
231 **Input:**
232 ```javascript
233 import {useEffect} from 'react';
234
235 function Component() {
236 let local;
237
238 const reassignLocal = newValue => {
239 local = newValue;
240 };
241
242 const onMount = newValue => {
243 reassignLocal('hello');
244
245 if (local === newValue) {
246 // Without React Compiler, `reassignLocal` is freshly created
247 // on each render, capturing a binding to the latest `local`,
248 // such that invoking reassignLocal will reassign the same
249 // binding that we are observing in the if condition, and
250 // we reach this branch
251 console.log('`local` was updated!');
252 } else {
253 // With React Compiler enabled, `reassignLocal` is only created
254 // once, capturing a binding to `local` in that render pass.
255 // Therefore, calling `reassignLocal` will reassign the wrong
256 // version of `local`, and not update the binding we are checking
257 // in the if condition.
258 throw new Error('`local` not updated!');
259 }
260 };
261
262 useEffect(() => {
263 onMount();
264 }, [onMount]);
265
266 return 'ok';
267 }
268 ```
269
270 **Error:**
271 ```
272 Error: Cannot reassign variable after render completes
273
274 Reassigning `local` after render has completed can cause inconsistent behavior on subsequent renders. Consider using state instead.
275
276 error.invalid-reassign-local-variable-in-effect.ts:7:4
277 5 |
278 6 | const reassignLocal = newValue => {
279 > 7 | local = newValue;
280 | ^^^^^ Cannot reassign `local` after render completes
281 8 | };
282 9 |
283 10 | const onMount = newValue => {
284 ```
285
286 ### Fixture: `error.invalid-reassign-local-variable-in-async-callback.js`
287
288 **Input:**
289 ```javascript
290 function Component() {
291 let value = null;
292 const reassign = async () => {
293 await foo().then(result => {
294 // Reassigning a local variable in an async function is *always* mutating
295 // after render, so this should error regardless of where this ends up
296 // getting called
297 value = result;
298 });
299 };
300
301 const onClick = async () => {
302 await reassign();
303 };
304 return <div onClick={onClick}>Click</div>;
305 }
306 ```
307
308 **Error:**
309 ```
310 Error: Cannot reassign variable in async function
311
312 Reassigning a variable in an async function can cause inconsistent behavior on subsequent renders. Consider using state instead.
313
314 error.invalid-reassign-local-variable-in-async-callback.ts:8:6
315 6 | // after render, so this should error regardless of where this ends up
316 7 | // getting called
317 > 8 | value = result;
318 | ^^^^^ Cannot reassign `value`
319 9 | });
320 10 | };
321 ```