From f9275aa2215b18cd883493be41f97dcb5eff4caf Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Fri, 23 Aug 2024 12:57:36 -0400 Subject: [PATCH] Schedule prerender after something suspends Adds the concept of a "prerender". These special renders are spawned whenever something suspends (and we're not already prerendering). The purpose is to move speculative rendering work into a separate phase that does not block the UI from updating. For example, during a transition, if something suspends, we should not speculatively prerender siblings that will be replaced by a fallback in the UI until *after* the fallback has been shown to the user. --- .../__tests__/ReactCacheOld-test.internal.js | 25 +- .../src/__tests__/ReactDOMFiberAsync-test.js | 2 +- .../src/__tests__/ReactDOMForm-test.js | 61 ++++- .../react-reconciler/src/ReactFiberLane.js | 50 ++++ .../react-reconciler/src/ReactFiberRoot.js | 1 + .../src/ReactFiberWorkLoop.js | 122 +++++++-- .../src/ReactInternalTypes.js | 1 + .../src/__tests__/ActivitySuspense-test.js | 20 +- .../src/__tests__/ReactActWarnings-test.js | 9 +- .../src/__tests__/ReactAsyncActions-test.js | 35 ++- .../src/__tests__/ReactCPUSuspense-test.js | 6 +- .../ReactConcurrentErrorRecovery-test.js | 74 ++++- .../src/__tests__/ReactDeferredValue-test.js | 10 + .../src/__tests__/ReactExpiration-test.js | 9 +- .../ReactHooksWithNoopRenderer-test.js | 51 +++- .../src/__tests__/ReactLazy-test.internal.js | 36 ++- .../__tests__/ReactSuspense-test.internal.js | 42 ++- .../ReactSuspenseEffectsSemantics-test.js | 49 +++- .../src/__tests__/ReactSuspenseList-test.js | 199 ++++++++++++-- .../ReactSuspenseWithNoopRenderer-test.js | 256 ++++++++++++++++-- .../src/__tests__/ReactTransition-test.js | 77 +++++- .../src/__tests__/ReactUse-test.js | 48 +++- .../src/__tests__/useMemoCache-test.js | 28 +- .../__tests__/useSyncExternalStore-test.js | 4 + 24 files changed, 1080 insertions(+), 135 deletions(-) diff --git a/packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js b/packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js index c448a25b83179..7589463c49e7b 100644 --- a/packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js +++ b/packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js @@ -148,7 +148,15 @@ describe('ReactCache', () => { error = e; } expect(error.message).toMatch('Failed to load: Hi'); - assertLog(['Promise rejected [Hi]', 'Error! [Hi]', 'Error! [Hi]']); + assertLog([ + 'Promise rejected [Hi]', + 'Error! [Hi]', + 'Error! [Hi]', + + ...(gate('enableSiblingPrerendering') + ? ['Error! [Hi]', 'Error! [Hi]'] + : []), + ]); // Should throw again on a subsequent read root.render(); @@ -191,6 +199,7 @@ describe('ReactCache', () => { } }); + // @gate enableSiblingPrerendering it('evicts least recently used values', async () => { ReactCache.unstable_setGlobalCacheLimit(3); @@ -206,15 +215,13 @@ describe('ReactCache', () => { await waitForAll(['Suspend! [1]', 'Loading...']); jest.advanceTimersByTime(100); assertLog(['Promise resolved [1]']); - await waitForAll([1, 'Suspend! [2]']); + await waitForAll([1, 'Suspend! [2]', 1, 'Suspend! [2]', 'Suspend! [3]']); jest.advanceTimersByTime(100); - assertLog(['Promise resolved [2]']); - await waitForAll([1, 2, 'Suspend! [3]']); + assertLog(['Promise resolved [2]', 'Promise resolved [3]']); + await waitForAll([1, 2, 3]); await act(() => jest.advanceTimersByTime(100)); - assertLog(['Promise resolved [3]', 1, 2, 3]); - expect(root).toMatchRenderedOutput('123'); // Render 1, 4, 5 @@ -234,6 +241,9 @@ describe('ReactCache', () => { 1, 4, 'Suspend! [5]', + 1, + 4, + 'Suspend! [5]', 'Promise resolved [5]', 1, 4, @@ -267,6 +277,9 @@ describe('ReactCache', () => { 1, 2, 'Suspend! [3]', + 1, + 2, + 'Suspend! [3]', 'Promise resolved [3]', 1, 2, diff --git a/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js b/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js index ee843996bef1c..027099d54707c 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js @@ -744,7 +744,7 @@ describe('ReactDOMFiberAsync', () => { // Because it suspended, it remains on the current path expect(div.textContent).toBe('/path/a'); }); - assertLog([]); + assertLog(gate('enableSiblingPrerendering') ? ['Suspend! [/path/b]'] : []); await act(async () => { resolvePromise(); diff --git a/packages/react-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js index 7ba4bea06bacf..b2a45a4c71b1b 100644 --- a/packages/react-dom/src/__tests__/ReactDOMForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -699,7 +699,15 @@ describe('ReactDOMForm', () => { // This should suspend because form actions are implicitly wrapped // in startTransition. await submit(formRef.current); - assertLog(['Pending...', 'Suspend! [Updated]', 'Loading...']); + assertLog([ + 'Pending...', + 'Suspend! [Updated]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') + ? ['Suspend! [Updated]', 'Loading...'] + : []), + ]); expect(container.textContent).toBe('Pending...Initial'); await act(() => resolveText('Updated')); @@ -736,7 +744,15 @@ describe('ReactDOMForm', () => { // Update await submit(formRef.current); - assertLog(['Pending...', 'Suspend! [Count: 1]', 'Loading...']); + assertLog([ + 'Pending...', + 'Suspend! [Count: 1]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') + ? ['Suspend! [Count: 1]', 'Loading...'] + : []), + ]); expect(container.textContent).toBe('Pending...Count: 0'); await act(() => resolveText('Count: 1')); @@ -745,7 +761,15 @@ describe('ReactDOMForm', () => { // Update again await submit(formRef.current); - assertLog(['Pending...', 'Suspend! [Count: 2]', 'Loading...']); + assertLog([ + 'Pending...', + 'Suspend! [Count: 2]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') + ? ['Suspend! [Count: 2]', 'Loading...'] + : []), + ]); expect(container.textContent).toBe('Pending...Count: 1'); await act(() => resolveText('Count: 2')); @@ -789,7 +813,14 @@ describe('ReactDOMForm', () => { assertLog(['Async action started', 'Pending...']); await act(() => resolveText('Wait')); - assertLog(['Suspend! [Updated]', 'Loading...']); + assertLog([ + 'Suspend! [Updated]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') + ? ['Suspend! [Updated]', 'Loading...'] + : []), + ]); expect(container.textContent).toBe('Pending...Initial'); await act(() => resolveText('Updated')); @@ -1475,7 +1506,15 @@ describe('ReactDOMForm', () => { // Now dispatch inside of a transition. This one does not trigger a // loading state. await act(() => startTransition(() => dispatch())); - assertLog(['Count: 1', 'Suspend! [Count: 2]', 'Loading...']); + assertLog([ + 'Count: 1', + 'Suspend! [Count: 2]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') + ? ['Suspend! [Count: 2]', 'Loading...'] + : []), + ]); expect(container.textContent).toBe('Count: 1'); await act(() => resolveText('Count: 2')); @@ -1495,7 +1534,11 @@ describe('ReactDOMForm', () => { const root = ReactDOMClient.createRoot(container); await act(() => root.render()); - assertLog(['Suspend! [Count: 0]']); + assertLog([ + 'Suspend! [Count: 0]', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Count: 0]'] : []), + ]); await act(() => resolveText('Count: 0')); assertLog(['Count: 0']); @@ -1508,7 +1551,11 @@ describe('ReactDOMForm', () => { {withoutStack: true}, ], ]); - assertLog(['Suspend! [Count: 1]']); + assertLog([ + 'Suspend! [Count: 1]', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Count: 1]'] : []), + ]); expect(container.textContent).toBe('Count: 0'); }); diff --git a/packages/react-reconciler/src/ReactFiberLane.js b/packages/react-reconciler/src/ReactFiberLane.js index 6ed4caae70974..2995548fbbee2 100644 --- a/packages/react-reconciler/src/ReactFiberLane.js +++ b/packages/react-reconciler/src/ReactFiberLane.js @@ -229,28 +229,49 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes { const suspendedLanes = root.suspendedLanes; const pingedLanes = root.pingedLanes; + const warmLanes = root.warmLanes; // Do not work on any idle work until all the non-idle work has finished, // even if the work is suspended. const nonIdlePendingLanes = pendingLanes & NonIdleLanes; if (nonIdlePendingLanes !== NoLanes) { + // First check for fresh updates. const nonIdleUnblockedLanes = nonIdlePendingLanes & ~suspendedLanes; if (nonIdleUnblockedLanes !== NoLanes) { nextLanes = getHighestPriorityLanes(nonIdleUnblockedLanes); } else { + // No fresh updates. Check if suspended work has been pinged. const nonIdlePingedLanes = nonIdlePendingLanes & pingedLanes; if (nonIdlePingedLanes !== NoLanes) { nextLanes = getHighestPriorityLanes(nonIdlePingedLanes); + } else { + // Nothing has been pinged. Check for lanes that need to be prewarmed. + const lanesToPrewarm = nonIdlePendingLanes & ~warmLanes; + if (lanesToPrewarm !== NoLanes) { + nextLanes = getHighestPriorityLanes(lanesToPrewarm); + } } } } else { // The only remaining work is Idle. + // TODO: Idle isn't really used anywhere, and the thinking around + // speculative rendering has evolved since this was implemented. Consider + // removing until we've thought about this again. + + // First check for fresh updates. const unblockedLanes = pendingLanes & ~suspendedLanes; if (unblockedLanes !== NoLanes) { nextLanes = getHighestPriorityLanes(unblockedLanes); } else { + // No fresh updates. Check if suspended work has been pinged. if (pingedLanes !== NoLanes) { nextLanes = getHighestPriorityLanes(pingedLanes); + } else { + // Nothing has been pinged. Check for lanes that need to be prewarmed. + const lanesToPrewarm = pendingLanes & ~warmLanes; + if (lanesToPrewarm !== NoLanes) { + nextLanes = getHighestPriorityLanes(lanesToPrewarm); + } } } } @@ -335,6 +356,21 @@ export function getNextLanesToFlushSync( return NoLanes; } +export function checkIfRootIsPrerendering( + root: FiberRoot, + renderLanes: Lanes, +): boolean { + const pendingLanes = root.pendingLanes; + const suspendedLanes = root.suspendedLanes; + const pingedLanes = root.pingedLanes; + // Remove lanes that are suspended (but not pinged) + const unblockedLanes = pendingLanes & ~(suspendedLanes & ~pingedLanes); + + // If there are no unsuspended or pinged lanes, that implies that we're + // performing a prerender. + return (unblockedLanes & renderLanes) === 0; +} + export function getEntangledLanes(root: FiberRoot, renderLanes: Lanes): Lanes { let entangledLanes = renderLanes; @@ -670,6 +706,7 @@ export function markRootUpdated(root: FiberRoot, updateLane: Lane) { if (updateLane !== IdleLane) { root.suspendedLanes = NoLanes; root.pingedLanes = NoLanes; + root.warmLanes = NoLanes; } } @@ -677,10 +714,19 @@ export function markRootSuspended( root: FiberRoot, suspendedLanes: Lanes, spawnedLane: Lane, + didSkipSuspendedSiblings: boolean, ) { root.suspendedLanes |= suspendedLanes; root.pingedLanes &= ~suspendedLanes; + if (!didSkipSuspendedSiblings) { + // Mark these lanes as warm so we know there's nothing else to work on. + root.warmLanes |= suspendedLanes; + } else { + // Render unwound without attempting all the siblings. Do no mark the lanes + // as warm. This will cause a prewarm render to be scheduled. + } + // The suspended lanes are no longer CPU-bound. Clear their expiration times. const expirationTimes = root.expirationTimes; let lanes = suspendedLanes; @@ -700,6 +746,9 @@ export function markRootSuspended( export function markRootPinged(root: FiberRoot, pingedLanes: Lanes) { root.pingedLanes |= root.suspendedLanes & pingedLanes; + // The data that just resolved could have unblocked additional children, which + // will also need to be prewarmed if something suspends again. + root.warmLanes &= ~pingedLanes; } export function markRootFinished( @@ -714,6 +763,7 @@ export function markRootFinished( // Let's try everything again root.suspendedLanes = NoLanes; root.pingedLanes = NoLanes; + root.warmLanes = NoLanes; root.expiredLanes &= remainingLanes; diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index 27590743ad79f..f62a5d7158db1 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -75,6 +75,7 @@ function FiberRootNode( this.pendingLanes = NoLanes; this.suspendedLanes = NoLanes; this.pingedLanes = NoLanes; + this.warmLanes = NoLanes; this.expiredLanes = NoLanes; this.finishedLanes = NoLanes; this.errorRecoveryDisabledLanes = NoLanes; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 3190cd0035b33..bdba919c79243 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -161,6 +161,7 @@ import { SyncUpdateLanes, UpdateLanes, claimNextTransitionLane, + checkIfRootIsPrerendering, } from './ReactFiberLane'; import { DiscreteEventPriority, @@ -329,6 +330,15 @@ const SuspendedOnHydration: SuspendedReason = 8; let workInProgressSuspendedReason: SuspendedReason = NotSuspended; let workInProgressThrownValue: mixed = null; +// Tracks whether any siblings were skipped during the unwind phase after +// something suspends. Used to determine whether to schedule another render +// to prewarm the skipped siblings. +let workInProgressRootDidSkipSuspendedSiblings: boolean = false; +// Whether the work-in-progress render is the result of a prewarm/prerender. +// This tells us whether or not we should render the siblings after +// something suspends. +let workInProgressRootIsPrerendering: boolean = false; + // Whether a ping listener was attached during this render. This is slightly // different that whether something suspended, because we don't add multiple // listeners to a promise we've already seen (per root and lane). @@ -731,6 +741,7 @@ export function scheduleUpdateOnFiber( root, workInProgressRootRenderLanes, workInProgressDeferredLane, + workInProgressRootDidSkipSuspendedSiblings, ); } @@ -797,6 +808,7 @@ export function scheduleUpdateOnFiber( root, workInProgressRootRenderLanes, workInProgressDeferredLane, + workInProgressRootDidSkipSuspendedSiblings, ); } } @@ -909,7 +921,12 @@ export function performConcurrentWorkOnRoot( // The render unwound without completing the tree. This happens in special // cases where need to exit the current render without producing a // consistent tree or committing. - markRootSuspended(root, lanes, NoLane); + markRootSuspended( + root, + lanes, + NoLane, + workInProgressRootDidSkipSuspendedSiblings, + ); } else { // The render completed. @@ -965,7 +982,12 @@ export function performConcurrentWorkOnRoot( } if (exitStatus === RootFatalErrored) { prepareFreshStack(root, NoLanes); - markRootSuspended(root, lanes, NoLane); + markRootSuspended( + root, + lanes, + NoLane, + workInProgressRootDidSkipSuspendedSiblings, + ); break; } @@ -1088,7 +1110,12 @@ function finishConcurrentRender( // This is a transition, so we should exit without committing a // placeholder and without scheduling a timeout. Delay indefinitely // until we receive more data. - markRootSuspended(root, lanes, workInProgressDeferredLane); + markRootSuspended( + root, + lanes, + workInProgressDeferredLane, + workInProgressRootDidSkipSuspendedSiblings, + ); return; } // Commit the placeholder. @@ -1132,7 +1159,12 @@ function finishConcurrentRender( // Don't bother with a very short suspense time. if (msUntilTimeout > 10) { - markRootSuspended(root, lanes, workInProgressDeferredLane); + markRootSuspended( + root, + lanes, + workInProgressDeferredLane, + workInProgressRootDidSkipSuspendedSiblings, + ); const nextLanes = getNextLanes(root, NoLanes); if (nextLanes !== NoLanes) { @@ -1156,6 +1188,7 @@ function finishConcurrentRender( workInProgressRootDidIncludeRecursiveRenderUpdate, lanes, workInProgressDeferredLane, + workInProgressRootDidSkipSuspendedSiblings, ), msUntilTimeout, ); @@ -1170,6 +1203,7 @@ function finishConcurrentRender( workInProgressRootDidIncludeRecursiveRenderUpdate, lanes, workInProgressDeferredLane, + workInProgressRootDidSkipSuspendedSiblings, ); } } @@ -1182,6 +1216,7 @@ function commitRootWhenReady( didIncludeRenderPhaseUpdate: boolean, lanes: Lanes, spawnedLane: Lane, + didSkipSuspendedSiblings: boolean, ) { // TODO: Combine retry throttling with Suspensey commits. Right now they run // one after the other. @@ -1221,7 +1256,7 @@ function commitRootWhenReady( didIncludeRenderPhaseUpdate, ), ); - markRootSuspended(root, lanes, spawnedLane); + markRootSuspended(root, lanes, spawnedLane, didSkipSuspendedSiblings); return; } } @@ -1333,6 +1368,7 @@ function markRootSuspended( root: FiberRoot, suspendedLanes: Lanes, spawnedLane: Lane, + didSkipSuspendedSiblings: boolean, ) { // When suspending, we should always exclude lanes that were pinged or (more // rarely, since we try to avoid it) updated during the render phase. @@ -1341,7 +1377,12 @@ function markRootSuspended( suspendedLanes, workInProgressRootInterleavedUpdatedLanes, ); - _markRootSuspended(root, suspendedLanes, spawnedLane); + _markRootSuspended( + root, + suspendedLanes, + spawnedLane, + didSkipSuspendedSiblings, + ); } // This is the entry point for synchronous tasks that don't go @@ -1393,7 +1434,7 @@ export function performSyncWorkOnRoot(root: FiberRoot, lanes: Lanes): null { if (exitStatus === RootFatalErrored) { prepareFreshStack(root, NoLanes); - markRootSuspended(root, lanes, NoLane); + markRootSuspended(root, lanes, NoLane, false); ensureRootIsScheduled(root); return null; } @@ -1402,7 +1443,12 @@ export function performSyncWorkOnRoot(root: FiberRoot, lanes: Lanes): null { // The render unwound without completing the tree. This happens in special // cases where need to exit the current render without producing a // consistent tree or committing. - markRootSuspended(root, lanes, workInProgressDeferredLane); + markRootSuspended( + root, + lanes, + workInProgressDeferredLane, + workInProgressRootDidSkipSuspendedSiblings, + ); ensureRootIsScheduled(root); return null; } @@ -1636,6 +1682,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { workInProgressRootRenderLanes = lanes; workInProgressSuspendedReason = NotSuspended; workInProgressThrownValue = null; + workInProgressRootDidSkipSuspendedSiblings = false; + workInProgressRootIsPrerendering = checkIfRootIsPrerendering(root, lanes); workInProgressRootDidAttachPingListener = false; workInProgressRootExitStatus = RootInProgress; workInProgressRootSkippedLanes = NoLanes; @@ -1940,6 +1988,7 @@ export function renderDidSuspendDelayIfPossible(): void { workInProgressRoot, workInProgressRootRenderLanes, workInProgressDeferredLane, + workInProgressRootDidSkipSuspendedSiblings, ); } } @@ -2589,7 +2638,30 @@ function throwAndUnwindWorkLoop( if (unitOfWork.flags & Incomplete) { // Unwind the stack until we reach the nearest boundary. - unwindUnitOfWork(unitOfWork); + let skipSiblings; + if (!enableSiblingPrerendering) { + skipSiblings = true; + } else { + if (workInProgressRootIsPrerendering) { + // This is a prerender. Don't skip the siblings. + skipSiblings = false; + } else if ( + // The currenty algorithm for both hydration and error handling assumes + // that the tree is rendered sequentially. So we always skip the siblings. + getIsHydrating() || + workInProgressSuspendedReason === SuspendedOnError + ) { + skipSiblings = true; + // We intentionally don't set workInProgressRootDidSkipSuspendedSiblings, + // because we don't want to trigger another prerender attempt. + } else { + // This is not a prerender. Skip the siblings during this render. A + // separate prerender will be scheduled for later. + skipSiblings = true; + workInProgressRootDidSkipSuspendedSiblings = true; + } + } + unwindUnitOfWork(unitOfWork, skipSiblings); } else { // Although the fiber suspended, we're intentionally going to commit it in // an inconsistent state. We can do this safely in cases where we know the @@ -2625,15 +2697,16 @@ function completeUnitOfWork(unitOfWork: Fiber): void { // sibling. If there are no more siblings, return to the parent fiber. let completedWork: Fiber = unitOfWork; do { - if (__DEV__) { - if ((completedWork.flags & Incomplete) !== NoFlags) { - // NOTE: If we re-enable sibling prerendering in some cases, this branch - // is where we would switch to the unwinding path. - console.error( - 'Internal React error: Expected this fiber to be complete, but ' + - "it isn't. It should have been unwound. This is a bug in React.", - ); - } + if ((completedWork.flags & Incomplete) !== NoFlags) { + // This fiber did not complete, because one of its children did not + // complete. Switch to unwinding the stack instead of completing it. + // + // The reason "unwind" and "complete" is interleaved is because when + // something suspends, we continue rendering the siblings even though + // they will be replaced by a fallback. + const skipSiblings = workInProgressRootDidSkipSuspendedSiblings; + unwindUnitOfWork(completedWork, skipSiblings); + return; } // The current, flushed, state of this fiber is the alternate. Ideally @@ -2697,7 +2770,7 @@ function completeUnitOfWork(unitOfWork: Fiber): void { } } -function unwindUnitOfWork(unitOfWork: Fiber): void { +function unwindUnitOfWork(unitOfWork: Fiber, skipSiblings: boolean): void { let incompleteWork: Fiber = unitOfWork; do { // The current, flushed, state of this fiber is the alternate. Ideally @@ -2754,9 +2827,14 @@ function unwindUnitOfWork(unitOfWork: Fiber): void { returnFiber.deletions = null; } - // NOTE: If we re-enable sibling prerendering in some cases, here we - // would switch to the normal completion path: check if a sibling - // exists, and if so, begin work on it. + if (!skipSiblings) { + const siblingFiber = incompleteWork.sibling; + if (siblingFiber !== null) { + // This branch will return us to the normal work loop. + workInProgress = siblingFiber; + return; + } + } // Otherwise, return to the parent // $FlowFixMe[incompatible-type] we bail out when we get a null diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 871325f379385..d160aa0d228f5 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -256,6 +256,7 @@ type BaseFiberRootProperties = { pendingLanes: Lanes, suspendedLanes: Lanes, pingedLanes: Lanes, + warmLanes: Lanes, expiredLanes: Lanes, errorRecoveryDisabledLanes: Lanes, shellSuspendCounter: number, diff --git a/packages/react-reconciler/src/__tests__/ActivitySuspense-test.js b/packages/react-reconciler/src/__tests__/ActivitySuspense-test.js index d02ae70e523ca..c2a29c8f2139f 100644 --- a/packages/react-reconciler/src/__tests__/ActivitySuspense-test.js +++ b/packages/react-reconciler/src/__tests__/ActivitySuspense-test.js @@ -215,7 +215,15 @@ describe('Activity Suspense', () => { ); }); }); - assertLog(['Open', 'Suspend! [Async]', 'Loading...']); + assertLog([ + 'Open', + 'Suspend! [Async]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') + ? ['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); @@ -276,7 +284,15 @@ describe('Activity Suspense', () => { ); }); }); - assertLog(['Open', 'Suspend! [Async]', 'Loading...']); + assertLog([ + 'Open', + 'Suspend! [Async]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') + ? ['Open', 'Suspend! [Async]', 'Loading...'] + : []), + ]); // It should suspend with delay to prevent the already-visible Suspense // boundary from switching to a fallback expect(root).toMatchRenderedOutput( diff --git a/packages/react-reconciler/src/__tests__/ReactActWarnings-test.js b/packages/react-reconciler/src/__tests__/ReactActWarnings-test.js index 03e04e8b1dc40..21fb6727527ab 100644 --- a/packages/react-reconciler/src/__tests__/ReactActWarnings-test.js +++ b/packages/react-reconciler/src/__tests__/ReactActWarnings-test.js @@ -349,7 +349,14 @@ describe('act warnings', () => { root.render(); }); }); - assertLog(['Suspend! [Async]', 'Loading...']); + assertLog([ + 'Suspend! [Async]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') + ? ['Suspend! [Async]', 'Loading...'] + : []), + ]); expect(root).toMatchRenderedOutput('(empty)'); // This is a ping, not a retry, because no fallback is showing. diff --git a/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js b/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js index ad4c2d5e9737a..4cf830876b642 100644 --- a/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js +++ b/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js @@ -297,7 +297,15 @@ describe('ReactAsyncActions', () => { // This will schedule an update on C, and also the async action scope // will end. This will allow React to attempt to render the updates. await act(() => resolveText('Wait before updating C')); - assertLog(['Async action ended', 'Pending: false', 'Suspend! [A1]']); + assertLog([ + 'Async action ended', + 'Pending: false', + 'Suspend! [A1]', + + ...(gate('enableSiblingPrerendering') + ? ['Pending: false', 'Suspend! [A1]', 'Suspend! [B1]', 'Suspend! [C1]'] + : []), + ]); expect(root).toMatchRenderedOutput( <> Pending: true @@ -309,7 +317,15 @@ describe('ReactAsyncActions', () => { // together, only when the all of A, B, and C updates are unblocked is the // render allowed to proceed. await act(() => resolveText('A1')); - assertLog(['Pending: false', 'A1', 'Suspend! [B1]']); + assertLog([ + 'Pending: false', + 'A1', + 'Suspend! [B1]', + + ...(gate('enableSiblingPrerendering') + ? ['Pending: false', 'A1', 'Suspend! [B1]', 'Suspend! [C1]'] + : []), + ]); expect(root).toMatchRenderedOutput( <> Pending: true @@ -317,7 +333,16 @@ describe('ReactAsyncActions', () => { , ); await act(() => resolveText('B1')); - assertLog(['Pending: false', 'A1', 'B1', 'Suspend! [C1]']); + assertLog([ + 'Pending: false', + 'A1', + 'B1', + 'Suspend! [C1]', + + ...(gate('enableSiblingPrerendering') + ? ['Pending: false', 'A1', 'B1', 'Suspend! [C1]'] + : []), + ]); expect(root).toMatchRenderedOutput( <> Pending: true @@ -690,6 +715,10 @@ describe('ReactAsyncActions', () => { // automatically reverted. 'Pending: false', 'Suspend! [B]', + + ...(gate('enableSiblingPrerendering') + ? ['Pending: false', 'Suspend! [B]'] + : []), ]); // Resolve the transition diff --git a/packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js b/packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js index d640f87205e45..b36164fca285b 100644 --- a/packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js +++ b/packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js @@ -231,7 +231,11 @@ describe('ReactSuspenseWithNoopRenderer', () => { ); }); // Inner contents suspended, so we continue showing a fallback. - assertLog(['Suspend! [Inner]']); + assertLog([ + 'Suspend! [Inner]', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Inner]'] : []), + ]); expect(root).toMatchRenderedOutput( <> Outer diff --git a/packages/react-reconciler/src/__tests__/ReactConcurrentErrorRecovery-test.js b/packages/react-reconciler/src/__tests__/ReactConcurrentErrorRecovery-test.js index 1af61323fc941..cec34dc722799 100644 --- a/packages/react-reconciler/src/__tests__/ReactConcurrentErrorRecovery-test.js +++ b/packages/react-reconciler/src/__tests__/ReactConcurrentErrorRecovery-test.js @@ -209,7 +209,16 @@ describe('ReactConcurrentErrorRecovery', () => { root.render(); }); }); - assertLog(['Suspend! [A2]', 'Loading...', 'Suspend! [B2]', 'Loading...']); + assertLog([ + 'Suspend! [A2]', + 'Loading...', + 'Suspend! [B2]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') + ? ['Suspend! [A2]', 'Loading...', 'Suspend! [B2]', 'Loading...'] + : []), + ]); // Because this is a refresh, we don't switch to a fallback expect(root).toMatchRenderedOutput('A1B1'); @@ -220,7 +229,16 @@ describe('ReactConcurrentErrorRecovery', () => { // Because we're still suspended on A, we can't show an error boundary. We // should wait for A to resolve. - assertLog(['Suspend! [A2]', 'Loading...', 'Error! [B2]', 'Oops!']); + assertLog([ + 'Suspend! [A2]', + 'Loading...', + 'Error! [B2]', + 'Oops!', + + ...(gate('enableSiblingPrerendering') + ? ['Suspend! [A2]', 'Loading...', 'Error! [B2]', 'Oops!'] + : []), + ]); // Remain on previous screen. expect(root).toMatchRenderedOutput('A1B1'); @@ -281,7 +299,16 @@ describe('ReactConcurrentErrorRecovery', () => { root.render(); }); }); - assertLog(['Suspend! [A2]', 'Loading...', 'Suspend! [B2]', 'Loading...']); + assertLog([ + 'Suspend! [A2]', + 'Loading...', + 'Suspend! [B2]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') + ? ['Suspend! [A2]', 'Loading...', 'Suspend! [B2]', 'Loading...'] + : []), + ]); // Because this is a refresh, we don't switch to a fallback expect(root).toMatchRenderedOutput('A1B1'); @@ -292,7 +319,16 @@ describe('ReactConcurrentErrorRecovery', () => { // Because we're still suspended on B, we can't show an error boundary. We // should wait for B to resolve. - assertLog(['Error! [A2]', 'Oops!', 'Suspend! [B2]', 'Loading...']); + assertLog([ + 'Error! [A2]', + 'Oops!', + 'Suspend! [B2]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') + ? ['Error! [A2]', 'Oops!', 'Suspend! [B2]', 'Loading...'] + : []), + ]); // Remain on previous screen. expect(root).toMatchRenderedOutput('A1B1'); @@ -328,7 +364,11 @@ describe('ReactConcurrentErrorRecovery', () => { root.render(); }); }); - assertLog(['Suspend! [Async]']); + assertLog([ + 'Suspend! [Async]', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Async]'] : []), + ]); expect(root).toMatchRenderedOutput(null); // This also works if the suspended component is wrapped with an error @@ -344,7 +384,11 @@ describe('ReactConcurrentErrorRecovery', () => { ); }); }); - assertLog(['Suspend! [Async]']); + assertLog([ + 'Suspend! [Async]', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Async]'] : []), + ]); expect(root).toMatchRenderedOutput(null); // Continues rendering once data resolves @@ -397,8 +441,14 @@ describe('ReactConcurrentErrorRecovery', () => { ); }); }); - assertLog(['Suspend! [Async]']); - // The render suspended without committing or surfacing the error. + assertLog([ + 'Suspend! [Async]', + + ...(gate('enableSiblingPrerendering') + ? ['Suspend! [Async]', 'Caught an error: Oops!'] + : []), + ]); + // The render suspended without committing the error. expect(root).toMatchRenderedOutput(null); // Try the reverse order, too: throw then suspend @@ -414,7 +464,13 @@ describe('ReactConcurrentErrorRecovery', () => { ); }); }); - assertLog(['Suspend! [Async]']); + assertLog([ + 'Suspend! [Async]', + + ...(gate('enableSiblingPrerendering') + ? ['Suspend! [Async]', 'Caught an error: Oops!'] + : []), + ]); expect(root).toMatchRenderedOutput(null); await act(async () => { diff --git a/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js b/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js index b2c38696cc028..bf4ecd02eb3be 100644 --- a/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js +++ b/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js @@ -420,6 +420,8 @@ describe('ReactDeferredValue', () => { // The initial value suspended, so we attempt the final value, which // also suspends. 'Suspend! [Final]', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Final]'] : []), ]); expect(root).toMatchRenderedOutput(null); @@ -459,6 +461,8 @@ describe('ReactDeferredValue', () => { // The initial value suspended, so we attempt the final value, which // also suspends. 'Suspend! [Final]', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Final]'] : []), ]); expect(root).toMatchRenderedOutput(null); @@ -531,6 +535,8 @@ describe('ReactDeferredValue', () => { // The initial value suspended, so we attempt the final value, which // also suspends. 'Suspend! [Final]', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Final]'] : []), ]); expect(root).toMatchRenderedOutput(null); @@ -540,6 +546,8 @@ describe('ReactDeferredValue', () => { 'Loading...', // Still waiting for the final value. 'Suspend! [Final]', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Final]'] : []), ]); expect(root).toMatchRenderedOutput('Loading...'); @@ -584,6 +592,8 @@ describe('ReactDeferredValue', () => { // boundaries work, where we always prefer to show the innermost // loading state.) 'Suspend! [Content]', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Content]'] : []), ]); // Still showing the App preview state because the inner // content suspended. diff --git a/packages/react-reconciler/src/__tests__/ReactExpiration-test.js b/packages/react-reconciler/src/__tests__/ReactExpiration-test.js index 1d659cae3c3bf..50fc16770916f 100644 --- a/packages/react-reconciler/src/__tests__/ReactExpiration-test.js +++ b/packages/react-reconciler/src/__tests__/ReactExpiration-test.js @@ -652,7 +652,14 @@ describe('ReactExpiration', () => { React.startTransition(() => { root.render(); }); - await waitForAll(['Suspend! [A1]', 'Loading...']); + await waitForAll([ + 'Suspend! [A1]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') + ? ['Suspend! [A1]', 'B', 'C', 'Loading...'] + : []), + ]); // Lots of time elapses before the promise resolves Scheduler.unstable_advanceTime(10000); diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js index b0d1182278135..640a3d467e499 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js @@ -652,14 +652,22 @@ describe('ReactHooksWithNoopRenderer', () => { React.startTransition(() => { root.render(); }); - await waitForAll(['Suspend!']); + await waitForAll([ + 'Suspend!', + + ...(gate('enableSiblingPrerendering') ? ['Suspend!'] : []), + ]); expect(root).toMatchRenderedOutput(); // Rendering again should suspend again. React.startTransition(() => { root.render(); }); - await waitForAll(['Suspend!']); + await waitForAll([ + 'Suspend!', + + ...(gate('enableSiblingPrerendering') ? ['Suspend!'] : []), + ]); }); it('discards render phase updates if something suspends, but not other updates in the same component', async () => { @@ -709,14 +717,22 @@ describe('ReactHooksWithNoopRenderer', () => { setLabel('B'); }); - await waitForAll(['Suspend!']); + await waitForAll([ + 'Suspend!', + + ...(gate('enableSiblingPrerendering') ? ['Suspend!'] : []), + ]); expect(root).toMatchRenderedOutput(); // Rendering again should suspend again. React.startTransition(() => { root.render(); }); - await waitForAll(['Suspend!']); + await waitForAll([ + 'Suspend!', + + ...(gate('enableSiblingPrerendering') ? ['Suspend!'] : []), + ]); // Flip the signal back to "cancel" the update. However, the update to // label should still proceed. It shouldn't have been dropped. @@ -3495,6 +3511,13 @@ describe('ReactHooksWithNoopRenderer', () => { 'Before... Pending: true', 'Suspend! [After... Pending: false]', 'Loading... Pending: false', + + ...(gate('enableSiblingPrerendering') + ? [ + 'Suspend! [After... Pending: false]', + 'Loading... Pending: false', + ] + : []), ]); expect(ReactNoop).toMatchRenderedOutput( , @@ -3563,7 +3586,17 @@ describe('ReactHooksWithNoopRenderer', () => { await act(async () => { _setText('B'); - await waitForAll(['B', 'A', 'B', 'Suspend! [B]', 'Loading']); + await waitForAll([ + 'B', + 'A', + 'B', + 'Suspend! [B]', + 'Loading', + + ...(gate('enableSiblingPrerendering') + ? ['B', 'Suspend! [B]', 'Loading'] + : []), + ]); await waitForAll([]); expect(ReactNoop).toMatchRenderedOutput( <> @@ -4201,7 +4234,13 @@ describe('ReactHooksWithNoopRenderer', () => { await act(async () => { await resolveText('A'); }); - assertLog(['Promise resolved [A]', 'A', 'Suspend! [B]']); + assertLog([ + 'Promise resolved [A]', + 'A', + 'Suspend! [B]', + + ...(gate('enableSiblingPrerendering') ? ['A', 'Suspend! [B]'] : []), + ]); await act(() => { root.render(null); diff --git a/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js b/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js index 22ffbfc4f0cb4..b6c422749683a 100644 --- a/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js @@ -198,7 +198,11 @@ describe('ReactLazy', () => { await resolveFakeImport(Foo); - await waitForAll(['Foo']); + await waitForAll([ + 'Foo', + + ...(gate('enableSiblingPrerendering') ? ['Foo'] : []), + ]); expect(root).not.toMatchRenderedOutput('FooBar'); await act(() => resolveFakeImport(Bar)); @@ -235,6 +239,13 @@ describe('ReactLazy', () => { assertConsoleErrorDev([ 'Expected the result of a dynamic import() call', 'Expected the result of a dynamic import() call', + + ...(gate('enableSiblingPrerendering') + ? [ + 'Expected the result of a dynamic import() call', + 'Expected the result of a dynamic import() call', + ] + : []), ]); expect(root).not.toMatchRenderedOutput('Hi'); }); @@ -1072,7 +1083,7 @@ describe('ReactLazy', () => { expect(ref.current).toBe(null); await act(() => resolveFakeImport(Foo)); - assertLog(['Foo']); + assertLog(['Foo', ...(gate('enableSiblingPrerendering') ? ['Foo'] : [])]); await act(() => resolveFakeImport(ForwardRefBar)); assertLog(['Foo', 'forwardRef', 'Bar']); @@ -1388,7 +1399,12 @@ describe('ReactLazy', () => { expect(root).not.toMatchRenderedOutput('AB'); await act(() => resolveFakeImport(ChildA)); - assertLog(['A', 'Init B']); + assertLog([ + 'A', + 'Init B', + + ...(gate('enableSiblingPrerendering') ? ['A'] : []), + ]); await act(() => resolveFakeImport(ChildB)); assertLog(['A', 'B', 'Did mount: A', 'Did mount: B']); @@ -1472,10 +1488,20 @@ describe('ReactLazy', () => { React.startTransition(() => { root.update(); }); - await waitForAll(['Init B2', 'Loading...']); + await waitForAll([ + 'Init B2', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Loading...'] : []), + ]); await act(() => resolveFakeImport(ChildB2)); // We need to flush to trigger the second one to load. - assertLog(['Init A2', 'Loading...']); + assertLog([ + 'Init A2', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Loading...'] : []), + ]); await act(() => resolveFakeImport(ChildA2)); assertLog(['b', 'a', 'Did update: b', 'Did update: a']); expect(root).toMatchRenderedOutput('ba'); diff --git a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js index 88c6e38160eea..164d3a9599eee 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js @@ -136,6 +136,10 @@ describe('ReactSuspense', () => { // A suspends 'Suspend! [A]', 'Loading...', + + ...(gate('enableSiblingPrerendering') + ? ['Foo', 'Bar', 'Suspend! [A]', 'B', 'Loading...'] + : []), ]); expect(container.textContent).toEqual(''); @@ -275,7 +279,15 @@ describe('ReactSuspense', () => { expect(container.textContent).toEqual('Loading...'); await resolveText('A'); - await waitForAll(['A', 'Suspend! [B]', 'Loading more...']); + await waitForAll([ + 'A', + 'Suspend! [B]', + 'Loading more...', + + ...(gate('enableSiblingPrerendering') + ? ['A', 'Suspend! [B]', 'Loading more...'] + : []), + ]); // By this point, we have enough info to show "A" and "Loading more..." // However, we've just shown the outer fallback. So we'll delay @@ -327,7 +339,14 @@ describe('ReactSuspense', () => { // B starts loading. Parent boundary is in throttle. // Still shows parent loading under throttle jest.advanceTimersByTime(10); - await waitForAll(['Suspend! [B]', 'Loading more...']); + await waitForAll([ + 'Suspend! [B]', + 'Loading more...', + + ...(gate('enableSiblingPrerendering') + ? ['A', 'Suspend! [B]', 'Loading more...'] + : []), + ]); expect(container.textContent).toEqual('Loading...'); // !! B could have finished before the throttle, but we show a fallback. @@ -361,7 +380,15 @@ describe('ReactSuspense', () => { expect(container.textContent).toEqual('Loading...'); await resolveText('A'); - await waitForAll(['A', 'Suspend! [B]', 'Loading more...']); + await waitForAll([ + 'A', + 'Suspend! [B]', + 'Loading more...', + + ...(gate('enableSiblingPrerendering') + ? ['A', 'Suspend! [B]', 'Loading more...'] + : []), + ]); // By this point, we have enough info to show "A" and "Loading more..." // However, we've just shown the outer fallback. So we'll delay @@ -655,7 +682,14 @@ describe('ReactSuspense', () => { assertLog(['Suspend! [Child 1]', 'Loading...']); await resolveText('Child 1'); - await waitForAll(['Child 1', 'Suspend! [Child 2]']); + await waitForAll([ + 'Child 1', + 'Suspend! [Child 2]', + + ...(gate('enableSiblingPrerendering') + ? ['Child 1', 'Suspend! [Child 2]'] + : []), + ]); jest.advanceTimersByTime(6000); diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js index e0137575164b0..688ff38a74c0a 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js @@ -1150,7 +1150,19 @@ describe('ReactSuspenseEffectsSemantics', () => { await act(async () => { await resolveText('InnerAsync_1'); }); - assertLog(['Text:Outer render', 'Suspend:OuterAsync_1']); + assertLog([ + 'Text:Outer render', + 'Suspend:OuterAsync_1', + + ...(gate('enableSiblingPrerendering') + ? [ + 'Text:Outer render', + 'Suspend:OuterAsync_1', + 'Text:Inner render', + 'AsyncText:InnerAsync_1 render', + ] + : []), + ]); expect(ReactNoop).toMatchRenderedOutput( <>