Skip to content

Commit

Permalink
Introspect use() Hooks with Debug Tools
Browse files Browse the repository at this point in the history
  • Loading branch information
sebmarkbage committed Feb 10, 2024
1 parent b7d4ccb commit ff29e08
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 ff29e08

Please sign in to comment.