|
| 1 | +/** |
| 2 | + * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | + * |
| 4 | + * This source code is licensed under the MIT license found in the |
| 5 | + * LICENSE file in the root directory of this source tree. |
| 6 | + */ |
| 7 | + |
| 8 | +import {CompilerError, ErrorSeverity, ValueKind} from '..'; |
| 9 | +import { |
| 10 | + AbstractValue, |
| 11 | + BasicBlock, |
| 12 | + Effect, |
| 13 | + Environment, |
| 14 | + FunctionEffect, |
| 15 | + Instruction, |
| 16 | + InstructionValue, |
| 17 | + Place, |
| 18 | + ValueReason, |
| 19 | + getHookKind, |
| 20 | + isRefOrRefValue, |
| 21 | +} from '../HIR'; |
| 22 | +import {eachInstructionOperand, eachTerminalOperand} from '../HIR/visitors'; |
| 23 | +import {assertExhaustive} from '../Utils/utils'; |
| 24 | + |
| 25 | +interface State { |
| 26 | + kind(place: Place): AbstractValue; |
| 27 | + values(place: Place): Array<InstructionValue>; |
| 28 | + isDefined(place: Place): boolean; |
| 29 | +} |
| 30 | + |
| 31 | +function inferOperandEffect(state: State, place: Place): null | FunctionEffect { |
| 32 | + const value = state.kind(place); |
| 33 | + CompilerError.invariant(value != null, { |
| 34 | + reason: 'Expected operand to have a kind', |
| 35 | + loc: null, |
| 36 | + }); |
| 37 | + |
| 38 | + switch (place.effect) { |
| 39 | + case Effect.Store: |
| 40 | + case Effect.Mutate: { |
| 41 | + if (isRefOrRefValue(place.identifier)) { |
| 42 | + break; |
| 43 | + } else if (value.kind === ValueKind.Context) { |
| 44 | + return { |
| 45 | + kind: 'ContextMutation', |
| 46 | + loc: place.loc, |
| 47 | + effect: place.effect, |
| 48 | + places: value.context.size === 0 ? new Set([place]) : value.context, |
| 49 | + }; |
| 50 | + } else if ( |
| 51 | + value.kind !== ValueKind.Mutable && |
| 52 | + // We ignore mutations of primitives since this is not a React-specific problem |
| 53 | + value.kind !== ValueKind.Primitive |
| 54 | + ) { |
| 55 | + let reason = getWriteErrorReason(value); |
| 56 | + return { |
| 57 | + kind: |
| 58 | + value.reason.size === 1 && value.reason.has(ValueReason.Global) |
| 59 | + ? 'GlobalMutation' |
| 60 | + : 'ReactMutation', |
| 61 | + error: { |
| 62 | + reason, |
| 63 | + description: |
| 64 | + place.identifier.name !== null && |
| 65 | + place.identifier.name.kind === 'named' |
| 66 | + ? `Found mutation of \`${place.identifier.name.value}\`` |
| 67 | + : null, |
| 68 | + loc: place.loc, |
| 69 | + suggestions: null, |
| 70 | + severity: ErrorSeverity.InvalidReact, |
| 71 | + }, |
| 72 | + }; |
| 73 | + } |
| 74 | + break; |
| 75 | + } |
| 76 | + } |
| 77 | + return null; |
| 78 | +} |
| 79 | + |
| 80 | +function inheritFunctionEffects( |
| 81 | + state: State, |
| 82 | + place: Place, |
| 83 | +): Array<FunctionEffect> { |
| 84 | + const effects = inferFunctionInstrEffects(state, place); |
| 85 | + |
| 86 | + return effects |
| 87 | + .flatMap(effect => { |
| 88 | + if (effect.kind === 'GlobalMutation' || effect.kind === 'ReactMutation') { |
| 89 | + return [effect]; |
| 90 | + } else { |
| 91 | + const effects: Array<FunctionEffect | null> = []; |
| 92 | + CompilerError.invariant(effect.kind === 'ContextMutation', { |
| 93 | + reason: 'Expected ContextMutation', |
| 94 | + loc: null, |
| 95 | + }); |
| 96 | + /** |
| 97 | + * Contextual effects need to be replayed against the current inference |
| 98 | + * state, which may know more about the value to which the effect applied. |
| 99 | + * The main cases are: |
| 100 | + * 1. The mutated context value is _still_ a context value in the current scope, |
| 101 | + * so we have to continue propagating the original context mutation. |
| 102 | + * 2. The mutated context value is a mutable value in the current scope, |
| 103 | + * so the context mutation was fine and we can skip propagating the effect. |
| 104 | + * 3. The mutated context value is an immutable value in the current scope, |
| 105 | + * resulting in a non-ContextMutation FunctionEffect. We propagate that new, |
| 106 | + * more detailed effect to the current function context. |
| 107 | + */ |
| 108 | + for (const place of effect.places) { |
| 109 | + if (state.isDefined(place)) { |
| 110 | + const replayedEffect = inferOperandEffect(state, { |
| 111 | + ...place, |
| 112 | + loc: effect.loc, |
| 113 | + effect: effect.effect, |
| 114 | + }); |
| 115 | + if (replayedEffect != null) { |
| 116 | + if (replayedEffect.kind === 'ContextMutation') { |
| 117 | + // Case 1, still a context value so propagate the original effect |
| 118 | + effects.push(effect); |
| 119 | + } else { |
| 120 | + // Case 3, immutable value so propagate the more precise effect |
| 121 | + effects.push(replayedEffect); |
| 122 | + } |
| 123 | + } // else case 2, local mutable value so this effect was fine |
| 124 | + } |
| 125 | + } |
| 126 | + return effects; |
| 127 | + } |
| 128 | + }) |
| 129 | + .filter((effect): effect is FunctionEffect => effect != null); |
| 130 | +} |
| 131 | + |
| 132 | +function inferFunctionInstrEffects( |
| 133 | + state: State, |
| 134 | + place: Place, |
| 135 | +): Array<FunctionEffect> { |
| 136 | + const effects: Array<FunctionEffect> = []; |
| 137 | + const instrs = state.values(place); |
| 138 | + CompilerError.invariant(instrs != null, { |
| 139 | + reason: 'Expected operand to have instructions', |
| 140 | + loc: null, |
| 141 | + }); |
| 142 | + |
| 143 | + for (const instr of instrs) { |
| 144 | + if ( |
| 145 | + (instr.kind === 'FunctionExpression' || instr.kind === 'ObjectMethod') && |
| 146 | + instr.loweredFunc.func.effects != null |
| 147 | + ) { |
| 148 | + effects.push(...instr.loweredFunc.func.effects); |
| 149 | + } |
| 150 | + } |
| 151 | + |
| 152 | + return effects; |
| 153 | +} |
| 154 | + |
| 155 | +function operandEffects( |
| 156 | + state: State, |
| 157 | + place: Place, |
| 158 | + filterRenderSafe: boolean, |
| 159 | +): Array<FunctionEffect> { |
| 160 | + const functionEffects: Array<FunctionEffect> = []; |
| 161 | + const effect = inferOperandEffect(state, place); |
| 162 | + effect && functionEffects.push(effect); |
| 163 | + functionEffects.push(...inheritFunctionEffects(state, place)); |
| 164 | + if (filterRenderSafe) { |
| 165 | + return functionEffects.filter(effect => !isEffectSafeOutsideRender(effect)); |
| 166 | + } else { |
| 167 | + return functionEffects; |
| 168 | + } |
| 169 | +} |
| 170 | + |
| 171 | +export function inferInstructionFunctionEffects( |
| 172 | + env: Environment, |
| 173 | + state: State, |
| 174 | + instr: Instruction, |
| 175 | +): Array<FunctionEffect> { |
| 176 | + const functionEffects: Array<FunctionEffect> = []; |
| 177 | + switch (instr.value.kind) { |
| 178 | + case 'JsxExpression': { |
| 179 | + if (instr.value.tag.kind === 'Identifier') { |
| 180 | + functionEffects.push(...operandEffects(state, instr.value.tag, false)); |
| 181 | + } |
| 182 | + instr.value.children?.forEach(child => |
| 183 | + functionEffects.push(...operandEffects(state, child, false)), |
| 184 | + ); |
| 185 | + for (const attr of instr.value.props) { |
| 186 | + if (attr.kind === 'JsxSpreadAttribute') { |
| 187 | + functionEffects.push(...operandEffects(state, attr.argument, false)); |
| 188 | + } else { |
| 189 | + functionEffects.push(...operandEffects(state, attr.place, true)); |
| 190 | + } |
| 191 | + } |
| 192 | + break; |
| 193 | + } |
| 194 | + case 'ObjectMethod': |
| 195 | + case 'FunctionExpression': { |
| 196 | + /** |
| 197 | + * If this function references other functions, propagate the referenced function's |
| 198 | + * effects to this function. |
| 199 | + * |
| 200 | + * ``` |
| 201 | + * let f = () => global = true; |
| 202 | + * let g = () => f(); |
| 203 | + * g(); |
| 204 | + * ``` |
| 205 | + * |
| 206 | + * In this example, because `g` references `f`, we propagate the GlobalMutation from |
| 207 | + * `f` to `g`. Thus, referencing `g` in `g()` will evaluate the GlobalMutation in the outer |
| 208 | + * function effect context and report an error. But if instead we do: |
| 209 | + * |
| 210 | + * ``` |
| 211 | + * let f = () => global = true; |
| 212 | + * let g = () => f(); |
| 213 | + * useEffect(() => g(), [g]) |
| 214 | + * ``` |
| 215 | + * |
| 216 | + * Now `g`'s effects will be discarded since they're in a useEffect. |
| 217 | + */ |
| 218 | + for (const operand of eachInstructionOperand(instr)) { |
| 219 | + instr.value.loweredFunc.func.effects ??= []; |
| 220 | + instr.value.loweredFunc.func.effects.push( |
| 221 | + ...inferFunctionInstrEffects(state, operand), |
| 222 | + ); |
| 223 | + } |
| 224 | + break; |
| 225 | + } |
| 226 | + case 'MethodCall': |
| 227 | + case 'CallExpression': { |
| 228 | + let callee; |
| 229 | + if (instr.value.kind === 'MethodCall') { |
| 230 | + callee = instr.value.property; |
| 231 | + functionEffects.push( |
| 232 | + ...operandEffects(state, instr.value.receiver, false), |
| 233 | + ); |
| 234 | + } else { |
| 235 | + callee = instr.value.callee; |
| 236 | + } |
| 237 | + functionEffects.push(...operandEffects(state, callee, false)); |
| 238 | + let isHook = getHookKind(env, callee.identifier) != null; |
| 239 | + for (const arg of instr.value.args) { |
| 240 | + const place = arg.kind === 'Identifier' ? arg : arg.place; |
| 241 | + /* |
| 242 | + * Join the effects of the argument with the effects of the enclosing function, |
| 243 | + * unless the we're detecting a global mutation inside a useEffect hook |
| 244 | + */ |
| 245 | + functionEffects.push(...operandEffects(state, place, isHook)); |
| 246 | + } |
| 247 | + break; |
| 248 | + } |
| 249 | + case 'StartMemoize': |
| 250 | + case 'FinishMemoize': |
| 251 | + case 'LoadLocal': |
| 252 | + case 'StoreLocal': { |
| 253 | + break; |
| 254 | + } |
| 255 | + case 'StoreGlobal': { |
| 256 | + functionEffects.push({ |
| 257 | + kind: 'GlobalMutation', |
| 258 | + error: { |
| 259 | + reason: |
| 260 | + 'Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render)', |
| 261 | + loc: instr.loc, |
| 262 | + suggestions: null, |
| 263 | + severity: ErrorSeverity.InvalidReact, |
| 264 | + }, |
| 265 | + }); |
| 266 | + break; |
| 267 | + } |
| 268 | + default: { |
| 269 | + for (const operand of eachInstructionOperand(instr)) { |
| 270 | + functionEffects.push(...operandEffects(state, operand, false)); |
| 271 | + } |
| 272 | + } |
| 273 | + } |
| 274 | + return functionEffects; |
| 275 | +} |
| 276 | + |
| 277 | +export function inferTerminalFunctionEffects( |
| 278 | + state: State, |
| 279 | + block: BasicBlock, |
| 280 | +): Array<FunctionEffect> { |
| 281 | + const functionEffects: Array<FunctionEffect> = []; |
| 282 | + for (const operand of eachTerminalOperand(block.terminal)) { |
| 283 | + functionEffects.push(...operandEffects(state, operand, true)); |
| 284 | + } |
| 285 | + return functionEffects; |
| 286 | +} |
| 287 | + |
| 288 | +export function raiseFunctionEffectErrors( |
| 289 | + functionEffects: Array<FunctionEffect>, |
| 290 | +): void { |
| 291 | + functionEffects.forEach(eff => { |
| 292 | + switch (eff.kind) { |
| 293 | + case 'ReactMutation': |
| 294 | + case 'GlobalMutation': { |
| 295 | + CompilerError.throw(eff.error); |
| 296 | + } |
| 297 | + case 'ContextMutation': { |
| 298 | + CompilerError.throw({ |
| 299 | + severity: ErrorSeverity.Invariant, |
| 300 | + reason: `Unexpected ContextMutation in top-level function effects`, |
| 301 | + loc: eff.loc, |
| 302 | + }); |
| 303 | + } |
| 304 | + default: |
| 305 | + assertExhaustive( |
| 306 | + eff, |
| 307 | + `Unexpected function effect kind \`${(eff as any).kind}\``, |
| 308 | + ); |
| 309 | + } |
| 310 | + }); |
| 311 | +} |
| 312 | + |
| 313 | +function isEffectSafeOutsideRender(effect: FunctionEffect): boolean { |
| 314 | + return effect.kind === 'GlobalMutation'; |
| 315 | +} |
| 316 | + |
| 317 | +function getWriteErrorReason(abstractValue: AbstractValue): string { |
| 318 | + if (abstractValue.reason.has(ValueReason.Global)) { |
| 319 | + return 'Writing to a variable defined outside a component or hook is not allowed. Consider using an effect'; |
| 320 | + } else if (abstractValue.reason.has(ValueReason.JsxCaptured)) { |
| 321 | + return 'Updating a value used previously in JSX is not allowed. Consider moving the mutation before the JSX'; |
| 322 | + } else if (abstractValue.reason.has(ValueReason.Context)) { |
| 323 | + return `Mutating a value returned from 'useContext()', which should not be mutated`; |
| 324 | + } else if (abstractValue.reason.has(ValueReason.KnownReturnSignature)) { |
| 325 | + return 'Mutating a value returned from a function whose return value should not be mutated'; |
| 326 | + } else if (abstractValue.reason.has(ValueReason.ReactiveFunctionArgument)) { |
| 327 | + return 'Mutating component props or hook arguments is not allowed. Consider using a local variable instead'; |
| 328 | + } else if (abstractValue.reason.has(ValueReason.State)) { |
| 329 | + return "Mutating a value returned from 'useState()', which should not be mutated. Use the setter function to update instead"; |
| 330 | + } else if (abstractValue.reason.has(ValueReason.ReducerState)) { |
| 331 | + return "Mutating a value returned from 'useReducer()', which should not be mutated. Use the dispatch function to update instead"; |
| 332 | + } else { |
| 333 | + return 'This mutates a variable that React considers immutable'; |
| 334 | + } |
| 335 | +} |
0 commit comments