diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 2dc1ddb02cac..12e3021d8afd 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -232,6 +232,8 @@ const Dispatcher: DispatcherType = { // Inspect type HooksNode = { + id: number | null, + isStateEditable: boolean, name: string, value: mixed, subHooks: Array, @@ -373,6 +375,7 @@ function buildTree(rootStack, readHookLog): HooksTree { let rootChildren = []; let prevStack = null; let levelChildren = rootChildren; + let nativeHookID = 0; let stackOfChildren = []; for (let i = 0; i < readHookLog.length; i++) { let hook = readHookLog[i]; @@ -403,6 +406,8 @@ function buildTree(rootStack, readHookLog): HooksTree { for (let j = stack.length - commonSteps - 1; j >= 1; j--) { let children = []; levelChildren.push({ + id: null, + isStateEditable: false, name: parseCustomHookName(stack[j - 1].functionName), value: undefined, subHooks: children, @@ -412,8 +417,22 @@ function buildTree(rootStack, readHookLog): HooksTree { } prevStack = stack; } + const {primitive} = hook; + + // For now, the "id" of stateful hooks is just the stateful hook index. + // Custom hooks have no ids, nor do non-stateful native hooks (e.g. Context, DebugValue). + const id = + primitive === 'Context' || primitive === 'DebugValue' + ? null + : nativeHookID++; + + // For the time being, only State and Reducer hooks support runtime overrides. + const isStateEditable = primitive === 'Reducer' || primitive === 'State'; + levelChildren.push({ - name: hook.primitive, + id, + isStateEditable, + name: primitive, value: hook.value, subHooks: [], }); diff --git a/packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js new file mode 100644 index 000000000000..113524f832af --- /dev/null +++ b/packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js @@ -0,0 +1,176 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 node + */ + +'use strict'; + +describe('React hooks DevTools integration', () => { + let React; + let ReactDebugTools; + let ReactTestRenderer; + let act; + let overrideHookState; + + beforeEach(() => { + global.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { + inject: injected => { + overrideHookState = injected.overrideHookState; + }, + supportsFiber: true, + onCommitFiberRoot: () => {}, + onCommitFiberUnmount: () => {}, + }; + + jest.resetModules(); + + React = require('react'); + ReactDebugTools = require('react-debug-tools'); + ReactTestRenderer = require('react-test-renderer'); + + act = ReactTestRenderer.act; + }); + + it('should support editing useState hooks', () => { + let setCountFn; + + function MyComponent() { + const [count, setCount] = React.useState(0); + setCountFn = setCount; + return
count:{count}
; + } + + const renderer = ReactTestRenderer.create(); + expect(renderer.toJSON()).toEqual({ + type: 'div', + props: {}, + children: ['count:', '0'], + }); + + const fiber = renderer.root.findByType(MyComponent)._currentFiber(); + const tree = ReactDebugTools.inspectHooksOfFiber(fiber); + const stateHook = tree[0]; + expect(stateHook.isStateEditable).toBe(true); + + if (__DEV__) { + overrideHookState(fiber, stateHook.id, [], 10); + expect(renderer.toJSON()).toEqual({ + type: 'div', + props: {}, + children: ['count:', '10'], + }); + + act(() => setCountFn(count => count + 1)); + expect(renderer.toJSON()).toEqual({ + type: 'div', + props: {}, + children: ['count:', '11'], + }); + } + }); + + it('should support editable useReducer hooks', () => { + const initialData = {foo: 'abc', bar: 123}; + + function reducer(state, action) { + switch (action.type) { + case 'swap': + return {foo: state.bar, bar: state.foo}; + default: + throw new Error(); + } + } + + let dispatchFn; + function MyComponent() { + const [state, dispatch] = React.useReducer(reducer, initialData); + dispatchFn = dispatch; + return ( +
+ foo:{state.foo}, bar:{state.bar} +
+ ); + } + + const renderer = ReactTestRenderer.create(); + expect(renderer.toJSON()).toEqual({ + type: 'div', + props: {}, + children: ['foo:', 'abc', ', bar:', '123'], + }); + + const fiber = renderer.root.findByType(MyComponent)._currentFiber(); + const tree = ReactDebugTools.inspectHooksOfFiber(fiber); + const reducerHook = tree[0]; + expect(reducerHook.isStateEditable).toBe(true); + + if (__DEV__) { + overrideHookState(fiber, reducerHook.id, ['foo'], 'def'); + expect(renderer.toJSON()).toEqual({ + type: 'div', + props: {}, + children: ['foo:', 'def', ', bar:', '123'], + }); + + act(() => dispatchFn({type: 'swap'})); + expect(renderer.toJSON()).toEqual({ + type: 'div', + props: {}, + children: ['foo:', '123', ', bar:', 'def'], + }); + } + }); + + // This test case is based on an open source bug report: + // facebookincubator/redux-react-hook/issues/34#issuecomment-466693787 + it('should handle interleaved stateful hooks (e.g. useState) and non-stateful hooks (e.g. useContext)', () => { + const MyContext = React.createContext(1); + + let setStateFn; + function useCustomHook() { + const context = React.useContext(MyContext); + const [state, setState] = React.useState({count: context}); + React.useDebugValue(state.count); + setStateFn = setState; + return state.count; + } + + function MyComponent() { + const count = useCustomHook(); + return
count:{count}
; + } + + const renderer = ReactTestRenderer.create(); + expect(renderer.toJSON()).toEqual({ + type: 'div', + props: {}, + children: ['count:', '1'], + }); + + const fiber = renderer.root.findByType(MyComponent)._currentFiber(); + const tree = ReactDebugTools.inspectHooksOfFiber(fiber); + const stateHook = tree[0].subHooks[1]; + expect(stateHook.isStateEditable).toBe(true); + + if (__DEV__) { + overrideHookState(fiber, stateHook.id, ['count'], 10); + expect(renderer.toJSON()).toEqual({ + type: 'div', + props: {}, + children: ['count:', '10'], + }); + + act(() => setStateFn(state => ({count: state.count + 1}))); + expect(renderer.toJSON()).toEqual({ + type: 'div', + props: {}, + children: ['count:', '11'], + }); + } + }); +}); diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js index b340779a74d1..f5b57a531aba 100644 --- a/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js @@ -28,6 +28,8 @@ describe('ReactHooksInspection', () => { let tree = ReactDebugTools.inspectHooks(Foo, {}); expect(tree).toEqual([ { + isStateEditable: true, + id: 0, name: 'State', value: 'hello world', subHooks: [], @@ -48,10 +50,14 @@ describe('ReactHooksInspection', () => { let tree = ReactDebugTools.inspectHooks(Foo, {}); expect(tree).toEqual([ { + isStateEditable: false, + id: null, name: 'Custom', value: __DEV__ ? 'custom hook label' : undefined, subHooks: [ { + isStateEditable: true, + id: 0, name: 'State', value: 'hello world', subHooks: [], @@ -80,15 +86,21 @@ describe('ReactHooksInspection', () => { let tree = ReactDebugTools.inspectHooks(Foo, {}); expect(tree).toEqual([ { + isStateEditable: false, + id: null, name: 'Custom', value: undefined, subHooks: [ { + isStateEditable: true, + id: 0, name: 'State', subHooks: [], value: 'hello', }, { + isStateEditable: false, + id: 1, name: 'Effect', subHooks: [], value: effect, @@ -96,15 +108,21 @@ describe('ReactHooksInspection', () => { ], }, { + isStateEditable: false, + id: null, name: 'Custom', value: undefined, subHooks: [ { + isStateEditable: true, + id: 2, name: 'State', value: 'world', subHooks: [], }, { + isStateEditable: false, + id: 3, name: 'Effect', value: effect, subHooks: [], @@ -143,19 +161,27 @@ describe('ReactHooksInspection', () => { let tree = ReactDebugTools.inspectHooks(Foo, {}); expect(tree).toEqual([ { + isStateEditable: false, + id: null, name: 'Bar', value: undefined, subHooks: [ { + isStateEditable: false, + id: null, name: 'Custom', value: undefined, subHooks: [ { + isStateEditable: true, + id: 0, name: 'Reducer', value: 'hello', subHooks: [], }, { + isStateEditable: false, + id: 1, name: 'Effect', value: effect, subHooks: [], @@ -163,6 +189,8 @@ describe('ReactHooksInspection', () => { ], }, { + isStateEditable: false, + id: 2, name: 'LayoutEffect', value: effect, subHooks: [], @@ -170,23 +198,33 @@ describe('ReactHooksInspection', () => { ], }, { + isStateEditable: false, + id: null, name: 'Baz', value: undefined, subHooks: [ { + isStateEditable: false, + id: 3, name: 'LayoutEffect', value: effect, subHooks: [], }, { + isStateEditable: false, + id: null, name: 'Custom', subHooks: [ { + isStateEditable: true, + id: 4, name: 'Reducer', subHooks: [], value: 'world', }, { + isStateEditable: false, + id: 5, name: 'Effect', subHooks: [], value: effect, @@ -208,6 +246,8 @@ describe('ReactHooksInspection', () => { let tree = ReactDebugTools.inspectHooks(Foo, {}); expect(tree).toEqual([ { + isStateEditable: false, + id: null, name: 'Context', value: 'default', subHooks: [], @@ -270,9 +310,19 @@ describe('ReactHooksInspection', () => { let tree = ReactDebugTools.inspectHooks(Foo, {}); expect(tree).toEqual([ { + isStateEditable: false, + id: null, name: 'Custom', value: __DEV__ ? 'bar:123' : undefined, - subHooks: [{name: 'State', subHooks: [], value: 0}], + subHooks: [ + { + isStateEditable: true, + id: 0, + name: 'State', + subHooks: [], + value: 0, + }, + ], }, ]); }); diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js index 8c56c8cd775a..c12270626b0a 100644 --- a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js @@ -40,8 +40,20 @@ describe('ReactHooksInspectionIntegration', () => { let childFiber = renderer.root.findByType(Foo)._currentFiber(); let tree = ReactDebugTools.inspectHooksOfFiber(childFiber); expect(tree).toEqual([ - {name: 'State', value: 'hello', subHooks: []}, - {name: 'State', value: 'world', subHooks: []}, + { + isStateEditable: true, + id: 0, + name: 'State', + value: 'hello', + subHooks: [], + }, + { + isStateEditable: true, + id: 1, + name: 'State', + value: 'world', + subHooks: [], + }, ]); let { @@ -55,8 +67,20 @@ describe('ReactHooksInspectionIntegration', () => { tree = ReactDebugTools.inspectHooksOfFiber(childFiber); expect(tree).toEqual([ - {name: 'State', value: 'Hi', subHooks: []}, - {name: 'State', value: 'world', subHooks: []}, + { + isStateEditable: true, + id: 0, + name: 'State', + value: 'Hi', + subHooks: [], + }, + { + isStateEditable: true, + id: 1, + name: 'State', + value: 'world', + subHooks: [], + }, ]); act(() => setStateB('world!')); @@ -65,8 +89,20 @@ describe('ReactHooksInspectionIntegration', () => { tree = ReactDebugTools.inspectHooksOfFiber(childFiber); expect(tree).toEqual([ - {name: 'State', value: 'Hi', subHooks: []}, - {name: 'State', value: 'world!', subHooks: []}, + { + isStateEditable: true, + id: 0, + name: 'State', + value: 'Hi', + subHooks: [], + }, + { + isStateEditable: true, + id: 1, + name: 'State', + value: 'world!', + subHooks: [], + }, ]); }); @@ -116,14 +152,56 @@ describe('ReactHooksInspectionIntegration', () => { let tree = ReactDebugTools.inspectHooksOfFiber(childFiber); expect(tree).toEqual([ - {name: 'State', value: 'a', subHooks: []}, - {name: 'Reducer', value: 'b', subHooks: []}, - {name: 'Ref', value: 'c', subHooks: []}, - {name: 'LayoutEffect', value: effect, subHooks: []}, - {name: 'Effect', value: effect, subHooks: []}, - {name: 'ImperativeHandle', value: outsideRef.current, subHooks: []}, - {name: 'Memo', value: 'ab', subHooks: []}, - {name: 'Callback', value: updateStates, subHooks: []}, + { + isStateEditable: true, + id: 0, + name: 'State', + value: 'a', + subHooks: [], + }, + { + isStateEditable: true, + id: 1, + name: 'Reducer', + value: 'b', + subHooks: [], + }, + {isStateEditable: false, id: 2, name: 'Ref', value: 'c', subHooks: []}, + { + isStateEditable: false, + id: 3, + name: 'LayoutEffect', + value: effect, + subHooks: [], + }, + { + isStateEditable: false, + id: 4, + name: 'Effect', + value: effect, + subHooks: [], + }, + { + isStateEditable: false, + id: 5, + name: 'ImperativeHandle', + value: outsideRef.current, + subHooks: [], + }, + { + isStateEditable: false, + id: 6, + name: 'Memo', + value: 'ab', + subHooks: [], + }, + { + isStateEditable: false, + id: 7, + name: 'Callback', + value: updateStates, + subHooks: [], + }, ]); updateStates(); @@ -132,14 +210,56 @@ describe('ReactHooksInspectionIntegration', () => { tree = ReactDebugTools.inspectHooksOfFiber(childFiber); expect(tree).toEqual([ - {name: 'State', value: 'A', subHooks: []}, - {name: 'Reducer', value: 'B', subHooks: []}, - {name: 'Ref', value: 'C', subHooks: []}, - {name: 'LayoutEffect', value: effect, subHooks: []}, - {name: 'Effect', value: effect, subHooks: []}, - {name: 'ImperativeHandle', value: outsideRef.current, subHooks: []}, - {name: 'Memo', value: 'Ab', subHooks: []}, - {name: 'Callback', value: updateStates, subHooks: []}, + { + isStateEditable: true, + id: 0, + name: 'State', + value: 'A', + subHooks: [], + }, + { + isStateEditable: true, + id: 1, + name: 'Reducer', + value: 'B', + subHooks: [], + }, + {isStateEditable: false, id: 2, name: 'Ref', value: 'C', subHooks: []}, + { + isStateEditable: false, + id: 3, + name: 'LayoutEffect', + value: effect, + subHooks: [], + }, + { + isStateEditable: false, + id: 4, + name: 'Effect', + value: effect, + subHooks: [], + }, + { + isStateEditable: false, + id: 5, + name: 'ImperativeHandle', + value: outsideRef.current, + subHooks: [], + }, + { + isStateEditable: false, + id: 6, + name: 'Memo', + value: 'Ab', + subHooks: [], + }, + { + isStateEditable: false, + id: 7, + name: 'Callback', + value: updateStates, + subHooks: [], + }, ]); }); @@ -158,6 +278,8 @@ describe('ReactHooksInspectionIntegration', () => { let tree = ReactDebugTools.inspectHooksOfFiber(childFiber); expect(tree).toEqual([ { + isStateEditable: false, + id: null, name: 'Context', value: 'contextual', subHooks: [], @@ -177,7 +299,13 @@ describe('ReactHooksInspectionIntegration', () => { let childFiber = renderer.root.findByType(Foo)._currentFiber(); let tree = ReactDebugTools.inspectHooksOfFiber(childFiber); expect(tree).toEqual([ - {name: 'ImperativeHandle', value: obj, subHooks: []}, + { + isStateEditable: false, + id: 0, + name: 'ImperativeHandle', + value: obj, + subHooks: [], + }, ]); }); @@ -191,7 +319,15 @@ describe('ReactHooksInspectionIntegration', () => { // TODO: Test renderer findByType is broken for memo. Have to search for the inner. let childFiber = renderer.root.findByType(InnerFoo)._currentFiber(); let tree = ReactDebugTools.inspectHooksOfFiber(childFiber); - expect(tree).toEqual([{name: 'State', value: 'hello', subHooks: []}]); + expect(tree).toEqual([ + { + isStateEditable: true, + id: 0, + name: 'State', + value: 'hello', + subHooks: [], + }, + ]); }); it('should inspect custom hooks', () => { @@ -208,9 +344,19 @@ describe('ReactHooksInspectionIntegration', () => { let tree = ReactDebugTools.inspectHooksOfFiber(childFiber); expect(tree).toEqual([ { + isStateEditable: false, + id: null, name: 'Custom', value: undefined, - subHooks: [{name: 'State', value: 'hello', subHooks: []}], + subHooks: [ + { + isStateEditable: true, + id: 0, + name: 'State', + value: 'hello', + subHooks: [], + }, + ], }, ]); }); @@ -238,24 +384,56 @@ describe('ReactHooksInspectionIntegration', () => { let tree = ReactDebugTools.inspectHooksOfFiber(childFiber); expect(tree).toEqual([ { + isStateEditable: false, + id: null, name: 'LabeledValue', value: __DEV__ ? 'custom label a' : undefined, - subHooks: [{name: 'State', value: 'a', subHooks: []}], + subHooks: [ + { + isStateEditable: true, + id: 0, + name: 'State', + value: 'a', + subHooks: [], + }, + ], }, { + isStateEditable: true, + id: 1, name: 'State', value: 'b', subHooks: [], }, { + isStateEditable: false, + id: null, name: 'Anonymous', value: undefined, - subHooks: [{name: 'State', value: 'c', subHooks: []}], + subHooks: [ + { + isStateEditable: true, + id: 2, + name: 'State', + value: 'c', + subHooks: [], + }, + ], }, { + isStateEditable: false, + id: null, name: 'LabeledValue', value: __DEV__ ? 'custom label d' : undefined, - subHooks: [{name: 'State', value: 'd', subHooks: []}], + subHooks: [ + { + isStateEditable: true, + id: 3, + name: 'State', + value: 'd', + subHooks: [], + }, + ], }, ]); }); @@ -278,13 +456,25 @@ describe('ReactHooksInspectionIntegration', () => { let tree = ReactDebugTools.inspectHooksOfFiber(childFiber); expect(tree).toEqual([ { + isStateEditable: false, + id: null, name: 'Outer', value: __DEV__ ? 'outer' : undefined, subHooks: [ { + isStateEditable: false, + id: null, name: 'Inner', value: __DEV__ ? 'inner' : undefined, - subHooks: [{name: 'State', value: 0, subHooks: []}], + subHooks: [ + { + isStateEditable: true, + id: 0, + name: 'State', + value: 0, + subHooks: [], + }, + ], }, ], }, @@ -313,19 +503,49 @@ describe('ReactHooksInspectionIntegration', () => { let tree = ReactDebugTools.inspectHooksOfFiber(childFiber); expect(tree).toEqual([ { + isStateEditable: false, + id: null, name: 'SingleLabelCustom', value: __DEV__ ? 'single one' : undefined, - subHooks: [{name: 'State', value: 0, subHooks: []}], + subHooks: [ + { + isStateEditable: true, + id: 0, + name: 'State', + value: 0, + subHooks: [], + }, + ], }, { + isStateEditable: false, + id: null, name: 'MultiLabelCustom', value: __DEV__ ? ['one', 'two', 'three'] : undefined, - subHooks: [{name: 'State', value: 0, subHooks: []}], + subHooks: [ + { + isStateEditable: true, + id: 1, + name: 'State', + value: 0, + subHooks: [], + }, + ], }, { + isStateEditable: false, + id: null, name: 'SingleLabelCustom', value: __DEV__ ? 'single two' : undefined, - subHooks: [{name: 'State', value: 0, subHooks: []}], + subHooks: [ + { + isStateEditable: true, + id: 2, + name: 'State', + value: 0, + subHooks: [], + }, + ], }, ]); }); @@ -355,9 +575,19 @@ describe('ReactHooksInspectionIntegration', () => { let tree = ReactDebugTools.inspectHooksOfFiber(childFiber); expect(tree).toEqual([ { + isStateEditable: false, + id: null, name: 'Custom', value: __DEV__ ? 'bar:123' : undefined, - subHooks: [{name: 'State', subHooks: [], value: 0}], + subHooks: [ + { + isStateEditable: true, + id: 0, + name: 'State', + subHooks: [], + value: 0, + }, + ], }, ]); }); @@ -390,7 +620,15 @@ describe('ReactHooksInspectionIntegration', () => { let childFiber = renderer.root._currentFiber(); let tree = ReactDebugTools.inspectHooksOfFiber(childFiber); - expect(tree).toEqual([{name: 'State', value: 'def', subHooks: []}]); + expect(tree).toEqual([ + { + isStateEditable: true, + id: 0, + name: 'State', + value: 'def', + subHooks: [], + }, + ]); }); it('should support an injected dispatcher', () => { @@ -461,8 +699,20 @@ describe('ReactHooksInspectionIntegration', () => { const childFiber = renderer.root._currentFiber(); const tree = ReactDebugTools.inspectHooksOfFiber(childFiber); expect(tree).toEqual([ - {name: 'Context', value: 1, subHooks: []}, - {name: 'State', value: {count: 2}, subHooks: []}, + { + isStateEditable: false, + id: null, + name: 'Context', + value: 1, + subHooks: [], + }, + { + isStateEditable: true, + id: 0, + name: 'State', + value: {count: 2}, + subHooks: [], + }, ]); }); }); diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index acb91ea3bb90..01e303c74bc4 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -341,6 +341,7 @@ export function findHostInstanceWithNoPortals( return hostFiber.stateNode; } +let overrideHookState = null; let overrideProps = null; if (__DEV__) { @@ -368,6 +369,38 @@ if (__DEV__) { return copyWithSetImpl(obj, path, 0, value); }; + // Support DevTools editable values for useState and useReducer. + overrideHookState = ( + fiber: Fiber, + id: number, + path: Array, + value: any, + ) => { + // For now, the "id" of stateful hooks is just the stateful hook index. + // This may change in the future with e.g. nested hooks. + let currentHook = fiber.memoizedState; + while (currentHook !== null && id > 0) { + currentHook = currentHook.next; + id--; + } + if (currentHook !== null) { + flushPassiveEffects(); + + const newState = copyWithSet(currentHook.memoizedState, path, value); + currentHook.memoizedState = newState; + currentHook.baseState = newState; + + // We aren't actually adding an update to the queue, + // because there is no update we can add for useReducer hooks that won't trigger an error. + // (There's no appropriate action type for DevTools overrides.) + // As a result though, React will see the scheduled update as a noop and bailout. + // Shallow cloning props works as a workaround for now to bypass the bailout check. + fiber.memoizedProps = {...fiber.memoizedProps}; + + scheduleWork(fiber, Sync); + } + }; + // Support DevTools props for function components, forwardRef, memo, host components, etc. overrideProps = (fiber: Fiber, path: Array, value: any) => { flushPassiveEffects(); @@ -385,6 +418,7 @@ export function injectIntoDevTools(devToolsConfig: DevToolsConfig): boolean { return injectInternals({ ...devToolsConfig, + overrideHookState, overrideProps, currentDispatcherRef: ReactCurrentDispatcher, findHostInstanceByFiber(fiber: Fiber): Instance | TextInstance | null {