Skip to content

Commit 7a32d71

Browse files
authored
[Debug Tools] Introspect Promises in use() (#28297)
Alternative to #28295. Instead of stashing all of the Usables eagerly, we can extract them by replaying the render when we need them like we do with any other hook. We already had an implementation of `use()` but it wasn't quite complete. These can also include further DebugInfo on them such as what Server Component rendered the Promise or async debug info. This is nice just to see which use() calls were made in the side-panel but it can also be used to gather everything that might have suspended. Together with #28286 we cover the case when a Promise was used a child and if it was unwrapped with use(). Notably we don't cover a Promise that was thrown (although we do support that in a Server Component which maybe we shouldn't). Throwing a Promise isn't officially supported though and that use case should move to the use() Hook. The pattern of conditionally suspending based on cache also isn't really supported with the use() pattern. You should always call use() if you previously called use() with the same input. This also ensures that we can track what might have suspended rather than what actually did. One limitation of this strategy is that it's hard to find all the places something might suspend in a tree without rerendering all the fibers again. So we might need to still add something to the tree to indicate which Fibers may have further debug info / thenables.
1 parent 3f93ca1 commit 7a32d71

File tree

4 files changed

+347
-16
lines changed

4 files changed

+347
-16
lines changed

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

Lines changed: 120 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import type {
1313
ReactProviderType,
1414
StartTransitionOptions,
1515
Usable,
16+
Thenable,
17+
ReactDebugInfo,
1618
} from 'shared/ReactTypes';
1719
import type {
1820
Fiber,
@@ -41,6 +43,7 @@ type HookLogEntry = {
4143
primitive: string,
4244
stackError: Error,
4345
value: mixed,
46+
debugInfo: ReactDebugInfo | null,
4447
};
4548

4649
let hookLog: Array<HookLogEntry> = [];
@@ -93,6 +96,27 @@ function getPrimitiveStackCache(): Map<string, Array<any>> {
9396
// This type check is for Flow only.
9497
Dispatcher.useFormState((s: mixed, p: mixed) => s, null);
9598
}
99+
if (typeof Dispatcher.use === 'function') {
100+
// This type check is for Flow only.
101+
Dispatcher.use(
102+
({
103+
$$typeof: REACT_CONTEXT_TYPE,
104+
_currentValue: null,
105+
}: any),
106+
);
107+
Dispatcher.use({
108+
then() {},
109+
status: 'fulfilled',
110+
value: null,
111+
});
112+
try {
113+
Dispatcher.use(
114+
({
115+
then() {},
116+
}: any),
117+
);
118+
} catch (x) {}
119+
}
96120
} finally {
97121
readHookLog = hookLog;
98122
hookLog = [];
@@ -122,22 +146,57 @@ function readContext<T>(context: ReactContext<T>): T {
122146
return context._currentValue;
123147
}
124148

149+
const SuspenseException: mixed = new Error(
150+
"Suspense Exception: This is not a real error! It's an implementation " +
151+
'detail of `use` to interrupt the current render. You must either ' +
152+
'rethrow it immediately, or move the `use` call outside of the ' +
153+
'`try/catch` block. Capturing without rethrowing will lead to ' +
154+
'unexpected behavior.\n\n' +
155+
'To handle async errors, wrap your component in an error boundary, or ' +
156+
"call the promise's `.catch` method and pass the result to `use`",
157+
);
158+
125159
function use<T>(usable: Usable<T>): T {
126160
if (usable !== null && typeof usable === 'object') {
127161
// $FlowFixMe[method-unbinding]
128162
if (typeof usable.then === 'function') {
129-
// TODO: What should this do if it receives an unresolved promise?
130-
throw new Error(
131-
'Support for `use(Promise)` not yet implemented in react-debug-tools.',
132-
);
163+
const thenable: Thenable<any> = (usable: any);
164+
switch (thenable.status) {
165+
case 'fulfilled': {
166+
const fulfilledValue: T = thenable.value;
167+
hookLog.push({
168+
primitive: 'Promise',
169+
stackError: new Error(),
170+
value: fulfilledValue,
171+
debugInfo:
172+
thenable._debugInfo === undefined ? null : thenable._debugInfo,
173+
});
174+
return fulfilledValue;
175+
}
176+
case 'rejected': {
177+
const rejectedError = thenable.reason;
178+
throw rejectedError;
179+
}
180+
}
181+
// If this was an uncached Promise we have to abandon this attempt
182+
// but we can still emit anything up until this point.
183+
hookLog.push({
184+
primitive: 'Unresolved',
185+
stackError: new Error(),
186+
value: thenable,
187+
debugInfo:
188+
thenable._debugInfo === undefined ? null : thenable._debugInfo,
189+
});
190+
throw SuspenseException;
133191
} else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
134192
const context: ReactContext<T> = (usable: any);
135193
const value = readContext(context);
136194

137195
hookLog.push({
138-
primitive: 'Use',
196+
primitive: 'Context (use)',
139197
stackError: new Error(),
140198
value,
199+
debugInfo: null,
141200
});
142201

143202
return value;
@@ -153,6 +212,7 @@ function useContext<T>(context: ReactContext<T>): T {
153212
primitive: 'Context',
154213
stackError: new Error(),
155214
value: context._currentValue,
215+
debugInfo: null,
156216
});
157217
return context._currentValue;
158218
}
@@ -168,7 +228,12 @@ function useState<S>(
168228
? // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
169229
initialState()
170230
: initialState;
171-
hookLog.push({primitive: 'State', stackError: new Error(), value: state});
231+
hookLog.push({
232+
primitive: 'State',
233+
stackError: new Error(),
234+
value: state,
235+
debugInfo: null,
236+
});
172237
return [state, (action: BasicStateAction<S>) => {}];
173238
}
174239

@@ -188,6 +253,7 @@ function useReducer<S, I, A>(
188253
primitive: 'Reducer',
189254
stackError: new Error(),
190255
value: state,
256+
debugInfo: null,
191257
});
192258
return [state, (action: A) => {}];
193259
}
@@ -199,6 +265,7 @@ function useRef<T>(initialValue: T): {current: T} {
199265
primitive: 'Ref',
200266
stackError: new Error(),
201267
value: ref.current,
268+
debugInfo: null,
202269
});
203270
return ref;
204271
}
@@ -209,6 +276,7 @@ function useCacheRefresh(): () => void {
209276
primitive: 'CacheRefresh',
210277
stackError: new Error(),
211278
value: hook !== null ? hook.memoizedState : function refresh() {},
279+
debugInfo: null,
212280
});
213281
return () => {};
214282
}
@@ -222,6 +290,7 @@ function useLayoutEffect(
222290
primitive: 'LayoutEffect',
223291
stackError: new Error(),
224292
value: create,
293+
debugInfo: null,
225294
});
226295
}
227296

@@ -234,6 +303,7 @@ function useInsertionEffect(
234303
primitive: 'InsertionEffect',
235304
stackError: new Error(),
236305
value: create,
306+
debugInfo: null,
237307
});
238308
}
239309

@@ -242,7 +312,12 @@ function useEffect(
242312
inputs: Array<mixed> | void | null,
243313
): void {
244314
nextHook();
245-
hookLog.push({primitive: 'Effect', stackError: new Error(), value: create});
315+
hookLog.push({
316+
primitive: 'Effect',
317+
stackError: new Error(),
318+
value: create,
319+
debugInfo: null,
320+
});
246321
}
247322

248323
function useImperativeHandle<T>(
@@ -263,6 +338,7 @@ function useImperativeHandle<T>(
263338
primitive: 'ImperativeHandle',
264339
stackError: new Error(),
265340
value: instance,
341+
debugInfo: null,
266342
});
267343
}
268344

@@ -271,6 +347,7 @@ function useDebugValue(value: any, formatterFn: ?(value: any) => any) {
271347
primitive: 'DebugValue',
272348
stackError: new Error(),
273349
value: typeof formatterFn === 'function' ? formatterFn(value) : value,
350+
debugInfo: null,
274351
});
275352
}
276353

@@ -280,6 +357,7 @@ function useCallback<T>(callback: T, inputs: Array<mixed> | void | null): T {
280357
primitive: 'Callback',
281358
stackError: new Error(),
282359
value: hook !== null ? hook.memoizedState[0] : callback,
360+
debugInfo: null,
283361
});
284362
return callback;
285363
}
@@ -290,7 +368,12 @@ function useMemo<T>(
290368
): T {
291369
const hook = nextHook();
292370
const value = hook !== null ? hook.memoizedState[0] : nextCreate();
293-
hookLog.push({primitive: 'Memo', stackError: new Error(), value});
371+
hookLog.push({
372+
primitive: 'Memo',
373+
stackError: new Error(),
374+
value,
375+
debugInfo: null,
376+
});
294377
return value;
295378
}
296379

@@ -309,6 +392,7 @@ function useSyncExternalStore<T>(
309392
primitive: 'SyncExternalStore',
310393
stackError: new Error(),
311394
value,
395+
debugInfo: null,
312396
});
313397
return value;
314398
}
@@ -326,6 +410,7 @@ function useTransition(): [
326410
primitive: 'Transition',
327411
stackError: new Error(),
328412
value: undefined,
413+
debugInfo: null,
329414
});
330415
return [false, callback => {}];
331416
}
@@ -336,6 +421,7 @@ function useDeferredValue<T>(value: T, initialValue?: T): T {
336421
primitive: 'DeferredValue',
337422
stackError: new Error(),
338423
value: hook !== null ? hook.memoizedState : value,
424+
debugInfo: null,
339425
});
340426
return value;
341427
}
@@ -347,6 +433,7 @@ function useId(): string {
347433
primitive: 'Id',
348434
stackError: new Error(),
349435
value: id,
436+
debugInfo: null,
350437
});
351438
return id;
352439
}
@@ -395,6 +482,7 @@ function useOptimistic<S, A>(
395482
primitive: 'Optimistic',
396483
stackError: new Error(),
397484
value: state,
485+
debugInfo: null,
398486
});
399487
return [state, (action: A) => {}];
400488
}
@@ -416,6 +504,7 @@ function useFormState<S, P>(
416504
primitive: 'FormState',
417505
stackError: new Error(),
418506
value: state,
507+
debugInfo: null,
419508
});
420509
return [state, (payload: P) => {}];
421510
}
@@ -480,6 +569,7 @@ export type HooksNode = {
480569
name: string,
481570
value: mixed,
482571
subHooks: Array<HooksNode>,
572+
debugInfo: null | ReactDebugInfo,
483573
hookSource?: HookSource,
484574
};
485575
export type HooksTree = Array<HooksNode>;
@@ -546,6 +636,15 @@ function isReactWrapper(functionName: any, primitiveName: string) {
546636
if (!functionName) {
547637
return false;
548638
}
639+
switch (primitiveName) {
640+
case 'Context':
641+
case 'Context (use)':
642+
case 'Promise':
643+
case 'Unresolved':
644+
if (functionName.endsWith('use')) {
645+
return true;
646+
}
647+
}
549648
const expectedPrimitiveName = 'use' + primitiveName;
550649
if (functionName.length < expectedPrimitiveName.length) {
551650
return false;
@@ -661,6 +760,7 @@ function buildTree(
661760
name: parseCustomHookName(stack[j - 1].functionName),
662761
value: undefined,
663762
subHooks: children,
763+
debugInfo: null,
664764
};
665765

666766
if (includeHooksSource) {
@@ -678,25 +778,29 @@ function buildTree(
678778
}
679779
prevStack = stack;
680780
}
681-
const {primitive} = hook;
781+
const {primitive, debugInfo} = hook;
682782

683783
// For now, the "id" of stateful hooks is just the stateful hook index.
684784
// Custom hooks have no ids, nor do non-stateful native hooks (e.g. Context, DebugValue).
685785
const id =
686786
primitive === 'Context' ||
787+
primitive === 'Context (use)' ||
687788
primitive === 'DebugValue' ||
688-
primitive === 'Use'
789+
primitive === 'Promise' ||
790+
primitive === 'Unresolved'
689791
? null
690792
: nativeHookID++;
691793

692794
// For the time being, only State and Reducer hooks support runtime overrides.
693795
const isStateEditable = primitive === 'Reducer' || primitive === 'State';
796+
const name = primitive === 'Context (use)' ? 'Context' : primitive;
694797
const levelChild: HooksNode = {
695798
id,
696799
isStateEditable,
697-
name: primitive,
800+
name: name,
698801
value: hook.value,
699802
subHooks: [],
803+
debugInfo: debugInfo,
700804
};
701805

702806
if (includeHooksSource) {
@@ -762,6 +866,11 @@ function processDebugValues(
762866

763867
function handleRenderFunctionError(error: any): void {
764868
// original error might be any type.
869+
if (error === SuspenseException) {
870+
// An uncached Promise was used. We can't synchronously resolve the rest of
871+
// the Hooks but we can at least show what ever we got so far.
872+
return;
873+
}
765874
if (
766875
error instanceof Error &&
767876
error.name === 'ReactDebugToolsUnsupportedHookError'

0 commit comments

Comments
 (0)