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

5 files changed

Lines changed: 567 additions & 38 deletions

File tree

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

Lines changed: 20 additions & 1 deletion
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
letrootChildren=[];
374376
letprevStack=null;
375377
letlevelChildren=rootChildren;
378+
letnativeHookID=0;
376379
letstackOfChildren=[];
377380
for(leti=0;i<readHookLog.length;i++){
378381
lethook=readHookLog[i];
@@ -403,6 +406,8 @@ function buildTree(rootStack, readHookLog): HooksTree {
403406
for(letj=stack.length-commonSteps-1;j>=1;j--){
404407
letchildren=[];
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+
constid=
425+
primitive==='Context'||primitive==='DebugValue'
426+
? null
427+
: nativeHookID++;
428+
429+
// For the time being, only State and Reducer hooks support runtime overrides.
430+
constisStateEditable=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
});
Lines changed: 176 additions & 0 deletions
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+
letReact;
15+
letReactDebugTools;
16+
letReactTestRenderer;
17+
letact;
18+
letoverrideHookState;
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+
letsetCountFn;
41+
42+
functionMyComponent(){
43+
const[count,setCount]=React.useState(0);
44+
setCountFn=setCount;
45+
return<div>count:{count}</div>;
46+
}
47+
48+
constrenderer=ReactTestRenderer.create(<MyComponent/>);
49+
expect(renderer.toJSON()).toEqual({
50+
type: 'div',
51+
props: {},
52+
children: ['count:','0'],
53+
});
54+
55+
constfiber=renderer.root.findByType(MyComponent)._currentFiber();
56+
consttree=ReactDebugTools.inspectHooksOfFiber(fiber);
57+
conststateHook=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+
constinitialData={foo: 'abc',bar: 123};
79+
80+
functionreducer(state,action){
81+
switch(action.type){
82+
case'swap':
83+
return{foo: state.bar,bar: state.foo};
84+
default:
85+
thrownewError();
86+
}
87+
}
88+
89+
letdispatchFn;
90+
functionMyComponent(){
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+
constrenderer=ReactTestRenderer.create(<MyComponent/>);
101+
expect(renderer.toJSON()).toEqual({
102+
type: 'div',
103+
props: {},
104+
children: ['foo:','abc',', bar:','123'],
105+
});
106+
107+
constfiber=renderer.root.findByType(MyComponent)._currentFiber();
108+
consttree=ReactDebugTools.inspectHooksOfFiber(fiber);
109+
constreducerHook=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+
constMyContext=React.createContext(1);
133+
134+
letsetStateFn;
135+
functionuseCustomHook(){
136+
constcontext=React.useContext(MyContext);
137+
const[state,setState]=React.useState({count: context});
138+
React.useDebugValue(state.count);
139+
setStateFn=setState;
140+
returnstate.count;
141+
}
142+
143+
functionMyComponent(){
144+
constcount=useCustomHook();
145+
return<div>count:{count}</div>;
146+
}
147+
148+
constrenderer=ReactTestRenderer.create(<MyComponent/>);
149+
expect(renderer.toJSON()).toEqual({
150+
type: 'div',
151+
props: {},
152+
children: ['count:','1'],
153+
});
154+
155+
constfiber=renderer.root.findByType(MyComponent)._currentFiber();
156+
consttree=ReactDebugTools.inspectHooksOfFiber(fiber);
157+
conststateHook=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‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ describe('ReactHooksInspection', () => {
2828
lettree=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
lettree=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
lettree=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
lettree=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
lettree=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
lettree=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)