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 6 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
36 changes: 30 additions & 6 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,11 +882,6 @@ 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++;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes

Copy link
Contributor

@hoxyq hoxyq Apr 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to skip 2 frames here, but I would also keep i < hookStack.length - 1 check.
Is it guaranteed that we will always have 2 frames from react?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't think of a case where we wouldn't have two frames. That would mean the dispatcher call is inlined into usercode. Maybe this can happen but at that point we'll never be able to recover the original React hook name. I'll add a check regardless since it's better to display the dispatcher name than crashing.

}
return i;
Expand Down Expand Up @@ -997,7 +1020,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