Skip to content

Commit

Permalink
Devtools: Add support for useFormStatus (#28413)
Browse files Browse the repository at this point in the history
  • Loading branch information
eps1lon authored Apr 16, 2024
1 parent 8f212cc commit 734956a
Show file tree
Hide file tree
Showing 4 changed files with 341 additions and 8 deletions.
42 changes: 35 additions & 7 deletions packages/react-debug-tools/src/ReactDebugHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
Fiber,
Dispatcher as DispatcherType,
} from 'react-reconciler/src/ReactInternalTypes';
import type {TransitionStatus} from 'react-reconciler/src/ReactFiberConfig';

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

Dispatcher.useId();

if (typeof Dispatcher.useHostTransitionStatus === 'function') {
// This type check is for Flow only.
Dispatcher.useHostTransitionStatus();
}
} finally {
readHookLog = hookLog;
hookLog = [];
Expand Down Expand Up @@ -711,6 +717,27 @@ function useActionState<S, P>(
return [state, (payload: P) => {}, false];
}

function useHostTransitionStatus(): TransitionStatus {
const status = readContext<TransitionStatus>(
// $FlowFixMe[prop-missing] `readContext` only needs _currentValue
({
// $FlowFixMe[incompatible-cast] TODO: Incorrect bottom value without access to Fiber config.
_currentValue: null,
}: ReactContext<TransitionStatus>),
);

hookLog.push({
displayName: null,
primitive: 'HostTransitionStatus',
stackError: new Error(),
value: status,
debugInfo: null,
dispatcherHookName: 'HostTransitionStatus',
});

return status;
}

const Dispatcher: DispatcherType = {
use,
readContext,
Expand All @@ -734,6 +761,7 @@ const Dispatcher: DispatcherType = {
useId,
useFormState,
useActionState,
useHostTransitionStatus,
};

// create a proxy to throw a custom error
Expand Down Expand Up @@ -854,12 +882,11 @@ function findPrimitiveIndex(hookStack: any, hook: HookLogEntry) {
isReactWrapper(hookStack[i].functionName, hook.dispatcherHookName)
) {
i++;
}
if (
i < hookStack.length - 1 &&
isReactWrapper(hookStack[i].functionName, hook.dispatcherHookName)
) {
i++;
// Guard against the dispatcher call being inlined.
// At this point we wouldn't be able to recover the actual React Hook name.
if (i < hookStack.length - 1) {
i++;
}
}
return i;
}
Expand Down Expand Up @@ -997,7 +1024,8 @@ function buildTree(
primitive === 'Context (use)' ||
primitive === 'DebugValue' ||
primitive === 'Promise' ||
primitive === 'Unresolved'
primitive === 'Unresolved' ||
primitive === 'HostTransitionStatus'
? null
: nativeHookID++;

Expand Down
144 changes: 144 additions & 0 deletions packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,150 @@ describe('ReactHooksInspection', () => {
`);
});

it('should not confuse built-in hooks with custom hooks that have the same name', () => {
function useState(value) {
React.useState(value);
React.useDebugValue('custom useState');
}
function useFormStatus() {
React.useState('custom useState');
React.useDebugValue('custom useFormStatus');
}
function Foo(props) {
useFormStatus();
useState('Hello, Dave!');
return null;
}
const tree = ReactDebugTools.inspectHooks(Foo, {});
if (__DEV__) {
expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(`
[
{
"debugInfo": null,
"hookSource": {
"columnNumber": 0,
"fileName": "**",
"functionName": "Foo",
"lineNumber": 0,
},
"id": null,
"isStateEditable": false,
"name": "FormStatus",
"subHooks": [
{
"debugInfo": null,
"hookSource": {
"columnNumber": 0,
"fileName": "**",
"functionName": "useFormStatus",
"lineNumber": 0,
},
"id": 0,
"isStateEditable": true,
"name": "State",
"subHooks": [],
"value": "custom useState",
},
],
"value": "custom useFormStatus",
},
{
"debugInfo": null,
"hookSource": {
"columnNumber": 0,
"fileName": "**",
"functionName": "Foo",
"lineNumber": 0,
},
"id": null,
"isStateEditable": false,
"name": "State",
"subHooks": [
{
"debugInfo": null,
"hookSource": {
"columnNumber": 0,
"fileName": "**",
"functionName": "useState",
"lineNumber": 0,
},
"id": 1,
"isStateEditable": true,
"name": "State",
"subHooks": [],
"value": "Hello, Dave!",
},
],
"value": "custom useState",
},
]
`);
} else {
expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(`
[
{
"debugInfo": null,
"hookSource": {
"columnNumber": 0,
"fileName": "**",
"functionName": "Foo",
"lineNumber": 0,
},
"id": null,
"isStateEditable": false,
"name": "FormStatus",
"subHooks": [
{
"debugInfo": null,
"hookSource": {
"columnNumber": 0,
"fileName": "**",
"functionName": "useFormStatus",
"lineNumber": 0,
},
"id": 0,
"isStateEditable": true,
"name": "State",
"subHooks": [],
"value": "custom useState",
},
],
"value": undefined,
},
{
"debugInfo": null,
"hookSource": {
"columnNumber": 0,
"fileName": "**",
"functionName": "Foo",
"lineNumber": 0,
},
"id": null,
"isStateEditable": false,
"name": "State",
"subHooks": [
{
"debugInfo": null,
"hookSource": {
"columnNumber": 0,
"fileName": "**",
"functionName": "useState",
"lineNumber": 0,
},
"id": 1,
"isStateEditable": true,
"name": "State",
"subHooks": [],
"value": "Hello, Dave!",
},
],
"value": undefined,
},
]
`);
}
});

it('should inspect the default value using the useContext hook', () => {
const MyContext = React.createContext('default');
function Foo(props) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
* @jest-environment jsdom
*/

'use strict';

let React;
let ReactDOM;
let ReactDOMClient;
let ReactDebugTools;
let act;

function normalizeSourceLoc(tree) {
tree.forEach(node => {
if (node.hookSource) {
node.hookSource.fileName = '**';
node.hookSource.lineNumber = 0;
node.hookSource.columnNumber = 0;
}
normalizeSourceLoc(node.subHooks);
});
return tree;
}

describe('ReactHooksInspectionIntegration', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactDOM = require('react-dom');
ReactDOMClient = require('react-dom/client');
act = require('internal-test-utils').act;
ReactDebugTools = require('react-debug-tools');
});

it('should support useFormStatus hook', async () => {
function FormStatus() {
const status = ReactDOM.useFormStatus();
React.useMemo(() => 'memo', []);
React.useMemo(() => 'not used', []);

return JSON.stringify(status);
}

const treeWithoutFiber = ReactDebugTools.inspectHooks(FormStatus);
expect(normalizeSourceLoc(treeWithoutFiber)).toEqual([
{
debugInfo: null,
hookSource: {
columnNumber: 0,
fileName: '**',
functionName: 'FormStatus',
lineNumber: 0,
},
id: null,
isStateEditable: false,
name: 'FormStatus',
subHooks: [],
value: null,
},
{
debugInfo: null,
hookSource: {
columnNumber: 0,
fileName: '**',
functionName: 'FormStatus',
lineNumber: 0,
},
id: 0,
isStateEditable: false,
name: 'Memo',
subHooks: [],
value: 'memo',
},
{
debugInfo: null,
hookSource: {
columnNumber: 0,
fileName: '**',
functionName: 'FormStatus',
lineNumber: 0,
},
id: 1,
isStateEditable: false,
name: 'Memo',
subHooks: [],
value: 'not used',
},
]);

const root = ReactDOMClient.createRoot(document.createElement('div'));

await act(() => {
root.render(
<form>
<FormStatus />
</form>,
);
});

// Implementation detail. Feel free to adjust the position of the Fiber in the tree.
const formStatusFiber = root._internalRoot.current.child.child;
const treeWithFiber = ReactDebugTools.inspectHooksOfFiber(formStatusFiber);
expect(normalizeSourceLoc(treeWithFiber)).toEqual([
{
debugInfo: null,
hookSource: {
columnNumber: 0,
fileName: '**',
functionName: 'FormStatus',
lineNumber: 0,
},
id: null,
isStateEditable: false,
name: 'FormStatus',
subHooks: [],
value: null,
},
{
debugInfo: null,
hookSource: {
columnNumber: 0,
fileName: '**',
functionName: 'FormStatus',
lineNumber: 0,
},
id: 0,
isStateEditable: false,
name: 'Memo',
subHooks: [],
value: 'memo',
},
{
debugInfo: null,
hookSource: {
columnNumber: 0,
fileName: '**',
functionName: 'FormStatus',
lineNumber: 0,
},
id: 1,
isStateEditable: false,
name: 'Memo',
subHooks: [],
value: 'not used',
},
]);
});
});
Loading

0 comments on commit 734956a

Please sign in to comment.