From 67de5e3fb09eecfab91321246246095058a708a9 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 9 Jun 2022 17:40:07 -0400 Subject: [PATCH] [FORKED] Hidden trees should capture Suspense If something suspends inside a hidden tree, it should not affect anything in the visible part of the UI. This means that Offscreen acts like a Suspense boundary whenever it's in its hidden state. --- .../react-reconciler/src/ReactFiber.new.js | 2 + .../react-reconciler/src/ReactFiber.old.js | 2 + .../src/ReactFiberBeginWork.new.js | 189 +++++++--- .../src/ReactFiberBeginWork.old.js | 2 + .../src/ReactFiberCommitWork.new.js | 107 ++++-- .../src/ReactFiberCommitWork.old.js | 2 +- .../src/ReactFiberCompleteWork.new.js | 13 +- .../src/ReactFiberOffscreenComponent.js | 6 +- .../src/ReactFiberSuspenseContext.new.js | 15 +- .../src/ReactFiberThrow.new.js | 98 ++++-- .../src/ReactFiberUnwindWork.new.js | 17 +- .../src/ReactFiberWorkLoop.new.js | 7 + .../__tests__/ReactOffscreenSuspense-test.js | 326 ++++++++++++++++++ scripts/error-codes/codes.json | 5 +- 14 files changed, 670 insertions(+), 121 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiber.new.js b/packages/react-reconciler/src/ReactFiber.new.js index 6865ef0b67a25..91d1211c20720 100644 --- a/packages/react-reconciler/src/ReactFiber.new.js +++ b/packages/react-reconciler/src/ReactFiber.new.js @@ -719,6 +719,7 @@ export function createFiberFromOffscreen( const primaryChildInstance: OffscreenInstance = { isHidden: false, pendingMarkers: null, + retryCache: null, transitions: null, }; fiber.stateNode = primaryChildInstance; @@ -740,6 +741,7 @@ export function createFiberFromLegacyHidden( isHidden: false, pendingMarkers: null, transitions: null, + retryCache: null, }; fiber.stateNode = instance; return fiber; diff --git a/packages/react-reconciler/src/ReactFiber.old.js b/packages/react-reconciler/src/ReactFiber.old.js index 1dac6117dfd0e..90d0d7a7ac6fa 100644 --- a/packages/react-reconciler/src/ReactFiber.old.js +++ b/packages/react-reconciler/src/ReactFiber.old.js @@ -719,6 +719,7 @@ export function createFiberFromOffscreen( const primaryChildInstance: OffscreenInstance = { isHidden: false, pendingMarkers: null, + retryCache: null, transitions: null, }; fiber.stateNode = primaryChildInstance; @@ -740,6 +741,7 @@ export function createFiberFromLegacyHidden( isHidden: false, pendingMarkers: null, transitions: null, + retryCache: null, }; fiber.stateNode = instance; return fiber; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 2816e72c60078..c3bf502ac6180 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -174,6 +174,8 @@ import { setShallowSuspenseListContext, pushPrimaryTreeSuspenseHandler, pushFallbackTreeSuspenseHandler, + pushOffscreenSuspenseHandler, + reuseSuspenseHandlerOnStack, popSuspenseHandler, } from './ReactFiberSuspenseContext.new'; import { @@ -678,6 +680,52 @@ function updateOffscreenComponent( (enableLegacyHidden && nextProps.mode === 'unstable-defer-without-hiding') ) { // Rendering a hidden tree. + + const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags; + if (didSuspend) { + // Something suspended inside a hidden tree + + // Include the base lanes from the last render + const nextBaseLanes = + prevState !== null + ? mergeLanes(prevState.baseLanes, renderLanes) + : renderLanes; + + if (current !== null) { + // Reset to the current children + let currentChild = (workInProgress.child = current.child); + + // The current render suspended, but there may be other lanes with + // pending work. We can't read `childLanes` from the current Offscreen + // fiber because we reset it when it was deferred; however, we can read + // the pending lanes from the child fibers. + let currentChildLanes = NoLanes; + while (currentChild !== null) { + currentChildLanes = mergeLanes( + mergeLanes(currentChildLanes, currentChild.lanes), + currentChild.childLanes, + ); + currentChild = currentChild.sibling; + } + const lanesWeJustAttempted = nextBaseLanes; + const remainingChildLanes = removeLanes( + currentChildLanes, + lanesWeJustAttempted, + ); + workInProgress.childLanes = remainingChildLanes; + } else { + workInProgress.childLanes = NoLanes; + workInProgress.child = null; + } + + return deferHiddenOffscreenComponent( + current, + workInProgress, + nextBaseLanes, + renderLanes, + ); + } + if ((workInProgress.mode & ConcurrentMode) === NoMode) { // In legacy sync mode, don't defer the subtree. Render it now. // TODO: Consider how Offscreen should work with transitions in the future @@ -694,50 +742,28 @@ function updateOffscreenComponent( } } reuseHiddenContextOnStack(workInProgress); + pushOffscreenSuspenseHandler(workInProgress); } else if (!includesSomeLane(renderLanes, (OffscreenLane: Lane))) { // We're hidden, and we're not rendering at Offscreen. We will bail out // and resume this tree later. - let nextBaseLanes = renderLanes; - if (prevState !== null) { - // Include the base lanes from the last render - nextBaseLanes = mergeLanes(nextBaseLanes, prevState.baseLanes); - } - // Schedule this fiber to re-render at offscreen priority. Then bailout. + // Schedule this fiber to re-render at Offscreen priority workInProgress.lanes = workInProgress.childLanes = laneToLanes( OffscreenLane, ); - const nextState: OffscreenState = { - baseLanes: nextBaseLanes, - // Save the cache pool so we can resume later. - cachePool: enableCache ? getOffscreenDeferredCache() : null, - }; - workInProgress.memoizedState = nextState; - workInProgress.updateQueue = null; - if (enableCache) { - // push the cache pool even though we're going to bail out - // because otherwise there'd be a context mismatch - if (current !== null) { - pushTransition(workInProgress, null, null); - } - } - - // We're about to bail out, but we need to push this to the stack anyway - // to avoid a push/pop misalignment. - reuseHiddenContextOnStack(workInProgress); - if (enableLazyContextPropagation && current !== null) { - // Since this tree will resume rendering in a separate render, we need - // to propagate parent contexts now so we don't lose track of which - // ones changed. - propagateParentContextChangesToDeferredTree( - current, - workInProgress, - renderLanes, - ); - } + // Include the base lanes from the last render + const nextBaseLanes = + prevState !== null + ? mergeLanes(prevState.baseLanes, renderLanes) + : renderLanes; - return null; + return deferHiddenOffscreenComponent( + current, + workInProgress, + nextBaseLanes, + renderLanes, + ); } else { // This is the second render. The surrounding visible content has already // committed. Now we resume rendering the hidden tree. @@ -764,6 +790,7 @@ function updateOffscreenComponent( } else { reuseHiddenContextOnStack(workInProgress); } + pushOffscreenSuspenseHandler(workInProgress); } } else { // Rendering a visible tree. @@ -791,6 +818,7 @@ function updateOffscreenComponent( // Push the lanes that were skipped when we bailed out. pushHiddenContext(workInProgress, prevState); + reuseSuspenseHandlerOnStack(workInProgress); // Since we're not hidden anymore, reset the state workInProgress.memoizedState = null; @@ -811,6 +839,7 @@ function updateOffscreenComponent( // We're about to bail out, but we need to push this to the stack anyway // to avoid a push/pop misalignment. reuseHiddenContextOnStack(workInProgress); + reuseSuspenseHandlerOnStack(workInProgress); } } @@ -818,6 +847,46 @@ function updateOffscreenComponent( return workInProgress.child; } +function deferHiddenOffscreenComponent( + current: Fiber | null, + workInProgress: Fiber, + nextBaseLanes: Lanes, + renderLanes: Lanes, +) { + const nextState: OffscreenState = { + baseLanes: nextBaseLanes, + // Save the cache pool so we can resume later. + cachePool: enableCache ? getOffscreenDeferredCache() : null, + }; + workInProgress.memoizedState = nextState; + if (enableCache) { + // push the cache pool even though we're going to bail out + // because otherwise there'd be a context mismatch + if (current !== null) { + pushTransition(workInProgress, null, null); + } + } + + // We're about to bail out, but we need to push this to the stack anyway + // to avoid a push/pop misalignment. + reuseHiddenContextOnStack(workInProgress); + + pushOffscreenSuspenseHandler(workInProgress); + + if (enableLazyContextPropagation && current !== null) { + // Since this tree will resume rendering in a separate render, we need + // to propagate parent contexts now so we don't lose track of which + // ones changed. + propagateParentContextChangesToDeferredTree( + current, + workInProgress, + renderLanes, + ); + } + + return null; +} + // Note: These happen to have identical begin phases, for now. We shouldn't hold // ourselves to this constraint, though. If the behavior diverges, we should // fork the function. @@ -2109,13 +2178,19 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { if (enableTransitionTracing) { const currentTransitions = getPendingTransitions(); if (currentTransitions !== null) { - // If there are no transitions, we don't need to keep track of tracing markers const parentMarkerInstances = getMarkerInstances(); - const primaryChildUpdateQueue: OffscreenQueue = { - transitions: currentTransitions, - markerInstances: parentMarkerInstances, - }; - primaryChildFragment.updateQueue = primaryChildUpdateQueue; + const offscreenQueue: OffscreenQueue | null = (primaryChildFragment.updateQueue: any); + if (offscreenQueue === null) { + const newOffscreenQueue: OffscreenQueue = { + transitions: currentTransitions, + markerInstances: parentMarkerInstances, + wakeables: null, + }; + primaryChildFragment.updateQueue = newOffscreenQueue; + } else { + offscreenQueue.transitions = currentTransitions; + offscreenQueue.markerInstances = parentMarkerInstances; + } } } @@ -2140,6 +2215,8 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { ); workInProgress.memoizedState = SUSPENDED_MARKER; + // TODO: Transition Tracing is not yet implemented for CPU Suspense. + // Since nothing actually suspended, there will nothing to ping this to // get it started back up to attempt the next item. While in terms of // priority this work has the same priority as this current render, it's @@ -2201,11 +2278,31 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { const currentTransitions = getPendingTransitions(); if (currentTransitions !== null) { const parentMarkerInstances = getMarkerInstances(); - const primaryChildUpdateQueue: OffscreenQueue = { - transitions: currentTransitions, - markerInstances: parentMarkerInstances, - }; - primaryChildFragment.updateQueue = primaryChildUpdateQueue; + const offscreenQueue: OffscreenQueue | null = (primaryChildFragment.updateQueue: any); + const currentOffscreenQueue: OffscreenQueue | null = (current.updateQueue: any); + if (offscreenQueue === null) { + const newOffscreenQueue: OffscreenQueue = { + transitions: currentTransitions, + markerInstances: parentMarkerInstances, + wakeables: null, + }; + primaryChildFragment.updateQueue = newOffscreenQueue; + } else if (offscreenQueue === currentOffscreenQueue) { + // If the work-in-progress queue is the same object as current, we + // can't modify it without cloning it first. + const newOffscreenQueue: OffscreenQueue = { + transitions: currentTransitions, + markerInstances: parentMarkerInstances, + wakeables: + currentOffscreenQueue !== null + ? currentOffscreenQueue.wakeables + : null, + }; + primaryChildFragment.updateQueue = newOffscreenQueue; + } else { + offscreenQueue.transitions = currentTransitions; + offscreenQueue.markerInstances = parentMarkerInstances; + } } } primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree( diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 8b19780af34df..a4fccc0bbd73d 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -2130,6 +2130,7 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { const primaryChildUpdateQueue: OffscreenQueue = { transitions: currentTransitions, markerInstances: parentMarkerInstances, + wakeables: null, }; primaryChildFragment.updateQueue = primaryChildUpdateQueue; } @@ -2216,6 +2217,7 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { const primaryChildUpdateQueue: OffscreenQueue = { transitions: currentTransitions, markerInstances: parentMarkerInstances, + wakeables: null, }; primaryChildFragment.updateQueue = primaryChildUpdateQueue; } diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index 3d6d9cde8b291..2bb8b00d1c4d5 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -1953,40 +1953,65 @@ function commitSuspenseHydrationCallbacks( } } -function attachSuspenseRetryListeners(finishedWork: Fiber) { +function getRetryCache(finishedWork) { + // TODO: Unify the interface for the retry cache so we don't have to switch + // on the tag like this. + switch (finishedWork.tag) { + case SuspenseComponent: + case SuspenseListComponent: { + let retryCache = finishedWork.stateNode; + if (retryCache === null) { + retryCache = finishedWork.stateNode = new PossiblyWeakSet(); + } + return retryCache; + } + case OffscreenComponent: { + const instance: OffscreenInstance = finishedWork.stateNode; + let retryCache = instance.retryCache; + if (retryCache === null) { + retryCache = instance.retryCache = new PossiblyWeakSet(); + } + return retryCache; + } + default: { + throw new Error( + `Unexpected Suspense handler tag (${finishedWork.tag}). This is a ` + + 'bug in React.', + ); + } + } +} + +function attachSuspenseRetryListeners( + finishedWork: Fiber, + wakeables: Set, +) { // If this boundary just timed out, then it will have a set of wakeables. // For each wakeable, attach a listener so that when it resolves, React // attempts to re-render the boundary in the primary (pre-timeout) state. - const wakeables: Set | null = (finishedWork.updateQueue: any); - if (wakeables !== null) { - finishedWork.updateQueue = null; - let retryCache = finishedWork.stateNode; - if (retryCache === null) { - retryCache = finishedWork.stateNode = new PossiblyWeakSet(); - } - wakeables.forEach(wakeable => { - // Memoize using the boundary fiber to prevent redundant listeners. - const retry = resolveRetryWakeable.bind(null, finishedWork, wakeable); - if (!retryCache.has(wakeable)) { - retryCache.add(wakeable); - - if (enableUpdaterTracking) { - if (isDevToolsPresent) { - if (inProgressLanes !== null && inProgressRoot !== null) { - // If we have pending work still, associate the original updaters with it. - restorePendingUpdaters(inProgressRoot, inProgressLanes); - } else { - throw Error( - 'Expected finished root and lanes to be set. This is a bug in React.', - ); - } + const retryCache = getRetryCache(finishedWork); + wakeables.forEach(wakeable => { + // Memoize using the boundary fiber to prevent redundant listeners. + const retry = resolveRetryWakeable.bind(null, finishedWork, wakeable); + if (!retryCache.has(wakeable)) { + retryCache.add(wakeable); + + if (enableUpdaterTracking) { + if (isDevToolsPresent) { + if (inProgressLanes !== null && inProgressRoot !== null) { + // If we have pending work still, associate the original updaters with it. + restorePendingUpdaters(inProgressRoot, inProgressLanes); + } else { + throw Error( + 'Expected finished root and lanes to be set. This is a bug in React.', + ); } } - - wakeable.then(retry, retry); } - }); - } + + wakeable.then(retry, retry); + } + }); } // This function detects when a Suspense boundary goes from visible to hidden. @@ -2307,7 +2332,11 @@ function commitMutationEffectsOnFiber( } catch (error) { captureCommitPhaseError(finishedWork, finishedWork.return, error); } - attachSuspenseRetryListeners(finishedWork); + const wakeables: Set | null = (finishedWork.updateQueue: any); + if (wakeables !== null) { + finishedWork.updateQueue = null; + attachSuspenseRetryListeners(finishedWork, wakeables); + } } return; } @@ -2362,6 +2391,18 @@ function commitMutationEffectsOnFiber( hideOrUnhideAllChildren(offscreenBoundary, isHidden); } } + + // TODO: Move to passive phase + if (flags & Update) { + const offscreenQueue: OffscreenQueue | null = (finishedWork.updateQueue: any); + if (offscreenQueue !== null) { + const wakeables = offscreenQueue.wakeables; + if (wakeables !== null) { + offscreenQueue.wakeables = null; + attachSuspenseRetryListeners(finishedWork, wakeables); + } + } + } return; } case SuspenseListComponent: { @@ -2369,7 +2410,11 @@ function commitMutationEffectsOnFiber( commitReconciliationEffects(finishedWork); if (flags & Update) { - attachSuspenseRetryListeners(finishedWork); + const wakeables: Set | null = (finishedWork.updateQueue: any); + if (wakeables !== null) { + finishedWork.updateQueue = null; + attachSuspenseRetryListeners(finishedWork, wakeables); + } } return; } @@ -2878,7 +2923,7 @@ function commitPassiveMountOnFiber( if (enableTransitionTracing) { const isFallback = finishedWork.memoizedState; - const queue: OffscreenQueue = (finishedWork.updateQueue: any); + const queue: OffscreenQueue | null = (finishedWork.updateQueue: any); const instance: OffscreenInstance = finishedWork.stateNode; if (queue !== null) { diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index 4c1cb2226c961..95318b19a856c 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -2878,7 +2878,7 @@ function commitPassiveMountOnFiber( if (enableTransitionTracing) { const isFallback = finishedWork.memoizedState; - const queue: OffscreenQueue = (finishedWork.updateQueue: any); + const queue: OffscreenQueue | null = (finishedWork.updateQueue: any); const instance: OffscreenInstance = finishedWork.stateNode; if (queue !== null) { diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index 3fb9f8dfb1263..058a14d802aaa 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -1508,6 +1508,7 @@ function completeWork( } case OffscreenComponent: case LegacyHiddenComponent: { + popSuspenseHandler(workInProgress); popHiddenContext(workInProgress); const nextState: OffscreenState | null = workInProgress.memoizedState; const nextIsHidden = nextState !== null; @@ -1529,7 +1530,11 @@ function completeWork( } else { // Don't bubble properties for hidden children unless we're rendering // at offscreen priority. - if (includesSomeLane(renderLanes, (OffscreenLane: Lane))) { + if ( + includesSomeLane(renderLanes, (OffscreenLane: Lane)) && + // Also don't bubble if the tree suspended + (workInProgress.flags & DidCapture) === NoLanes + ) { bubbleProperties(workInProgress); // Check if there was an insertion or update in the hidden subtree. // If so, we need to hide those nodes in the commit phase, so @@ -1544,6 +1549,12 @@ function completeWork( } } + if (workInProgress.updateQueue !== null) { + // Schedule an effect to attach Suspense retry listeners + // TODO: Move to passive phase + workInProgress.flags |= Update; + } + if (enableCache) { let previousCache: Cache | null = null; if ( diff --git a/packages/react-reconciler/src/ReactFiberOffscreenComponent.js b/packages/react-reconciler/src/ReactFiberOffscreenComponent.js index 4fdff5c8dfb0d..0c989dd53fe67 100644 --- a/packages/react-reconciler/src/ReactFiberOffscreenComponent.js +++ b/packages/react-reconciler/src/ReactFiberOffscreenComponent.js @@ -7,7 +7,7 @@ * @flow */ -import type {ReactNodeList, OffscreenMode} from 'shared/ReactTypes'; +import type {ReactNodeList, OffscreenMode, Wakeable} from 'shared/ReactTypes'; import type {Lanes} from './ReactFiberLane.old'; import type {SpawnedCachePool} from './ReactFiberCacheComponent.new'; import type { @@ -40,10 +40,12 @@ export type OffscreenState = {| export type OffscreenQueue = {| transitions: Array | null, markerInstances: Array | null, -|} | null; + wakeables: Set | null, +|}; export type OffscreenInstance = {| isHidden: boolean, pendingMarkers: Set | null, transitions: Set | null, + retryCache: WeakSet | Set | null, |}; diff --git a/packages/react-reconciler/src/ReactFiberSuspenseContext.new.js b/packages/react-reconciler/src/ReactFiberSuspenseContext.new.js index a49862d3fad85..c73dd77d559fa 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseContext.new.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseContext.new.js @@ -14,7 +14,7 @@ import type {SuspenseState} from './ReactFiberSuspenseComponent.new'; import {enableSuspenseAvoidThisFallback} from 'shared/ReactFeatureFlags'; import {createCursor, push, pop} from './ReactFiberStack.new'; import {isCurrentTreeHidden} from './ReactFiberHiddenContext.new'; -import {SuspenseComponent} from './ReactWorkTags'; +import {SuspenseComponent, OffscreenComponent} from './ReactWorkTags'; // The Suspense handler is the boundary that should capture if something // suspends, i.e. it's the nearest `catch` block on the stack. @@ -77,6 +77,19 @@ export function pushFallbackTreeSuspenseHandler(fiber: Fiber): void { // We're about to render the fallback. If something in the fallback suspends, // it's akin to throwing inside of a `catch` block. This boundary should not // capture. Reuse the existing handler on the stack. + reuseSuspenseHandlerOnStack(fiber); +} + +export function pushOffscreenSuspenseHandler(fiber: Fiber): void { + if (fiber.tag === OffscreenComponent) { + push(suspenseHandlerStackCursor, fiber, fiber); + } else { + // This is a LegacyHidden component. + reuseSuspenseHandlerOnStack(fiber); + } +} + +export function reuseSuspenseHandlerOnStack(fiber: Fiber) { push(suspenseHandlerStackCursor, getSuspenseHandler(), fiber); } diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index 92af7312a78c1..4a75d3076ab6c 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -13,6 +13,7 @@ import type {Lane, Lanes} from './ReactFiberLane.new'; import type {CapturedValue} from './ReactCapturedValue'; import type {Update} from './ReactFiberClassUpdateQueue.new'; import type {Wakeable} from 'shared/ReactTypes'; +import type {OffscreenQueue} from './ReactFiberOffscreenComponent'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; import { @@ -22,6 +23,8 @@ import { FunctionComponent, ForwardRef, SimpleMemoComponent, + SuspenseComponent, + OffscreenComponent, } from './ReactWorkTags'; import { DidCapture, @@ -196,33 +199,6 @@ function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) { } } -function attachRetryListener( - suspenseBoundary: Fiber, - root: FiberRoot, - wakeable: Wakeable, - lanes: Lanes, -) { - // Retry listener - // - // If the fallback does commit, we need to attach a different type of - // listener. This one schedules an update on the Suspense boundary to turn - // the fallback state off. - // - // Stash the wakeable on the boundary fiber so we can access it in the - // commit phase. - // - // When the wakeable resolves, we'll attempt to render the boundary - // again ("retry"). - const wakeables: Set | null = (suspenseBoundary.updateQueue: any); - if (wakeables === null) { - const updateQueue = (new Set(): any); - updateQueue.add(wakeable); - suspenseBoundary.updateQueue = updateQueue; - } else { - wakeables.add(wakeable); - } -} - function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) { if (enableLazyContextPropagation) { const currentSourceFiber = sourceFiber.alternate; @@ -419,20 +395,70 @@ function throwException( // Schedule the nearest Suspense to re-render the timed out view. const suspenseBoundary = getSuspenseHandler(); if (suspenseBoundary !== null) { - suspenseBoundary.flags &= ~ForceClientRender; - markSuspenseBoundaryShouldCapture( - suspenseBoundary, - returnFiber, - sourceFiber, - root, - rootRenderLanes, - ); + switch (suspenseBoundary.tag) { + case SuspenseComponent: { + suspenseBoundary.flags &= ~ForceClientRender; + markSuspenseBoundaryShouldCapture( + suspenseBoundary, + returnFiber, + sourceFiber, + root, + rootRenderLanes, + ); + // Retry listener + // + // If the fallback does commit, we need to attach a different type of + // listener. This one schedules an update on the Suspense boundary to + // turn the fallback state off. + // + // Stash the wakeable on the boundary fiber so we can access it in the + // commit phase. + // + // When the wakeable resolves, we'll attempt to render the boundary + // again ("retry"). + const wakeables: Set | null = (suspenseBoundary.updateQueue: any); + if (wakeables === null) { + suspenseBoundary.updateQueue = new Set([wakeable]); + } else { + wakeables.add(wakeable); + } + break; + } + case OffscreenComponent: { + if (suspenseBoundary.mode & ConcurrentMode) { + suspenseBoundary.flags |= ShouldCapture; + const offscreenQueue: OffscreenQueue | null = (suspenseBoundary.updateQueue: any); + if (offscreenQueue === null) { + const newOffscreenQueue: OffscreenQueue = { + transitions: null, + markerInstances: null, + wakeables: new Set([wakeable]), + }; + suspenseBoundary.updateQueue = newOffscreenQueue; + } else { + const wakeables = offscreenQueue.wakeables; + if (wakeables === null) { + offscreenQueue.wakeables = new Set([wakeable]); + } else { + wakeables.add(wakeable); + } + } + break; + } + } + // eslint-disable-next-line no-fallthrough + default: { + throw new Error( + `Unexpected Suspense handler tag (${suspenseBoundary.tag}). This ` + + 'is a bug in React.', + ); + } + } // We only attach ping listeners in concurrent mode. Legacy Suspense always // commits fallbacks synchronously, so there are no pings. if (suspenseBoundary.mode & ConcurrentMode) { attachPingListener(root, wakeable, rootRenderLanes); } - attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes); return; } else { // No boundary was found. Unless this is a sync update, this is OK. diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.new.js b/packages/react-reconciler/src/ReactFiberUnwindWork.new.js index 2659a74d80fd5..1de655dfb0264 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.new.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.new.js @@ -162,10 +162,24 @@ function unwindWork( popProvider(context, workInProgress); return null; case OffscreenComponent: - case LegacyHiddenComponent: + case LegacyHiddenComponent: { + popSuspenseHandler(workInProgress); popHiddenContext(workInProgress); popTransition(workInProgress, current); + const flags = workInProgress.flags; + if (flags & ShouldCapture) { + workInProgress.flags = (flags & ~ShouldCapture) | DidCapture; + // Captured a suspense effect. Re-render the boundary. + if ( + enableProfilerTimer && + (workInProgress.mode & ProfileMode) !== NoMode + ) { + transferActualDuration(workInProgress); + } + return workInProgress; + } return null; + } case CacheComponent: if (enableCache) { const cache: Cache = workInProgress.memoizedState.cache; @@ -238,6 +252,7 @@ function unwindInterruptedWork( break; case OffscreenComponent: case LegacyHiddenComponent: + popSuspenseHandler(interruptedWork); popHiddenContext(interruptedWork); popTransition(interruptedWork, current); break; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index de03d9f1daa28..74049d3a2136c 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -20,6 +20,7 @@ import type { MarkerTransitionObject, Transition, } from './ReactFiberTracingMarkerComponent.new'; +import type {OffscreenInstance} from './ReactFiberOffscreenComponent'; import { warnAboutDeprecatedLifecycles, @@ -96,6 +97,7 @@ import { ClassComponent, SuspenseComponent, SuspenseListComponent, + OffscreenComponent, FunctionComponent, ForwardRef, MemoComponent, @@ -2748,6 +2750,11 @@ export function resolveRetryWakeable(boundaryFiber: Fiber, wakeable: Wakeable) { case SuspenseListComponent: retryCache = boundaryFiber.stateNode; break; + case OffscreenComponent: { + const instance: OffscreenInstance = boundaryFiber.stateNode; + retryCache = instance.retryCache; + break; + } default: throw new Error( 'Pinged unknown suspense boundary type. ' + diff --git a/packages/react-reconciler/src/__tests__/ReactOffscreenSuspense-test.js b/packages/react-reconciler/src/__tests__/ReactOffscreenSuspense-test.js index b4b531b45e8fb..d2b6dbce65beb 100644 --- a/packages/react-reconciler/src/__tests__/ReactOffscreenSuspense-test.js +++ b/packages/react-reconciler/src/__tests__/ReactOffscreenSuspense-test.js @@ -2,10 +2,12 @@ let React; let ReactNoop; let Scheduler; let act; +let LegacyHidden; let Offscreen; let Suspense; let useState; let useEffect; +let startTransition; let textCache; describe('ReactOffscreen', () => { @@ -16,10 +18,12 @@ describe('ReactOffscreen', () => { ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); act = require('jest-react').act; + LegacyHidden = React.unstable_LegacyHidden; Offscreen = React.unstable_Offscreen; Suspense = React.Suspense; useState = React.useState; useEffect = React.useEffect; + startTransition = React.startTransition; textCache = new Map(); }); @@ -86,6 +90,328 @@ describe('ReactOffscreen', () => { return text; } + // Only works in new reconciler + // @gate variant + // @gate enableOffscreen + test('basic example of suspending inside hidden tree', async () => { + const root = ReactNoop.createRoot(); + + function App() { + return ( + }> + + + + + + + + + + ); + } + + // The hidden tree hasn't finished loading, but we should still be able to + // show the surrounding contents. The outer Suspense boundary + // isn't affected. + await act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Visible', 'Suspend! [Hidden]']); + expect(root).toMatchRenderedOutput(Visible); + + // When the data resolves, we should be able to finish prerendering + // the hidden tree. + await act(async () => { + await resolveText('Hidden'); + }); + expect(Scheduler).toHaveYielded(['Hidden']); + expect(root).toMatchRenderedOutput( + <> + Visible + + , + ); + }); + + // @gate www + test('LegacyHidden does not handle suspense', async () => { + const root = ReactNoop.createRoot(); + + function App() { + return ( + }> + + + + + + + + + + ); + } + + // Unlike Offscreen, LegacyHidden never captures if something suspends + await act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded([ + 'Visible', + 'Suspend! [Hidden]', + 'Loading...', + ]); + // Nearest Suspense boundary switches to a fallback even though the + // suspended content is hidden. + expect(root).toMatchRenderedOutput( + <> + + Loading... + , + ); + }); + + // Only works in new reconciler + // @gate variant + // @gate experimental || www + test("suspending inside currently hidden tree that's switching to visible", async () => { + const root = ReactNoop.createRoot(); + + function Details({open, children}) { + return ( + }> + + + + + {children} + + + ); + } + + // The hidden tree hasn't finished loading, but we should still be able to + // show the surrounding contents. It doesn't matter that there's no + // Suspense boundary because the unfinished content isn't visible. + await act(async () => { + root.render( +
+ +
, + ); + }); + expect(Scheduler).toHaveYielded(['Closed', 'Suspend! [Async]']); + expect(root).toMatchRenderedOutput(Closed); + + // But when we switch the boundary from hidden to visible, it should + // now bubble to the nearest Suspense boundary. + await act(async () => { + startTransition(() => { + root.render( +
+ +
, + ); + }); + }); + expect(Scheduler).toHaveYielded(['Open', 'Suspend! [Async]', 'Loading...']); + // It should suspend with delay to prevent the already-visible Suspense + // boundary from switching to a fallback + expect(root).toMatchRenderedOutput(Closed); + + // Resolve the data and finish rendering + await act(async () => { + await resolveText('Async'); + }); + expect(Scheduler).toHaveYielded(['Open', 'Async']); + expect(root).toMatchRenderedOutput( + <> + Open + Async + , + ); + }); + + // Only works in new reconciler + // @gate variant + // @gate enableOffscreen + test("suspending inside currently visible tree that's switching to hidden", async () => { + const root = ReactNoop.createRoot(); + + function Details({open, children}) { + return ( + }> + + + + + {children} + + + ); + } + + // Initial mount. Nothing suspends + await act(async () => { + root.render( +
+ +
, + ); + }); + expect(Scheduler).toHaveYielded(['Open', '(empty)']); + expect(root).toMatchRenderedOutput( + <> + Open + (empty) + , + ); + + // Update that suspends inside the currently visible tree + await act(async () => { + startTransition(() => { + root.render( +
+ +
, + ); + }); + }); + expect(Scheduler).toHaveYielded(['Open', 'Suspend! [Async]', 'Loading...']); + // It should suspend with delay to prevent the already-visible Suspense + // boundary from switching to a fallback + expect(root).toMatchRenderedOutput( + <> + Open + (empty) + , + ); + + // Update that hides the suspended tree + await act(async () => { + startTransition(() => { + root.render( +
+ +
, + ); + }); + }); + // Now the visible part of the tree can commit without being blocked + // by the suspended content, which is hidden. + expect(Scheduler).toHaveYielded(['Closed', 'Suspend! [Async]']); + expect(root).toMatchRenderedOutput( + <> + Closed + + , + ); + + // Resolve the data and finish rendering + await act(async () => { + await resolveText('Async'); + }); + expect(Scheduler).toHaveYielded(['Async']); + expect(root).toMatchRenderedOutput( + <> + Closed + + , + ); + }); + + // @gate experimental || www + test('update that suspends inside hidden tree', async () => { + let setText; + function Child() { + const [text, _setText] = useState('A'); + setText = _setText; + return ; + } + + function App({show}) { + return ( + + + + + + ); + } + + const root = ReactNoop.createRoot(); + resolveText('A'); + await act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A']); + + await act(async () => { + startTransition(() => { + setText('B'); + }); + }); + }); + + // Only works in new reconciler + // @gate variant + // @gate experimental || www + test('updates at multiple priorities that suspend inside hidden tree', async () => { + let setText; + let setStep; + function Child() { + const [text, _setText] = useState('A'); + setText = _setText; + + const [step, _setStep] = useState(0); + setStep = _setStep; + + return ; + } + + function App({show}) { + return ( + + + + + + ); + } + + const root = ReactNoop.createRoot(); + resolveText('A0'); + await act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A0']); + expect(root).toMatchRenderedOutput(); + + await act(async () => { + setStep(1); + ReactNoop.flushSync(() => { + setText('B'); + }); + }); + expect(Scheduler).toHaveYielded([ + // The high priority render suspends again + 'Suspend! [B0]', + // There's still pending work in another lane, so we should attempt + // that, too. + 'Suspend! [B1]', + ]); + expect(root).toMatchRenderedOutput(); + + // Resolve the data and finish rendering + await act(async () => { + resolveText('B1'); + }); + expect(Scheduler).toHaveYielded(['B1']); + expect(root).toMatchRenderedOutput(); + }); + + // Only works in new reconciler // @gate enableOffscreen test('detect updates to a hidden tree during a concurrent event', async () => { // This is a pretty complex test case. It relates to how we detect if an diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 48afd882a0c79..010afa06e70f3 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -419,5 +419,6 @@ "431": "React elements are not allowed in ServerContext", "432": "The render was aborted by the server without a reason.", "433": "useId can only be used while React is rendering", - "434": "`dangerouslySetInnerHTML` does not make sense on ." -} \ No newline at end of file + "434": "`dangerouslySetInnerHTML` does not make sense on <title>.", + "435": "Unexpected Suspense handler tag (%s). This is a bug in React." +}