| 1 | # validateHooksUsage |
| 2 | |
| 3 | ## File |
| 4 | `src/Validation/ValidateHooksUsage.ts` |
| 5 | |
| 6 | ## Purpose |
| 7 | This validation pass ensures that the function honors the [Rules of Hooks](https://react.dev/warnings/invalid-hook-call-warning). Specifically, it validates that: |
| 8 | |
| 9 | 1. Hooks may only be called unconditionally (not in if statements, loops, etc.) |
| 10 | 2. Hooks cannot be used as first-class values (passed around, stored in variables, etc.) |
| 11 | 3. Hooks must be the same function on every render (no dynamic hooks) |
| 12 | 4. Hooks must be called at the top level, not within nested function expressions |
| 13 | |
| 14 | ## Input Invariants |
| 15 | - The function has been lowered to HIR |
| 16 | - Global bindings have been resolved and typed |
| 17 | - Nested function expressions have been lowered |
| 18 | |
| 19 | ## Value Kinds Lattice |
| 20 | |
| 21 | The pass uses abstract interpretation with a lattice of value kinds: |
| 22 | |
| 23 | ```typescript |
| 24 | enum Kind { |
| 25 | Error, // Hook already used in an invalid way (stop reporting) |
| 26 | KnownHook, // Definitely a hook (from LoadGlobal with hook type) |
| 27 | PotentialHook, // Might be a hook (hook-like name but not from global) |
| 28 | Global, // A global value that is not a hook |
| 29 | Local, // A local variable |
| 30 | } |
| 31 | ``` |
| 32 | |
| 33 | The `joinKinds` function merges kinds, with earlier kinds taking precedence: |
| 34 | - `Error` > `KnownHook` > `PotentialHook` > `Global` > `Local` |
| 35 | |
| 36 | ## Validation Rules |
| 37 | |
| 38 | ### Rule 1: No Conditional Hook Calls |
| 39 | Hooks must always be called in a consistent order. |
| 40 | |
| 41 | **Error:** |
| 42 | ``` |
| 43 | Error: Hooks must always be called in a consistent order, and may not be called conditionally. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning) |
| 44 | ``` |
| 45 | |
| 46 | ### Rule 2: No Hooks as First-Class Values |
| 47 | Known hooks may not be referenced as normal values (only called). |
| 48 | |
| 49 | **Error:** |
| 50 | ``` |
| 51 | Error: Hooks may not be referenced as normal values, they must be called. See https://react.dev/reference/rules/react-calls-components-and-hooks#never-pass-around-hooks-as-regular-values |
| 52 | ``` |
| 53 | |
| 54 | ### Rule 3: No Dynamic Hooks |
| 55 | Potential hooks (hook-like names from local scope) may change between renders. |
| 56 | |
| 57 | **Error:** |
| 58 | ``` |
| 59 | Error: Hooks must be the same function on every render, but this value may change over time to a different function. See https://react.dev/reference/rules/react-calls-components-and-hooks#dont-dynamically-use-hooks |
| 60 | ``` |
| 61 | |
| 62 | ### Rule 4: No Hooks in Nested Functions |
| 63 | Hooks must be called at the top level of a component or custom hook. |
| 64 | |
| 65 | **Error:** |
| 66 | ``` |
| 67 | Error: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning) |
| 68 | |
| 69 | Cannot call [hookKind] within a function expression |
| 70 | ``` |
| 71 | |
| 72 | ## Algorithm |
| 73 | |
| 74 | ### Phase 1: Compute Unconditional Blocks |
| 75 | ```typescript |
| 76 | const unconditionalBlocks = computeUnconditionalBlocks(fn); |
| 77 | ``` |
| 78 | Determines which blocks are guaranteed to execute on every render (not inside conditionals). |
| 79 | |
| 80 | ### Phase 2: Initialize Tracking |
| 81 | ```typescript |
| 82 | const valueKinds = new Map<IdentifierId, Kind>(); |
| 83 | |
| 84 | // Initialize parameters |
| 85 | for (const param of fn.params) { |
| 86 | const place = param.kind === 'Identifier' ? param : param.place; |
| 87 | const kind = getKindForPlace(place); // PotentialHook if hook-like name |
| 88 | setKind(place, kind); |
| 89 | } |
| 90 | ``` |
| 91 | |
| 92 | ### Phase 3: Track Value Kinds Through Instructions |
| 93 | |
| 94 | For each instruction, the pass tracks how hook-ness flows through values: |
| 95 | |
| 96 | ```typescript |
| 97 | case 'LoadGlobal': |
| 98 | // Globals are the source of KnownHook |
| 99 | if (getHookKind(fn.env, instr.lvalue.identifier) != null) { |
| 100 | setKind(instr.lvalue, Kind.KnownHook); |
| 101 | } else { |
| 102 | setKind(instr.lvalue, Kind.Global); |
| 103 | } |
| 104 | break; |
| 105 | |
| 106 | case 'PropertyLoad': |
| 107 | // Hook-like property of Global -> KnownHook |
| 108 | // Hook-like property of Local -> PotentialHook |
| 109 | // Property of KnownHook -> KnownHook (if hook-like name) |
| 110 | const objectKind = getKindForPlace(value.object); |
| 111 | const isHookProperty = isHookName(value.property); |
| 112 | // Determine kind based on object kind and property name |
| 113 | break; |
| 114 | |
| 115 | case 'CallExpression': |
| 116 | const calleeKind = getKindForPlace(value.callee); |
| 117 | const isHookCallee = calleeKind === Kind.KnownHook || calleeKind === Kind.PotentialHook; |
| 118 | |
| 119 | if (isHookCallee && !unconditionalBlocks.has(block.id)) { |
| 120 | recordConditionalHookError(value.callee); |
| 121 | } else if (calleeKind === Kind.PotentialHook) { |
| 122 | recordDynamicHookUsageError(value.callee); |
| 123 | } |
| 124 | break; |
| 125 | ``` |
| 126 | |
| 127 | ### Phase 4: Check for Invalid Hook References |
| 128 | |
| 129 | When a `KnownHook` is used as an operand (not as a callee), it's an error: |
| 130 | |
| 131 | ```typescript |
| 132 | function visitPlace(place: Place): void { |
| 133 | const kind = valueKinds.get(place.identifier.id); |
| 134 | if (kind === Kind.KnownHook) { |
| 135 | recordInvalidHookUsageError(place); |
| 136 | } |
| 137 | } |
| 138 | ``` |
| 139 | |
| 140 | ### Phase 5: Validate Nested Function Expressions |
| 141 | |
| 142 | Recursively check that nested functions don't call hooks: |
| 143 | |
| 144 | ```typescript |
| 145 | function visitFunctionExpression(errors: CompilerError, fn: HIRFunction) { |
| 146 | for (const instr of allInstructions(fn)) { |
| 147 | if (isCall(instr)) { |
| 148 | const callee = getCallee(instr); |
| 149 | const hookKind = getHookKind(fn.env, callee.identifier); |
| 150 | if (hookKind != null) { |
| 151 | errors.push({ |
| 152 | reason: 'Hooks must be called at the top level...', |
| 153 | description: `Cannot call ${hookKind} within a function expression`, |
| 154 | }); |
| 155 | } |
| 156 | } |
| 157 | // Recursively check nested functions |
| 158 | if (isFunctionExpression(instr)) { |
| 159 | visitFunctionExpression(errors, instr.value.loweredFunc.func); |
| 160 | } |
| 161 | } |
| 162 | } |
| 163 | ``` |
| 164 | |
| 165 | ### Phi Node Handling |
| 166 | |
| 167 | For phi nodes (control flow join points), the pass joins the kinds of all operands: |
| 168 | |
| 169 | ```typescript |
| 170 | for (const phi of block.phis) { |
| 171 | let kind = isHookName(phi.place.identifier.name) ? Kind.PotentialHook : Kind.Local; |
| 172 | for (const [, operand] of phi.operands) { |
| 173 | const operandKind = valueKinds.get(operand.identifier.id); |
| 174 | if (operandKind !== undefined) { |
| 175 | kind = joinKinds(kind, operandKind); |
| 176 | } |
| 177 | } |
| 178 | valueKinds.set(phi.place.identifier.id, kind); |
| 179 | } |
| 180 | ``` |
| 181 | |
| 182 | ## Edge Cases |
| 183 | |
| 184 | ### Optional Calls |
| 185 | Optional calls like `useHook?.()` are treated as conditional: |
| 186 | ```javascript |
| 187 | const result = useHook?.(); // Error: conditional hook call |
| 188 | ``` |
| 189 | |
| 190 | ### Property Access on Hooks |
| 191 | Hook-like properties of known hooks are also known hooks: |
| 192 | ```javascript |
| 193 | const useFoo = useHook.useFoo; // useFoo is KnownHook |
| 194 | useFoo(); // Must be called unconditionally |
| 195 | ``` |
| 196 | |
| 197 | ### Destructuring from Global |
| 198 | Destructuring hook-like names from a global creates known hooks: |
| 199 | ```javascript |
| 200 | const {useState} = React; // useState is KnownHook |
| 201 | ``` |
| 202 | |
| 203 | ### Hook-Like Names from Local Variables |
| 204 | Hook-like names from local variables are potential hooks: |
| 205 | ```javascript |
| 206 | const obj = createObject(); |
| 207 | const useFoo = obj.useFoo; // PotentialHook |
| 208 | useFoo(); // Error: dynamic hook |
| 209 | ``` |
| 210 | |
| 211 | ### Error Deduplication |
| 212 | The pass deduplicates errors by source location, and once an error is recorded for a place, it's marked as `Kind.Error` to prevent further errors for the same place. |
| 213 | |
| 214 | ## TODOs |
| 215 | |
| 216 | 1. **Fixpoint iteration for loops** - The pass currently skips phi operands whose value is unknown (which can occur in loops). A follow-up could expand this to fixpoint iteration: |
| 217 | ```typescript |
| 218 | // NOTE: we currently skip operands whose value is unknown |
| 219 | // (which can only occur for functions with loops), we may |
| 220 | // cause us to miss invalid code in some cases. We should |
| 221 | // expand this to a fixpoint iteration in a follow-up. |
| 222 | ``` |
| 223 | |
| 224 | ## Example |
| 225 | |
| 226 | ### Fixture: `rules-of-hooks/error.invalid-hook-if-consequent.js` |
| 227 | |
| 228 | **Input:** |
| 229 | ```javascript |
| 230 | function Component(props) { |
| 231 | let x = null; |
| 232 | if (props.cond) { |
| 233 | x = useHook(); |
| 234 | } |
| 235 | return x; |
| 236 | } |
| 237 | ``` |
| 238 | |
| 239 | **Error:** |
| 240 | ``` |
| 241 | Error: Hooks must always be called in a consistent order, and may not be called conditionally. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning) |
| 242 | |
| 243 | error.invalid-hook-if-consequent.ts:4:8 |
| 244 | 2 | let x = null; |
| 245 | 3 | if (props.cond) { |
| 246 | > 4 | x = useHook(); |
| 247 | | ^^^^^^^ Hooks must always be called in a consistent order... |
| 248 | 5 | } |
| 249 | 6 | return x; |
| 250 | ``` |
| 251 | |
| 252 | ### Fixture: `rules-of-hooks/error.invalid-hook-as-prop.js` |
| 253 | |
| 254 | **Input:** |
| 255 | ```javascript |
| 256 | function Component({useFoo}) { |
| 257 | useFoo(); |
| 258 | } |
| 259 | ``` |
| 260 | |
| 261 | **Error:** |
| 262 | ``` |
| 263 | Error: Hooks must be the same function on every render, but this value may change over time to a different function. See https://react.dev/reference/rules/react-calls-components-and-hooks#dont-dynamically-use-hooks |
| 264 | |
| 265 | error.invalid-hook-as-prop.ts:2:2 |
| 266 | 1 | function Component({useFoo}) { |
| 267 | > 2 | useFoo(); |
| 268 | | ^^^^^^ Hooks must be the same function on every render... |
| 269 | 3 | } |
| 270 | ``` |
| 271 | |
| 272 | ### Fixture: `rules-of-hooks/error.invalid-hook-in-nested-function-expression-object-expression.js` |
| 273 | |
| 274 | **Input:** |
| 275 | ```javascript |
| 276 | function Component() { |
| 277 | 'use memo'; |
| 278 | const f = () => { |
| 279 | const x = { |
| 280 | outer() { |
| 281 | const g = () => { |
| 282 | const y = { |
| 283 | inner() { |
| 284 | return useFoo(); |
| 285 | }, |
| 286 | }; |
| 287 | return y; |
| 288 | }; |
| 289 | }, |
| 290 | }; |
| 291 | return x; |
| 292 | }; |
| 293 | } |
| 294 | ``` |
| 295 | |
| 296 | **Error:** |
| 297 | ``` |
| 298 | Error: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning) |
| 299 | |
| 300 | Cannot call hook within a function expression. |
| 301 | |
| 302 | error.invalid-hook-in-nested-function-expression-object-expression.ts:10:21 |
| 303 | 8 | const y = { |
| 304 | 9 | inner() { |
| 305 | > 10 | return useFoo(); |
| 306 | | ^^^^^^ Hooks must be called at the top level... |
| 307 | 11 | }, |
| 308 | 12 | }; |
| 309 | ``` |
| 310 | |
| 311 | ### Fixture: `rules-of-hooks/error.invalid-hook-optionalcall.js` |
| 312 | |
| 313 | **Input:** |
| 314 | ```javascript |
| 315 | function Component() { |
| 316 | const {result} = useConditionalHook?.() ?? {}; |
| 317 | return result; |
| 318 | } |
| 319 | ``` |
| 320 | |
| 321 | **Error:** |
| 322 | ``` |
| 323 | Error: Hooks must always be called in a consistent order, and may not be called conditionally. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning) |
| 324 | |
| 325 | error.invalid-hook-optionalcall.ts:2:19 |
| 326 | 1 | function Component() { |
| 327 | > 2 | const {result} = useConditionalHook?.() ?? {}; |
| 328 | | ^^^^^^^^^^^^^^^^^^ Hooks must always be called in a consistent order... |
| 329 | 3 | return result; |
| 330 | ``` |