@samitouri / QOS-React / commits / edb1f59564

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 +}