Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Devtools: Add support for useFormStatus #28413

Merged
merged 7 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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