Skip to content

Commit

Permalink
[Debug Tools] Introspect Promises in use() (facebook#28297)
Browse files Browse the repository at this point in the history
Alternative to facebook#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 facebook#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.
  • Loading branch information
sebmarkbage authored and AndyPengc12 committed Apr 15, 2024
1 parent ffc250e commit 1f033bd
Show file tree
Hide file tree
Showing 4 changed files with 347 additions and 16 deletions.
131 changes: 120 additions & 11 deletions packages/react-debug-tools/src/ReactDebugHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import type {
ReactProviderType,
StartTransitionOptions,
Usable,
Thenable,
ReactDebugInfo,
} from 'shared/ReactTypes';
import type {
Fiber,
Expand Down Expand Up @@ -41,6 +43,7 @@ type HookLogEntry = {
primitive: string,
stackError: Error,
value: mixed,
debugInfo: ReactDebugInfo | null,
};

let hookLog: Array<HookLogEntry> = [];
Expand Down Expand Up @@ -93,6 +96,27 @@ function getPrimitiveStackCache(): Map<string, Array<any>> {
// This type check is for Flow only.
Dispatcher.useFormState((s: mixed, p: mixed) => s, null);
}
if (typeof Dispatcher.use === 'function') {
// This type check is for Flow only.
Dispatcher.use(
({
$$typeof: REACT_CONTEXT_TYPE,
_currentValue: null,
}: any),
);
Dispatcher.use({
then() {},
status: 'fulfilled',
value: null,
});
try {
Dispatcher.use(
({
then() {},
}: any),
);
} catch (x) {}
}
} finally {
readHookLog = hookLog;
hookLog = [];
Expand Down Expand Up @@ -122,22 +146,57 @@ function readContext<T>(context: ReactContext<T>): T {
return context._currentValue;
}

const SuspenseException: mixed = new Error(
"Suspense Exception: This is not a real error! It's an implementation " +
'detail of `use` to interrupt the current render. You must either ' +
'rethrow it immediately, or move the `use` call outside of the ' +
'`try/catch` block. Capturing without rethrowing will lead to ' +
'unexpected behavior.\n\n' +
'To handle async errors, wrap your component in an error boundary, or ' +
"call the promise's `.catch` method and pass the result to `use`",
);

function use<T>(usable: Usable<T>): T {
if (usable !== null && typeof usable === 'object') {
// $FlowFixMe[method-unbinding]
if (typeof usable.then === 'function') {
// TODO: What should this do if it receives an unresolved promise?
throw new Error(
'Support for `use(Promise)` not yet implemented in react-debug-tools.',
);
const thenable: Thenable<any> = (usable: any);
switch (thenable.status) {
case 'fulfilled': {
const fulfilledValue: T = thenable.value;
hookLog.push({
primitive: 'Promise',
stackError: new Error(),
value: fulfilledValue,
debugInfo:
thenable._debugInfo === undefined ? null : thenable._debugInfo,
});
return fulfilledValue;
}
case 'rejected': {
const rejectedError = thenable.reason;
throw rejectedError;
}
}
// If this was an uncached Promise we have to abandon this attempt
// but we can still emit anything up until this point.
hookLog.push({
primitive: 'Unresolved',
stackError: new Error(),
value: thenable,
debugInfo:
thenable._debugInfo === undefined ? null : thenable._debugInfo,
});
throw SuspenseException;
} else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
const context: ReactContext<T> = (usable: any);
const value = readContext(context);

hookLog.push({
primitive: 'Use',
primitive: 'Context (use)',
stackError: new Error(),
value,
debugInfo: null,
});

return value;
Expand All @@ -153,6 +212,7 @@ function useContext<T>(context: ReactContext<T>): T {
primitive: 'Context',
stackError: new Error(),
value: context._currentValue,
debugInfo: null,
});
return context._currentValue;
}
Expand All @@ -168,7 +228,12 @@ function useState<S>(
? // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
initialState()
: initialState;
hookLog.push({primitive: 'State', stackError: new Error(), value: state});
hookLog.push({
primitive: 'State',
stackError: new Error(),
value: state,
debugInfo: null,
});
return [state, (action: BasicStateAction<S>) => {}];
}

Expand All @@ -188,6 +253,7 @@ function useReducer<S, I, A>(
primitive: 'Reducer',
stackError: new Error(),
value: state,
debugInfo: null,
});
return [state, (action: A) => {}];
}
Expand All @@ -199,6 +265,7 @@ function useRef<T>(initialValue: T): {current: T} {
primitive: 'Ref',
stackError: new Error(),
value: ref.current,
debugInfo: null,
});
return ref;
}
Expand All @@ -209,6 +276,7 @@ function useCacheRefresh(): () => void {
primitive: 'CacheRefresh',
stackError: new Error(),
value: hook !== null ? hook.memoizedState : function refresh() {},
debugInfo: null,
});
return () => {};
}
Expand All @@ -222,6 +290,7 @@ function useLayoutEffect(
primitive: 'LayoutEffect',
stackError: new Error(),
value: create,
debugInfo: null,
});
}

Expand All @@ -234,6 +303,7 @@ function useInsertionEffect(
primitive: 'InsertionEffect',
stackError: new Error(),
value: create,
debugInfo: null,
});
}

Expand All @@ -242,7 +312,12 @@ function useEffect(
inputs: Array<mixed> | void | null,
): void {
nextHook();
hookLog.push({primitive: 'Effect', stackError: new Error(), value: create});
hookLog.push({
primitive: 'Effect',
stackError: new Error(),
value: create,
debugInfo: null,
});
}

function useImperativeHandle<T>(
Expand All @@ -263,6 +338,7 @@ function useImperativeHandle<T>(
primitive: 'ImperativeHandle',
stackError: new Error(),
value: instance,
debugInfo: null,
});
}

Expand All @@ -271,6 +347,7 @@ function useDebugValue(value: any, formatterFn: ?(value: any) => any) {
primitive: 'DebugValue',
stackError: new Error(),
value: typeof formatterFn === 'function' ? formatterFn(value) : value,
debugInfo: null,
});
}

Expand All @@ -280,6 +357,7 @@ function useCallback<T>(callback: T, inputs: Array<mixed> | void | null): T {
primitive: 'Callback',
stackError: new Error(),
value: hook !== null ? hook.memoizedState[0] : callback,
debugInfo: null,
});
return callback;
}
Expand All @@ -290,7 +368,12 @@ function useMemo<T>(
): T {
const hook = nextHook();
const value = hook !== null ? hook.memoizedState[0] : nextCreate();
hookLog.push({primitive: 'Memo', stackError: new Error(), value});
hookLog.push({
primitive: 'Memo',
stackError: new Error(),
value,
debugInfo: null,
});
return value;
}

Expand All @@ -309,6 +392,7 @@ function useSyncExternalStore<T>(
primitive: 'SyncExternalStore',
stackError: new Error(),
value,
debugInfo: null,
});
return value;
}
Expand All @@ -326,6 +410,7 @@ function useTransition(): [
primitive: 'Transition',
stackError: new Error(),
value: undefined,
debugInfo: null,
});
return [false, callback => {}];
}
Expand All @@ -336,6 +421,7 @@ function useDeferredValue<T>(value: T, initialValue?: T): T {
primitive: 'DeferredValue',
stackError: new Error(),
value: hook !== null ? hook.memoizedState : value,
debugInfo: null,
});
return value;
}
Expand All @@ -347,6 +433,7 @@ function useId(): string {
primitive: 'Id',
stackError: new Error(),
value: id,
debugInfo: null,
});
return id;
}
Expand Down Expand Up @@ -395,6 +482,7 @@ function useOptimistic<S, A>(
primitive: 'Optimistic',
stackError: new Error(),
value: state,
debugInfo: null,
});
return [state, (action: A) => {}];
}
Expand All @@ -416,6 +504,7 @@ function useFormState<S, P>(
primitive: 'FormState',
stackError: new Error(),
value: state,
debugInfo: null,
});
return [state, (payload: P) => {}];
}
Expand Down Expand Up @@ -480,6 +569,7 @@ export type HooksNode = {
name: string,
value: mixed,
subHooks: Array<HooksNode>,
debugInfo: null | ReactDebugInfo,
hookSource?: HookSource,
};
export type HooksTree = Array<HooksNode>;
Expand Down Expand Up @@ -546,6 +636,15 @@ function isReactWrapper(functionName: any, primitiveName: string) {
if (!functionName) {
return false;
}
switch (primitiveName) {
case 'Context':
case 'Context (use)':
case 'Promise':
case 'Unresolved':
if (functionName.endsWith('use')) {
return true;
}
}
const expectedPrimitiveName = 'use' + primitiveName;
if (functionName.length < expectedPrimitiveName.length) {
return false;
Expand Down Expand Up @@ -661,6 +760,7 @@ function buildTree(
name: parseCustomHookName(stack[j - 1].functionName),
value: undefined,
subHooks: children,
debugInfo: null,
};

if (includeHooksSource) {
Expand All @@ -678,25 +778,29 @@ function buildTree(
}
prevStack = stack;
}
const {primitive} = hook;
const {primitive, debugInfo} = hook;

// For now, the "id" of stateful hooks is just the stateful hook index.
// Custom hooks have no ids, nor do non-stateful native hooks (e.g. Context, DebugValue).
const id =
primitive === 'Context' ||
primitive === 'Context (use)' ||
primitive === 'DebugValue' ||
primitive === 'Use'
primitive === 'Promise' ||
primitive === 'Unresolved'
? null
: nativeHookID++;

// For the time being, only State and Reducer hooks support runtime overrides.
const isStateEditable = primitive === 'Reducer' || primitive === 'State';
const name = primitive === 'Context (use)' ? 'Context' : primitive;
const levelChild: HooksNode = {
id,
isStateEditable,
name: primitive,
name: name,
value: hook.value,
subHooks: [],
debugInfo: debugInfo,
};

if (includeHooksSource) {
Expand Down Expand Up @@ -762,6 +866,11 @@ function processDebugValues(

function handleRenderFunctionError(error: any): void {
// original error might be any type.
if (error === SuspenseException) {
// An uncached Promise was used. We can't synchronously resolve the rest of
// the Hooks but we can at least show what ever we got so far.
return;
}
if (
error instanceof Error &&
error.name === 'ReactDebugToolsUnsupportedHookError'
Expand Down
Loading

0 comments on commit 1f033bd

Please sign in to comment.