@samitouri / QOS-React / commits / 734956ace6

Devtools: Add support for useFormStatus (#28413)

Sebastian Silbermann committed Apr 16, 2024 at 10:28 UTC 734956ace6450bc0c95d8d749dee74f4a140597b
4 files changed +341 -8
packages/react-debug-tools/src/ReactDebugHooks.js
+35 -7
@@ -21,6 +21,7 @@ import type {
21 Fiber,
22 Dispatcher as DispatcherType,
23 } from 'react-reconciler/src/ReactInternalTypes';
24 +import type {TransitionStatus} from 'react-reconciler/src/ReactFiberConfig';
25
26 import ErrorStackParser from 'error-stack-parser';
27 import assign from 'shared/assign';
@@ -134,6 +135,11 @@ function getPrimitiveStackCache(): Map<string, Array<any>> {
135 }
136
137 Dispatcher.useId();
138 +
139 + if (typeof Dispatcher.useHostTransitionStatus === 'function') {
140 + // This type check is for Flow only.
141 + Dispatcher.useHostTransitionStatus();
142 + }
143 } finally {
144 readHookLog = hookLog;
145 hookLog = [];
@@ -711,6 +717,27 @@ function useActionState<S, P>(
717 return [state, (payload: P) => {}, false];
718 }
719
720 +function useHostTransitionStatus(): TransitionStatus {
721 + const status = readContext<TransitionStatus>(
722 + // $FlowFixMe[prop-missing] `readContext` only needs _currentValue
723 + ({
724 + // $FlowFixMe[incompatible-cast] TODO: Incorrect bottom value without access to Fiber config.
725 + _currentValue: null,
726 + }: ReactContext<TransitionStatus>),
727 + );
728 +
729 + hookLog.push({
730 + displayName: null,
731 + primitive: 'HostTransitionStatus',
732 + stackError: new Error(),
733 + value: status,
734 + debugInfo: null,
735 + dispatcherHookName: 'HostTransitionStatus',
736 + });
737 +
738 + return status;
739 +}
740 +
741 const Dispatcher: DispatcherType = {
742 use,
743 readContext,
@@ -734,6 +761,7 @@ const Dispatcher: DispatcherType = {
761 useId,
762 useFormState,
763 useActionState,
764 + useHostTransitionStatus,
765 };
766
767 // create a proxy to throw a custom error
@@ -854,12 +882,11 @@ function findPrimitiveIndex(hookStack: any, hook: HookLogEntry) {
882 isReactWrapper(hookStack[i].functionName, hook.dispatcherHookName)
883 ) {
884 i++;
857 - }
858 - if (
859 - i < hookStack.length - 1 &&
860 - isReactWrapper(hookStack[i].functionName, hook.dispatcherHookName)
861 - ) {
862 - i++;
885 + // Guard against the dispatcher call being inlined.
886 + // At this point we wouldn't be able to recover the actual React Hook name.
887 + if (i < hookStack.length - 1) {
888 + i++;
889 + }
890 }
891 return i;
892 }
@@ -997,7 +1024,8 @@ function buildTree(
1024 primitive === 'Context (use)' ||
1025 primitive === 'DebugValue' ||
1026 primitive === 'Promise' ||
1000 - primitive === 'Unresolved'
1027 + primitive === 'Unresolved' ||
1028 + primitive === 'HostTransitionStatus'
1029 ? null
1030 : nativeHookID++;
1031
packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js
+144
@@ -426,6 +426,150 @@ describe('ReactHooksInspection', () => {
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) {
packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegrationDOM-test.js new
+154
@@ -0,0 +1,154 @@
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 jsdom
9 + */
10 +
11 +'use strict';
12 +
13 +let React;
14 +let ReactDOM;
15 +let ReactDOMClient;
16 +let ReactDebugTools;
17 +let act;
18 +
19 +function normalizeSourceLoc(tree) {
20 + tree.forEach(node => {
21 + if (node.hookSource) {
22 + node.hookSource.fileName = '**';
23 + node.hookSource.lineNumber = 0;
24 + node.hookSource.columnNumber = 0;
25 + }
26 + normalizeSourceLoc(node.subHooks);
27 + });
28 + return tree;
29 +}
30 +
31 +describe('ReactHooksInspectionIntegration', () => {
32 + beforeEach(() => {
33 + jest.resetModules();
34 + React = require('react');
35 + ReactDOM = require('react-dom');
36 + ReactDOMClient = require('react-dom/client');
37 + act = require('internal-test-utils').act;
38 + ReactDebugTools = require('react-debug-tools');
39 + });
40 +
41 + it('should support useFormStatus hook', async () => {
42 + function FormStatus() {
43 + const status = ReactDOM.useFormStatus();
44 + React.useMemo(() => 'memo', []);
45 + React.useMemo(() => 'not used', []);
46 +
47 + return JSON.stringify(status);
48 + }
49 +
50 + const treeWithoutFiber = ReactDebugTools.inspectHooks(FormStatus);
51 + expect(normalizeSourceLoc(treeWithoutFiber)).toEqual([
52 + {
53 + debugInfo: null,
54 + hookSource: {
55 + columnNumber: 0,
56 + fileName: '**',
57 + functionName: 'FormStatus',
58 + lineNumber: 0,
59 + },
60 + id: null,
61 + isStateEditable: false,
62 + name: 'FormStatus',
63 + subHooks: [],
64 + value: null,
65 + },
66 + {
67 + debugInfo: null,
68 + hookSource: {
69 + columnNumber: 0,
70 + fileName: '**',
71 + functionName: 'FormStatus',
72 + lineNumber: 0,
73 + },
74 + id: 0,
75 + isStateEditable: false,
76 + name: 'Memo',
77 + subHooks: [],
78 + value: 'memo',
79 + },
80 + {
81 + debugInfo: null,
82 + hookSource: {
83 + columnNumber: 0,
84 + fileName: '**',
85 + functionName: 'FormStatus',
86 + lineNumber: 0,
87 + },
88 + id: 1,
89 + isStateEditable: false,
90 + name: 'Memo',
91 + subHooks: [],
92 + value: 'not used',
93 + },
94 + ]);
95 +
96 + const root = ReactDOMClient.createRoot(document.createElement('div'));
97 +
98 + await act(() => {
99 + root.render(
100 + <form>
101 + <FormStatus />
102 + </form>,
103 + );
104 + });
105 +
106 + // Implementation detail. Feel free to adjust the position of the Fiber in the tree.
107 + const formStatusFiber = root._internalRoot.current.child.child;
108 + const treeWithFiber = ReactDebugTools.inspectHooksOfFiber(formStatusFiber);
109 + expect(normalizeSourceLoc(treeWithFiber)).toEqual([
110 + {
111 + debugInfo: null,
112 + hookSource: {
113 + columnNumber: 0,
114 + fileName: '**',
115 + functionName: 'FormStatus',
116 + lineNumber: 0,
117 + },
118 + id: null,
119 + isStateEditable: false,
120 + name: 'FormStatus',
121 + subHooks: [],
122 + value: null,
123 + },
124 + {
125 + debugInfo: null,
126 + hookSource: {
127 + columnNumber: 0,
128 + fileName: '**',
129 + functionName: 'FormStatus',
130 + lineNumber: 0,
131 + },
132 + id: 0,
133 + isStateEditable: false,
134 + name: 'Memo',
135 + subHooks: [],
136 + value: 'memo',
137 + },
138 + {
139 + debugInfo: null,
140 + hookSource: {
141 + columnNumber: 0,
142 + fileName: '**',
143 + functionName: 'FormStatus',
144 + lineNumber: 0,
145 + },
146 + id: 1,
147 + isStateEditable: false,
148 + name: 'Memo',
149 + subHooks: [],
150 + value: 'not used',
151 + },
152 + ]);
153 + });
154 +});
packages/react-devtools-shell/src/app/InspectableElements/CustomHooks.js
+8 -1
@@ -21,7 +21,7 @@ import {
21 useState,
22 use,
23 } from 'react';
24 -import {useFormState} from 'react-dom';
24 +import {useFormState, useFormStatus} from 'react-dom';
25
26 const object = {
27 string: 'abc',
@@ -164,6 +164,12 @@ function incrementWithDelay(previousState: number, formData: FormData) {
164 });
165 }
166
167 +function FormStatus() {
168 + const status = useFormStatus();
169 +
170 + return <pre>{JSON.stringify(status)}</pre>;
171 +}
172 +
173 function Forms() {
174 const [state, formAction] = useFormState<any, any>(incrementWithDelay, 0);
175 return (
@@ -184,6 +190,7 @@ function Forms() {
190 <input name="shouldReject" type="checkbox" />
191 </label>
192 <button formAction={formAction}>Increment</button>
193 + <FormStatus />
194 </form>
195 );
196 }