From 30eb267abdb032bf78e6394bd9ad79102e1d3fb1 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Fri, 8 Jul 2022 11:55:53 -0400 Subject: [PATCH] Land forked reconciler changes (#24878) This applies forked changes from the "new" reconciler to the "old" one. Includes: - 67de5e3 [FORKED] Hidden trees should capture Suspense - 6ab05ee [FORKED] Track nearest Suspense handler on stack - 051ac55 [FORKED] Add HiddenContext to track if subtree is hidden --- .../src/ReactFiberBeginWork.old.js | 359 +++++++++++------- .../src/ReactFiberCommitWork.old.js | 105 +++-- .../src/ReactFiberCompleteWork.old.js | 87 +++-- .../src/ReactFiberHiddenContext.old.js | 71 +++- .../src/ReactFiberSuspenseComponent.old.js | 32 -- .../src/ReactFiberSuspenseContext.old.js | 125 ++++-- .../src/ReactFiberThrow.old.js | 131 ++++--- .../src/ReactFiberUnwindWork.old.js | 36 +- .../src/ReactFiberWorkLoop.old.js | 63 ++- .../__tests__/ReactOffscreenSuspense-test.js | 8 - scripts/merge-fork/forked-revisions | 3 - 11 files changed, 634 insertions(+), 386 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index a4fccc0bbd73d..f9a53f6610077 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -38,7 +38,6 @@ import type {UpdateQueue} from './ReactFiberClassUpdateQueue.old'; import type {RootState} from './ReactFiberRoot.old'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.old'; import { - enableSuspenseAvoidThisFallback, enableCPUSuspense, enableUseMutableSource, } from 'shared/ReactFeatureFlags'; @@ -168,14 +167,21 @@ import {shouldError, shouldSuspend} from './ReactFiberReconciler'; import {pushHostContext, pushHostContainer} from './ReactFiberHostContext.old'; import { suspenseStackCursor, - pushSuspenseContext, - InvisibleParentSuspenseContext, + pushSuspenseListContext, ForceSuspenseFallback, - hasSuspenseContext, - setDefaultShallowSuspenseContext, - addSubtreeSuspenseContext, - setShallowSuspenseContext, + hasSuspenseListContext, + setDefaultShallowSuspenseListContext, + setShallowSuspenseListContext, + pushPrimaryTreeSuspenseHandler, + pushFallbackTreeSuspenseHandler, + pushOffscreenSuspenseHandler, + reuseSuspenseHandlerOnStack, + popSuspenseHandler, } from './ReactFiberSuspenseContext.old'; +import { + pushHiddenContext, + reuseHiddenContextOnStack, +} from './ReactFiberHiddenContext.old'; import {findFirstSuspended} from './ReactFiberSuspenseComponent.old'; import { pushProvider, @@ -233,7 +239,6 @@ import { renderDidSuspendDelayIfPossible, markSkippedUpdateLanes, getWorkInProgressRoot, - pushRenderLanes, } from './ReactFiberWorkLoop.old'; import {enqueueConcurrentRenderForLane} from './ReactFiberConcurrentUpdates.old'; import {setWorkInProgressVersion} from './ReactMutableSource.old'; @@ -675,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 @@ -690,57 +741,29 @@ function updateOffscreenComponent( pushTransition(workInProgress, null, null); } } - pushRenderLanes(workInProgress, renderLanes); + reuseHiddenContextOnStack(workInProgress); + pushOffscreenSuspenseHandler(workInProgress); } else if (!includesSomeLane(renderLanes, (OffscreenLane: Lane))) { - let spawnedCachePool: SpawnedCachePool | null = null; // We're hidden, and we're not rendering at Offscreen. We will bail out // and resume this tree later. - let nextBaseLanes; - if (prevState !== null) { - const prevBaseLanes = prevState.baseLanes; - nextBaseLanes = mergeLanes(prevBaseLanes, renderLanes); - if (enableCache) { - // Save the cache pool so we can resume later. - spawnedCachePool = getOffscreenDeferredCache(); - } - } else { - nextBaseLanes = renderLanes; - } - // 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, - cachePool: spawnedCachePool, - }; - 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. - pushRenderLanes(workInProgress, nextBaseLanes); + // Include the base lanes from the last render + const nextBaseLanes = + prevState !== null + ? mergeLanes(prevState.baseLanes, renderLanes) + : renderLanes; - 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; + 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. @@ -751,9 +774,6 @@ function updateOffscreenComponent( cachePool: null, }; workInProgress.memoizedState = nextState; - // Push the lanes that were skipped when we bailed out. - const subtreeRenderLanes = - prevState !== null ? prevState.baseLanes : renderLanes; if (enableCache && current !== null) { // If the render that spawned this one accessed the cache pool, resume // using the same cache. Unless the parent changed, since that means @@ -764,16 +784,18 @@ function updateOffscreenComponent( pushTransition(workInProgress, prevCachePool, null); } - pushRenderLanes(workInProgress, subtreeRenderLanes); + // Push the lanes that were skipped when we bailed out. + if (prevState !== null) { + pushHiddenContext(workInProgress, prevState); + } else { + reuseHiddenContextOnStack(workInProgress); + } + pushOffscreenSuspenseHandler(workInProgress); } } else { // Rendering a visible tree. - let subtreeRenderLanes; if (prevState !== null) { // We're going from hidden -> visible. - - subtreeRenderLanes = mergeLanes(prevState.baseLanes, renderLanes); - let prevCachePool = null; if (enableCache) { // If the render that spawned this one accessed the cache pool, resume @@ -794,13 +816,16 @@ function updateOffscreenComponent( pushTransition(workInProgress, prevCachePool, transitions); + // 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; } else { // We weren't previously hidden, and we still aren't, so there's nothing // special to do. Need to push to the stack regardless, though, to avoid // a push/pop misalignment. - subtreeRenderLanes = renderLanes; if (enableCache) { // If the render that spawned this one accessed the cache pool, resume @@ -810,14 +835,58 @@ function updateOffscreenComponent( 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); + reuseSuspenseHandlerOnStack(workInProgress); } - pushRenderLanes(workInProgress, subtreeRenderLanes); } reconcileChildren(current, workInProgress, nextChildren, renderLanes); 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. @@ -1981,7 +2050,6 @@ function updateSuspenseOffscreenState( // TODO: Probably should inline this back function shouldRemainOnFallback( - suspenseContext: SuspenseContext, current: null | Fiber, workInProgress: Fiber, renderLanes: Lanes, @@ -2001,7 +2069,8 @@ function shouldRemainOnFallback( } // Not currently showing content. Consult the Suspense context. - return hasSuspenseContext( + const suspenseContext: SuspenseContext = suspenseStackCursor.current; + return hasSuspenseListContext( suspenseContext, (ForceSuspenseFallback: SuspenseContext), ); @@ -2022,50 +2091,18 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { } } - let suspenseContext: SuspenseContext = suspenseStackCursor.current; - let showFallback = false; const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags; - if ( didSuspend || - shouldRemainOnFallback( - suspenseContext, - current, - workInProgress, - renderLanes, - ) + shouldRemainOnFallback(current, workInProgress, renderLanes) ) { // Something in this boundary's subtree already suspended. Switch to // rendering the fallback children. showFallback = true; workInProgress.flags &= ~DidCapture; - } else { - // Attempting the main content - if ( - current === null || - (current.memoizedState: null | SuspenseState) !== null - ) { - // This is a new mount or this boundary is already showing a fallback state. - // Mark this subtree context as having at least one invisible parent that could - // handle the fallback state. - // Avoided boundaries are not considered since they cannot handle preferred fallback states. - if ( - !enableSuspenseAvoidThisFallback || - nextProps.unstable_avoidThisFallback !== true - ) { - suspenseContext = addSubtreeSuspenseContext( - suspenseContext, - InvisibleParentSuspenseContext, - ); - } - } } - suspenseContext = setDefaultShallowSuspenseContext(suspenseContext); - - pushSuspenseContext(workInProgress, suspenseContext); - // OK, the next part is confusing. We're about to reconcile the Suspense // boundary's children. This involves some custom reconciliation logic. Two // main reasons this is so complicated. @@ -2093,24 +2130,40 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { // Special path for hydration // If we're currently hydrating, try to hydrate this boundary. - tryToClaimNextHydratableInstance(workInProgress); - // This could've been a dehydrated suspense component. - const suspenseState: null | SuspenseState = workInProgress.memoizedState; - if (suspenseState !== null) { - const dehydrated = suspenseState.dehydrated; - if (dehydrated !== null) { - return mountDehydratedSuspenseComponent( - workInProgress, - dehydrated, - renderLanes, - ); + if (getIsHydrating()) { + // We must push the suspense handler context *before* attempting to + // hydrate, to avoid a mismatch in case it errors. + if (showFallback) { + pushPrimaryTreeSuspenseHandler(workInProgress); + } else { + pushFallbackTreeSuspenseHandler(workInProgress); + } + tryToClaimNextHydratableInstance(workInProgress); + // This could've been a dehydrated suspense component. + const suspenseState: null | SuspenseState = workInProgress.memoizedState; + if (suspenseState !== null) { + const dehydrated = suspenseState.dehydrated; + if (dehydrated !== null) { + return mountDehydratedSuspenseComponent( + workInProgress, + dehydrated, + renderLanes, + ); + } } + // If hydration didn't succeed, fall through to the normal Suspense path. + // To avoid a stack mismatch we need to pop the Suspense handler that we + // pushed above. This will become less awkward when move the hydration + // logic to its own fiber. + popSuspenseHandler(workInProgress); } const nextPrimaryChildren = nextProps.children; const nextFallbackChildren = nextProps.fallback; if (showFallback) { + pushFallbackTreeSuspenseHandler(workInProgress); + const fallbackFragment = mountSuspenseFallbackChildren( workInProgress, nextPrimaryChildren, @@ -2125,14 +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, - wakeables: null, - }; - 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; + } } } @@ -2144,6 +2202,7 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { // This is a CPU-bound tree. Skip this tree and show a placeholder to // unblock the surrounding content. Then immediately retry after the // initial commit. + pushFallbackTreeSuspenseHandler(workInProgress); const fallbackFragment = mountSuspenseFallbackChildren( workInProgress, nextPrimaryChildren, @@ -2156,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 @@ -2167,6 +2228,7 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { workInProgress.lanes = SomeRetryLane; return fallbackFragment; } else { + pushPrimaryTreeSuspenseHandler(workInProgress); return mountSuspensePrimaryChildren( workInProgress, nextPrimaryChildren, @@ -2194,6 +2256,8 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { } if (showFallback) { + pushFallbackTreeSuspenseHandler(workInProgress); + const nextFallbackChildren = nextProps.fallback; const nextPrimaryChildren = nextProps.children; const fallbackChildFragment = updateSuspenseFallbackChildren( @@ -2214,12 +2278,31 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { const currentTransitions = getPendingTransitions(); if (currentTransitions !== null) { const parentMarkerInstances = getMarkerInstances(); - const primaryChildUpdateQueue: OffscreenQueue = { - transitions: currentTransitions, - markerInstances: parentMarkerInstances, - wakeables: null, - }; - 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( @@ -2229,6 +2312,8 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { workInProgress.memoizedState = SUSPENDED_MARKER; return fallbackChildFragment; } else { + pushPrimaryTreeSuspenseHandler(workInProgress); + const nextPrimaryChildren = nextProps.children; const primaryChildFragment = updateSuspensePrimaryChildren( current, @@ -2599,6 +2684,7 @@ function updateDehydratedSuspenseComponent( ): null | Fiber { if (!didSuspend) { // This is the first render pass. Attempt to hydrate. + pushPrimaryTreeSuspenseHandler(workInProgress); // We should never be hydrating at this point because it is the first pass, // but after we've already committed once. @@ -2765,6 +2851,8 @@ function updateDehydratedSuspenseComponent( if (workInProgress.flags & ForceClientRender) { // Something errored during hydration. Try again without hydrating. + pushPrimaryTreeSuspenseHandler(workInProgress); + workInProgress.flags &= ~ForceClientRender; const capturedValue = createCapturedValue( new Error( @@ -2781,6 +2869,10 @@ function updateDehydratedSuspenseComponent( } else if ((workInProgress.memoizedState: null | SuspenseState) !== null) { // Something suspended and we should still be in dehydrated mode. // Leave the existing child in place. + + // Push to avoid a mismatch + pushFallbackTreeSuspenseHandler(workInProgress); + workInProgress.child = current.child; // The dehydrated completion pass expects this flag to be there // but the normal suspense pass doesn't. @@ -2789,6 +2881,8 @@ function updateDehydratedSuspenseComponent( } else { // Suspended but we should no longer be in dehydrated mode. // Therefore we now have to render the fallback. + pushFallbackTreeSuspenseHandler(workInProgress); + const nextPrimaryChildren = nextProps.children; const nextFallbackChildren = nextProps.fallback; const fallbackChildFragment = mountSuspenseFallbackAfterRetryWithoutHydrating( @@ -3084,12 +3178,12 @@ function updateSuspenseListComponent( let suspenseContext: SuspenseContext = suspenseStackCursor.current; - const shouldForceFallback = hasSuspenseContext( + const shouldForceFallback = hasSuspenseListContext( suspenseContext, (ForceSuspenseFallback: SuspenseContext), ); if (shouldForceFallback) { - suspenseContext = setShallowSuspenseContext( + suspenseContext = setShallowSuspenseListContext( suspenseContext, ForceSuspenseFallback, ); @@ -3107,9 +3201,9 @@ function updateSuspenseListComponent( renderLanes, ); } - suspenseContext = setDefaultShallowSuspenseContext(suspenseContext); + suspenseContext = setDefaultShallowSuspenseListContext(suspenseContext); } - pushSuspenseContext(workInProgress, suspenseContext); + pushSuspenseListContext(workInProgress, suspenseContext); if ((workInProgress.mode & ConcurrentMode) === NoMode) { // In legacy mode, SuspenseList doesn't work so we just @@ -3577,10 +3671,9 @@ function attemptEarlyBailoutIfNoScheduledUpdate( const state: SuspenseState | null = workInProgress.memoizedState; if (state !== null) { if (state.dehydrated !== null) { - pushSuspenseContext( - workInProgress, - setDefaultShallowSuspenseContext(suspenseStackCursor.current), - ); + // We're not going to render the children, so this is just to maintain + // push/pop symmetry + pushPrimaryTreeSuspenseHandler(workInProgress); // We know that this component will suspend again because if it has // been unsuspended it has committed as a resolved Suspense component. // If it needs to be retried, it should have work scheduled on it. @@ -3603,10 +3696,7 @@ function attemptEarlyBailoutIfNoScheduledUpdate( } else { // The primary child fragment does not have pending work marked // on it - pushSuspenseContext( - workInProgress, - setDefaultShallowSuspenseContext(suspenseStackCursor.current), - ); + pushPrimaryTreeSuspenseHandler(workInProgress); // The primary children do not have pending work with sufficient // priority. Bailout. const child = bailoutOnAlreadyFinishedWork( @@ -3626,10 +3716,7 @@ function attemptEarlyBailoutIfNoScheduledUpdate( } } } else { - pushSuspenseContext( - workInProgress, - setDefaultShallowSuspenseContext(suspenseStackCursor.current), - ); + pushPrimaryTreeSuspenseHandler(workInProgress); } break; } @@ -3687,7 +3774,7 @@ function attemptEarlyBailoutIfNoScheduledUpdate( renderState.tail = null; renderState.lastEffect = null; } - pushSuspenseContext(workInProgress, suspenseStackCursor.current); + pushSuspenseListContext(workInProgress, suspenseStackCursor.current); if (hasChildWork) { break; diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index ab279b6b05e3e..7415997335ea7 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -1962,40 +1962,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. @@ -2325,7 +2350,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; } @@ -2383,6 +2412,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: { @@ -2390,7 +2431,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; } diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js index 6dd8fb07ab09a..d21f726693dc9 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -27,7 +27,6 @@ import type { SuspenseState, SuspenseListRenderState, } from './ReactFiberSuspenseComponent.old'; -import type {SuspenseContext} from './ReactFiberSuspenseContext.old'; import type {OffscreenState} from './ReactFiberOffscreenComponent'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.old'; import type {Cache} from './ReactFiberCacheComponent.old'; @@ -110,14 +109,17 @@ import { } from './ReactFiberHostContext.old'; import { suspenseStackCursor, - InvisibleParentSuspenseContext, - hasSuspenseContext, - popSuspenseContext, - pushSuspenseContext, - setShallowSuspenseContext, + popSuspenseListContext, + popSuspenseHandler, + pushSuspenseListContext, + setShallowSuspenseListContext, ForceSuspenseFallback, - setDefaultShallowSuspenseContext, + setDefaultShallowSuspenseListContext, } from './ReactFiberSuspenseContext.old'; +import { + popHiddenContext, + isCurrentTreeHidden, +} from './ReactFiberHiddenContext.old'; import {findFirstSuspended} from './ReactFiberSuspenseComponent.old'; import { isContextProvider as isLegacyContextProvider, @@ -147,9 +149,7 @@ import { renderDidSuspend, renderDidSuspendDelayIfPossible, renderHasNotSuspendedYet, - popRenderLanes, getRenderTargetTime, - subtreeRenderLanes, getWorkInProgressTransitions, } from './ReactFiberWorkLoop.old'; import { @@ -1086,7 +1086,7 @@ function completeWork( return null; } case SuspenseComponent: { - popSuspenseContext(workInProgress); + popSuspenseHandler(workInProgress); const nextState: null | SuspenseState = workInProgress.memoizedState; // Special path for dehydrated boundaries. We may eventually move this @@ -1195,25 +1195,23 @@ function completeWork( // If this render already had a ping or lower pri updates, // and this is the first time we know we're going to suspend we // should be able to immediately restart from within throwException. - const hasInvisibleChildContext = - current === null && - (workInProgress.memoizedProps.unstable_avoidThisFallback !== - true || - !enableSuspenseAvoidThisFallback); - if ( - hasInvisibleChildContext || - hasSuspenseContext( - suspenseStackCursor.current, - (InvisibleParentSuspenseContext: SuspenseContext), - ) - ) { - // If this was in an invisible tree or a new render, then showing - // this boundary is ok. - renderDidSuspend(); - } else { - // Otherwise, we're going to have to hide content so we should - // suspend for longer if possible. + + // Check if this is a "bad" fallback state or a good one. A bad + // fallback state is one that we only show as a last resort; if this + // is a transition, we'll block it from displaying, and wait for + // more data to arrive. + const isBadFallback = + // It's bad to switch to a fallback if content is already visible + (current !== null && !prevDidTimeout && !isCurrentTreeHidden()) || + // Experimental: Some fallbacks are always bad + (enableSuspenseAvoidThisFallback && + workInProgress.memoizedProps.unstable_avoidThisFallback === + true); + + if (isBadFallback) { renderDidSuspendDelayIfPossible(); + } else { + renderDidSuspend(); } } } @@ -1275,7 +1273,7 @@ function completeWork( return null; } case SuspenseListComponent: { - popSuspenseContext(workInProgress); + popSuspenseListContext(workInProgress); const renderState: null | SuspenseListRenderState = workInProgress.memoizedState; @@ -1341,11 +1339,11 @@ function completeWork( workInProgress.subtreeFlags = NoFlags; resetChildFibers(workInProgress, renderLanes); - // Set up the Suspense Context to force suspense and immediately - // rerender the children. - pushSuspenseContext( + // Set up the Suspense List Context to force suspense and + // immediately rerender the children. + pushSuspenseListContext( workInProgress, - setShallowSuspenseContext( + setShallowSuspenseListContext( suspenseStackCursor.current, ForceSuspenseFallback, ), @@ -1468,14 +1466,16 @@ function completeWork( // setting it the first time we go from not suspended to suspended. let suspenseContext = suspenseStackCursor.current; if (didSuspendAlready) { - suspenseContext = setShallowSuspenseContext( + suspenseContext = setShallowSuspenseListContext( suspenseContext, ForceSuspenseFallback, ); } else { - suspenseContext = setDefaultShallowSuspenseContext(suspenseContext); + suspenseContext = setDefaultShallowSuspenseListContext( + suspenseContext, + ); } - pushSuspenseContext(workInProgress, suspenseContext); + pushSuspenseListContext(workInProgress, suspenseContext); // Do a pass over the next row. // Don't bubble properties in this case. return next; @@ -1508,7 +1508,8 @@ function completeWork( } case OffscreenComponent: case LegacyHiddenComponent: { - popRenderLanes(workInProgress); + 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(subtreeRenderLanes, (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/ReactFiberHiddenContext.old.js b/packages/react-reconciler/src/ReactFiberHiddenContext.old.js index 087fc9e69be5c..c62fa3d12fa81 100644 --- a/packages/react-reconciler/src/ReactFiberHiddenContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHiddenContext.old.js @@ -1 +1,70 @@ -// Intentionally blank. File only exists in new reconciler fork. +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Fiber} from './ReactInternalTypes'; +import type {StackCursor} from './ReactFiberStack.old'; +import type {Lanes} from './ReactFiberLane.old'; + +import {createCursor, push, pop} from './ReactFiberStack.old'; + +import {getRenderLanes, setRenderLanes} from './ReactFiberWorkLoop.old'; +import {NoLanes, mergeLanes} from './ReactFiberLane.old'; + +// TODO: Remove `renderLanes` context in favor of hidden context +type HiddenContext = { + // Represents the lanes that must be included when processing updates in + // order to reveal the hidden content. + // TODO: Remove `subtreeLanes` context from work loop in favor of this one. + baseLanes: number, +}; + +// TODO: This isn't being used yet, but it's intended to replace the +// InvisibleParentContext that is currently managed by SuspenseContext. +export const currentTreeHiddenStackCursor: StackCursor = createCursor( + null, +); +export const prevRenderLanesStackCursor: StackCursor = createCursor( + NoLanes, +); + +export function pushHiddenContext(fiber: Fiber, context: HiddenContext): void { + const prevRenderLanes = getRenderLanes(); + push(prevRenderLanesStackCursor, prevRenderLanes, fiber); + push(currentTreeHiddenStackCursor, context, fiber); + + // When rendering a subtree that's currently hidden, we must include all + // lanes that would have rendered if the hidden subtree hadn't been deferred. + // That is, in order to reveal content from hidden -> visible, we must commit + // all the updates that we skipped when we originally hid the tree. + setRenderLanes(mergeLanes(prevRenderLanes, context.baseLanes)); +} + +export function reuseHiddenContextOnStack(fiber: Fiber): void { + // This subtree is not currently hidden, so we don't need to add any lanes + // to the render lanes. But we still need to push something to avoid a + // context mismatch. Reuse the existing context on the stack. + push(prevRenderLanesStackCursor, getRenderLanes(), fiber); + push( + currentTreeHiddenStackCursor, + currentTreeHiddenStackCursor.current, + fiber, + ); +} + +export function popHiddenContext(fiber: Fiber): void { + // Restore the previous render lanes from the stack + setRenderLanes(prevRenderLanesStackCursor.current); + + pop(currentTreeHiddenStackCursor, fiber); + pop(prevRenderLanesStackCursor, fiber); +} + +export function isCurrentTreeHidden() { + return currentTreeHiddenStackCursor.current !== null; +} diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js index 2089dc6cac567..cdcde4fd67009 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js @@ -13,7 +13,6 @@ import type {SuspenseInstance} from './ReactFiberHostConfig'; import type {Lane} from './ReactFiberLane.old'; import type {TreeContext} from './ReactFiberTreeContext.old'; -import {enableSuspenseAvoidThisFallback} from 'shared/ReactFeatureFlags'; import {SuspenseComponent, SuspenseListComponent} from './ReactWorkTags'; import {NoFlags, DidCapture} from './ReactFiberFlags'; import { @@ -67,37 +66,6 @@ export type SuspenseListRenderState = {| tailMode: SuspenseListTailMode, |}; -export function shouldCaptureSuspense( - workInProgress: Fiber, - hasInvisibleParent: boolean, -): boolean { - // If it was the primary children that just suspended, capture and render the - // fallback. Otherwise, don't capture and bubble to the next boundary. - const nextState: SuspenseState | null = workInProgress.memoizedState; - if (nextState !== null) { - if (nextState.dehydrated !== null) { - // A dehydrated boundary always captures. - return true; - } - return false; - } - const props = workInProgress.memoizedProps; - // Regular boundaries always capture. - if ( - !enableSuspenseAvoidThisFallback || - props.unstable_avoidThisFallback !== true - ) { - return true; - } - // If it's a boundary we should avoid, then we prefer to bubble up to the - // parent boundary if it is currently invisible. - if (hasInvisibleParent) { - return false; - } - // If the parent is not able to handle it, we must handle it. - return true; -} - export function findFirstSuspended(row: Fiber): null | Fiber { let node = row; while (node !== null) { diff --git a/packages/react-reconciler/src/ReactFiberSuspenseContext.old.js b/packages/react-reconciler/src/ReactFiberSuspenseContext.old.js index 2bb3512fa301a..4186467d65343 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseContext.old.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseContext.old.js @@ -9,33 +9,109 @@ import type {Fiber} from './ReactInternalTypes'; import type {StackCursor} from './ReactFiberStack.old'; +import type {SuspenseState} from './ReactFiberSuspenseComponent.old'; +import {enableSuspenseAvoidThisFallback} from 'shared/ReactFeatureFlags'; import {createCursor, push, pop} from './ReactFiberStack.old'; +import {isCurrentTreeHidden} from './ReactFiberHiddenContext.old'; +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. +const suspenseHandlerStackCursor: StackCursor = createCursor( + null, +); + +function shouldAvoidedBoundaryCapture( + workInProgress: Fiber, + handlerOnStack: Fiber, + props: any, +): boolean { + if (enableSuspenseAvoidThisFallback) { + // If the parent is already showing content, and we're not inside a hidden + // tree, then we should show the avoided fallback. + if (handlerOnStack.alternate !== null && !isCurrentTreeHidden()) { + return true; + } + + // If the handler on the stack is also an avoided boundary, then we should + // favor this inner one. + if ( + handlerOnStack.tag === SuspenseComponent && + handlerOnStack.memoizedProps.unstable_avoidThisFallback === true + ) { + return true; + } + + // If this avoided boundary is dehydrated, then it should capture. + const suspenseState: SuspenseState | null = workInProgress.memoizedState; + if (suspenseState !== null && suspenseState.dehydrated !== null) { + return true; + } + } + + // If none of those cases apply, then we should avoid this fallback and show + // the outer one instead. + return false; +} + +export function pushPrimaryTreeSuspenseHandler(handler: Fiber): void { + const props = handler.pendingProps; + const handlerOnStack = suspenseHandlerStackCursor.current; + if ( + enableSuspenseAvoidThisFallback && + props.unstable_avoidThisFallback === true && + handlerOnStack !== null && + !shouldAvoidedBoundaryCapture(handler, handlerOnStack, props) + ) { + // This boundary should not capture if something suspends. Reuse the + // existing handler on the stack. + push(suspenseHandlerStackCursor, handlerOnStack, handler); + } else { + // Push this handler onto the stack. + push(suspenseHandlerStackCursor, handler, handler); + } +} + +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); +} + +export function getSuspenseHandler(): Fiber | null { + return suspenseHandlerStackCursor.current; +} + +export function popSuspenseHandler(fiber: Fiber): void { + pop(suspenseHandlerStackCursor, fiber); +} + +// SuspenseList context +// TODO: Move to a separate module? We may change the SuspenseList +// implementation to hide/show in the commit phase, anyway. export opaque type SuspenseContext = number; export opaque type SubtreeSuspenseContext: SuspenseContext = number; export opaque type ShallowSuspenseContext: SuspenseContext = number; const DefaultSuspenseContext: SuspenseContext = 0b00; -// The Suspense Context is split into two parts. The lower bits is -// inherited deeply down the subtree. The upper bits only affect -// this immediate suspense boundary and gets reset each new -// boundary or suspense list. const SubtreeSuspenseContextMask: SuspenseContext = 0b01; -// Subtree Flags: - -// InvisibleParentSuspenseContext indicates that one of our parent Suspense -// boundaries is not currently showing visible main content. -// Either because it is already showing a fallback or is not mounted at all. -// We can use this to determine if it is desirable to trigger a fallback at -// the parent. If not, then we might need to trigger undesirable boundaries -// and/or suspend the commit to avoid hiding the parent content. -export const InvisibleParentSuspenseContext: SubtreeSuspenseContext = 0b01; - -// Shallow Flags: - // ForceSuspenseFallback can be used by SuspenseList to force newly added // items into their fallback state during one of the render passes. export const ForceSuspenseFallback: ShallowSuspenseContext = 0b10; @@ -44,40 +120,33 @@ export const suspenseStackCursor: StackCursor = createCursor( DefaultSuspenseContext, ); -export function hasSuspenseContext( +export function hasSuspenseListContext( parentContext: SuspenseContext, flag: SuspenseContext, ): boolean { return (parentContext & flag) !== 0; } -export function setDefaultShallowSuspenseContext( +export function setDefaultShallowSuspenseListContext( parentContext: SuspenseContext, ): SuspenseContext { return parentContext & SubtreeSuspenseContextMask; } -export function setShallowSuspenseContext( +export function setShallowSuspenseListContext( parentContext: SuspenseContext, shallowContext: ShallowSuspenseContext, ): SuspenseContext { return (parentContext & SubtreeSuspenseContextMask) | shallowContext; } -export function addSubtreeSuspenseContext( - parentContext: SuspenseContext, - subtreeContext: SubtreeSuspenseContext, -): SuspenseContext { - return parentContext | subtreeContext; -} - -export function pushSuspenseContext( +export function pushSuspenseListContext( fiber: Fiber, newContext: SuspenseContext, ): void { push(suspenseStackCursor, newContext, fiber); } -export function popSuspenseContext(fiber: Fiber): void { +export function popSuspenseListContext(fiber: Fiber): void { pop(suspenseStackCursor, fiber); } diff --git a/packages/react-reconciler/src/ReactFiberThrow.old.js b/packages/react-reconciler/src/ReactFiberThrow.old.js index b6ddcec76d39f..d34c32770e881 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.old.js +++ b/packages/react-reconciler/src/ReactFiberThrow.old.js @@ -13,17 +13,18 @@ import type {Lane, Lanes} from './ReactFiberLane.old'; import type {CapturedValue} from './ReactCapturedValue'; import type {Update} from './ReactFiberClassUpdateQueue.old'; import type {Wakeable} from 'shared/ReactTypes'; -import type {SuspenseContext} from './ReactFiberSuspenseContext.old'; +import type {OffscreenQueue} from './ReactFiberOffscreenComponent'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; import { ClassComponent, HostRoot, - SuspenseComponent, IncompleteClassComponent, FunctionComponent, ForwardRef, SimpleMemoComponent, + SuspenseComponent, + OffscreenComponent, } from './ReactWorkTags'; import { DidCapture, @@ -34,7 +35,6 @@ import { ForceUpdateForLegacySuspense, ForceClientRender, } from './ReactFiberFlags'; -import {shouldCaptureSuspense} from './ReactFiberSuspenseComponent.old'; import {NoMode, ConcurrentMode, DebugTracingMode} from './ReactTypeOfMode'; import { enableDebugTracing, @@ -50,11 +50,7 @@ import { enqueueUpdate, } from './ReactFiberClassUpdateQueue.old'; import {markFailedErrorBoundaryForHotReloading} from './ReactFiberHotReloading.old'; -import { - suspenseStackCursor, - InvisibleParentSuspenseContext, - hasSuspenseContext, -} from './ReactFiberSuspenseContext.old'; +import {getSuspenseHandler} from './ReactFiberSuspenseContext.old'; import { renderDidError, renderDidSuspendDelayIfPossible, @@ -203,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; @@ -269,26 +238,6 @@ function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) { } } -function getNearestSuspenseBoundaryToCapture(returnFiber: Fiber) { - let node = returnFiber; - const hasInvisibleParentBoundary = hasSuspenseContext( - suspenseStackCursor.current, - (InvisibleParentSuspenseContext: SuspenseContext), - ); - do { - if ( - node.tag === SuspenseComponent && - shouldCaptureSuspense(node, hasInvisibleParentBoundary) - ) { - return node; - } - // This boundary already captured during this render. Continue to the next - // boundary. - node = node.return; - } while (node !== null); - return null; -} - function markSuspenseBoundaryShouldCapture( suspenseBoundary: Fiber, returnFiber: Fiber, @@ -444,22 +393,72 @@ function throwException( } // Schedule the nearest Suspense to re-render the timed out view. - const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber); + 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. @@ -496,7 +495,7 @@ function throwException( // This is a regular error, not a Suspense wakeable. if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) { markDidThrowWhileHydratingDEV(); - const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber); + const suspenseBoundary = getSuspenseHandler(); // If the error was thrown during hydration, we may be able to recover by // discarding the dehydrated content and switching to a client render. // Instead of surfacing the error, find the nearest Suspense boundary diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.old.js b/packages/react-reconciler/src/ReactFiberUnwindWork.old.js index f56c8ce5d2893..218d41919eb2a 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.old.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.old.js @@ -37,7 +37,11 @@ import { } from 'shared/ReactFeatureFlags'; import {popHostContainer, popHostContext} from './ReactFiberHostContext.old'; -import {popSuspenseContext} from './ReactFiberSuspenseContext.old'; +import { + popSuspenseListContext, + popSuspenseHandler, +} from './ReactFiberSuspenseContext.old'; +import {popHiddenContext} from './ReactFiberHiddenContext.old'; import {resetHydrationState} from './ReactFiberHydrationContext.old'; import { isContextProvider as isLegacyContextProvider, @@ -45,7 +49,6 @@ import { popTopLevelContextObject as popTopLevelLegacyContextObject, } from './ReactFiberContext.old'; import {popProvider} from './ReactFiberNewContext.old'; -import {popRenderLanes} from './ReactFiberWorkLoop.old'; import {popCacheProvider} from './ReactFiberCacheComponent.old'; import {transferActualDuration} from './ReactProfilerTimer.old'; import {popTreeContext} from './ReactFiberTreeContext.old'; @@ -118,7 +121,7 @@ function unwindWork( return null; } case SuspenseComponent: { - popSuspenseContext(workInProgress); + popSuspenseHandler(workInProgress); const suspenseState: null | SuspenseState = workInProgress.memoizedState; if (suspenseState !== null && suspenseState.dehydrated !== null) { if (workInProgress.alternate === null) { @@ -146,7 +149,7 @@ function unwindWork( return null; } case SuspenseListComponent: { - popSuspenseContext(workInProgress); + popSuspenseListContext(workInProgress); // SuspenseList doesn't actually catch anything. It should've been // caught by a nested boundary. If not, it should bubble through. return null; @@ -159,10 +162,24 @@ function unwindWork( popProvider(context, workInProgress); return null; case OffscreenComponent: - case LegacyHiddenComponent: - popRenderLanes(workInProgress); + 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; @@ -224,10 +241,10 @@ function unwindInterruptedWork( popHostContainer(interruptedWork); break; case SuspenseComponent: - popSuspenseContext(interruptedWork); + popSuspenseHandler(interruptedWork); break; case SuspenseListComponent: - popSuspenseContext(interruptedWork); + popSuspenseListContext(interruptedWork); break; case ContextProvider: const context: ReactContext = interruptedWork.type._context; @@ -235,7 +252,8 @@ function unwindInterruptedWork( break; case OffscreenComponent: case LegacyHiddenComponent: - popRenderLanes(interruptedWork); + popSuspenseHandler(interruptedWork); + popHiddenContext(interruptedWork); popTransition(interruptedWork, current); break; case CacheComponent: diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 3d3a32d8c9d5d..235d0523cbbf4 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -11,7 +11,6 @@ import type {Wakeable} from 'shared/ReactTypes'; import type {Fiber, FiberRoot} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane.old'; import type {SuspenseState} from './ReactFiberSuspenseComponent.old'; -import type {StackCursor} from './ReactFiberStack.old'; import type {Flags} from './ReactFiberFlags'; import type {FunctionComponentUpdateQueue} from './ReactFiberHooks.old'; import type {EventPriority} from './ReactEventPriorities.old'; @@ -20,6 +19,7 @@ import type { MarkerTransition, Transition, } from './ReactFiberTracingMarkerComponent.old'; +import type {OffscreenInstance} from './ReactFiberOffscreenComponent'; import { warnAboutDeprecatedLifecycles, @@ -96,6 +96,7 @@ import { ClassComponent, SuspenseComponent, SuspenseListComponent, + OffscreenComponent, FunctionComponent, ForwardRef, MemoComponent, @@ -191,11 +192,6 @@ import { createCapturedValueAtFiber, type CapturedValue, } from './ReactCapturedValue'; -import { - push as pushToStack, - pop as popFromStack, - createCursor, -} from './ReactFiberStack.old'; import { enqueueConcurrentRenderForLane, finishQueueingConcurrentUpdates, @@ -284,26 +280,20 @@ let workInProgress: Fiber | null = null; // The lanes we're rendering let workInProgressRootRenderLanes: Lanes = NoLanes; -// Stack that allows components to change the render lanes for its subtree -// This is a superset of the lanes we started working on at the root. The only -// case where it's different from `workInProgressRootRenderLanes` is when we -// enter a subtree that is hidden and needs to be unhidden: Suspense and -// Offscreen component. +// 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 +// the hidden tree hadn't been deferred. This is modified by the +// HiddenContext module. // // Most things in the work loop should deal with workInProgressRootRenderLanes. -// Most things in begin/complete phases should deal with subtreeRenderLanes. -export let subtreeRenderLanes: Lanes = NoLanes; -const subtreeRenderLanesCursor: StackCursor = createCursor(NoLanes); +// Most things in begin/complete phases should deal with renderLanes. +export let renderLanes: Lanes = NoLanes; // Whether to root completed, errored, suspended, etc. let workInProgressRootExitStatus: RootExitStatus = RootInProgress; // A fatal error, if one is thrown let workInProgressRootFatalError: mixed = null; -// "Included" lanes refer to lanes that were worked on during this render. It's -// slightly different than `renderLanes` because `renderLanes` can change as you -// enter and exit an Offscreen tree. This value is the combination of all render -// lanes for the entire render phase. -let workInProgressRootIncludedLanes: Lanes = NoLanes; // The work left over by components that were visited during this render. Only // includes unprocessed updates, not work in bailed out children. let workInProgressRootSkippedLanes: Lanes = NoLanes; @@ -1454,18 +1444,16 @@ export function flushControlled(fn: () => mixed): void { } } -export function pushRenderLanes(fiber: Fiber, lanes: Lanes) { - pushToStack(subtreeRenderLanesCursor, subtreeRenderLanes, fiber); - subtreeRenderLanes = mergeLanes(subtreeRenderLanes, lanes); - workInProgressRootIncludedLanes = mergeLanes( - workInProgressRootIncludedLanes, - lanes, - ); +// This is called by the HiddenContext module when we enter or leave a +// hidden subtree. The stack logic is managed there because that's the only +// place that ever modifies it. Which module it lives in doesn't matter for +// performance because this function will get inlined regardless +export function setRenderLanes(subtreeRenderLanes: Lanes) { + renderLanes = subtreeRenderLanes; } -export function popRenderLanes(fiber: Fiber) { - subtreeRenderLanes = subtreeRenderLanesCursor.current; - popFromStack(subtreeRenderLanesCursor, fiber); +export function getRenderLanes(): Lanes { + return renderLanes; } function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { @@ -1496,7 +1484,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { workInProgressRoot = root; const rootWorkInProgress = createWorkInProgress(root.current, null); workInProgress = rootWorkInProgress; - workInProgressRootRenderLanes = subtreeRenderLanes = workInProgressRootIncludedLanes = lanes; + workInProgressRootRenderLanes = renderLanes = lanes; workInProgressRootExitStatus = RootInProgress; workInProgressRootFatalError = null; workInProgressRootSkippedLanes = NoLanes; @@ -1863,10 +1851,10 @@ function performUnitOfWork(unitOfWork: Fiber): void { let next; if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) { startProfilerTimer(unitOfWork); - next = beginWork(current, unitOfWork, subtreeRenderLanes); + next = beginWork(current, unitOfWork, renderLanes); stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true); } else { - next = beginWork(current, unitOfWork, subtreeRenderLanes); + next = beginWork(current, unitOfWork, renderLanes); } resetCurrentDebugFiberInDEV(); @@ -1900,10 +1888,10 @@ function completeUnitOfWork(unitOfWork: Fiber): void { !enableProfilerTimer || (completedWork.mode & ProfileMode) === NoMode ) { - next = completeWork(current, completedWork, subtreeRenderLanes); + next = completeWork(current, completedWork, renderLanes); } else { startProfilerTimer(completedWork); - next = completeWork(current, completedWork, subtreeRenderLanes); + next = completeWork(current, completedWork, renderLanes); // Update render duration assuming we didn't error. stopProfilerTimerIfRunningAndRecordDelta(completedWork, false); } @@ -1918,7 +1906,7 @@ function completeUnitOfWork(unitOfWork: Fiber): void { // This fiber did not complete because something threw. Pop values off // the stack without entering the complete phase. If this is a boundary, // capture values if possible. - const next = unwindWork(current, completedWork, subtreeRenderLanes); + const next = unwindWork(current, completedWork, renderLanes); // Because this fiber did not complete, don't reset its lanes. @@ -2761,6 +2749,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 d2b6dbce65beb..290dd33b89a09 100644 --- a/packages/react-reconciler/src/__tests__/ReactOffscreenSuspense-test.js +++ b/packages/react-reconciler/src/__tests__/ReactOffscreenSuspense-test.js @@ -90,8 +90,6 @@ 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(); @@ -172,8 +170,6 @@ describe('ReactOffscreen', () => { ); }); - // 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(); @@ -233,8 +229,6 @@ describe('ReactOffscreen', () => { ); }); - // 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(); @@ -354,8 +348,6 @@ describe('ReactOffscreen', () => { }); }); - // Only works in new reconciler - // @gate variant // @gate experimental || www test('updates at multiple priorities that suspend inside hidden tree', async () => { let setText; diff --git a/scripts/merge-fork/forked-revisions b/scripts/merge-fork/forked-revisions index 64912a3ba95e7..e69de29bb2d1d 100644 --- a/scripts/merge-fork/forked-revisions +++ b/scripts/merge-fork/forked-revisions @@ -1,3 +0,0 @@ -67de5e3fb09eecfab91321246246095058a708a9 [FORKED] Hidden trees should capture Suspense -6ab05ee2e9c5b1f4c8dc1f7ae8906bf613788ba7 [FORKED] Track nearest Suspense handler on stack -051ac55cb75f426b81f8f75b143f34255476b9bc [FORKED] Add HiddenContext to track if subtree is hidden