Support configurable labels for custom hooks (#14559)
* react-debug-tools accepts currentDispatcher ref as param * ReactDebugHooks injected dispatcher ref is optional * Support custom values for custom hooks * PR feedback: 1. Renamed useDebugValueLabel hook to useDebugValue 2. Wrapped useDebugValue internals in if-DEV so that it could be removed from production builds. * PR feedback: 1. Fixed some minor typos 2. Added inline comment explaining the purpose of rollupDebugValues() 3. Refactored rollupDebugValues() to use a for loop rather than filter() 4. Improve check for useDebugValue hook to lessen the chance of a false positive 5. Added optional formatter function param to useDebugValue * Nitpick renamed a method
Brian Vaughn committed
Jan 14, 2019 at 14:53 UTC
edb1f595649b013a59a18f43c03a57035ddea19e
7 files changed
+249
-2
packages/react-debug-tools/src/ReactDebugHooks.js
+49
-1
@@ -55,6 +55,7 @@ function getPrimitiveStackCache(): Map<string, Array<any>> {
55
Dispatcher.useLayoutEffect(() => {});
56
Dispatcher.useEffect(() => {});
57
Dispatcher.useImperativeHandle(undefined, () => null);
58
+ Dispatcher.useDebugValue(null);
59
Dispatcher.useCallback(() => {});
60
Dispatcher.useMemo(() => null);
61
} finally {
@@ -180,6 +181,14 @@ function useImperativeHandle<T>(
181
});
182
}
183
184
+function useDebugValue(value: any, formatterFn: ?(value: any) => any) {
185
+ hookLog.push({
186
+ primitive: 'DebugValue',
187
+ stackError: new Error(),
188
+ value: typeof formatterFn === 'function' ? formatterFn(value) : value,
189
+ });
190
+}
191
+
192
function useCallback<T>(callback: T, inputs: Array<mixed> | void | null): T {
193
let hook = nextHook();
194
hookLog.push({
@@ -206,6 +215,7 @@ const Dispatcher = {
215
useContext,
216
useEffect,
217
useImperativeHandle,
218
+ useDebugValue,
219
useLayoutEffect,
220
useMemo,
221
useReducer,
@@ -388,7 +398,7 @@ function buildTree(rootStack, readHookLog): HooksTree {
398
let children = [];
399
levelChildren.push({
400
name: parseCustomHookName(stack[j - 1].functionName),
391
- value: undefined, // TODO: Support custom inspectable values.
401
+ value: undefined,
402
subHooks: children,
403
});
404
stackOfChildren.push(levelChildren);
@@ -402,9 +412,47 @@ function buildTree(rootStack, readHookLog): HooksTree {
412
subHooks: [],
413
});
414
}
415
+
416
+ // Associate custom hook values (useDebugValue() hook entries) with the correct hooks.
417
+ processDebugValues(rootChildren, null);
418
+
419
return rootChildren;
420
}
421
422
+// Custom hooks support user-configurable labels (via the special useDebugValue() hook).
423
+// That hook adds user-provided values to the hooks tree,
424
+// but these values aren't intended to appear alongside of the other hooks.
425
+// Instead they should be attributed to their parent custom hook.
426
+// This method walks the tree and assigns debug values to their custom hook owners.
427
+function processDebugValues(
428
+ hooksTree: HooksTree,
429
+ parentHooksNode: HooksNode | null,
430
+): void {
431
+ let debugValueHooksNodes: Array<HooksNode> = [];
432
+
433
+ for (let i = 0; i < hooksTree.length; i++) {
434
+ const hooksNode = hooksTree[i];
435
+ if (hooksNode.name === 'DebugValue' && hooksNode.subHooks.length === 0) {
436
+ hooksTree.splice(i, 1);
437
+ i--;
438
+ debugValueHooksNodes.push(hooksNode);
439
+ } else {
440
+ processDebugValues(hooksNode.subHooks, hooksNode);
441
+ }
442
+ }
443
+
444
+ // Bubble debug value labels to their custom hook owner.
445
+ // If there is no parent hook, just ignore them for now.
446
+ // (We may warn about this in the future.)
447
+ if (parentHooksNode !== null) {
448
+ if (debugValueHooksNodes.length === 1) {
449
+ parentHooksNode.value = debugValueHooksNodes[0].value;
450
+ } else if (debugValueHooksNodes.length > 1) {
451
+ parentHooksNode.value = debugValueHooksNodes.map(({value}) => value);
452
+ }
453
+ }
454
+}
455
+
456
export function inspectHooks<Props>(
457
renderFunction: Props => React$Node,
458
props: Props,
packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.internal.js
+32
-1
@@ -41,6 +41,7 @@ describe('ReactHooksInspection', () => {
41
it('should inspect a simple custom hook', () => {
42
function useCustom(value) {
43
let [state] = React.useState(value);
44
+ React.useDebugValue('custom hook label');
45
return state;
46
}
47
function Foo(props) {
@@ -51,7 +52,7 @@ describe('ReactHooksInspection', () => {
52
expect(tree).toEqual([
53
{
54
name: 'Custom',
54
- value: undefined,
55
+ value: __DEV__ ? 'custom hook label' : undefined,
56
subHooks: [
57
{
58
name: 'State',
@@ -249,4 +250,34 @@ describe('ReactHooksInspection', () => {
250
expect(setterCalls[0]).not.toBe(initial);
251
expect(setterCalls[1]).toBe(initial);
252
});
253
+
254
+ describe('useDebugValue', () => {
255
+ it('should be ignored when called outside of a custom hook', () => {
256
+ function Foo(props) {
257
+ React.useDebugValue('this is invalid');
258
+ return null;
259
+ }
260
+ let tree = ReactDebugTools.inspectHooks(Foo, {});
261
+ expect(tree).toHaveLength(0);
262
+ });
263
+
264
+ it('should support an optional formatter function param', () => {
265
+ function useCustom() {
266
+ React.useDebugValue({bar: 123}, object => `bar:${object.bar}`);
267
+ React.useState(0);
268
+ }
269
+ function Foo(props) {
270
+ useCustom();
271
+ return null;
272
+ }
273
+ let tree = ReactDebugTools.inspectHooks(Foo, {});
274
+ expect(tree).toEqual([
275
+ {
276
+ name: 'Custom',
277
+ value: __DEV__ ? 'bar:123' : undefined,
278
+ subHooks: [{name: 'State', subHooks: [], value: 0}],
279
+ },
280
+ ]);
281
+ });
282
+ });
283
});
packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.internal.js
+148
@@ -212,6 +212,154 @@ describe('ReactHooksInspectionIntergration', () => {
212
]);
213
});
214
215
+ describe('useDebugValue', () => {
216
+ it('should support inspectable values for multiple custom hooks', () => {
217
+ function useLabeledValue(label) {
218
+ let [value] = React.useState(label);
219
+ React.useDebugValue(`custom label ${label}`);
220
+ return value;
221
+ }
222
+ function useAnonymous(label) {
223
+ let [value] = React.useState(label);
224
+ return value;
225
+ }
226
+ function Example() {
227
+ useLabeledValue('a');
228
+ React.useState('b');
229
+ useAnonymous('c');
230
+ useLabeledValue('d');
231
+ return null;
232
+ }
233
+ let renderer = ReactTestRenderer.create(<Example />);
234
+ let childFiber = renderer.root.findByType(Example)._currentFiber();
235
+ let tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
236
+ expect(tree).toEqual([
237
+ {
238
+ name: 'LabeledValue',
239
+ value: __DEV__ ? 'custom label a' : undefined,
240
+ subHooks: [{name: 'State', value: 'a', subHooks: []}],
241
+ },
242
+ {
243
+ name: 'State',
244
+ value: 'b',
245
+ subHooks: [],
246
+ },
247
+ {
248
+ name: 'Anonymous',
249
+ value: undefined,
250
+ subHooks: [{name: 'State', value: 'c', subHooks: []}],
251
+ },
252
+ {
253
+ name: 'LabeledValue',
254
+ value: __DEV__ ? 'custom label d' : undefined,
255
+ subHooks: [{name: 'State', value: 'd', subHooks: []}],
256
+ },
257
+ ]);
258
+ });
259
+
260
+ it('should support inspectable values for nested custom hooks', () => {
261
+ function useInner() {
262
+ React.useDebugValue('inner');
263
+ React.useState(0);
264
+ }
265
+ function useOuter() {
266
+ React.useDebugValue('outer');
267
+ useInner();
268
+ }
269
+ function Example() {
270
+ useOuter();
271
+ return null;
272
+ }
273
+ let renderer = ReactTestRenderer.create(<Example />);
274
+ let childFiber = renderer.root.findByType(Example)._currentFiber();
275
+ let tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
276
+ expect(tree).toEqual([
277
+ {
278
+ name: 'Outer',
279
+ value: __DEV__ ? 'outer' : undefined,
280
+ subHooks: [
281
+ {
282
+ name: 'Inner',
283
+ value: __DEV__ ? 'inner' : undefined,
284
+ subHooks: [{name: 'State', value: 0, subHooks: []}],
285
+ },
286
+ ],
287
+ },
288
+ ]);
289
+ });
290
+
291
+ it('should support multiple inspectable values per custom hooks', () => {
292
+ function useMultiLabelCustom() {
293
+ React.useDebugValue('one');
294
+ React.useDebugValue('two');
295
+ React.useDebugValue('three');
296
+ React.useState(0);
297
+ }
298
+ function useSingleLabelCustom(value) {
299
+ React.useDebugValue(`single ${value}`);
300
+ React.useState(0);
301
+ }
302
+ function Example() {
303
+ useSingleLabelCustom('one');
304
+ useMultiLabelCustom();
305
+ useSingleLabelCustom('two');
306
+ return null;
307
+ }
308
+ let renderer = ReactTestRenderer.create(<Example />);
309
+ let childFiber = renderer.root.findByType(Example)._currentFiber();
310
+ let tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
311
+ expect(tree).toEqual([
312
+ {
313
+ name: 'SingleLabelCustom',
314
+ value: __DEV__ ? 'single one' : undefined,
315
+ subHooks: [{name: 'State', value: 0, subHooks: []}],
316
+ },
317
+ {
318
+ name: 'MultiLabelCustom',
319
+ value: __DEV__ ? ['one', 'two', 'three'] : undefined,
320
+ subHooks: [{name: 'State', value: 0, subHooks: []}],
321
+ },
322
+ {
323
+ name: 'SingleLabelCustom',
324
+ value: __DEV__ ? 'single two' : undefined,
325
+ subHooks: [{name: 'State', value: 0, subHooks: []}],
326
+ },
327
+ ]);
328
+ });
329
+
330
+ it('should ignore useDebugValue() made outside of a custom hook', () => {
331
+ function Example() {
332
+ React.useDebugValue('this is invalid');
333
+ return null;
334
+ }
335
+ let renderer = ReactTestRenderer.create(<Example />);
336
+ let childFiber = renderer.root.findByType(Example)._currentFiber();
337
+ let tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
338
+ expect(tree).toHaveLength(0);
339
+ });
340
+
341
+ it('should support an optional formatter function param', () => {
342
+ function useCustom() {
343
+ React.useDebugValue({bar: 123}, object => `bar:${object.bar}`);
344
+ React.useState(0);
345
+ }
346
+ function Example() {
347
+ useCustom();
348
+ return null;
349
+ }
350
+ let renderer = ReactTestRenderer.create(<Example />);
351
+ let childFiber = renderer.root.findByType(Example)._currentFiber();
352
+ let tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
353
+ expect(tree).toEqual([
354
+ {
355
+ name: 'Custom',
356
+ value: __DEV__ ? 'bar:123' : undefined,
357
+ subHooks: [{name: 'State', subHooks: [], value: 0}],
358
+ },
359
+ ]);
360
+ });
361
+ });
362
+
363
it('should support defaultProps and lazy', async () => {
364
let Suspense = React.Suspense;
365
packages/react-reconciler/src/ReactFiberDispatcher.js
+2
@@ -13,6 +13,7 @@ import {
13
useContext,
14
useEffect,
15
useImperativeHandle,
16
+ useDebugValue,
17
useLayoutEffect,
18
useMemo,
19
useReducer,
@@ -26,6 +27,7 @@ export const Dispatcher = {
27
useContext,
28
useEffect,
29
useImperativeHandle,
30
+ useDebugValue,
31
useLayoutEffect,
32
useMemo,
33
useReducer,
packages/react-reconciler/src/ReactFiberHooks.js
+9
@@ -588,6 +588,15 @@ export function useImperativeHandle<T>(
588
}, nextInputs);
589
}
590
591
+export function useDebugValue(
592
+ value: any,
593
+ formatterFn: ?(value: any) => any,
594
+): void {
595
+ // This hook is normally a no-op.
596
+ // The react-debug-hooks package injects its own implementation
597
+ // so that e.g. DevTools can display custom hook values.
598
+}
599
+
600
export function useCallback<T>(
601
callback: T,
602
inputs: Array<mixed> | void | null,
packages/react/src/React.js
+2
@@ -33,6 +33,7 @@ import {
33
useContext,
34
useEffect,
35
useImperativeHandle,
36
+ useDebugValue,
37
useLayoutEffect,
38
useMemo,
39
useReducer,
@@ -99,6 +100,7 @@ if (enableHooks) {
100
React.useContext = useContext;
101
React.useEffect = useEffect;
102
React.useImperativeHandle = useImperativeHandle;
103
+ React.useDebugValue = useDebugValue;
104
React.useLayoutEffect = useLayoutEffect;
105
React.useMemo = useMemo;
106
React.useReducer = useReducer;
packages/react/src/ReactHooks.js
+7
@@ -110,3 +110,10 @@ export function useImperativeHandle<T>(
110
const dispatcher = resolveDispatcher();
111
return dispatcher.useImperativeHandle(ref, create, inputs);
112
}
113
+
114
+export function useDebugValue(value: any, formatterFn: ?(value: any) => any) {
115
+ if (__DEV__) {
116
+ const dispatcher = resolveDispatcher();
117
+ return dispatcher.useDebugValue(value, formatterFn);
118
+ }
119
+}