Skip to content

Commit

Permalink
Support editable useState hooks in DevTools (#14906)
Browse files Browse the repository at this point in the history
* ReactDebugHooks identifies State and Reducer hooks as editable
* Inject overrideHookState() method to DevTools to support editing in DEV builds
* Added an integration test for React DevTools, react-debug-tools, and overrideHookState
  • Loading branch information
bvaughn authored Feb 28, 2019
1 parent 69060e1 commit bb2939c
Show file tree
Hide file tree
Showing 5 changed files with 567 additions and 38 deletions.
21 changes: 20 additions & 1 deletion packages/react-debug-tools/src/ReactDebugHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ const Dispatcher: DispatcherType = {
// Inspect

type HooksNode = {
id: number | null,
isStateEditable: boolean,
name: string,
value: mixed,
subHooks: Array<HooksNode>,
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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,
Expand All @@ -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: [],
});
Expand Down
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
Expand Up @@ -28,6 +28,8 @@ describe('ReactHooksInspection', () => {
let tree = ReactDebugTools.inspectHooks(Foo, {});
expect(tree).toEqual([
{
isStateEditable: true,
id: 0,
name: 'State',
value: 'hello world',
subHooks: [],
Expand All @@ -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: [],
Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -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,
Expand All @@ -208,6 +246,8 @@ describe('ReactHooksInspection', () => {
let tree = ReactDebugTools.inspectHooks(Foo, {});
expect(tree).toEqual([
{
isStateEditable: false,
id: null,
name: 'Context',
value: 'default',
subHooks: [],
Expand Down Expand Up @@ -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,
},
],
},
]);
});
Expand Down
Loading

0 comments on commit bb2939c

Please sign in to comment.