Devtools: Add support for useFormStatus (#28413)

This commit is contained in:
Sebastian Silbermann
2024-04-16 10:28:16 +02:00
committed by GitHub
parent bc8cc6e2ed
commit 4f6f4a2ade
4 changed files with 341 additions and 8 deletions
+35 -7
View File
@@ -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';
@@ -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 = [];
@@ -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,
@@ -734,6 +761,7 @@ const Dispatcher: DispatcherType = {
useId,
useFormState,
useActionState,
useHostTransitionStatus,
};
// create a proxy to throw a custom error
@@ -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;
}
@@ -997,7 +1024,8 @@ function buildTree(
primitive === 'Context (use)' ||
primitive === 'DebugValue' ||
primitive === 'Promise' ||
primitive === 'Unresolved'
primitive === 'Unresolved' ||
primitive === 'HostTransitionStatus'
? null
: nativeHookID++;
@@ -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) {
@@ -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',
},
]);
});
});
@@ -21,7 +21,7 @@ import {
useState,
use,
} from 'react';
import {useFormState} from 'react-dom';
import {useFormState, useFormStatus} from 'react-dom';
const object = {
string: 'abc',
@@ -164,6 +164,12 @@ function incrementWithDelay(previousState: number, formData: FormData) {
});
}
function FormStatus() {
const status = useFormStatus();
return <pre>{JSON.stringify(status)}</pre>;
}
function Forms() {
const [state, formAction] = useFormState<any, any>(incrementWithDelay, 0);
return (
@@ -184,6 +190,7 @@ function Forms() {
<input name="shouldReject" type="checkbox" />
</label>
<button formAction={formAction}>Increment</button>
<FormStatus />
</form>
);
}