From cf0c5ae3eb97dae857d34cfd6ddadefa3c3bba21 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 11 Aug 2022 16:24:13 -0400 Subject: [PATCH] use(promise) Adds experimental support to Fiber for unwrapping the value of a promise inside a component. It is not yet implemented for Server Components, but that is planned. If promise has already resolved, the value can be unwrapped "immediately" without showing a fallback. The trick we use to implement this is to yield to the main thread (literally suspending the work loop), wait for the microtask queue to drain, then check if the promise resolved in the meantime. If so, we can resume the last attempted fiber without unwinding the stack. This functionality was implemented in previous commits. Another feature is that the promises do not need to be cached between attempts. Because we assume idempotent execution of components, React will track the promises that were used during the previous attempt and reuse the result. You shouldn't rely on this property, but during initial render it mostly just works. Updates are trickier, though, because if you used an uncached promise, we have no way of knowing whether the underlying data has changed, so we have to unwrap the promise every time. It will still work, but it's inefficient and can lead to unnecessary fallbacks if it happens during a discrete update. When we implement this for Server Components, this will be less of an issue because there are no updates in that environment. However, it's still better for performance to cache data requests, so the same principles largely apply. The intention is that this will eventually be the only supported way to suspend on arbitrary promises. Throwing a promise directly will be deprecated. --- .../src/ReactFiberHooks.new.js | 77 +++++++- .../src/ReactFiberHooks.old.js | 77 +++++++- .../src/ReactFiberLane.new.js | 18 +- .../src/ReactFiberLane.old.js | 18 +- .../src/ReactFiberRoot.new.js | 1 + .../src/ReactFiberRoot.old.js | 1 + .../src/ReactFiberThrow.new.js | 44 +---- .../src/ReactFiberThrow.old.js | 44 +---- .../src/ReactFiberWakeable.new.js | 40 +++- .../src/ReactFiberWakeable.old.js | 40 +++- .../src/ReactFiberWorkLoop.new.js | 143 ++++++++++++-- .../src/ReactFiberWorkLoop.old.js | 143 ++++++++++++-- .../src/ReactInternalTypes.js | 1 + .../src/__tests__/ReactWakeable-test.js | 178 ++++++++++++++++++ scripts/error-codes/codes.json | 3 +- 15 files changed, 701 insertions(+), 127 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index e802fe452fdd7..f52aac204c693 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -14,6 +14,7 @@ import type { ReactContext, StartTransitionOptions, Usable, + Thenable, } from 'shared/ReactTypes'; import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane.new'; @@ -122,6 +123,10 @@ import { } from './ReactFiberConcurrentUpdates.new'; import {getTreeId} from './ReactFiberTreeContext.new'; import {now} from './Scheduler'; +import { + trackUsedThenable, + getPreviouslyUsedThenableAtIndex, +} from './ReactFiberWakeable.new'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -207,6 +212,9 @@ let didScheduleRenderPhaseUpdate: boolean = false; let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false; // Counts the number of useId hooks in this component. let localIdCounter: number = 0; +// Counts number of `use`-d thenables +let thenableIndexCounter: number = 0; + // Used for ids that are generated completely client-side (i.e. not during // hydration). This counter is global, so client ids are not stable across // render attempts. @@ -405,6 +413,7 @@ export function renderWithHooks( // didScheduleRenderPhaseUpdate = false; // localIdCounter = 0; + // thenableIndexCounter = 0; // TODO Warn if no hooks are used at all during mount, then some are used during update. // Currently we will identify the update render as a mount because memoizedState === null. @@ -443,6 +452,7 @@ export function renderWithHooks( do { didScheduleRenderPhaseUpdateDuringThisPass = false; localIdCounter = 0; + thenableIndexCounter = 0; if (numberOfReRenders >= RE_RENDER_LIMIT) { throw new Error( @@ -526,6 +536,7 @@ export function renderWithHooks( didScheduleRenderPhaseUpdate = false; // This is reset by checkDidRenderIdHook // localIdCounter = 0; + thenableIndexCounter = 0; if (didRenderTooFewHooks) { throw new Error( @@ -633,6 +644,7 @@ export function resetHooksAfterThrow(): void { didScheduleRenderPhaseUpdateDuringThisPass = false; localIdCounter = 0; + thenableIndexCounter = 0; } function mountWorkInProgressHook(): Hook { @@ -725,7 +737,70 @@ function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue { } function use(usable: Usable): T { - throw new Error('Not implemented.'); + if ( + usable !== null && + typeof usable === 'object' && + typeof usable.then === 'function' + ) { + // This is a thenable. + const thenable: Thenable = (usable: any); + + // Track the position of the thenable within this fiber. + const index = thenableIndexCounter; + thenableIndexCounter += 1; + + switch (thenable.status) { + case 'fulfilled': { + const fulfilledValue: T = thenable.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError = thenable.reason; + throw rejectedError; + } + default: { + const prevThenableAtIndex: Thenable | null = getPreviouslyUsedThenableAtIndex( + index, + ); + if (prevThenableAtIndex !== null) { + switch (prevThenableAtIndex.status) { + case 'fulfilled': { + const fulfilledValue: T = prevThenableAtIndex.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError: mixed = prevThenableAtIndex.reason; + throw rejectedError; + } + default: { + // The thenable still hasn't resolved. Suspend with the same + // thenable as last time to avoid redundant listeners. + throw prevThenableAtIndex; + } + } + } else { + // This is the first time something has been used at this index. + // Stash the thenable at the current index so we can reuse it during + // the next attempt. + trackUsedThenable(thenable, index); + + // Suspend. + // TODO: Throwing here is an implementation detail that allows us to + // unwind the call stack. But we shouldn't allow it to leak into + // userspace. Throw an opaque placeholder value instead of the + // actual thenable. If it doesn't get captured by the work loop, log + // a warning, because that means something in userspace must have + // caught it. + throw thenable; + } + } + } + } + + // TODO: Add support for Context + + // eslint-disable-next-line react-internal/safe-string-coercion + throw new Error('An unsupported type was passed to use(): ' + String(usable)); } function useMemoCache(size: number): Array { diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index f31bca97ac8e5..473b8aace6ccd 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -14,6 +14,7 @@ import type { ReactContext, StartTransitionOptions, Usable, + Thenable, } from 'shared/ReactTypes'; import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane.old'; @@ -122,6 +123,10 @@ import { } from './ReactFiberConcurrentUpdates.old'; import {getTreeId} from './ReactFiberTreeContext.old'; import {now} from './Scheduler'; +import { + trackUsedThenable, + getPreviouslyUsedThenableAtIndex, +} from './ReactFiberWakeable.old'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -207,6 +212,9 @@ let didScheduleRenderPhaseUpdate: boolean = false; let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false; // Counts the number of useId hooks in this component. let localIdCounter: number = 0; +// Counts number of `use`-d thenables +let thenableIndexCounter: number = 0; + // Used for ids that are generated completely client-side (i.e. not during // hydration). This counter is global, so client ids are not stable across // render attempts. @@ -405,6 +413,7 @@ export function renderWithHooks( // didScheduleRenderPhaseUpdate = false; // localIdCounter = 0; + // thenableIndexCounter = 0; // TODO Warn if no hooks are used at all during mount, then some are used during update. // Currently we will identify the update render as a mount because memoizedState === null. @@ -443,6 +452,7 @@ export function renderWithHooks( do { didScheduleRenderPhaseUpdateDuringThisPass = false; localIdCounter = 0; + thenableIndexCounter = 0; if (numberOfReRenders >= RE_RENDER_LIMIT) { throw new Error( @@ -526,6 +536,7 @@ export function renderWithHooks( didScheduleRenderPhaseUpdate = false; // This is reset by checkDidRenderIdHook // localIdCounter = 0; + thenableIndexCounter = 0; if (didRenderTooFewHooks) { throw new Error( @@ -633,6 +644,7 @@ export function resetHooksAfterThrow(): void { didScheduleRenderPhaseUpdateDuringThisPass = false; localIdCounter = 0; + thenableIndexCounter = 0; } function mountWorkInProgressHook(): Hook { @@ -725,7 +737,70 @@ function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue { } function use(usable: Usable): T { - throw new Error('Not implemented.'); + if ( + usable !== null && + typeof usable === 'object' && + typeof usable.then === 'function' + ) { + // This is a thenable. + const thenable: Thenable = (usable: any); + + // Track the position of the thenable within this fiber. + const index = thenableIndexCounter; + thenableIndexCounter += 1; + + switch (thenable.status) { + case 'fulfilled': { + const fulfilledValue: T = thenable.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError = thenable.reason; + throw rejectedError; + } + default: { + const prevThenableAtIndex: Thenable | null = getPreviouslyUsedThenableAtIndex( + index, + ); + if (prevThenableAtIndex !== null) { + switch (prevThenableAtIndex.status) { + case 'fulfilled': { + const fulfilledValue: T = prevThenableAtIndex.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError: mixed = prevThenableAtIndex.reason; + throw rejectedError; + } + default: { + // The thenable still hasn't resolved. Suspend with the same + // thenable as last time to avoid redundant listeners. + throw prevThenableAtIndex; + } + } + } else { + // This is the first time something has been used at this index. + // Stash the thenable at the current index so we can reuse it during + // the next attempt. + trackUsedThenable(thenable, index); + + // Suspend. + // TODO: Throwing here is an implementation detail that allows us to + // unwind the call stack. But we shouldn't allow it to leak into + // userspace. Throw an opaque placeholder value instead of the + // actual thenable. If it doesn't get captured by the work loop, log + // a warning, because that means something in userspace must have + // caught it. + throw thenable; + } + } + } + } + + // TODO: Add support for Context + + // eslint-disable-next-line react-internal/safe-string-coercion + throw new Error('An unsupported type was passed to use(): ' + String(usable)); } function useMemoCache(size: number): Array { diff --git a/packages/react-reconciler/src/ReactFiberLane.new.js b/packages/react-reconciler/src/ReactFiberLane.new.js index c3f8f5329ac79..4aebeba7a205e 100644 --- a/packages/react-reconciler/src/ReactFiberLane.new.js +++ b/packages/react-reconciler/src/ReactFiberLane.new.js @@ -403,7 +403,11 @@ export function markStarvedLanesAsExpired( // Iterate through the pending lanes and check if we've reached their // expiration time. If so, we'll assume the update is being starved and mark // it as expired to force it to finish. - let lanes = pendingLanes; + // + // We exclude retry lanes because those must always be time sliced, in order + // to unwrap uncached promises. + // TODO: Write a test for this + let lanes = pendingLanes & ~RetryLanes; while (lanes > 0) { const index = pickArbitraryLaneIndex(lanes); const lane = 1 << index; @@ -435,7 +439,15 @@ export function getHighestPriorityPendingLanes(root: FiberRoot) { return getHighestPriorityLanes(root.pendingLanes); } -export function getLanesToRetrySynchronouslyOnError(root: FiberRoot): Lanes { +export function getLanesToRetrySynchronouslyOnError( + root: FiberRoot, + originallyAttemptedLanes: Lanes, +): Lanes { + if (root.errorRecoveryDisabledLanes & originallyAttemptedLanes) { + // The error recovery mechanism is disabled until these lanes are cleared. + return NoLanes; + } + const everythingButOffscreen = root.pendingLanes & ~OffscreenLane; if (everythingButOffscreen !== NoLanes) { return everythingButOffscreen; @@ -646,6 +658,8 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) { root.entangledLanes &= remainingLanes; + root.errorRecoveryDisabledLanes &= remainingLanes; + const entanglements = root.entanglements; const eventTimes = root.eventTimes; const expirationTimes = root.expirationTimes; diff --git a/packages/react-reconciler/src/ReactFiberLane.old.js b/packages/react-reconciler/src/ReactFiberLane.old.js index b3f31ec0ceac7..5861e9d3d3252 100644 --- a/packages/react-reconciler/src/ReactFiberLane.old.js +++ b/packages/react-reconciler/src/ReactFiberLane.old.js @@ -403,7 +403,11 @@ export function markStarvedLanesAsExpired( // Iterate through the pending lanes and check if we've reached their // expiration time. If so, we'll assume the update is being starved and mark // it as expired to force it to finish. - let lanes = pendingLanes; + // + // We exclude retry lanes because those must always be time sliced, in order + // to unwrap uncached promises. + // TODO: Write a test for this + let lanes = pendingLanes & ~RetryLanes; while (lanes > 0) { const index = pickArbitraryLaneIndex(lanes); const lane = 1 << index; @@ -435,7 +439,15 @@ export function getHighestPriorityPendingLanes(root: FiberRoot) { return getHighestPriorityLanes(root.pendingLanes); } -export function getLanesToRetrySynchronouslyOnError(root: FiberRoot): Lanes { +export function getLanesToRetrySynchronouslyOnError( + root: FiberRoot, + originallyAttemptedLanes: Lanes, +): Lanes { + if (root.errorRecoveryDisabledLanes & originallyAttemptedLanes) { + // The error recovery mechanism is disabled until these lanes are cleared. + return NoLanes; + } + const everythingButOffscreen = root.pendingLanes & ~OffscreenLane; if (everythingButOffscreen !== NoLanes) { return everythingButOffscreen; @@ -646,6 +658,8 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) { root.entangledLanes &= remainingLanes; + root.errorRecoveryDisabledLanes &= remainingLanes; + const entanglements = root.entanglements; const eventTimes = root.eventTimes; const expirationTimes = root.expirationTimes; diff --git a/packages/react-reconciler/src/ReactFiberRoot.new.js b/packages/react-reconciler/src/ReactFiberRoot.new.js index f171ca0de3943..892fe78ac1b1e 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.new.js +++ b/packages/react-reconciler/src/ReactFiberRoot.new.js @@ -70,6 +70,7 @@ function FiberRootNode( this.expiredLanes = NoLanes; this.mutableReadLanes = NoLanes; this.finishedLanes = NoLanes; + this.errorRecoveryDisabledLanes = NoLanes; this.entangledLanes = NoLanes; this.entanglements = createLaneMap(NoLanes); diff --git a/packages/react-reconciler/src/ReactFiberRoot.old.js b/packages/react-reconciler/src/ReactFiberRoot.old.js index 9b37cee41edab..f7e16f0bbdcc8 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.old.js +++ b/packages/react-reconciler/src/ReactFiberRoot.old.js @@ -70,6 +70,7 @@ function FiberRootNode( this.expiredLanes = NoLanes; this.mutableReadLanes = NoLanes; this.finishedLanes = NoLanes; + this.errorRecoveryDisabledLanes = NoLanes; this.entangledLanes = NoLanes; this.entanglements = createLaneMap(NoLanes); diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index 3536417dc87e2..d4e69b7c66940 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -57,7 +57,7 @@ import { onUncaughtError, markLegacyErrorBoundaryAsFailed, isAlreadyFailedLegacyErrorBoundary, - pingSuspendedRoot, + attachPingListener, restorePendingUpdaters, } from './ReactFiberWorkLoop.new'; import {propagateParentContextChangesToDeferredTree} from './ReactFiberNewContext.new'; @@ -78,8 +78,6 @@ import { queueHydrationError, } from './ReactFiberHydrationContext.new'; -const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; - function createRootErrorUpdate( fiber: Fiber, errorInfo: CapturedValue, @@ -159,46 +157,6 @@ function createClassErrorUpdate( return update; } -function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) { - // Attach a ping listener - // - // The data might resolve before we have a chance to commit the fallback. Or, - // in the case of a refresh, we'll never commit a fallback. So we need to - // attach a listener now. When it resolves ("pings"), we can decide whether to - // try rendering the tree again. - // - // Only attach a listener if one does not already exist for the lanes - // we're currently rendering (which acts like a "thread ID" here). - // - // We only need to do this in concurrent mode. Legacy Suspense always - // commits fallbacks synchronously, so there are no pings. - let pingCache = root.pingCache; - let threadIDs; - if (pingCache === null) { - pingCache = root.pingCache = new PossiblyWeakMap(); - threadIDs = new Set(); - pingCache.set(wakeable, threadIDs); - } else { - threadIDs = pingCache.get(wakeable); - if (threadIDs === undefined) { - threadIDs = new Set(); - pingCache.set(wakeable, threadIDs); - } - } - if (!threadIDs.has(lanes)) { - // Memoize using the thread ID to prevent redundant listeners. - threadIDs.add(lanes); - const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); - if (enableUpdaterTracking) { - if (isDevToolsPresent) { - // If we have pending work still, restore the original updaters - restorePendingUpdaters(root, lanes); - } - } - wakeable.then(ping, ping); - } -} - function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) { if (enableLazyContextPropagation) { const currentSourceFiber = sourceFiber.alternate; diff --git a/packages/react-reconciler/src/ReactFiberThrow.old.js b/packages/react-reconciler/src/ReactFiberThrow.old.js index 9177dd34ad326..cdc7d3c2a79e4 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.old.js +++ b/packages/react-reconciler/src/ReactFiberThrow.old.js @@ -57,7 +57,7 @@ import { onUncaughtError, markLegacyErrorBoundaryAsFailed, isAlreadyFailedLegacyErrorBoundary, - pingSuspendedRoot, + attachPingListener, restorePendingUpdaters, } from './ReactFiberWorkLoop.old'; import {propagateParentContextChangesToDeferredTree} from './ReactFiberNewContext.old'; @@ -78,8 +78,6 @@ import { queueHydrationError, } from './ReactFiberHydrationContext.old'; -const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; - function createRootErrorUpdate( fiber: Fiber, errorInfo: CapturedValue, @@ -159,46 +157,6 @@ function createClassErrorUpdate( return update; } -function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) { - // Attach a ping listener - // - // The data might resolve before we have a chance to commit the fallback. Or, - // in the case of a refresh, we'll never commit a fallback. So we need to - // attach a listener now. When it resolves ("pings"), we can decide whether to - // try rendering the tree again. - // - // Only attach a listener if one does not already exist for the lanes - // we're currently rendering (which acts like a "thread ID" here). - // - // We only need to do this in concurrent mode. Legacy Suspense always - // commits fallbacks synchronously, so there are no pings. - let pingCache = root.pingCache; - let threadIDs; - if (pingCache === null) { - pingCache = root.pingCache = new PossiblyWeakMap(); - threadIDs = new Set(); - pingCache.set(wakeable, threadIDs); - } else { - threadIDs = pingCache.get(wakeable); - if (threadIDs === undefined) { - threadIDs = new Set(); - pingCache.set(wakeable, threadIDs); - } - } - if (!threadIDs.has(lanes)) { - // Memoize using the thread ID to prevent redundant listeners. - threadIDs.add(lanes); - const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); - if (enableUpdaterTracking) { - if (isDevToolsPresent) { - // If we have pending work still, restore the original updaters - restorePendingUpdaters(root, lanes); - } - } - wakeable.then(ping, ping); - } -} - function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) { if (enableLazyContextPropagation) { const currentSourceFiber = sourceFiber.alternate; diff --git a/packages/react-reconciler/src/ReactFiberWakeable.new.js b/packages/react-reconciler/src/ReactFiberWakeable.new.js index 1b3c4ec6c3bf9..83bfad32c5cf1 100644 --- a/packages/react-reconciler/src/ReactFiberWakeable.new.js +++ b/packages/react-reconciler/src/ReactFiberWakeable.new.js @@ -18,6 +18,9 @@ import type { let suspendedThenable: Thenable | null = null; let adHocSuspendCount: number = 0; +let usedThenables: Array | void> | null = null; +let lastUsedThenable: Thenable | null = null; + const MAX_AD_HOC_SUSPEND_COUNT = 50; export function isTrackingSuspendedThenable() { @@ -39,7 +42,15 @@ export function trackSuspendedWakeable(wakeable: Wakeable) { // TODO: Get rid of the Wakeable type? It's superseded by UntrackedThenable. const thenable: Thenable = (wakeable: any); - adHocSuspendCount++; + if (thenable !== lastUsedThenable) { + // If this wakeable was not just `use`-d, it must be an ad hoc wakeable + // that was thrown by an older Suspense implementation. Keep a count of + // these so that we can detect an infinite ping loop. + // TODO: Once `use` throws an opaque signal instead of the actual thenable, + // a better way to count ad hoc suspends is whether an actual thenable + // is caught by the work loop. + adHocSuspendCount++; + } suspendedThenable = thenable; // We use an expando to track the status and result of a thenable so that we @@ -86,9 +97,14 @@ export function trackSuspendedWakeable(wakeable: Wakeable) { } } -export function resetWakeableState() { +export function resetWakeableStateAfterEachAttempt() { suspendedThenable = null; adHocSuspendCount = 0; + lastUsedThenable = null; +} + +export function resetThenableStateOnCompletion() { + usedThenables = null; } export function throwIfInfinitePingLoopDetected() { @@ -98,3 +114,23 @@ export function throwIfInfinitePingLoopDetected() { // the render phase so that it gets the component stack. } } + +export function trackUsedThenable(thenable: Thenable, index: number) { + if (usedThenables === null) { + usedThenables = []; + } + usedThenables[index] = thenable; + lastUsedThenable = thenable; +} + +export function getPreviouslyUsedThenableAtIndex( + index: number, +): Thenable | null { + if (usedThenables !== null) { + const thenable = usedThenables[index]; + if (thenable !== undefined) { + return thenable; + } + } + return null; +} diff --git a/packages/react-reconciler/src/ReactFiberWakeable.old.js b/packages/react-reconciler/src/ReactFiberWakeable.old.js index 1b3c4ec6c3bf9..83bfad32c5cf1 100644 --- a/packages/react-reconciler/src/ReactFiberWakeable.old.js +++ b/packages/react-reconciler/src/ReactFiberWakeable.old.js @@ -18,6 +18,9 @@ import type { let suspendedThenable: Thenable | null = null; let adHocSuspendCount: number = 0; +let usedThenables: Array | void> | null = null; +let lastUsedThenable: Thenable | null = null; + const MAX_AD_HOC_SUSPEND_COUNT = 50; export function isTrackingSuspendedThenable() { @@ -39,7 +42,15 @@ export function trackSuspendedWakeable(wakeable: Wakeable) { // TODO: Get rid of the Wakeable type? It's superseded by UntrackedThenable. const thenable: Thenable = (wakeable: any); - adHocSuspendCount++; + if (thenable !== lastUsedThenable) { + // If this wakeable was not just `use`-d, it must be an ad hoc wakeable + // that was thrown by an older Suspense implementation. Keep a count of + // these so that we can detect an infinite ping loop. + // TODO: Once `use` throws an opaque signal instead of the actual thenable, + // a better way to count ad hoc suspends is whether an actual thenable + // is caught by the work loop. + adHocSuspendCount++; + } suspendedThenable = thenable; // We use an expando to track the status and result of a thenable so that we @@ -86,9 +97,14 @@ export function trackSuspendedWakeable(wakeable: Wakeable) { } } -export function resetWakeableState() { +export function resetWakeableStateAfterEachAttempt() { suspendedThenable = null; adHocSuspendCount = 0; + lastUsedThenable = null; +} + +export function resetThenableStateOnCompletion() { + usedThenables = null; } export function throwIfInfinitePingLoopDetected() { @@ -98,3 +114,23 @@ export function throwIfInfinitePingLoopDetected() { // the render phase so that it gets the component stack. } } + +export function trackUsedThenable(thenable: Thenable, index: number) { + if (usedThenables === null) { + usedThenables = []; + } + usedThenables[index] = thenable; + lastUsedThenable = thenable; +} + +export function getPreviouslyUsedThenableAtIndex( + index: number, +): Thenable | null { + if (usedThenables !== null) { + const thenable = usedThenables[index]; + if (thenable !== undefined) { + return thenable; + } + } + return null; +} diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index e119919334add..7c1d74b4cde6c 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -253,7 +253,8 @@ import { } from './ReactFiberAct.new'; import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.new'; import { - resetWakeableState, + resetWakeableStateAfterEachAttempt, + resetThenableStateOnCompletion, trackSuspendedWakeable, suspendedThenableDidResolve, isTrackingSuspendedThenable, @@ -261,6 +262,8 @@ import { const ceil = Math.ceil; +const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; + const { ReactCurrentDispatcher, ReactCurrentOwner, @@ -300,6 +303,11 @@ let workInProgressRootRenderLanes: Lanes = NoLanes; let workInProgressIsSuspended: boolean = false; let workInProgressThrownValue: mixed = null; +// Whether a ping listener was attached during this render. This is slightly +// different that whether something suspended, because we don't add multiple +// listeners to a promise we've already seen (per root and lane). +let workInProgressRootDidAttachPingListener: boolean = false; + // A contextual version of workInProgressRootRenderLanes. It is a superset of // the lanes that we started working on at the root. When we enter a subtree // that is currently hidden, we add the lanes that would have committed if @@ -980,10 +988,18 @@ function performConcurrentWorkOnRoot(root, didTimeout) { // render synchronously to block concurrent data mutations, and we'll // includes all pending updates are included. If it still fails after // the second attempt, we'll give up and commit the resulting tree. - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); } } if (exitStatus === RootFatalErrored) { @@ -1023,10 +1039,18 @@ function performConcurrentWorkOnRoot(root, didTimeout) { // We need to check again if something threw if (exitStatus === RootErrored) { - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); // We assume the tree is now consistent because we didn't yield to any // concurrent events. } @@ -1057,14 +1081,19 @@ function performConcurrentWorkOnRoot(root, didTimeout) { return null; } -function recoverFromConcurrentError(root, errorRetryLanes) { +function recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, +) { // If an error occurred during hydration, discard server response and fall // back to client side render. // Before rendering again, save the errors from the previous attempt. const errorsFromFirstAttempt = workInProgressRootConcurrentErrors; - if (isRootDehydrated(root)) { + const wasRootDehydrated = isRootDehydrated(root); + if (wasRootDehydrated) { // The shell failed to hydrate. Set a flag to force a client rendering // during the next attempt. To do this, we call prepareFreshStack now // to create the root work-in-progress fiber. This is a bit weird in terms @@ -1087,6 +1116,32 @@ function recoverFromConcurrentError(root, errorRetryLanes) { if (exitStatus !== RootErrored) { // Successfully finished rendering on retry + if (workInProgressRootDidAttachPingListener && !wasRootDehydrated) { + // During the synchronous render, we attached additional ping listeners. + // This is highly suggestive of an uncached promise (though it's not the + // only reason this would happen). If it was an uncached promise, then + // it may have masked a downstream error from ocurring without actually + // fixing it. Example: + // + // use(Promise.resolve('uncached')) + // throw new Error('Oops!') + // + // When this happens, there's a conflict between blocking potential + // concurrent data races and unwrapping uncached promise values. We + // have to choose one or the other. Because the data race recovery is + // a last ditch effort, we'll disable it. + root.errorRecoveryDisabledLanes = mergeLanes( + root.errorRecoveryDisabledLanes, + originallyAttemptedLanes, + ); + + // Mark the current render as suspended and force it to restart. Once + // these lanes finish successfully, we'll re-enable the error recovery + // mechanism for subsequent updates. + workInProgressRootInterleavedUpdatedLanes |= originallyAttemptedLanes; + return RootSuspendedWithDelay; + } + // The errors from the failed first attempt have been recovered. Add // them to the collection of recoverable errors. We'll log them in the // commit phase. @@ -1343,10 +1398,18 @@ function performSyncWorkOnRoot(root) { // synchronously to block concurrent data mutations, and we'll includes // all pending updates are included. If it still fails after the second // attempt, we'll give up and commit the resulting tree. - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); } } @@ -1563,7 +1626,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { ); interruptedWork = interruptedWork.return; } - resetWakeableState(); + resetWakeableStateAfterEachAttempt(); + resetThenableStateOnCompletion(); } workInProgressRoot = root; const rootWorkInProgress = createWorkInProgress(root.current, null); @@ -1571,6 +1635,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { workInProgressRootRenderLanes = renderLanes = lanes; workInProgressIsSuspended = false; workInProgressThrownValue = null; + workInProgressRootDidAttachPingListener = false; workInProgressRootExitStatus = RootInProgress; workInProgressRootFatalError = null; workInProgressRootSkippedLanes = NoLanes; @@ -1971,11 +2036,12 @@ function resumeSuspendedUnitOfWork( // additional logic out of the work loop's hot path. const wasPinged = suspendedThenableDidResolve(); - resetWakeableState(); + resetWakeableStateAfterEachAttempt(); if (!wasPinged) { // The thenable wasn't pinged. Return to the normal work loop. This will // unwind the stack, and potentially result in showing a fallback. + resetThenableStateOnCompletion(); const returnFiber = unitOfWork.return; if (returnFiber === null || workInProgressRoot === null) { @@ -2037,9 +2103,10 @@ function resumeSuspendedUnitOfWork( next = beginWork(current, unitOfWork, renderLanes); } - // The begin phase finished successfully without suspending. Return to the - // normal work loop. - resetWakeableState(); + // The begin phase finished successfully without suspending. Reset the state + // used to track the fiber while it was suspended. Then return to the normal + // work loop. + resetThenableStateOnCompletion(); resetCurrentDebugFiberInDEV(); unitOfWork.memoizedProps = unitOfWork.pendingProps; @@ -2840,7 +2907,53 @@ export function captureCommitPhaseError( } } -export function pingSuspendedRoot( +export function attachPingListener( + root: FiberRoot, + wakeable: Wakeable, + lanes: Lanes, +) { + // Attach a ping listener + // + // The data might resolve before we have a chance to commit the fallback. Or, + // in the case of a refresh, we'll never commit a fallback. So we need to + // attach a listener now. When it resolves ("pings"), we can decide whether to + // try rendering the tree again. + // + // Only attach a listener if one does not already exist for the lanes + // we're currently rendering (which acts like a "thread ID" here). + // + // We only need to do this in concurrent mode. Legacy Suspense always + // commits fallbacks synchronously, so there are no pings. + let pingCache = root.pingCache; + let threadIDs; + if (pingCache === null) { + pingCache = root.pingCache = new PossiblyWeakMap(); + threadIDs = new Set(); + pingCache.set(wakeable, threadIDs); + } else { + threadIDs = pingCache.get(wakeable); + if (threadIDs === undefined) { + threadIDs = new Set(); + pingCache.set(wakeable, threadIDs); + } + } + if (!threadIDs.has(lanes)) { + workInProgressRootDidAttachPingListener = true; + + // Memoize using the thread ID to prevent redundant listeners. + threadIDs.add(lanes); + const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); + if (enableUpdaterTracking) { + if (isDevToolsPresent) { + // If we have pending work still, restore the original updaters + restorePendingUpdaters(root, lanes); + } + } + wakeable.then(ping, ping); + } +} + +function pingSuspendedRoot( root: FiberRoot, wakeable: Wakeable, pingedLanes: Lanes, diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index d4b01dc20f378..e3ce8c7a4bab4 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -253,7 +253,8 @@ import { } from './ReactFiberAct.old'; import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.old'; import { - resetWakeableState, + resetWakeableStateAfterEachAttempt, + resetThenableStateOnCompletion, trackSuspendedWakeable, suspendedThenableDidResolve, isTrackingSuspendedThenable, @@ -261,6 +262,8 @@ import { const ceil = Math.ceil; +const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; + const { ReactCurrentDispatcher, ReactCurrentOwner, @@ -300,6 +303,11 @@ let workInProgressRootRenderLanes: Lanes = NoLanes; let workInProgressIsSuspended: boolean = false; let workInProgressThrownValue: mixed = null; +// Whether a ping listener was attached during this render. This is slightly +// different that whether something suspended, because we don't add multiple +// listeners to a promise we've already seen (per root and lane). +let workInProgressRootDidAttachPingListener: boolean = false; + // A contextual version of workInProgressRootRenderLanes. It is a superset of // the lanes that we started working on at the root. When we enter a subtree // that is currently hidden, we add the lanes that would have committed if @@ -980,10 +988,18 @@ function performConcurrentWorkOnRoot(root, didTimeout) { // render synchronously to block concurrent data mutations, and we'll // includes all pending updates are included. If it still fails after // the second attempt, we'll give up and commit the resulting tree. - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); } } if (exitStatus === RootFatalErrored) { @@ -1023,10 +1039,18 @@ function performConcurrentWorkOnRoot(root, didTimeout) { // We need to check again if something threw if (exitStatus === RootErrored) { - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); // We assume the tree is now consistent because we didn't yield to any // concurrent events. } @@ -1057,14 +1081,19 @@ function performConcurrentWorkOnRoot(root, didTimeout) { return null; } -function recoverFromConcurrentError(root, errorRetryLanes) { +function recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, +) { // If an error occurred during hydration, discard server response and fall // back to client side render. // Before rendering again, save the errors from the previous attempt. const errorsFromFirstAttempt = workInProgressRootConcurrentErrors; - if (isRootDehydrated(root)) { + const wasRootDehydrated = isRootDehydrated(root); + if (wasRootDehydrated) { // The shell failed to hydrate. Set a flag to force a client rendering // during the next attempt. To do this, we call prepareFreshStack now // to create the root work-in-progress fiber. This is a bit weird in terms @@ -1087,6 +1116,32 @@ function recoverFromConcurrentError(root, errorRetryLanes) { if (exitStatus !== RootErrored) { // Successfully finished rendering on retry + if (workInProgressRootDidAttachPingListener && !wasRootDehydrated) { + // During the synchronous render, we attached additional ping listeners. + // This is highly suggestive of an uncached promise (though it's not the + // only reason this would happen). If it was an uncached promise, then + // it may have masked a downstream error from ocurring without actually + // fixing it. Example: + // + // use(Promise.resolve('uncached')) + // throw new Error('Oops!') + // + // When this happens, there's a conflict between blocking potential + // concurrent data races and unwrapping uncached promise values. We + // have to choose one or the other. Because the data race recovery is + // a last ditch effort, we'll disable it. + root.errorRecoveryDisabledLanes = mergeLanes( + root.errorRecoveryDisabledLanes, + originallyAttemptedLanes, + ); + + // Mark the current render as suspended and force it to restart. Once + // these lanes finish successfully, we'll re-enable the error recovery + // mechanism for subsequent updates. + workInProgressRootInterleavedUpdatedLanes |= originallyAttemptedLanes; + return RootSuspendedWithDelay; + } + // The errors from the failed first attempt have been recovered. Add // them to the collection of recoverable errors. We'll log them in the // commit phase. @@ -1343,10 +1398,18 @@ function performSyncWorkOnRoot(root) { // synchronously to block concurrent data mutations, and we'll includes // all pending updates are included. If it still fails after the second // attempt, we'll give up and commit the resulting tree. - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); } } @@ -1563,7 +1626,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { ); interruptedWork = interruptedWork.return; } - resetWakeableState(); + resetWakeableStateAfterEachAttempt(); + resetThenableStateOnCompletion(); } workInProgressRoot = root; const rootWorkInProgress = createWorkInProgress(root.current, null); @@ -1571,6 +1635,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { workInProgressRootRenderLanes = renderLanes = lanes; workInProgressIsSuspended = false; workInProgressThrownValue = null; + workInProgressRootDidAttachPingListener = false; workInProgressRootExitStatus = RootInProgress; workInProgressRootFatalError = null; workInProgressRootSkippedLanes = NoLanes; @@ -1971,11 +2036,12 @@ function resumeSuspendedUnitOfWork( // additional logic out of the work loop's hot path. const wasPinged = suspendedThenableDidResolve(); - resetWakeableState(); + resetWakeableStateAfterEachAttempt(); if (!wasPinged) { // The thenable wasn't pinged. Return to the normal work loop. This will // unwind the stack, and potentially result in showing a fallback. + resetThenableStateOnCompletion(); const returnFiber = unitOfWork.return; if (returnFiber === null || workInProgressRoot === null) { @@ -2037,9 +2103,10 @@ function resumeSuspendedUnitOfWork( next = beginWork(current, unitOfWork, renderLanes); } - // The begin phase finished successfully without suspending. Return to the - // normal work loop. - resetWakeableState(); + // The begin phase finished successfully without suspending. Reset the state + // used to track the fiber while it was suspended. Then return to the normal + // work loop. + resetThenableStateOnCompletion(); resetCurrentDebugFiberInDEV(); unitOfWork.memoizedProps = unitOfWork.pendingProps; @@ -2840,7 +2907,53 @@ export function captureCommitPhaseError( } } -export function pingSuspendedRoot( +export function attachPingListener( + root: FiberRoot, + wakeable: Wakeable, + lanes: Lanes, +) { + // Attach a ping listener + // + // The data might resolve before we have a chance to commit the fallback. Or, + // in the case of a refresh, we'll never commit a fallback. So we need to + // attach a listener now. When it resolves ("pings"), we can decide whether to + // try rendering the tree again. + // + // Only attach a listener if one does not already exist for the lanes + // we're currently rendering (which acts like a "thread ID" here). + // + // We only need to do this in concurrent mode. Legacy Suspense always + // commits fallbacks synchronously, so there are no pings. + let pingCache = root.pingCache; + let threadIDs; + if (pingCache === null) { + pingCache = root.pingCache = new PossiblyWeakMap(); + threadIDs = new Set(); + pingCache.set(wakeable, threadIDs); + } else { + threadIDs = pingCache.get(wakeable); + if (threadIDs === undefined) { + threadIDs = new Set(); + pingCache.set(wakeable, threadIDs); + } + } + if (!threadIDs.has(lanes)) { + workInProgressRootDidAttachPingListener = true; + + // Memoize using the thread ID to prevent redundant listeners. + threadIDs.add(lanes); + const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); + if (enableUpdaterTracking) { + if (isDevToolsPresent) { + // If we have pending work still, restore the original updaters + restorePendingUpdaters(root, lanes); + } + } + wakeable.then(ping, ping); + } +} + +function pingSuspendedRoot( root: FiberRoot, wakeable: Wakeable, pingedLanes: Lanes, diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index d73f3265083e5..95e6285929d92 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -239,6 +239,7 @@ type BaseFiberRootProperties = {| pingedLanes: Lanes, expiredLanes: Lanes, mutableReadLanes: Lanes, + errorRecoveryDisabledLanes: Lanes, finishedLanes: Lanes, diff --git a/packages/react-reconciler/src/__tests__/ReactWakeable-test.js b/packages/react-reconciler/src/__tests__/ReactWakeable-test.js index 40dcfe7ed7170..646fab635d125 100644 --- a/packages/react-reconciler/src/__tests__/ReactWakeable-test.js +++ b/packages/react-reconciler/src/__tests__/ReactWakeable-test.js @@ -4,6 +4,7 @@ let React; let ReactNoop; let Scheduler; let act; +let use; let Suspense; let startTransition; @@ -15,6 +16,7 @@ describe('ReactWakeable', () => { ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); act = require('jest-react').act; + use = React.experimental_use; Suspense = React.Suspense; startTransition = React.startTransition; }); @@ -133,4 +135,180 @@ describe('ReactWakeable', () => { expect(Scheduler).toHaveYielded(['Suspend!', 'Loading...']); expect(root).toMatchRenderedOutput('Loading...'); }); + + // @gate enableUseHook + test('basic use(promise)', async () => { + const promiseA = Promise.resolve('A'); + const promiseB = Promise.resolve('B'); + const promiseC = Promise.resolve('C'); + + function Async() { + const text = use(promiseA) + use(promiseB) + use(promiseC); + return ; + } + + function App() { + return ( + }> + + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + }); + expect(Scheduler).toHaveYielded(['ABC']); + expect(root).toMatchRenderedOutput('ABC'); + }); + + // @gate enableUseHook + test("using a promise that's not cached between attempts", async () => { + function Async() { + const text = + use(Promise.resolve('A')) + + use(Promise.resolve('B')) + + use(Promise.resolve('C')); + return ; + } + + function App() { + return ( + }> + + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + }); + expect(Scheduler).toHaveYielded(['ABC']); + expect(root).toMatchRenderedOutput('ABC'); + }); + + // @gate enableUseHook + test('using a rejected promise will throw', async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return ; + } + return this.props.children; + } + } + + const promiseA = Promise.resolve('A'); + const promiseB = Promise.reject(new Error('Oops!')); + const promiseC = Promise.resolve('C'); + + // Jest/Node will raise an unhandled rejected error unless we await this. It + // works fine in the browser, though. + await expect(promiseB).rejects.toThrow('Oops!'); + + function Async() { + const text = use(promiseA) + use(promiseB) + use(promiseC); + return ; + } + + function App() { + return ( + + + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + }); + expect(Scheduler).toHaveYielded(['Oops!', 'Oops!']); + }); + + // @gate enableUseHook + test('erroring in the same component as an uncached promise does not result in an infinite loop', async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return ; + } + return this.props.children; + } + } + + let i = 0; + function Async() { + if (i++ > 50) { + throw new Error('Infinite loop detected'); + } + try { + use(Promise.resolve('Async')); + } catch (e) { + Scheduler.unstable_yieldValue('Suspend! [Async]'); + throw e; + } + throw new Error('Oops!'); + } + + function App() { + return ( + }> + + + + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + }); + expect(Scheduler).toHaveYielded([ + // First attempt. The uncached promise suspends. + 'Suspend! [Async]', + // Because the promise already resolved, we're able to unwrap the value + // immediately in a microtask. + // + // Then we proceed to the rest of the component, which throws an error. + 'Caught an error: Oops!', + + // During the sync error recovery pass, the component suspends, because + // we were unable to unwrap the value of the promise. + 'Suspend! [Async]', + 'Loading...', + + // Because the error recovery attempt suspended, React can't tell if the + // error was actually fixed, or it was masked by the suspended data. + // In this case, it wasn't actually fixed, so if we were to commit the + // suspended fallback, it would enter an endless error recovery loop. + // + // Instead, we disable error recovery for these lanes and start + // over again. + + // This time, the error is thrown and we commit the result. + 'Suspend! [Async]', + 'Caught an error: Oops!', + ]); + expect(root).toMatchRenderedOutput('Caught an error: Oops!'); + }); }); diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 15e92133a14d8..423b30de52dd2 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -422,5 +422,6 @@ "434": "`dangerouslySetInnerHTML` does not make sense on .", "435": "Unexpected Suspense handler tag (%s). This is a bug in React.", "436": "Stylesheet resources need a unique representation in the DOM while hydrating and more than one matching DOM Node was found. To fix, ensure you are only rendering one stylesheet link with an href attribute of \"%s\".", - "437": "the \"precedence\" prop for links to stylesheets expects to receive a string but received something of type \"%s\" instead." + "437": "the \"precedence\" prop for links to stylesheets expects to receive a string but received something of type \"%s\" instead.", + "438": "An unsupported type was passed to use(): %s" }