Skip to content

Commit 734956a

Browse files
authored
Devtools: Add support for useFormStatus (#28413)
1 parent 8f212cc commit 734956a

File tree

4 files changed

+341
-8
lines changed

4 files changed

+341
-8
lines changed

packages/react-debug-tools/src/ReactDebugHooks.js

+35-7
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
Fiber,
2222
Dispatcher as DispatcherType,
2323
} from 'react-reconciler/src/ReactInternalTypes';
24+
import type {TransitionStatus} from 'react-reconciler/src/ReactFiberConfig';
2425

2526
import ErrorStackParser from 'error-stack-parser';
2627
import assign from 'shared/assign';
@@ -134,6 +135,11 @@ function getPrimitiveStackCache(): Map<string, Array<any>> {
134135
}
135136

136137
Dispatcher.useId();
138+
139+
if (typeof Dispatcher.useHostTransitionStatus === 'function') {
140+
// This type check is for Flow only.
141+
Dispatcher.useHostTransitionStatus();
142+
}
137143
} finally {
138144
readHookLog = hookLog;
139145
hookLog = [];
@@ -711,6 +717,27 @@ function useActionState<S, P>(
711717
return [state, (payload: P) => {}, false];
712718
}
713719

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+
714741
const Dispatcher: DispatcherType = {
715742
use,
716743
readContext,
@@ -734,6 +761,7 @@ const Dispatcher: DispatcherType = {
734761
useId,
735762
useFormState,
736763
useActionState,
764+
useHostTransitionStatus,
737765
};
738766

739767
// create a proxy to throw a custom error
@@ -854,12 +882,11 @@ function findPrimitiveIndex(hookStack: any, hook: HookLogEntry) {
854882
isReactWrapper(hookStack[i].functionName, hook.dispatcherHookName)
855883
) {
856884
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+
}
863890
}
864891
return i;
865892
}
@@ -997,7 +1024,8 @@ function buildTree(
9971024
primitive === 'Context (use)' ||
9981025
primitive === 'DebugValue' ||
9991026
primitive === 'Promise' ||
1000-
primitive === 'Unresolved'
1027+
primitive === 'Unresolved' ||
1028+
primitive === 'HostTransitionStatus'
10011029
? null
10021030
: nativeHookID++;
10031031

packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js

+144
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,150 @@ describe('ReactHooksInspection', () => {
426426
`);
427427
});
428428

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+
429573
it('should inspect the default value using the useContext hook', () => {
430574
const MyContext = React.createContext('default');
431575
function Foo(props) {
Original file line numberDiff line numberDiff line change
@@ -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+
});

0 commit comments

Comments
 (0)