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
}