main
ts 456 lines 15.7 KB
Raw
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 * as t from '@babel/types';
9 import {CompilerErrorDetail, ErrorCategory} from '../CompilerError';
10 import {computeUnconditionalBlocks} from '../HIR/ComputeUnconditionalBlocks';
11 import {Environment, isHookName} from '../HIR/Environment';
12 import {
13 HIRFunction,
14 IdentifierId,
15 Place,
16 SourceLocation,
17 getHookKind,
18 } from '../HIR/HIR';
19 import {
20 eachInstructionLValue,
21 eachInstructionOperand,
22 eachTerminalOperand,
23 } from '../HIR/visitors';
24 import {assertExhaustive} from '../Utils/utils';
25
26 /**
27 * Represents the possible kinds of value which may be stored at a given Place during
28 * abstract interpretation. The kinds form a lattice, with earlier items taking
29 * precedence over later items (see joinKinds()).
30 */
31 enum Kind {
32 // A potential/known hook which was already used in an invalid way
33 Error = 'Error',
34
35 /*
36 * A known hook. Sources include:
37 * - LoadGlobal instructions whose type was inferred as a hook
38 * - PropertyLoad, ComputedLoad, and Destructuring instructions
39 * where the object is a KnownHook
40 * - PropertyLoad, ComputedLoad, and Destructuring instructions
41 * where the object is a Global and the property name is hook-like
42 */
43 KnownHook = 'KnownHook',
44
45 /*
46 * A potential hook. Sources include:
47 * - LValues (other than LoadGlobal) where the name is hook-like
48 * - PropertyLoad, ComputedLoad, and Destructuring instructions
49 * where the object is a potential hook or the property name
50 * is hook-like
51 */
52 PotentialHook = 'PotentialHook',
53
54 // LoadGlobal values whose type was not inferred as a hook
55 Global = 'Global',
56
57 // All other values, ie local variables
58 Local = 'Local',
59 }
60
61 function joinKinds(a: Kind, b: Kind): Kind {
62 if (a === Kind.Error || b === Kind.Error) {
63 return Kind.Error;
64 } else if (a === Kind.KnownHook || b === Kind.KnownHook) {
65 return Kind.KnownHook;
66 } else if (a === Kind.PotentialHook || b === Kind.PotentialHook) {
67 return Kind.PotentialHook;
68 } else if (a === Kind.Global || b === Kind.Global) {
69 return Kind.Global;
70 } else {
71 return Kind.Local;
72 }
73 }
74
75 /*
76 * Validates that the function honors the [Rules of Hooks](https://react.dev/warnings/invalid-hook-call-warning)
77 * rule that hooks may only be called and not otherwise referenced as first-class values.
78 *
79 * Specifically this pass implements the following rules:
80 * - Known hooks may only be called unconditionally, and cannot be used as first-class values.
81 * See the note for Kind.KnownHook for sources of known hooks
82 * - Potential hooks may be referenced as first-class values, with the exception that they
83 * may not appear as the callee of a conditional call.
84 * See the note for Kind.PotentialHook for sources of potential hooks
85 */
86 export function validateHooksUsage(fn: HIRFunction): void {
87 const unconditionalBlocks = computeUnconditionalBlocks(fn);
88
89 const errorsByPlace = new Map<t.SourceLocation, CompilerErrorDetail>();
90
91 function trackError(
92 loc: SourceLocation,
93 errorDetail: CompilerErrorDetail,
94 ): void {
95 if (typeof loc === 'symbol') {
96 fn.env.recordError(errorDetail);
97 } else {
98 errorsByPlace.set(loc, errorDetail);
99 }
100 }
101
102 function recordConditionalHookError(place: Place): void {
103 // Once a particular hook has a conditional call error, don't report any further issues for this hook
104 setKind(place, Kind.Error);
105
106 const reason =
107 '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)';
108 const previousError =
109 typeof place.loc !== 'symbol' ? errorsByPlace.get(place.loc) : undefined;
110
111 /*
112 * In some circumstances such as optional calls, we may first encounter a "hook may not be referenced as normal values" error.
113 * If that same place is also used as a conditional call, upgrade the error to a conditonal hook error
114 */
115 if (previousError === undefined || previousError.reason !== reason) {
116 trackError(
117 place.loc,
118 new CompilerErrorDetail({
119 category: ErrorCategory.Hooks,
120 description: null,
121 reason,
122 loc: place.loc,
123 suggestions: null,
124 }),
125 );
126 }
127 }
128 function recordInvalidHookUsageError(place: Place): void {
129 const previousError =
130 typeof place.loc !== 'symbol' ? errorsByPlace.get(place.loc) : undefined;
131 if (previousError === undefined) {
132 trackError(
133 place.loc,
134 new CompilerErrorDetail({
135 category: ErrorCategory.Hooks,
136 description: null,
137 reason:
138 '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',
139 loc: place.loc,
140 suggestions: null,
141 }),
142 );
143 }
144 }
145 function recordDynamicHookUsageError(place: Place): void {
146 const previousError =
147 typeof place.loc !== 'symbol' ? errorsByPlace.get(place.loc) : undefined;
148 if (previousError === undefined) {
149 trackError(
150 place.loc,
151 new CompilerErrorDetail({
152 category: ErrorCategory.Hooks,
153 description: null,
154 reason:
155 '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',
156 loc: place.loc,
157 suggestions: null,
158 }),
159 );
160 }
161 }
162
163 const valueKinds = new Map<IdentifierId, Kind>();
164 function getKindForPlace(place: Place): Kind {
165 const knownKind = valueKinds.get(place.identifier.id);
166 if (
167 place.identifier.name !== null &&
168 isHookName(place.identifier.name.value)
169 ) {
170 return joinKinds(knownKind ?? Kind.Local, Kind.PotentialHook);
171 } else {
172 return knownKind ?? Kind.Local;
173 }
174 }
175
176 function visitPlace(place: Place): void {
177 const kind = valueKinds.get(place.identifier.id);
178 if (kind === Kind.KnownHook) {
179 recordInvalidHookUsageError(place);
180 }
181 }
182
183 function setKind(place: Place, kind: Kind): void {
184 valueKinds.set(place.identifier.id, kind);
185 }
186
187 for (const param of fn.params) {
188 const place = param.kind === 'Identifier' ? param : param.place;
189 const kind = getKindForPlace(place);
190 setKind(place, kind);
191 }
192
193 for (const [, block] of fn.body.blocks) {
194 for (const phi of block.phis) {
195 let kind: Kind =
196 phi.place.identifier.name !== null &&
197 isHookName(phi.place.identifier.name.value)
198 ? Kind.PotentialHook
199 : Kind.Local;
200 for (const [, operand] of phi.operands) {
201 const operandKind = valueKinds.get(operand.identifier.id);
202 /*
203 * NOTE: we currently skip operands whose value is unknown
204 * (which can only occur for functions with loops), we may
205 * cause us to miss invalid code in some cases. We should
206 * expand this to a fixpoint iteration in a follow-up.
207 */
208 if (operandKind !== undefined) {
209 kind = joinKinds(kind, operandKind);
210 }
211 }
212 valueKinds.set(phi.place.identifier.id, kind);
213 }
214 for (const instr of block.instructions) {
215 switch (instr.value.kind) {
216 case 'LoadGlobal': {
217 /*
218 * Globals are the one source of known hooks: they are either
219 * directly a hook, or infer a Global kind from which knownhooks
220 * can be derived later via property access (PropertyLoad etc)
221 */
222 if (getHookKind(fn.env, instr.lvalue.identifier) != null) {
223 setKind(instr.lvalue, Kind.KnownHook);
224 } else {
225 setKind(instr.lvalue, Kind.Global);
226 }
227 break;
228 }
229 case 'LoadContext':
230 case 'LoadLocal': {
231 visitPlace(instr.value.place);
232 const kind = getKindForPlace(instr.value.place);
233 setKind(instr.lvalue, kind);
234 break;
235 }
236 case 'StoreLocal':
237 case 'StoreContext': {
238 visitPlace(instr.value.value);
239 const kind = joinKinds(
240 getKindForPlace(instr.value.value),
241 getKindForPlace(instr.value.lvalue.place),
242 );
243 setKind(instr.value.lvalue.place, kind);
244 setKind(instr.lvalue, kind);
245 break;
246 }
247 case 'ComputedLoad': {
248 visitPlace(instr.value.object);
249 const kind = getKindForPlace(instr.value.object);
250 setKind(instr.lvalue, joinKinds(getKindForPlace(instr.lvalue), kind));
251 break;
252 }
253 case 'PropertyLoad': {
254 const objectKind = getKindForPlace(instr.value.object);
255 const isHookProperty =
256 typeof instr.value.property === 'string' &&
257 isHookName(instr.value.property);
258 let kind: Kind;
259 switch (objectKind) {
260 case Kind.Error: {
261 kind = Kind.Error;
262 break;
263 }
264 case Kind.KnownHook: {
265 /**
266 * const useFoo;
267 * function Component() {
268 * let x = useFoo.useBar; // useFoo is KnownHook, any property from it inherits KnownHook
269 * }
270 */
271 kind = isHookProperty ? Kind.KnownHook : Kind.Local;
272 break;
273 }
274 case Kind.PotentialHook: {
275 /**
276 * function Component(props) {
277 * let useFoo;
278 * let x = useFoo.useBar; // useFoo is PotentialHook, any property from it inherits PotentialHook
279 * }
280 */
281 kind = Kind.PotentialHook;
282 break;
283 }
284 case Kind.Global: {
285 /**
286 * function Component() {
287 * let x = React.useState; // hook-named property of global is knownhook
288 * let y = React.foo; // else inherit Global
289 * }
290 */
291 kind = isHookProperty ? Kind.KnownHook : Kind.Global;
292 break;
293 }
294 case Kind.Local: {
295 /**
296 * function Component() {
297 * let o = createObject();
298 * let x = o.useState; // hook-named property of local is potentialhook
299 * let y = o.foo; // else inherit local
300 * }
301 */
302 kind = isHookProperty ? Kind.PotentialHook : Kind.Local;
303 break;
304 }
305 default: {
306 assertExhaustive(objectKind, `Unexpected kind \`${objectKind}\``);
307 }
308 }
309 setKind(instr.lvalue, kind);
310 break;
311 }
312 case 'CallExpression': {
313 const calleeKind = getKindForPlace(instr.value.callee);
314 const isHookCallee =
315 calleeKind === Kind.KnownHook || calleeKind === Kind.PotentialHook;
316 if (isHookCallee && !unconditionalBlocks.has(block.id)) {
317 recordConditionalHookError(instr.value.callee);
318 } else if (calleeKind === Kind.PotentialHook) {
319 recordDynamicHookUsageError(instr.value.callee);
320 }
321 /**
322 * We intentionally skip the callee because it's validated above
323 */
324 for (const operand of eachInstructionOperand(instr)) {
325 if (operand === instr.value.callee) {
326 continue;
327 }
328 visitPlace(operand);
329 }
330 break;
331 }
332 case 'MethodCall': {
333 const calleeKind = getKindForPlace(instr.value.property);
334 const isHookCallee =
335 calleeKind === Kind.KnownHook || calleeKind === Kind.PotentialHook;
336 if (isHookCallee && !unconditionalBlocks.has(block.id)) {
337 recordConditionalHookError(instr.value.property);
338 } else if (calleeKind === Kind.PotentialHook) {
339 recordDynamicHookUsageError(instr.value.property);
340 }
341 /*
342 * We intentionally skip the property because it's validated above
343 */
344 for (const operand of eachInstructionOperand(instr)) {
345 if (operand === instr.value.property) {
346 continue;
347 }
348 visitPlace(operand);
349 }
350 break;
351 }
352 case 'Destructure': {
353 visitPlace(instr.value.value);
354 const objectKind = getKindForPlace(instr.value.value);
355 for (const lvalue of eachInstructionLValue(instr)) {
356 const isHookProperty =
357 lvalue.identifier.name !== null &&
358 isHookName(lvalue.identifier.name.value);
359 let kind: Kind;
360 switch (objectKind) {
361 case Kind.Error: {
362 kind = Kind.Error;
363 break;
364 }
365 case Kind.KnownHook: {
366 kind = Kind.KnownHook;
367 break;
368 }
369 case Kind.PotentialHook: {
370 kind = Kind.PotentialHook;
371 break;
372 }
373 case Kind.Global: {
374 kind = isHookProperty ? Kind.KnownHook : Kind.Global;
375 break;
376 }
377 case Kind.Local: {
378 kind = isHookProperty ? Kind.PotentialHook : Kind.Local;
379 break;
380 }
381 default: {
382 assertExhaustive(
383 objectKind,
384 `Unexpected kind \`${objectKind}\``,
385 );
386 }
387 }
388 setKind(lvalue, kind);
389 }
390 break;
391 }
392 case 'ObjectMethod':
393 case 'FunctionExpression': {
394 visitFunctionExpression(fn.env, instr.value.loweredFunc.func);
395 break;
396 }
397 default: {
398 /*
399 * Else check usages of operands, but do *not* flow properties
400 * from operands into the lvalues. For example, `let x = identity(y)`
401 * does not infer `x` as a potential hook even if `y` is a potential hook.
402 */
403 for (const operand of eachInstructionOperand(instr)) {
404 visitPlace(operand);
405 }
406 for (const lvalue of eachInstructionLValue(instr)) {
407 const kind = getKindForPlace(lvalue);
408 setKind(lvalue, kind);
409 }
410 }
411 }
412 }
413 for (const operand of eachTerminalOperand(block.terminal)) {
414 visitPlace(operand);
415 }
416 }
417
418 for (const [, error] of errorsByPlace) {
419 fn.env.recordError(error);
420 }
421 }
422
423 function visitFunctionExpression(env: Environment, fn: HIRFunction): void {
424 for (const [, block] of fn.body.blocks) {
425 for (const instr of block.instructions) {
426 switch (instr.value.kind) {
427 case 'ObjectMethod':
428 case 'FunctionExpression': {
429 visitFunctionExpression(env, instr.value.loweredFunc.func);
430 break;
431 }
432 case 'MethodCall':
433 case 'CallExpression': {
434 const callee =
435 instr.value.kind === 'CallExpression'
436 ? instr.value.callee
437 : instr.value.property;
438 const hookKind = getHookKind(fn.env, callee.identifier);
439 if (hookKind != null) {
440 env.recordError(
441 new CompilerErrorDetail({
442 category: ErrorCategory.Hooks,
443 reason:
444 '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)',
445 loc: callee.loc,
446 description: `Cannot call ${hookKind === 'Custom' ? 'hook' : hookKind} within a function expression`,
447 suggestions: null,
448 }),
449 );
450 }
451 break;
452 }
453 }
454 }
455 }
456 }