Skip to content

Commit edb1f59

Browse files
authored
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
1 parent 3e15b1c commit edb1f59

File tree

7 files changed

+249
-2
lines changed

7 files changed

+249
-2
lines changed

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

+49-1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ function getPrimitiveStackCache(): Map<string, Array<any>> {
5555
Dispatcher.useLayoutEffect(() => {});
5656
Dispatcher.useEffect(() => {});
5757
Dispatcher.useImperativeHandle(undefined, () => null);
58+
Dispatcher.useDebugValue(null);
5859
Dispatcher.useCallback(() => {});
5960
Dispatcher.useMemo(() => null);
6061
} finally {
@@ -180,6 +181,14 @@ function useImperativeHandle<T>(
180181
});
181182
}
182183

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+
183192
function useCallback<T>(callback: T, inputs: Array<mixed> | void | null): T {
184193
let hook = nextHook();
185194
hookLog.push({
@@ -206,6 +215,7 @@ const Dispatcher = {
206215
useContext,
207216
useEffect,
208217
useImperativeHandle,
218+
useDebugValue,
209219
useLayoutEffect,
210220
useMemo,
211221
useReducer,
@@ -388,7 +398,7 @@ function buildTree(rootStack, readHookLog): HooksTree {
388398
let children = [];
389399
levelChildren.push({
390400
name: parseCustomHookName(stack[j - 1].functionName),
391-
value: undefined, // TODO: Support custom inspectable values.
401+
value: undefined,
392402
subHooks: children,
393403
});
394404
stackOfChildren.push(levelChildren);
@@ -402,9 +412,47 @@ function buildTree(rootStack, readHookLog): HooksTree {
402412
subHooks: [],
403413
});
404414
}
415+
416+
// Associate custom hook values (useDebugValue() hook entries) with the correct hooks.
417+
processDebugValues(rootChildren, null);
418+
405419
return rootChildren;
406420
}
407421

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+
408456
export function inspectHooks<Props>(
409457
renderFunction: Props => React$Node,
410458
props: Props,

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

+32-1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ describe('ReactHooksInspection', () => {
4141
it('should inspect a simple custom hook', () => {
4242
function useCustom(value) {
4343
let [state] = React.useState(value);
44+
React.useDebugValue('custom hook label');
4445
return state;
4546
}
4647
function Foo(props) {
@@ -51,7 +52,7 @@ describe('ReactHooksInspection', () => {
5152
expect(tree).toEqual([
5253
{
5354
name: 'Custom',
54-
value: undefined,
55+
value: __DEV__ ? 'custom hook label' : undefined,
5556
subHooks: [
5657
{
5758
name: 'State',
@@ -249,4 +250,34 @@ describe('ReactHooksInspection', () => {
249250
expect(setterCalls[0]).not.toBe(initial);
250251
expect(setterCalls[1]).toBe(initial);
251252
});
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+
});
252283
});

packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.internal.js

+148
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,154 @@ describe('ReactHooksInspectionIntergration', () => {
212212
]);
213213
});
214214

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+
215363
it('should support defaultProps and lazy', async () => {
216364
let Suspense = React.Suspense;
217365

packages/react-reconciler/src/ReactFiberDispatcher.js

+2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
useContext,
1414
useEffect,
1515
useImperativeHandle,
16+
useDebugValue,
1617
useLayoutEffect,
1718
useMemo,
1819
useReducer,
@@ -26,6 +27,7 @@ export const Dispatcher = {
2627
useContext,
2728
useEffect,
2829
useImperativeHandle,
30+
useDebugValue,
2931
useLayoutEffect,
3032
useMemo,
3133
useReducer,

packages/react-reconciler/src/ReactFiberHooks.js

+9
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,15 @@ export function useImperativeHandle<T>(
588588
}, nextInputs);
589589
}
590590

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+
591600
export function useCallback<T>(
592601
callback: T,
593602
inputs: Array<mixed> | void | null,

packages/react/src/React.js

+2
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
useContext,
3434
useEffect,
3535
useImperativeHandle,
36+
useDebugValue,
3637
useLayoutEffect,
3738
useMemo,
3839
useReducer,
@@ -99,6 +100,7 @@ if (enableHooks) {
99100
React.useContext = useContext;
100101
React.useEffect = useEffect;
101102
React.useImperativeHandle = useImperativeHandle;
103+
React.useDebugValue = useDebugValue;
102104
React.useLayoutEffect = useLayoutEffect;
103105
React.useMemo = useMemo;
104106
React.useReducer = useReducer;

packages/react/src/ReactHooks.js

+7
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,10 @@ export function useImperativeHandle<T>(
110110
const dispatcher = resolveDispatcher();
111111
return dispatcher.useImperativeHandle(ref, create, inputs);
112112
}
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+
}

0 commit comments

Comments
 (0)