main
js 866 lines 23.8 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 * @emails react-core
8 * @jest-environment node
9 */
10
11 'use strict';
12
13 let React;
14 let ReactDebugTools;
15
16 function normalizeSourceLoc(tree) {
17 tree.forEach(node => {
18 if (node.hookSource) {
19 node.hookSource.fileName = '**';
20 node.hookSource.lineNumber = 0;
21 node.hookSource.columnNumber = 0;
22 }
23 normalizeSourceLoc(node.subHooks);
24 });
25 return tree;
26 }
27
28 describe('ReactHooksInspection', () => {
29 beforeEach(() => {
30 jest.resetModules();
31 React = require('react');
32 ReactDebugTools = require('react-debug-tools');
33 });
34
35 it('should inspect a simple useState hook', () => {
36 function Foo(props) {
37 const [state] = React.useState('hello world');
38 return <div>{state}</div>;
39 }
40 const tree = ReactDebugTools.inspectHooks(Foo, {});
41 expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(`
42 [
43 {
44 "debugInfo": null,
45 "hookSource": {
46 "columnNumber": 0,
47 "fileName": "**",
48 "functionName": "Foo",
49 "lineNumber": 0,
50 },
51 "id": 0,
52 "isStateEditable": true,
53 "name": "State",
54 "subHooks": [],
55 "value": "hello world",
56 },
57 ]
58 `);
59 });
60
61 it('should inspect a simple custom hook', () => {
62 function useCustom(value) {
63 const [state] = React.useState(value);
64 React.useDebugValue('custom hook label');
65 return state;
66 }
67 function Foo(props) {
68 const value = useCustom('hello world');
69 return <div>{value}</div>;
70 }
71 const tree = ReactDebugTools.inspectHooks(Foo, {});
72 if (__DEV__) {
73 expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(`
74 [
75 {
76 "debugInfo": null,
77 "hookSource": {
78 "columnNumber": 0,
79 "fileName": "**",
80 "functionName": "Foo",
81 "lineNumber": 0,
82 },
83 "id": null,
84 "isStateEditable": false,
85 "name": "Custom",
86 "subHooks": [
87 {
88 "debugInfo": null,
89 "hookSource": {
90 "columnNumber": 0,
91 "fileName": "**",
92 "functionName": "useCustom",
93 "lineNumber": 0,
94 },
95 "id": 0,
96 "isStateEditable": true,
97 "name": "State",
98 "subHooks": [],
99 "value": "hello world",
100 },
101 ],
102 "value": "custom hook label",
103 },
104 ]
105 `);
106 } else {
107 expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(`
108 [
109 {
110 "debugInfo": null,
111 "hookSource": {
112 "columnNumber": 0,
113 "fileName": "**",
114 "functionName": "Foo",
115 "lineNumber": 0,
116 },
117 "id": null,
118 "isStateEditable": false,
119 "name": "Custom",
120 "subHooks": [
121 {
122 "debugInfo": null,
123 "hookSource": {
124 "columnNumber": 0,
125 "fileName": "**",
126 "functionName": "useCustom",
127 "lineNumber": 0,
128 },
129 "id": 0,
130 "isStateEditable": true,
131 "name": "State",
132 "subHooks": [],
133 "value": "hello world",
134 },
135 ],
136 "value": undefined,
137 },
138 ]
139 `);
140 }
141 });
142
143 it('should inspect a tree of multiple hooks', () => {
144 function effect() {}
145 function useCustom(value) {
146 const [state] = React.useState(value);
147 React.useEffect(effect);
148 return state;
149 }
150 function Foo(props) {
151 const value1 = useCustom('hello');
152 const value2 = useCustom('world');
153 return (
154 <div>
155 {value1} {value2}
156 </div>
157 );
158 }
159 const tree = ReactDebugTools.inspectHooks(Foo, {});
160 expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(`
161 [
162 {
163 "debugInfo": null,
164 "hookSource": {
165 "columnNumber": 0,
166 "fileName": "**",
167 "functionName": "Foo",
168 "lineNumber": 0,
169 },
170 "id": null,
171 "isStateEditable": false,
172 "name": "Custom",
173 "subHooks": [
174 {
175 "debugInfo": null,
176 "hookSource": {
177 "columnNumber": 0,
178 "fileName": "**",
179 "functionName": "useCustom",
180 "lineNumber": 0,
181 },
182 "id": 0,
183 "isStateEditable": true,
184 "name": "State",
185 "subHooks": [],
186 "value": "hello",
187 },
188 {
189 "debugInfo": null,
190 "hookSource": {
191 "columnNumber": 0,
192 "fileName": "**",
193 "functionName": "useCustom",
194 "lineNumber": 0,
195 },
196 "id": 1,
197 "isStateEditable": false,
198 "name": "Effect",
199 "subHooks": [],
200 "value": [Function],
201 },
202 ],
203 "value": undefined,
204 },
205 {
206 "debugInfo": null,
207 "hookSource": {
208 "columnNumber": 0,
209 "fileName": "**",
210 "functionName": "Foo",
211 "lineNumber": 0,
212 },
213 "id": null,
214 "isStateEditable": false,
215 "name": "Custom",
216 "subHooks": [
217 {
218 "debugInfo": null,
219 "hookSource": {
220 "columnNumber": 0,
221 "fileName": "**",
222 "functionName": "useCustom",
223 "lineNumber": 0,
224 },
225 "id": 2,
226 "isStateEditable": true,
227 "name": "State",
228 "subHooks": [],
229 "value": "world",
230 },
231 {
232 "debugInfo": null,
233 "hookSource": {
234 "columnNumber": 0,
235 "fileName": "**",
236 "functionName": "useCustom",
237 "lineNumber": 0,
238 },
239 "id": 3,
240 "isStateEditable": false,
241 "name": "Effect",
242 "subHooks": [],
243 "value": [Function],
244 },
245 ],
246 "value": undefined,
247 },
248 ]
249 `);
250 });
251
252 it('should inspect a tree of multiple levels of hooks', () => {
253 function effect() {}
254 function useCustom(value) {
255 const [state] = React.useReducer((s, a) => s, value);
256 React.useEffect(effect);
257 return state;
258 }
259 function useBar(value) {
260 const result = useCustom(value);
261 React.useLayoutEffect(effect);
262 return result;
263 }
264 function useBaz(value) {
265 React.useLayoutEffect(effect);
266 const result = useCustom(value);
267 return result;
268 }
269 function Foo(props) {
270 const value1 = useBar('hello');
271 const value2 = useBaz('world');
272 return (
273 <div>
274 {value1} {value2}
275 </div>
276 );
277 }
278 const tree = ReactDebugTools.inspectHooks(Foo, {});
279 expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(`
280 [
281 {
282 "debugInfo": null,
283 "hookSource": {
284 "columnNumber": 0,
285 "fileName": "**",
286 "functionName": "Foo",
287 "lineNumber": 0,
288 },
289 "id": null,
290 "isStateEditable": false,
291 "name": "Bar",
292 "subHooks": [
293 {
294 "debugInfo": null,
295 "hookSource": {
296 "columnNumber": 0,
297 "fileName": "**",
298 "functionName": "useBar",
299 "lineNumber": 0,
300 },
301 "id": null,
302 "isStateEditable": false,
303 "name": "Custom",
304 "subHooks": [
305 {
306 "debugInfo": null,
307 "hookSource": {
308 "columnNumber": 0,
309 "fileName": "**",
310 "functionName": "useCustom",
311 "lineNumber": 0,
312 },
313 "id": 0,
314 "isStateEditable": true,
315 "name": "Reducer",
316 "subHooks": [],
317 "value": "hello",
318 },
319 {
320 "debugInfo": null,
321 "hookSource": {
322 "columnNumber": 0,
323 "fileName": "**",
324 "functionName": "useCustom",
325 "lineNumber": 0,
326 },
327 "id": 1,
328 "isStateEditable": false,
329 "name": "Effect",
330 "subHooks": [],
331 "value": [Function],
332 },
333 ],
334 "value": undefined,
335 },
336 {
337 "debugInfo": null,
338 "hookSource": {
339 "columnNumber": 0,
340 "fileName": "**",
341 "functionName": "useBar",
342 "lineNumber": 0,
343 },
344 "id": 2,
345 "isStateEditable": false,
346 "name": "LayoutEffect",
347 "subHooks": [],
348 "value": [Function],
349 },
350 ],
351 "value": undefined,
352 },
353 {
354 "debugInfo": null,
355 "hookSource": {
356 "columnNumber": 0,
357 "fileName": "**",
358 "functionName": "Foo",
359 "lineNumber": 0,
360 },
361 "id": null,
362 "isStateEditable": false,
363 "name": "Baz",
364 "subHooks": [
365 {
366 "debugInfo": null,
367 "hookSource": {
368 "columnNumber": 0,
369 "fileName": "**",
370 "functionName": "useBaz",
371 "lineNumber": 0,
372 },
373 "id": 3,
374 "isStateEditable": false,
375 "name": "LayoutEffect",
376 "subHooks": [],
377 "value": [Function],
378 },
379 {
380 "debugInfo": null,
381 "hookSource": {
382 "columnNumber": 0,
383 "fileName": "**",
384 "functionName": "useBaz",
385 "lineNumber": 0,
386 },
387 "id": null,
388 "isStateEditable": false,
389 "name": "Custom",
390 "subHooks": [
391 {
392 "debugInfo": null,
393 "hookSource": {
394 "columnNumber": 0,
395 "fileName": "**",
396 "functionName": "useCustom",
397 "lineNumber": 0,
398 },
399 "id": 4,
400 "isStateEditable": true,
401 "name": "Reducer",
402 "subHooks": [],
403 "value": "world",
404 },
405 {
406 "debugInfo": null,
407 "hookSource": {
408 "columnNumber": 0,
409 "fileName": "**",
410 "functionName": "useCustom",
411 "lineNumber": 0,
412 },
413 "id": 5,
414 "isStateEditable": false,
415 "name": "Effect",
416 "subHooks": [],
417 "value": [Function],
418 },
419 ],
420 "value": undefined,
421 },
422 ],
423 "value": undefined,
424 },
425 ]
426 `);
427 });
428
429 it('should not confuse built-in hooks with custom hooks that have the same name', () => {
430 function useState(value) {
431 React.useState(value);
432 React.useDebugValue('custom useState');
433 }
434 function useFormStatus() {
435 React.useState('custom useState');
436 React.useDebugValue('custom useFormStatus');
437 }
438 function Foo(props) {
439 useFormStatus();
440 useState('Hello, Dave!');
441 return null;
442 }
443 const tree = ReactDebugTools.inspectHooks(Foo, {});
444 if (__DEV__) {
445 expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(`
446 [
447 {
448 "debugInfo": null,
449 "hookSource": {
450 "columnNumber": 0,
451 "fileName": "**",
452 "functionName": "Foo",
453 "lineNumber": 0,
454 },
455 "id": null,
456 "isStateEditable": false,
457 "name": "FormStatus",
458 "subHooks": [
459 {
460 "debugInfo": null,
461 "hookSource": {
462 "columnNumber": 0,
463 "fileName": "**",
464 "functionName": "useFormStatus",
465 "lineNumber": 0,
466 },
467 "id": 0,
468 "isStateEditable": true,
469 "name": "State",
470 "subHooks": [],
471 "value": "custom useState",
472 },
473 ],
474 "value": "custom useFormStatus",
475 },
476 {
477 "debugInfo": null,
478 "hookSource": {
479 "columnNumber": 0,
480 "fileName": "**",
481 "functionName": "Foo",
482 "lineNumber": 0,
483 },
484 "id": null,
485 "isStateEditable": false,
486 "name": "State",
487 "subHooks": [
488 {
489 "debugInfo": null,
490 "hookSource": {
491 "columnNumber": 0,
492 "fileName": "**",
493 "functionName": "useState",
494 "lineNumber": 0,
495 },
496 "id": 1,
497 "isStateEditable": true,
498 "name": "State",
499 "subHooks": [],
500 "value": "Hello, Dave!",
501 },
502 ],
503 "value": "custom useState",
504 },
505 ]
506 `);
507 } else {
508 expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(`
509 [
510 {
511 "debugInfo": null,
512 "hookSource": {
513 "columnNumber": 0,
514 "fileName": "**",
515 "functionName": "Foo",
516 "lineNumber": 0,
517 },
518 "id": null,
519 "isStateEditable": false,
520 "name": "FormStatus",
521 "subHooks": [
522 {
523 "debugInfo": null,
524 "hookSource": {
525 "columnNumber": 0,
526 "fileName": "**",
527 "functionName": "useFormStatus",
528 "lineNumber": 0,
529 },
530 "id": 0,
531 "isStateEditable": true,
532 "name": "State",
533 "subHooks": [],
534 "value": "custom useState",
535 },
536 ],
537 "value": undefined,
538 },
539 {
540 "debugInfo": null,
541 "hookSource": {
542 "columnNumber": 0,
543 "fileName": "**",
544 "functionName": "Foo",
545 "lineNumber": 0,
546 },
547 "id": null,
548 "isStateEditable": false,
549 "name": "State",
550 "subHooks": [
551 {
552 "debugInfo": null,
553 "hookSource": {
554 "columnNumber": 0,
555 "fileName": "**",
556 "functionName": "useState",
557 "lineNumber": 0,
558 },
559 "id": 1,
560 "isStateEditable": true,
561 "name": "State",
562 "subHooks": [],
563 "value": "Hello, Dave!",
564 },
565 ],
566 "value": undefined,
567 },
568 ]
569 `);
570 }
571 });
572
573 it('should inspect the default value using the useContext hook', () => {
574 const MyContext = React.createContext('default');
575 function Foo(props) {
576 const value = React.useContext(MyContext);
577 return <div>{value}</div>;
578 }
579 const tree = ReactDebugTools.inspectHooks(Foo, {});
580 expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(`
581 [
582 {
583 "debugInfo": null,
584 "hookSource": {
585 "columnNumber": 0,
586 "fileName": "**",
587 "functionName": "Foo",
588 "lineNumber": 0,
589 },
590 "id": null,
591 "isStateEditable": false,
592 "name": "Context",
593 "subHooks": [],
594 "value": "default",
595 },
596 ]
597 `);
598 });
599
600 it('should inspect use() calls for Promise and Context', async () => {
601 const MyContext = React.createContext('hi');
602 const promise = Promise.resolve('world');
603 await promise;
604 promise.status = 'fulfilled';
605 promise.value = 'world';
606 promise._debugInfo = [{name: 'Hello'}];
607
608 function useCustom() {
609 const value = React.use(promise);
610 const [state] = React.useState(value);
611 return state;
612 }
613 function Foo(props) {
614 const value1 = React.use(MyContext);
615 const value2 = useCustom();
616 return (
617 <div>
618 {value1} {value2}
619 </div>
620 );
621 }
622 const tree = ReactDebugTools.inspectHooks(Foo, {});
623 expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(`
624 [
625 {
626 "debugInfo": null,
627 "hookSource": {
628 "columnNumber": 0,
629 "fileName": "**",
630 "functionName": "Foo",
631 "lineNumber": 0,
632 },
633 "id": null,
634 "isStateEditable": false,
635 "name": "Context",
636 "subHooks": [],
637 "value": "hi",
638 },
639 {
640 "debugInfo": null,
641 "hookSource": {
642 "columnNumber": 0,
643 "fileName": "**",
644 "functionName": "Foo",
645 "lineNumber": 0,
646 },
647 "id": null,
648 "isStateEditable": false,
649 "name": "Custom",
650 "subHooks": [
651 {
652 "debugInfo": [
653 {
654 "name": "Hello",
655 },
656 ],
657 "hookSource": {
658 "columnNumber": 0,
659 "fileName": "**",
660 "functionName": "useCustom",
661 "lineNumber": 0,
662 },
663 "id": null,
664 "isStateEditable": false,
665 "name": "Use",
666 "subHooks": [],
667 "value": "world",
668 },
669 {
670 "debugInfo": null,
671 "hookSource": {
672 "columnNumber": 0,
673 "fileName": "**",
674 "functionName": "useCustom",
675 "lineNumber": 0,
676 },
677 "id": 0,
678 "isStateEditable": true,
679 "name": "State",
680 "subHooks": [],
681 "value": "world",
682 },
683 ],
684 "value": undefined,
685 },
686 ]
687 `);
688 });
689
690 it('should inspect use() calls for unresolved Promise', () => {
691 const promise = Promise.resolve('hi');
692
693 function Foo(props) {
694 const value = React.use(promise);
695 return <div>{value}</div>;
696 }
697 const tree = ReactDebugTools.inspectHooks(Foo, {});
698 const results = normalizeSourceLoc(tree);
699 expect(results).toHaveLength(1);
700 expect(results[0]).toMatchInlineSnapshot(
701 {
702 value: expect.any(Promise),
703 },
704 `
705 {
706 "debugInfo": null,
707 "hookSource": {
708 "columnNumber": 0,
709 "fileName": "**",
710 "functionName": "Foo",
711 "lineNumber": 0,
712 },
713 "id": null,
714 "isStateEditable": false,
715 "name": "Use",
716 "subHooks": [],
717 "value": Any<Promise>,
718 }
719 `,
720 );
721 });
722
723 it('should inspect use() calls in anonymous loops', () => {
724 function Foo({entries}) {
725 const values = Object.fromEntries(
726 Object.entries(entries).map(([key, value]) => {
727 return [key, React.use(value)];
728 }),
729 );
730 return <div>{values}</div>;
731 }
732 const tree = ReactDebugTools.inspectHooks(Foo, {
733 entries: {one: Promise.resolve('one'), two: Promise.resolve('two')},
734 });
735 const results = normalizeSourceLoc(tree);
736 expect(results).toHaveLength(1);
737 expect(results[0]).toMatchInlineSnapshot(
738 {
739 subHooks: [{value: expect.any(Promise)}],
740 },
741 `
742 {
743 "debugInfo": null,
744 "hookSource": {
745 "columnNumber": 0,
746 "fileName": "**",
747 "functionName": "Foo",
748 "lineNumber": 0,
749 },
750 "id": null,
751 "isStateEditable": false,
752 "name": "",
753 "subHooks": [
754 {
755 "debugInfo": null,
756 "hookSource": {
757 "columnNumber": 0,
758 "fileName": "**",
759 "functionName": null,
760 "lineNumber": 0,
761 },
762 "id": null,
763 "isStateEditable": false,
764 "name": "Use",
765 "subHooks": [],
766 "value": Any<Promise>,
767 },
768 ],
769 "value": undefined,
770 }
771 `,
772 );
773 });
774
775 describe('useDebugValue', () => {
776 it('should be ignored when called outside of a custom hook', () => {
777 function Foo(props) {
778 React.useDebugValue('this is invalid');
779 return null;
780 }
781 const tree = ReactDebugTools.inspectHooks(Foo, {});
782 expect(tree).toHaveLength(0);
783 });
784
785 it('should support an optional formatter function param', () => {
786 function useCustom() {
787 React.useDebugValue({bar: 123}, object => `bar:${object.bar}`);
788 React.useState(0);
789 }
790 function Foo(props) {
791 useCustom();
792 return null;
793 }
794 const tree = ReactDebugTools.inspectHooks(Foo, {});
795 if (__DEV__) {
796 expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(`
797 [
798 {
799 "debugInfo": null,
800 "hookSource": {
801 "columnNumber": 0,
802 "fileName": "**",
803 "functionName": "Foo",
804 "lineNumber": 0,
805 },
806 "id": null,
807 "isStateEditable": false,
808 "name": "Custom",
809 "subHooks": [
810 {
811 "debugInfo": null,
812 "hookSource": {
813 "columnNumber": 0,
814 "fileName": "**",
815 "functionName": "useCustom",
816 "lineNumber": 0,
817 },
818 "id": 0,
819 "isStateEditable": true,
820 "name": "State",
821 "subHooks": [],
822 "value": 0,
823 },
824 ],
825 "value": "bar:123",
826 },
827 ]
828 `);
829 } else {
830 expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(`
831 [
832 {
833 "debugInfo": null,
834 "hookSource": {
835 "columnNumber": 0,
836 "fileName": "**",
837 "functionName": "Foo",
838 "lineNumber": 0,
839 },
840 "id": null,
841 "isStateEditable": false,
842 "name": "Custom",
843 "subHooks": [
844 {
845 "debugInfo": null,
846 "hookSource": {
847 "columnNumber": 0,
848 "fileName": "**",
849 "functionName": "useCustom",
850 "lineNumber": 0,
851 },
852 "id": 0,
853 "isStateEditable": true,
854 "name": "State",
855 "subHooks": [],
856 "value": 0,
857 },
858 ],
859 "value": undefined,
860 },
861 ]
862 `);
863 }
864 });
865 });
866 });