Skip to content

Commit bb2939c

Browse files
authored
Support editable useState hooks in DevTools (#14906)
* 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
1 parent 69060e1 commit bb2939c

File tree

5 files changed

+567
-38
lines changed

5 files changed

+567
-38
lines changed

packages/react-debug-tools/src/ReactDebugHooks.js

+20-1
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,8 @@ const Dispatcher: DispatcherType = {
232232
// Inspect
233233

234234
type HooksNode = {
235+
id: number | null,
236+
isStateEditable: boolean,
235237
name: string,
236238
value: mixed,
237239
subHooks: Array<HooksNode>,
@@ -373,6 +375,7 @@ function buildTree(rootStack, readHookLog): HooksTree {
373375
let rootChildren = [];
374376
let prevStack = null;
375377
let levelChildren = rootChildren;
378+
let nativeHookID = 0;
376379
let stackOfChildren = [];
377380
for (let i = 0; i < readHookLog.length; i++) {
378381
let hook = readHookLog[i];
@@ -403,6 +406,8 @@ function buildTree(rootStack, readHookLog): HooksTree {
403406
for (let j = stack.length - commonSteps - 1; j >= 1; j--) {
404407
let children = [];
405408
levelChildren.push({
409+
id: null,
410+
isStateEditable: false,
406411
name: parseCustomHookName(stack[j - 1].functionName),
407412
value: undefined,
408413
subHooks: children,
@@ -412,8 +417,22 @@ function buildTree(rootStack, readHookLog): HooksTree {
412417
}
413418
prevStack = stack;
414419
}
420+
const {primitive} = hook;
421+
422+
// For now, the "id" of stateful hooks is just the stateful hook index.
423+
// Custom hooks have no ids, nor do non-stateful native hooks (e.g. Context, DebugValue).
424+
const id =
425+
primitive === 'Context' || primitive === 'DebugValue'
426+
? null
427+
: nativeHookID++;
428+
429+
// For the time being, only State and Reducer hooks support runtime overrides.
430+
const isStateEditable = primitive === 'Reducer' || primitive === 'State';
431+
415432
levelChildren.push({
416-
name: hook.primitive,
433+
id,
434+
isStateEditable,
435+
name: primitive,
417436
value: hook.value,
418437
subHooks: [],
419438
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @emails react-core
8+
* @jest-environment node
9+
*/
10+
11+
'use strict';
12+
13+
describe('React hooks DevTools integration', () => {
14+
let React;
15+
let ReactDebugTools;
16+
let ReactTestRenderer;
17+
let act;
18+
let overrideHookState;
19+
20+
beforeEach(() => {
21+
global.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
22+
inject: injected => {
23+
overrideHookState = injected.overrideHookState;
24+
},
25+
supportsFiber: true,
26+
onCommitFiberRoot: () => {},
27+
onCommitFiberUnmount: () => {},
28+
};
29+
30+
jest.resetModules();
31+
32+
React = require('react');
33+
ReactDebugTools = require('react-debug-tools');
34+
ReactTestRenderer = require('react-test-renderer');
35+
36+
act = ReactTestRenderer.act;
37+
});
38+
39+
it('should support editing useState hooks', () => {
40+
let setCountFn;
41+
42+
function MyComponent() {
43+
const [count, setCount] = React.useState(0);
44+
setCountFn = setCount;
45+
return <div>count:{count}</div>;
46+
}
47+
48+
const renderer = ReactTestRenderer.create(<MyComponent />);
49+
expect(renderer.toJSON()).toEqual({
50+
type: 'div',
51+
props: {},
52+
children: ['count:', '0'],
53+
});
54+
55+
const fiber = renderer.root.findByType(MyComponent)._currentFiber();
56+
const tree = ReactDebugTools.inspectHooksOfFiber(fiber);
57+
const stateHook = tree[0];
58+
expect(stateHook.isStateEditable).toBe(true);
59+
60+
if (__DEV__) {
61+
overrideHookState(fiber, stateHook.id, [], 10);
62+
expect(renderer.toJSON()).toEqual({
63+
type: 'div',
64+
props: {},
65+
children: ['count:', '10'],
66+
});
67+
68+
act(() => setCountFn(count => count + 1));
69+
expect(renderer.toJSON()).toEqual({
70+
type: 'div',
71+
props: {},
72+
children: ['count:', '11'],
73+
});
74+
}
75+
});
76+
77+
it('should support editable useReducer hooks', () => {
78+
const initialData = {foo: 'abc', bar: 123};
79+
80+
function reducer(state, action) {
81+
switch (action.type) {
82+
case 'swap':
83+
return {foo: state.bar, bar: state.foo};
84+
default:
85+
throw new Error();
86+
}
87+
}
88+
89+
let dispatchFn;
90+
function MyComponent() {
91+
const [state, dispatch] = React.useReducer(reducer, initialData);
92+
dispatchFn = dispatch;
93+
return (
94+
<div>
95+
foo:{state.foo}, bar:{state.bar}
96+
</div>
97+
);
98+
}
99+
100+
const renderer = ReactTestRenderer.create(<MyComponent />);
101+
expect(renderer.toJSON()).toEqual({
102+
type: 'div',
103+
props: {},
104+
children: ['foo:', 'abc', ', bar:', '123'],
105+
});
106+
107+
const fiber = renderer.root.findByType(MyComponent)._currentFiber();
108+
const tree = ReactDebugTools.inspectHooksOfFiber(fiber);
109+
const reducerHook = tree[0];
110+
expect(reducerHook.isStateEditable).toBe(true);
111+
112+
if (__DEV__) {
113+
overrideHookState(fiber, reducerHook.id, ['foo'], 'def');
114+
expect(renderer.toJSON()).toEqual({
115+
type: 'div',
116+
props: {},
117+
children: ['foo:', 'def', ', bar:', '123'],
118+
});
119+
120+
act(() => dispatchFn({type: 'swap'}));
121+
expect(renderer.toJSON()).toEqual({
122+
type: 'div',
123+
props: {},
124+
children: ['foo:', '123', ', bar:', 'def'],
125+
});
126+
}
127+
});
128+
129+
// This test case is based on an open source bug report:
130+
// facebookincubator/redux-react-hook/issues/34#issuecomment-466693787
131+
it('should handle interleaved stateful hooks (e.g. useState) and non-stateful hooks (e.g. useContext)', () => {
132+
const MyContext = React.createContext(1);
133+
134+
let setStateFn;
135+
function useCustomHook() {
136+
const context = React.useContext(MyContext);
137+
const [state, setState] = React.useState({count: context});
138+
React.useDebugValue(state.count);
139+
setStateFn = setState;
140+
return state.count;
141+
}
142+
143+
function MyComponent() {
144+
const count = useCustomHook();
145+
return <div>count:{count}</div>;
146+
}
147+
148+
const renderer = ReactTestRenderer.create(<MyComponent />);
149+
expect(renderer.toJSON()).toEqual({
150+
type: 'div',
151+
props: {},
152+
children: ['count:', '1'],
153+
});
154+
155+
const fiber = renderer.root.findByType(MyComponent)._currentFiber();
156+
const tree = ReactDebugTools.inspectHooksOfFiber(fiber);
157+
const stateHook = tree[0].subHooks[1];
158+
expect(stateHook.isStateEditable).toBe(true);
159+
160+
if (__DEV__) {
161+
overrideHookState(fiber, stateHook.id, ['count'], 10);
162+
expect(renderer.toJSON()).toEqual({
163+
type: 'div',
164+
props: {},
165+
children: ['count:', '10'],
166+
});
167+
168+
act(() => setStateFn(state => ({count: state.count + 1})));
169+
expect(renderer.toJSON()).toEqual({
170+
type: 'div',
171+
props: {},
172+
children: ['count:', '11'],
173+
});
174+
}
175+
});
176+
});

packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js

+51-1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ describe('ReactHooksInspection', () => {
2828
let tree = ReactDebugTools.inspectHooks(Foo, {});
2929
expect(tree).toEqual([
3030
{
31+
isStateEditable: true,
32+
id: 0,
3133
name: 'State',
3234
value: 'hello world',
3335
subHooks: [],
@@ -48,10 +50,14 @@ describe('ReactHooksInspection', () => {
4850
let tree = ReactDebugTools.inspectHooks(Foo, {});
4951
expect(tree).toEqual([
5052
{
53+
isStateEditable: false,
54+
id: null,
5155
name: 'Custom',
5256
value: __DEV__ ? 'custom hook label' : undefined,
5357
subHooks: [
5458
{
59+
isStateEditable: true,
60+
id: 0,
5561
name: 'State',
5662
value: 'hello world',
5763
subHooks: [],
@@ -80,31 +86,43 @@ describe('ReactHooksInspection', () => {
8086
let tree = ReactDebugTools.inspectHooks(Foo, {});
8187
expect(tree).toEqual([
8288
{
89+
isStateEditable: false,
90+
id: null,
8391
name: 'Custom',
8492
value: undefined,
8593
subHooks: [
8694
{
95+
isStateEditable: true,
96+
id: 0,
8797
name: 'State',
8898
subHooks: [],
8999
value: 'hello',
90100
},
91101
{
102+
isStateEditable: false,
103+
id: 1,
92104
name: 'Effect',
93105
subHooks: [],
94106
value: effect,
95107
},
96108
],
97109
},
98110
{
111+
isStateEditable: false,
112+
id: null,
99113
name: 'Custom',
100114
value: undefined,
101115
subHooks: [
102116
{
117+
isStateEditable: true,
118+
id: 2,
103119
name: 'State',
104120
value: 'world',
105121
subHooks: [],
106122
},
107123
{
124+
isStateEditable: false,
125+
id: 3,
108126
name: 'Effect',
109127
value: effect,
110128
subHooks: [],
@@ -143,50 +161,70 @@ describe('ReactHooksInspection', () => {
143161
let tree = ReactDebugTools.inspectHooks(Foo, {});
144162
expect(tree).toEqual([
145163
{
164+
isStateEditable: false,
165+
id: null,
146166
name: 'Bar',
147167
value: undefined,
148168
subHooks: [
149169
{
170+
isStateEditable: false,
171+
id: null,
150172
name: 'Custom',
151173
value: undefined,
152174
subHooks: [
153175
{
176+
isStateEditable: true,
177+
id: 0,
154178
name: 'Reducer',
155179
value: 'hello',
156180
subHooks: [],
157181
},
158182
{
183+
isStateEditable: false,
184+
id: 1,
159185
name: 'Effect',
160186
value: effect,
161187
subHooks: [],
162188
},
163189
],
164190
},
165191
{
192+
isStateEditable: false,
193+
id: 2,
166194
name: 'LayoutEffect',
167195
value: effect,
168196
subHooks: [],
169197
},
170198
],
171199
},
172200
{
201+
isStateEditable: false,
202+
id: null,
173203
name: 'Baz',
174204
value: undefined,
175205
subHooks: [
176206
{
207+
isStateEditable: false,
208+
id: 3,
177209
name: 'LayoutEffect',
178210
value: effect,
179211
subHooks: [],
180212
},
181213
{
214+
isStateEditable: false,
215+
id: null,
182216
name: 'Custom',
183217
subHooks: [
184218
{
219+
isStateEditable: true,
220+
id: 4,
185221
name: 'Reducer',
186222
subHooks: [],
187223
value: 'world',
188224
},
189225
{
226+
isStateEditable: false,
227+
id: 5,
190228
name: 'Effect',
191229
subHooks: [],
192230
value: effect,
@@ -208,6 +246,8 @@ describe('ReactHooksInspection', () => {
208246
let tree = ReactDebugTools.inspectHooks(Foo, {});
209247
expect(tree).toEqual([
210248
{
249+
isStateEditable: false,
250+
id: null,
211251
name: 'Context',
212252
value: 'default',
213253
subHooks: [],
@@ -270,9 +310,19 @@ describe('ReactHooksInspection', () => {
270310
let tree = ReactDebugTools.inspectHooks(Foo, {});
271311
expect(tree).toEqual([
272312
{
313+
isStateEditable: false,
314+
id: null,
273315
name: 'Custom',
274316
value: __DEV__ ? 'bar:123' : undefined,
275-
subHooks: [{name: 'State', subHooks: [], value: 0}],
317+
subHooks: [
318+
{
319+
isStateEditable: true,
320+
id: 0,
321+
name: 'State',
322+
subHooks: [],
323+
value: 0,
324+
},
325+
],
276326
},
277327
]);
278328
});

0 commit comments

Comments
 (0)