| 1 | # validateNoRefAccessInRender |
| 2 | |
| 3 | ## File |
| 4 | `src/Validation/ValidateNoRefAccessInRender.ts` |
| 5 | |
| 6 | ## Purpose |
| 7 | This validation pass ensures that React refs are not mutated during render. Refs are mutable containers for values that are not needed for rendering. Accessing or mutating `ref.current` during render can cause components to not update as expected because React does not track ref mutations. |
| 8 | |
| 9 | The pass validates both direct ref mutations at the component level and ref mutations inside functions that are called during render. |
| 10 | |
| 11 | ## Input Invariants |
| 12 | - The function has been through type inference |
| 13 | - Ref types are properly identified (`useRef` return values) |
| 14 | - Function expressions have been lowered |
| 15 | |
| 16 | ## Validation Rules |
| 17 | The pass produces errors for: |
| 18 | |
| 19 | 1. **Direct ref mutation in render**: Assigning to `ref.current` at the top level of a component |
| 20 | 2. **Ref mutation in render helper**: Mutating a ref inside a function that is called during render |
| 21 | 3. **Duplicate ref initialization**: Initializing a ref more than once within null-guard blocks |
| 22 | |
| 23 | **Exception - Null-guard initialization pattern**: The pass allows a single initialization of `ref.current` inside an `if (ref.current == null)` block. This is a common pattern for lazy initialization: |
| 24 | |
| 25 | ```javascript |
| 26 | // ALLOWED - null-guard initialization |
| 27 | if (ref.current == null) { |
| 28 | ref.current = expensiveComputation(); |
| 29 | } |
| 30 | ``` |
| 31 | |
| 32 | Error messages produced: |
| 33 | - Category: `Refs` |
| 34 | - Reason: "Cannot access refs during render" |
| 35 | - Messages: |
| 36 | - "Cannot update ref during render" |
| 37 | - "Ref is initialized more than once during render" |
| 38 | - "Ref was first initialized here" (for duplicate initialization) |
| 39 | |
| 40 | ## Algorithm |
| 41 | |
| 42 | ### Phase 1: Initialize Ref Tracking |
| 43 | Track refs from function parameters and context (captured variables): |
| 44 | |
| 45 | ```typescript |
| 46 | for (const param of fn.params) { |
| 47 | if (isUseRefType(place.identifier)) { |
| 48 | refs.set(place.identifier.id, {kind: 'Ref', refId: makeRefId()}); |
| 49 | } |
| 50 | } |
| 51 | ``` |
| 52 | |
| 53 | ### Phase 2: Single Forward Pass |
| 54 | Process all blocks in order, tracking: |
| 55 | - `refs`: Map of identifier IDs to ref information |
| 56 | - `nullables`: Set of identifiers known to be null/undefined |
| 57 | - `guards`: Map of comparison results (e.g., `ref.current == null`) |
| 58 | - `safeBlocks`: Map of blocks where null-guard allows initialization |
| 59 | - `refMutatingFunctions`: Map of function identifiers that mutate refs |
| 60 | |
| 61 | ### Phase 3: Process Instructions |
| 62 | For each instruction, handle: |
| 63 | |
| 64 | ```typescript |
| 65 | switch (value.kind) { |
| 66 | case 'PropertyLoad': { |
| 67 | // Track ref.current access |
| 68 | if (objRef?.kind === 'Ref' && value.property === 'current') { |
| 69 | refs.set(lvalue.identifier.id, {kind: 'RefValue', refId: objRef.refId}); |
| 70 | } |
| 71 | break; |
| 72 | } |
| 73 | case 'PropertyStore': { |
| 74 | // Check for ref mutation |
| 75 | if (isRef && isCurrentProperty && !isNullGuardInit) { |
| 76 | if (isTopLevel) { |
| 77 | errors.pushDiagnostic(makeRefMutationError(instr.loc)); |
| 78 | } |
| 79 | return mutation; |
| 80 | } |
| 81 | break; |
| 82 | } |
| 83 | case 'FunctionExpression': { |
| 84 | // Recursively validate with isTopLevel=false |
| 85 | const mutation = validateFunction(..., false, errors); |
| 86 | if (mutation != null) { |
| 87 | refMutatingFunctions.set(lvalue.identifier.id, mutation); |
| 88 | } |
| 89 | break; |
| 90 | } |
| 91 | case 'CallExpression': { |
| 92 | // Check if calling a ref-mutating function |
| 93 | if (refMutatingFunctions.has(callee.identifier.id) && isTopLevel) { |
| 94 | errors.pushDiagnostic(makeRefMutationError(mutationInfo.loc)); |
| 95 | } |
| 96 | break; |
| 97 | } |
| 98 | } |
| 99 | ``` |
| 100 | |
| 101 | ### Phase 4: Guard Detection and Propagation |
| 102 | When encountering an `if` terminal with a null-guard condition: |
| 103 | |
| 104 | ```typescript |
| 105 | if (block.terminal.kind === 'if') { |
| 106 | const guard = guards.get(block.terminal.test.identifier.id); |
| 107 | if (guard != null) { |
| 108 | // For equality checks (==, ===), consequent is safe |
| 109 | // For inequality checks (!=, !==), alternate is safe |
| 110 | const safeBlock = guard.isEquality |
| 111 | ? block.terminal.consequent |
| 112 | : block.terminal.alternate; |
| 113 | // Propagate safety through control flow |
| 114 | } |
| 115 | } |
| 116 | ``` |
| 117 | |
| 118 | ## Edge Cases |
| 119 | |
| 120 | ### Null-Guard Initialization Pattern (Allowed) |
| 121 | ```javascript |
| 122 | function Component() { |
| 123 | const ref = useRef(null); |
| 124 | if (ref.current == null) { |
| 125 | ref.current = computeValue(); // OK - first initialization |
| 126 | } |
| 127 | return <div />; |
| 128 | } |
| 129 | ``` |
| 130 | |
| 131 | ### Duplicate Initialization (Error) |
| 132 | ```javascript |
| 133 | function Component() { |
| 134 | const ref = useRef(null); |
| 135 | if (ref.current == null) { |
| 136 | ref.current = value1; // First init - tracked |
| 137 | } |
| 138 | if (ref.current == null) { |
| 139 | ref.current = value2; // Error: duplicate initialization |
| 140 | } |
| 141 | } |
| 142 | ``` |
| 143 | |
| 144 | ### Negated Null Check |
| 145 | The pass correctly handles negated null checks: |
| 146 | ```javascript |
| 147 | if (ref.current !== null) { |
| 148 | // NOT safe for initialization |
| 149 | } else { |
| 150 | // Safe for initialization (ref.current is null here) |
| 151 | } |
| 152 | ``` |
| 153 | |
| 154 | ### Ref Mutation in Called Function |
| 155 | ```javascript |
| 156 | function Component(props) { |
| 157 | const ref = useRef(null); |
| 158 | const renderItem = item => { |
| 159 | ref.current = item; // Mutation tracked in function |
| 160 | return <Item item={item} />; |
| 161 | }; |
| 162 | // Error: calling function that mutates ref during render |
| 163 | return <List>{props.items.map(renderItem)}</List>; |
| 164 | } |
| 165 | ``` |
| 166 | |
| 167 | ### Ref Mutation in Event Handler (Allowed) |
| 168 | ```javascript |
| 169 | function Component() { |
| 170 | const ref = useRef(null); |
| 171 | const onClick = () => { |
| 172 | ref.current = value; // OK - not called during render |
| 173 | }; |
| 174 | return <button onClick={onClick} />; // onClick is passed, not called |
| 175 | } |
| 176 | ``` |
| 177 | |
| 178 | ### Arbitrary Comparison Values (Error) |
| 179 | Only `null` or `undefined` comparisons are recognized as null guards: |
| 180 | ```javascript |
| 181 | const DEFAULT_VALUE = 1; |
| 182 | if (ref.current == DEFAULT_VALUE) { |
| 183 | ref.current = 1; // Error: not a null guard |
| 184 | } |
| 185 | ``` |
| 186 | |
| 187 | ## TODOs |
| 188 | None in the source file. |
| 189 | |
| 190 | ## Example |
| 191 | |
| 192 | ### Fixture: `error.invalid-disallow-mutating-ref-in-render.js` |
| 193 | |
| 194 | **Input:** |
| 195 | ```javascript |
| 196 | // @validateRefAccessDuringRender |
| 197 | function Component() { |
| 198 | const ref = useRef(null); |
| 199 | ref.current = false; |
| 200 | |
| 201 | return <button ref={ref} />; |
| 202 | } |
| 203 | ``` |
| 204 | |
| 205 | **Error:** |
| 206 | ``` |
| 207 | Found 1 error: |
| 208 | |
| 209 | Error: Cannot access refs during render |
| 210 | |
| 211 | React refs are values that are not needed for rendering. Refs should only be |
| 212 | accessed outside of render, such as in event handlers or effects. Accessing a |
| 213 | ref value (the `current` property) during render can cause your component not |
| 214 | to update as expected (https://react.dev/reference/react/useRef). |
| 215 | |
| 216 | error.invalid-disallow-mutating-ref-in-render.ts:4:2 |
| 217 | 2 | function Component() { |
| 218 | 3 | const ref = useRef(null); |
| 219 | > 4 | ref.current = false; |
| 220 | | ^^^^^^^^^^^ Cannot update ref during render |
| 221 | 5 | |
| 222 | 6 | return <button ref={ref} />; |
| 223 | 7 | } |
| 224 | ``` |
| 225 | |
| 226 | ### Fixture: `error.invalid-ref-in-callback-invoked-during-render.js` |
| 227 | |
| 228 | **Input:** |
| 229 | ```javascript |
| 230 | // @validateRefAccessDuringRender |
| 231 | function Component(props) { |
| 232 | const ref = useRef(null); |
| 233 | const renderItem = item => { |
| 234 | const current = ref.current; |
| 235 | return <Foo item={item} current={current} />; |
| 236 | }; |
| 237 | return <Items>{props.items.map(item => renderItem(item))}</Items>; |
| 238 | } |
| 239 | ``` |
| 240 | |
| 241 | **Error:** |
| 242 | ``` |
| 243 | Found 1 error: |
| 244 | |
| 245 | Error: Cannot access ref value during render |
| 246 | |
| 247 | React refs are values that are not needed for rendering... |
| 248 | |
| 249 | error.invalid-ref-in-callback-invoked-during-render.ts:6:37 |
| 250 | 4 | const renderItem = item => { |
| 251 | 5 | const current = ref.current; |
| 252 | > 6 | return <Foo item={item} current={current} />; |
| 253 | | ^^^^^^^ Ref value is used during render |
| 254 | 7 | }; |
| 255 | 8 | return <Items>{props.items.map(item => renderItem(item))}</Items>; |
| 256 | |
| 257 | error.invalid-ref-in-callback-invoked-during-render.ts:5:20 |
| 258 | 3 | const ref = useRef(null); |
| 259 | 4 | const renderItem = item => { |
| 260 | > 5 | const current = ref.current; |
| 261 | | ^^^^^^^^^^^ Ref is initially accessed |
| 262 | ``` |
| 263 | |
| 264 | Key observations: |
| 265 | - Direct mutation at render level is an immediate error |
| 266 | - Functions that mutate refs are tracked; errors occur when those functions are called at render level |
| 267 | - The null-guard pattern allows a single initialization |
| 268 | - The pass distinguishes between refs (`useRef` return type) and ref values (`.current` property) |