Skip to content

Support editable useState hooks in DevTools #14906

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

Merged
merged 10 commits into from
Feb 28, 2019
21 changes: 20 additions & 1 deletion packages/react-debug-tools/src/ReactDebugHooks.js
Original file line number Diff line number Diff line change
@@ -232,6 +232,8 @@ const Dispatcher: DispatcherType = {
// Inspect

type HooksNode = {
id: number | null,
isStateEditable: boolean,
name: string,
value: mixed,
subHooks: Array<HooksNode>,
@@ -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: [],
});
Original file line number Diff line number Diff line change
@@ -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 <div>count:{count}</div>;
}

const renderer = ReactTestRenderer.create(<MyComponent />);
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 (
<div>
foo:{state.foo}, bar:{state.bar}
</div>
);
}

const renderer = ReactTestRenderer.create(<MyComponent />);
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 <div>count:{count}</div>;
}

const renderer = ReactTestRenderer.create(<MyComponent />);
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'],
});
}
});
});
Original file line number Diff line number Diff line change
@@ -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,31 +86,43 @@ 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,
},
],
},
{
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,50 +161,70 @@ 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: [],
},
],
},
{
isStateEditable: false,
id: 2,
name: 'LayoutEffect',
value: effect,
subHooks: [],
},
],
},
{
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,
},
],
},
]);
});
Original file line number Diff line number Diff line change
@@ -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: [],
},
]);
});
});
34 changes: 34 additions & 0 deletions packages/react-reconciler/src/ReactFiberReconciler.js
Original file line number Diff line number Diff line change
@@ -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<string | number>,
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<string | number>, 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 {