From 01447446dde6b0e53ff058eaccd9002e98d26a3c Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: Wed, 28 Feb 2024 23:52:59 +0100 Subject: [PATCH] Devtools: Unwrap Promise in useFormState (#28319) --- .../react-debug-tools/src/ReactDebugHooks.js | 55 ++++++++++++++++-- .../app/InspectableElements/CustomHooks.js | 56 +++++++++++++++++-- 2 files changed, 100 insertions(+), 11 deletions(-) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 487aebe630271..446ac8463106b 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -521,19 +521,62 @@ function useFormState( ): [Awaited, (P) => void] { const hook = nextHook(); // FormState nextHook(); // ActionQueue - let state; + const stackError = new Error(); + let value; + let debugInfo = null; + let error = null; + if (hook !== null) { - state = hook.memoizedState; + const actionResult = hook.memoizedState; + if ( + typeof actionResult === 'object' && + actionResult !== null && + // $FlowFixMe[method-unbinding] + typeof actionResult.then === 'function' + ) { + const thenable: Thenable> = (actionResult: any); + switch (thenable.status) { + case 'fulfilled': { + value = thenable.value; + debugInfo = + thenable._debugInfo === undefined ? null : thenable._debugInfo; + break; + } + case 'rejected': { + const rejectedError = thenable.reason; + error = rejectedError; + break; + } + default: + // If this was an uncached Promise we have to abandon this attempt + // but we can still emit anything up until this point. + error = SuspenseException; + debugInfo = + thenable._debugInfo === undefined ? null : thenable._debugInfo; + value = thenable; + } + } else { + value = (actionResult: any); + } } else { - state = initialState; + value = initialState; } + hookLog.push({ displayName: null, primitive: 'FormState', - stackError: new Error(), - value: state, - debugInfo: null, + stackError: stackError, + value: value, + debugInfo: debugInfo, }); + + if (error !== null) { + throw error; + } + + // value being a Thenable is equivalent to error being not null + // i.e. we only reach this point with Awaited + const state = ((value: any): Awaited); return [state, (payload: P) => {}]; } diff --git a/packages/react-devtools-shell/src/app/InspectableElements/CustomHooks.js b/packages/react-devtools-shell/src/app/InspectableElements/CustomHooks.js index aa3a9a6295731..2a845ec12e4e9 100644 --- a/packages/react-devtools-shell/src/app/InspectableElements/CustomHooks.js +++ b/packages/react-devtools-shell/src/app/InspectableElements/CustomHooks.js @@ -120,18 +120,62 @@ function wrapWithHoc(Component: (props: any, ref: React$Ref) => any) { } const HocWithHooks = wrapWithHoc(FunctionWithHooks); +function incrementWithDelay(previousState: number, formData: FormData) { + const incrementDelay = +formData.get('incrementDelay'); + const shouldReject = formData.get('shouldReject'); + const reason = formData.get('reason'); + + return new Promise((resolve, reject) => { + setTimeout(() => { + if (shouldReject) { + reject(reason); + } else { + resolve(previousState + 1); + } + }, incrementDelay); + }); +} + function Forms() { - const [state, formAction] = useFormState((n: number, formData: FormData) => { - return n + 1; - }, 0); + const [state, formAction] = useFormState(incrementWithDelay, 0); return (
- {state} + State: {state}  + +
); } +class ErrorBoundary extends React.Component<{children?: React$Node}> { + state: {error: any} = {error: null}; + static getDerivedStateFromError(error: mixed): {error: any} { + return {error}; + } + componentDidCatch(error: any, info: any) { + console.error(error, info); + } + render(): any { + if (this.state.error) { + return
Error: {String(this.state.error)}
; + } + return this.props.children; + } +} + export default function CustomHooks(): React.Node { return ( @@ -139,7 +183,9 @@ export default function CustomHooks(): React.Node { - + + + ); }