main
js 637 lines 22.4 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 * @flow
8 */
9
10 import {withSyncPerfMeasurements} from 'react-devtools-shared/src/PerformanceLoggingUtils';
11 import traverse from '@babel/traverse';
12
13 import type {HooksNode} from 'react-debug-tools/src/ReactDebugHooks';
14
15 // Missing types in @babel/traverse
16 type NodePath = any;
17 type Node = any;
18 // Missing types in @babel/types
19 type File = any;
20
21 export type Position = {
22 line: number,
23 column: number,
24 };
25
26 export type SourceFileASTWithHookDetails = {
27 sourceFileAST: File,
28 line: number,
29 source: string,
30 };
31
32 export const NO_HOOK_NAME = '<no-hook>';
33
34 const AST_NODE_TYPES = Object.freeze({
35 PROGRAM: 'Program',
36 CALL_EXPRESSION: 'CallExpression',
37 MEMBER_EXPRESSION: 'MemberExpression',
38 ARRAY_PATTERN: 'ArrayPattern',
39 IDENTIFIER: 'Identifier',
40 NUMERIC_LITERAL: 'NumericLiteral',
41 VARIABLE_DECLARATOR: 'VariableDeclarator',
42 });
43
44 // Check if line number obtained from source map and the line number in hook node match
45 function checkNodeLocation(
46 path: NodePath,
47 line: number,
48 column?: number | null = null,
49 ): boolean {
50 const {start, end} = path.node.loc;
51
52 if (line !== start.line) {
53 return false;
54 }
55
56 if (column !== null) {
57 // Column numbers are represented differently between tools/engines.
58 // Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based.
59 //
60 // In practice this will probably never matter,
61 // because this code matches the 1-based Error stack location for the hook Identifier (e.g. useState)
62 // with the larger 0-based VariableDeclarator (e.g. [foo, setFoo] = useState())
63 // so the ranges should always overlap.
64 //
65 // For more info see https://github.com/facebook/react/pull/21833#discussion_r666831276
66 column -= 1;
67 if (
68 (line === start.line && column < start.column) ||
69 (line === end.line && column > end.column)
70 ) {
71 return false;
72 }
73 }
74
75 return true;
76 }
77
78 // Checks whether hookNode is a member of targetHookNode
79 function filterMemberNodesOfTargetHook(
80 targetHookNode: NodePath,
81 hookNode: NodePath,
82 ): boolean {
83 const targetHookName = targetHookNode.node.id.name;
84 return (
85 targetHookName != null &&
86 (targetHookName ===
87 (hookNode.node.init.object && hookNode.node.init.object.name) ||
88 targetHookName === hookNode.node.init.name)
89 );
90 }
91
92 // Checks whether hook is the first member node of a state variable declaration node
93 function filterMemberWithHookVariableName(hook: NodePath): boolean {
94 return (
95 hook.node.init.property.type === AST_NODE_TYPES.NUMERIC_LITERAL &&
96 hook.node.init.property.value === 0
97 );
98 }
99
100 // Returns all AST Nodes associated with 'potentialReactHookASTNode'
101 function getFilteredHookASTNodes(
102 potentialReactHookASTNode: NodePath,
103 potentialHooksFound: Array<NodePath>,
104 source: string,
105 ): Array<NodePath> {
106 let nodesAssociatedWithReactHookASTNode: NodePath[] = [];
107 if (nodeContainsHookVariableName(potentialReactHookASTNode)) {
108 // made custom hooks to enter this, always
109 // Case 1.
110 // Directly usable Node -> const ref = useRef(null);
111 // -> const [tick, setTick] = useState(1);
112 // Case 2.
113 // Custom Hooks -> const someVariable = useSomeCustomHook();
114 // -> const [someVariable, someFunction] = useAnotherCustomHook();
115 nodesAssociatedWithReactHookASTNode.unshift(potentialReactHookASTNode);
116 } else {
117 // Case 3.
118 // Indirectly usable Node -> const tickState = useState(1);
119 // [tick, setTick] = tickState;
120 // -> const tickState = useState(1);
121 // const tick = tickState[0];
122 // const setTick = tickState[1];
123 nodesAssociatedWithReactHookASTNode = potentialHooksFound.filter(hookNode =>
124 filterMemberNodesOfTargetHook(potentialReactHookASTNode, hookNode),
125 );
126 }
127 return nodesAssociatedWithReactHookASTNode;
128 }
129
130 // Returns Hook name
131 export function getHookName(
132 hook: HooksNode,
133 originalSourceAST: mixed,
134 originalSourceCode: string,
135 originalSourceLineNumber: number,
136 originalSourceColumnNumber: number,
137 ): string | null {
138 const hooksFromAST = withSyncPerfMeasurements(
139 'getPotentialHookDeclarationsFromAST(originalSourceAST)',
140 () => getPotentialHookDeclarationsFromAST(originalSourceAST),
141 );
142
143 let potentialReactHookASTNode = null;
144 if (originalSourceColumnNumber === 0) {
145 // This most likely indicates a source map type like 'cheap-module-source-map'
146 // that intentionally drops column numbers for compilation speed in DEV builds.
147 // In this case, we can assume there's probably only one hook per line (true in most cases)
148 // and just fail if we find more than one match.
149 const matchingNodes = hooksFromAST.filter(node => {
150 const nodeLocationCheck = checkNodeLocation(
151 node,
152 originalSourceLineNumber,
153 );
154
155 const hookDeclarationCheck = isConfirmedHookDeclaration(node);
156 return nodeLocationCheck && hookDeclarationCheck;
157 });
158
159 if (matchingNodes.length === 1) {
160 potentialReactHookASTNode = matchingNodes[0];
161 }
162 } else {
163 potentialReactHookASTNode = hooksFromAST.find(node => {
164 const nodeLocationCheck = checkNodeLocation(
165 node,
166 originalSourceLineNumber,
167 originalSourceColumnNumber,
168 );
169
170 const hookDeclarationCheck = isConfirmedHookDeclaration(node);
171 return nodeLocationCheck && hookDeclarationCheck;
172 });
173 }
174
175 if (!potentialReactHookASTNode) {
176 return null;
177 }
178
179 // nodesAssociatedWithReactHookASTNode could directly be used to obtain the hook variable name
180 // depending on the type of potentialReactHookASTNode
181 try {
182 const nodesAssociatedWithReactHookASTNode = withSyncPerfMeasurements(
183 'getFilteredHookASTNodes()',
184 () =>
185 getFilteredHookASTNodes(
186 potentialReactHookASTNode,
187 hooksFromAST,
188 originalSourceCode,
189 ),
190 );
191
192 const name = withSyncPerfMeasurements('getHookNameFromNode()', () =>
193 getHookNameFromNode(
194 hook,
195 nodesAssociatedWithReactHookASTNode,
196 potentialReactHookASTNode,
197 ),
198 );
199
200 return name;
201 } catch (error) {
202 console.error(error);
203 return null;
204 }
205 }
206
207 function getHookNameFromNode(
208 originalHook: HooksNode,
209 nodesAssociatedWithReactHookASTNode: NodePath[],
210 potentialReactHookASTNode: NodePath,
211 ): string | null {
212 let hookVariableName: string | null;
213 const isCustomHook = originalHook.id === null;
214
215 switch (nodesAssociatedWithReactHookASTNode.length) {
216 case 1:
217 // CASE 1A (nodesAssociatedWithReactHookASTNode[0] !== potentialReactHookASTNode):
218 // const flagState = useState(true); -> later referenced as
219 // const [flag, setFlag] = flagState;
220 //
221 // CASE 1B (nodesAssociatedWithReactHookASTNode[0] === potentialReactHookASTNode):
222 // const [flag, setFlag] = useState(true); -> we have access to the hook variable straight away
223 //
224 // CASE 1C (isCustomHook && nodesAssociatedWithReactHookASTNode[0] === potentialReactHookASTNode):
225 // const someVariable = useSomeCustomHook(); -> we have access to hook variable straight away
226 // const [someVariable, someFunction] = useAnotherCustomHook(); -> we ignore variable names in this case
227 // as it is unclear what variable name to show
228 if (
229 isCustomHook &&
230 nodesAssociatedWithReactHookASTNode[0] === potentialReactHookASTNode
231 ) {
232 hookVariableName = getHookVariableName(
233 potentialReactHookASTNode,
234 isCustomHook,
235 );
236 break;
237 }
238 hookVariableName = getHookVariableName(
239 nodesAssociatedWithReactHookASTNode[0],
240 );
241 break;
242
243 case 2:
244 // const flagState = useState(true); -> later referenced as
245 // const flag = flagState[0];
246 // const setFlag = flagState[1];
247 nodesAssociatedWithReactHookASTNode =
248 nodesAssociatedWithReactHookASTNode.filter(hookPath =>
249 filterMemberWithHookVariableName(hookPath),
250 );
251
252 if (nodesAssociatedWithReactHookASTNode.length !== 1) {
253 // Something went wrong, only a single desirable hook should remain here
254 throw new Error("Couldn't isolate AST Node containing hook variable.");
255 }
256 hookVariableName = getHookVariableName(
257 nodesAssociatedWithReactHookASTNode[0],
258 );
259 break;
260
261 default:
262 // Case 0:
263 // const flagState = useState(true); -> which is not accessed anywhere
264 //
265 // Case > 2 (fallback):
266 // const someState = React.useState(() => 0)
267 //
268 // const stateVariable = someState[0]
269 // const setStateVariable = someState[1]
270 //
271 // const [number2, setNumber2] = someState
272 //
273 // We assign the state variable for 'someState' to multiple variables,
274 // and hence cannot isolate a unique variable name. In such cases,
275 // default to showing 'someState'
276
277 hookVariableName = getHookVariableName(potentialReactHookASTNode);
278 break;
279 }
280
281 return hookVariableName;
282 }
283
284 // Extracts the variable name from hook node path
285 function getHookVariableName(
286 hook: NodePath,
287 isCustomHook: boolean = false,
288 ): string | null {
289 const nodeType = hook.node.id.type;
290 switch (nodeType) {
291 case AST_NODE_TYPES.ARRAY_PATTERN:
292 return !isCustomHook ? (hook.node.id.elements[0]?.name ?? null) : null;
293
294 case AST_NODE_TYPES.IDENTIFIER:
295 return hook.node.id.name;
296
297 default:
298 return null;
299 }
300 }
301
302 function getPotentialHookDeclarationsFromAST(sourceAST: File): NodePath[] {
303 const potentialHooksFound: NodePath[] = [];
304 withSyncPerfMeasurements('traverse(sourceAST)', () =>
305 traverse(sourceAST, {
306 enter(path) {
307 if (path.isVariableDeclarator() && isPotentialHookDeclaration(path)) {
308 potentialHooksFound.push(path);
309 }
310 },
311 }),
312 );
313 return potentialHooksFound;
314 }
315
316 /**
317 * This function traverses the sourceAST and returns a mapping
318 * that maps locations in the source code to their corresponding
319 * Hook name, if there is a relevant Hook name for that location.
320 *
321 * A location in the source code is represented by line and column
322 * numbers as a Position object: { line, column }.
323 * - line is 1-indexed.
324 * - column is 0-indexed.
325 *
326 * A Hook name will be assigned to a Hook CallExpression if the
327 * CallExpression is for a variable declaration (i.e. it returns
328 * a value that is assigned to a variable), and if we can reliably
329 * infer the correct name to use (see comments in the function body
330 * for more details).
331 *
332 * The returned mapping is an array of locations and their assigned
333 * names, sorted by location. Specifically, each entry in the array
334 * contains a `name` and a `start` Position. The `name` of a given
335 * entry is the "assigned" name in the source code until the `start`
336 * of the **next** entry. This means that given the mapping, in order
337 * to determine the Hook name assigned for a given source location, we
338 * need to find the adjacent entries that most closely contain the given
339 * location.
340 *
341 * E.g. for the following code:
342 *
343 * 1| function Component() {
344 * 2| const [state, setState] = useState(0);
345 * 3| ^---------^ -> Cols 28 - 38: Hook CallExpression
346 * 4|
347 * 5| useEffect(() => {...}); -> call ignored since not declaring a variable
348 * 6|
349 * 7| return (...);
350 * 8| }
351 *
352 * The returned "mapping" would be something like:
353 * [
354 * {name: '<no-hook>', start: {line: 1, column: 0}},
355 * {name: 'state', start: {line: 2, column: 28}},
356 * {name: '<no-hook>', start: {line: 2, column: 38}},
357 * ]
358 *
359 * Where the Hook name `state` (corresponding to the `state` variable)
360 * is assigned to the location in the code for the CallExpression
361 * representing the call to `useState(0)` (line 2, col 28-38).
362 */
363 export function getHookNamesMappingFromAST(
364 sourceAST: File,
365 ): $ReadOnlyArray<{name: string, start: Position}> {
366 const hookStack: Array<{name: string, start: $FlowFixMe}> = [];
367 const hookNames = [];
368 const pushFrame = (name: string, node: Node) => {
369 const nameInfo = {name, start: {...node.loc.start}};
370 hookStack.unshift(nameInfo);
371 hookNames.push(nameInfo);
372 };
373 const popFrame = (node: Node) => {
374 hookStack.shift();
375 const top = hookStack[0];
376 if (top != null) {
377 hookNames.push({name: top.name, start: {...node.loc.end}});
378 }
379 };
380
381 traverse(sourceAST, {
382 [AST_NODE_TYPES.PROGRAM]: {
383 enter(path) {
384 pushFrame(NO_HOOK_NAME, path.node);
385 },
386 exit(path) {
387 popFrame(path.node);
388 },
389 },
390 [AST_NODE_TYPES.VARIABLE_DECLARATOR]: {
391 enter(path) {
392 // Check if this variable declaration corresponds to a variable
393 // declared by calling a Hook.
394 if (isConfirmedHookDeclaration(path)) {
395 const hookDeclaredVariableName = getHookVariableName(path);
396 if (!hookDeclaredVariableName) {
397 return;
398 }
399 const callExpressionNode = assertCallExpression(path.node.init);
400
401 // Check if this variable declaration corresponds to a call to a
402 // built-in Hook that returns a tuple (useState, useReducer,
403 // useTransition).
404 // If it doesn't, we immediately use the declared variable name
405 // as the Hook name. We do this because for any other Hooks that
406 // aren't the built-in Hooks that return a tuple, we can't reliably
407 // extract a Hook name from other variable declarations derived from
408 // this one, since we don't know which of the declared variables
409 // are the relevant ones to track and show in dev tools.
410 if (!isBuiltInHookThatReturnsTuple(path)) {
411 pushFrame(hookDeclaredVariableName, callExpressionNode);
412 return;
413 }
414
415 // Check if the variable declared by the Hook call is referenced
416 // anywhere else in the code. If not, we immediately use the
417 // declared variable name as the Hook name.
418 const referencePaths =
419 hookDeclaredVariableName != null
420 ? path.scope.bindings[hookDeclaredVariableName]?.referencePaths
421 : null;
422 if (referencePaths == null) {
423 pushFrame(hookDeclaredVariableName, callExpressionNode);
424 return;
425 }
426
427 // Check each reference to the variable declared by the Hook call,
428 // and for each, we do the following:
429 let declaredVariableName = null;
430 for (let i = 0; i <= referencePaths.length; i++) {
431 const referencePath = referencePaths[i];
432 if (declaredVariableName != null) {
433 break;
434 }
435
436 // 1. Check if the reference is contained within a VariableDeclarator
437 // Node. This will allow us to determine if the variable declared by
438 // the Hook call is being used to declare other variables.
439 let variableDeclaratorPath = referencePath;
440 while (
441 variableDeclaratorPath != null &&
442 variableDeclaratorPath.node.type !==
443 AST_NODE_TYPES.VARIABLE_DECLARATOR
444 ) {
445 variableDeclaratorPath = variableDeclaratorPath.parentPath;
446 }
447
448 // 2. If we find a VariableDeclarator containing the
449 // referenced variable, we extract the Hook name from the new
450 // variable declaration.
451 // E.g., a case like the following:
452 // const countState = useState(0);
453 // const count = countState[0];
454 // const setCount = countState[1]
455 // Where the reference to `countState` is later referenced
456 // within a VariableDeclarator, so we can extract `count` as
457 // the Hook name.
458 const varDeclInit = variableDeclaratorPath?.node.init;
459 if (varDeclInit != null) {
460 switch (varDeclInit.type) {
461 case AST_NODE_TYPES.MEMBER_EXPRESSION: {
462 // When encountering a MemberExpression inside the new
463 // variable declaration, we only want to extract the variable
464 // name if we're assigning the value of the first member,
465 // which is handled by `filterMemberWithHookVariableName`.
466 // E.g.
467 // const countState = useState(0);
468 // const count = countState[0]; -> extract the name from this reference
469 // const setCount = countState[1]; -> ignore this reference
470 if (
471 filterMemberWithHookVariableName(variableDeclaratorPath)
472 ) {
473 declaredVariableName = getHookVariableName(
474 variableDeclaratorPath,
475 );
476 }
477 break;
478 }
479 case AST_NODE_TYPES.IDENTIFIER: {
480 declaredVariableName = getHookVariableName(
481 variableDeclaratorPath,
482 );
483 break;
484 }
485 default:
486 break;
487 }
488 }
489 }
490
491 // If we were able to extract a name from the new variable
492 // declaration, use it as the Hook name. Otherwise, use the
493 // original declared variable as the variable name.
494 if (declaredVariableName != null) {
495 pushFrame(declaredVariableName, callExpressionNode);
496 } else {
497 pushFrame(hookDeclaredVariableName, callExpressionNode);
498 }
499 }
500 },
501 exit(path) {
502 if (isConfirmedHookDeclaration(path)) {
503 const callExpressionNode = assertCallExpression(path.node.init);
504 popFrame(callExpressionNode);
505 }
506 },
507 },
508 });
509 return hookNames;
510 }
511
512 // Check if 'path' contains declaration of the form const X = useState(0);
513 function isConfirmedHookDeclaration(path: NodePath): boolean {
514 const nodeInit = path.node.init;
515 if (nodeInit == null || nodeInit.type !== AST_NODE_TYPES.CALL_EXPRESSION) {
516 return false;
517 }
518 const callee = nodeInit.callee;
519 return isHook(callee);
520 }
521
522 // We consider hooks to be a hook name identifier or a member expression containing a hook name.
523 function isHook(node: Node): boolean {
524 if (node.type === AST_NODE_TYPES.IDENTIFIER) {
525 return isHookName(node.name);
526 } else if (
527 node.type === AST_NODE_TYPES.MEMBER_EXPRESSION &&
528 !node.computed &&
529 isHook(node.property)
530 ) {
531 const obj = node.object;
532 const isPascalCaseNameSpace = /^[A-Z].*/;
533 return (
534 obj.type === AST_NODE_TYPES.IDENTIFIER &&
535 isPascalCaseNameSpace.test(obj.name)
536 );
537 } else {
538 // TODO Possibly handle inline require statements e.g. require("useStable")(...)
539 // This does not seem like a high priority, since inline requires are probably
540 // not common and are also typically in compiled code rather than source code.
541
542 return false;
543 }
544 }
545
546 // Catch all identifiers that begin with "use"
547 // followed by an uppercase Latin character to exclude identifiers like "user".
548 // Copied from packages/eslint-plugin-react-hooks/src/RulesOfHooks
549 function isHookName(name: string): boolean {
550 return /^use[A-Z0-9].*$/.test(name);
551 }
552
553 // Check if the AST Node COULD be a React Hook
554 function isPotentialHookDeclaration(path: NodePath): boolean {
555 // The array potentialHooksFound will contain all potential hook declaration cases we support
556 const nodePathInit = path.node.init;
557 if (nodePathInit != null) {
558 if (nodePathInit.type === AST_NODE_TYPES.CALL_EXPRESSION) {
559 // CASE: CallExpression
560 // 1. const [count, setCount] = useState(0); -> destructured pattern
561 // 2. const [A, setA] = useState(0), const [B, setB] = useState(0); -> multiple inline declarations
562 // 3. const [
563 // count,
564 // setCount
565 // ] = useState(0); -> multiline hook declaration
566 // 4. const ref = useRef(null); -> generic hooks
567 const callee = nodePathInit.callee;
568 return isHook(callee);
569 } else if (
570 nodePathInit.type === AST_NODE_TYPES.MEMBER_EXPRESSION ||
571 nodePathInit.type === AST_NODE_TYPES.IDENTIFIER
572 ) {
573 // CASE: MemberExpression
574 // const countState = React.useState(0);
575 // const count = countState[0];
576 // const setCount = countState[1]; -> Accessing members following hook declaration
577
578 // CASE: Identifier
579 // const countState = React.useState(0);
580 // const [count, setCount] = countState; -> destructuring syntax following hook declaration
581 return true;
582 }
583 }
584 return false;
585 }
586
587 /// Check whether 'node' is hook declaration of form useState(0); OR React.useState(0);
588 function isReactFunction(node: Node, functionName: string): boolean {
589 return (
590 node.name === functionName ||
591 (node.type === 'MemberExpression' &&
592 node.object.name === 'React' &&
593 node.property.name === functionName)
594 );
595 }
596
597 // Check if 'path' is either State or Reducer hook
598 function isBuiltInHookThatReturnsTuple(path: NodePath): boolean {
599 const callee = path.node.init.callee;
600 return (
601 isReactFunction(callee, 'useState') ||
602 isReactFunction(callee, 'useReducer') ||
603 isReactFunction(callee, 'useTransition')
604 );
605 }
606
607 // Check whether hookNode of a declaration contains obvious variable name
608 function nodeContainsHookVariableName(hookNode: NodePath): boolean {
609 // We determine cases where variable names are obvious in declarations. Examples:
610 // const [tick, setTick] = useState(1); OR const ref = useRef(null);
611 // Here tick/ref are obvious hook variables in the hook declaration node itself
612 // 1. True for satisfying above cases
613 // 2. False for everything else. Examples:
614 // const countState = React.useState(0);
615 // const count = countState[0];
616 // const setCount = countState[1]; -> not obvious, hook variable can't be determined
617 // from the hook declaration node alone
618 // 3. For custom hooks we force pass true since we are only concerned with the AST node
619 // regardless of how it is accessed in source code. (See: getHookVariableName)
620
621 const node = hookNode.node.id;
622 if (
623 node.type === AST_NODE_TYPES.ARRAY_PATTERN ||
624 (node.type === AST_NODE_TYPES.IDENTIFIER &&
625 !isBuiltInHookThatReturnsTuple(hookNode))
626 ) {
627 return true;
628 }
629 return false;
630 }
631
632 function assertCallExpression(node: Node): Node {
633 if (node.type !== AST_NODE_TYPES.CALL_EXPRESSION) {
634 throw new Error('Expected a CallExpression node for a Hook declaration.');
635 }
636 return node;
637 }