diff --git a/packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js b/packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js index 3285f0774ece1..bffb7b96818c0 100644 --- a/packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js +++ b/packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js @@ -18,6 +18,7 @@ let Suspense; let TextResource; let textResourceShouldFail; let waitForAll; +let waitForPaint; let assertLog; let waitForThrow; let act; @@ -37,6 +38,7 @@ describe('ReactCache', () => { waitForAll = InternalTestUtils.waitForAll; assertLog = InternalTestUtils.assertLog; waitForThrow = InternalTestUtils.waitForThrow; + waitForPaint = InternalTestUtils.waitForPaint; act = InternalTestUtils.act; TextResource = createResource( @@ -119,7 +121,12 @@ describe('ReactCache', () => { const root = ReactNoop.createRoot(); root.render(); - await waitForAll(['Suspend! [Hi]', 'Loading...']); + await waitForAll([ + 'Suspend! [Hi]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Hi]'] : []), + ]); jest.advanceTimersByTime(100); assertLog(['Promise resolved [Hi]']); @@ -138,7 +145,12 @@ describe('ReactCache', () => { const root = ReactNoop.createRoot(); root.render(); - await waitForAll(['Suspend! [Hi]', 'Loading...']); + await waitForAll([ + 'Suspend! [Hi]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Hi]'] : []), + ]); textResourceShouldFail = true; let error; @@ -179,12 +191,19 @@ describe('ReactCache', () => { if (__DEV__) { await expect(async () => { - await waitForAll(['App', 'Loading...']); + await waitForAll([ + 'App', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['App'] : []), + ]); }).toErrorDev([ 'Invalid key type. Expected a string, number, symbol, or ' + "boolean, but instead received: [ 'Hi', 100 ]\n\n" + 'To use non-primitive values as keys, you must pass a hash ' + 'function as the second argument to createResource().', + + ...(gate('enableSiblingPrerendering') ? ['Invalid key type'] : []), ]); } else { await waitForAll(['App', 'Loading...']); @@ -204,7 +223,7 @@ describe('ReactCache', () => { , ); - await waitForAll(['Suspend! [1]', 'Loading...']); + await waitForPaint(['Suspend! [1]', 'Loading...']); jest.advanceTimersByTime(100); assertLog(['Promise resolved [1]']); await waitForAll([1, 'Suspend! [2]', 1, 'Suspend! [2]', 'Suspend! [3]']); @@ -225,25 +244,18 @@ describe('ReactCache', () => { , ); - await waitForAll([1, 'Suspend! [4]', 'Loading...']); - - await act(() => jest.advanceTimersByTime(100)); - assertLog([ - 'Promise resolved [4]', - + await waitForAll([ 1, - 4, - 'Suspend! [5]', + 'Suspend! [4]', + 'Loading...', 1, - 4, + 'Suspend! [4]', 'Suspend! [5]', - - 'Promise resolved [5]', - 1, - 4, - 5, ]); + await act(() => jest.advanceTimersByTime(100)); + assertLog(['Promise resolved [4]', 'Promise resolved [5]', 1, 4, 5]); + expect(root).toMatchRenderedOutput('145'); // We've now rendered values 1, 2, 3, 4, 5, over our limit of 3. The least @@ -263,24 +275,14 @@ describe('ReactCache', () => { // 2 and 3 suspend because they were evicted from the cache 'Suspend! [2]', 'Loading...', - ]); - await act(() => jest.advanceTimersByTime(100)); - assertLog([ - 'Promise resolved [2]', - - 1, - 2, - 'Suspend! [3]', 1, - 2, + 'Suspend! [2]', 'Suspend! [3]', - - 'Promise resolved [3]', - 1, - 2, - 3, ]); + + await act(() => jest.advanceTimersByTime(100)); + assertLog(['Promise resolved [2]', 'Promise resolved [3]', 1, 2, 3]); expect(root).toMatchRenderedOutput('123'); }); @@ -355,7 +357,12 @@ describe('ReactCache', () => { , ); - await waitForAll(['Suspend! [Hi]', 'Loading...']); + await waitForAll([ + 'Suspend! [Hi]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Hi]'] : []), + ]); resolveThenable('Hi'); // This thenable improperly resolves twice. We should not update the diff --git a/packages/react-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js index 5a44cb6afec09..168d44722d473 100644 --- a/packages/react-dom/src/__tests__/ReactDOMForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -1459,13 +1459,23 @@ describe('ReactDOMForm', () => { , ), ); - assertLog(['Suspend! [Count: 0]', 'Loading...']); + assertLog([ + 'Suspend! [Count: 0]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Count: 0]'] : []), + ]); await act(() => resolveText('Count: 0')); assertLog(['Count: 0']); // Dispatch outside of a transition. This will trigger a loading state. await act(() => dispatch()); - assertLog(['Suspend! [Count: 1]', 'Loading...']); + assertLog([ + 'Suspend! [Count: 1]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Count: 1]'] : []), + ]); expect(container.textContent).toBe('Loading...'); await act(() => resolveText('Count: 1')); diff --git a/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js b/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js index 50ba64a478888..fa22142702527 100644 --- a/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js @@ -160,7 +160,13 @@ describe('ReactDOMSuspensePlaceholder', () => { }); expect(container.textContent).toEqual('Loading...'); - assertLog(['A', 'Suspend! [B]', 'Loading...']); + assertLog([ + 'A', + 'Suspend! [B]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['A', 'Suspend! [B]', 'C'] : []), + ]); await act(() => { resolveText('B'); }); diff --git a/packages/react-dom/src/__tests__/ReactWrongReturnPointer-test.js b/packages/react-dom/src/__tests__/ReactWrongReturnPointer-test.js index 468d5b54e1ae2..796d429625a70 100644 --- a/packages/react-dom/src/__tests__/ReactWrongReturnPointer-test.js +++ b/packages/react-dom/src/__tests__/ReactWrongReturnPointer-test.js @@ -192,7 +192,13 @@ test('regression (#20932): return pointer is correct before entering deleted tre await act(() => { root.render(); }); - assertLog(['Suspend! [0]', 'Loading Async...', 'Loading Tail...']); + assertLog([ + 'Suspend! [0]', + 'Loading Async...', + 'Loading Tail...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [0]'] : []), + ]); await act(() => { resolveText(0); }); @@ -205,5 +211,7 @@ test('regression (#20932): return pointer is correct before entering deleted tre 'Loading Async...', 'Suspend! [1]', 'Loading Async...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [1]'] : []), ]); }); diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index c5d0c1575be04..e3fba900dd116 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -155,6 +155,7 @@ import { getRenderTargetTime, getWorkInProgressTransitions, shouldRemainOnPreviousScreen, + markSpawnedRetryLane, } from './ReactFiberWorkLoop'; import { OffscreenLane, @@ -600,25 +601,28 @@ function scheduleRetryEffect( // Schedule an effect to attach a retry listener to the promise. // TODO: Move to passive phase workInProgress.flags |= Update; - } else { - // This boundary suspended, but no wakeables were added to the retry - // queue. Check if the renderer suspended commit. If so, this means - // that once the fallback is committed, we can immediately retry - // rendering again, because rendering wasn't actually blocked. Only - // the commit phase. - // TODO: Consider a model where we always schedule an immediate retry, even - // for normal Suspense. That way the retry can partially render up to the - // first thing that suspends. - if (workInProgress.flags & ScheduleRetry) { - const retryLane = - // TODO: This check should probably be moved into claimNextRetryLane - // I also suspect that we need some further consolidation of offscreen - // and retry lanes. - workInProgress.tag !== OffscreenComponent - ? claimNextRetryLane() - : OffscreenLane; - workInProgress.lanes = mergeLanes(workInProgress.lanes, retryLane); - } + } + + // Check if we need to schedule an immediate retry. This should happen + // whenever we unwind a suspended tree without fully rendering its siblings; + // we need to begin the retry so we can start prerendering them. + // + // We also use this mechanism for Suspensey Resources (e.g. stylesheets), + // because those don't actually block the render phase, only the commit phase. + // So we can start rendering even before the resources are ready. + if (workInProgress.flags & ScheduleRetry) { + const retryLane = + // TODO: This check should probably be moved into claimNextRetryLane + // I also suspect that we need some further consolidation of offscreen + // and retry lanes. + workInProgress.tag !== OffscreenComponent + ? claimNextRetryLane() + : OffscreenLane; + workInProgress.lanes = mergeLanes(workInProgress.lanes, retryLane); + + // Track the lanes that have been scheduled for an immediate retry so that + // we can mark them as suspended upon committing the root. + markSpawnedRetryLane(retryLane); } } diff --git a/packages/react-reconciler/src/ReactFiberLane.js b/packages/react-reconciler/src/ReactFiberLane.js index fe36ce548b33a..8f6f7c9c3f94f 100644 --- a/packages/react-reconciler/src/ReactFiberLane.js +++ b/packages/react-reconciler/src/ReactFiberLane.js @@ -26,9 +26,11 @@ import { syncLaneExpirationMs, transitionLaneExpirationMs, retryLaneExpirationMs, + disableLegacyMode, } from 'shared/ReactFeatureFlags'; import {isDevToolsPresent} from './ReactFiberDevToolsHook'; import {clz32} from './clz32'; +import {LegacyRoot} from './ReactRootTags'; // Lane values below should be kept in sync with getLabelForLane(), used by react-devtools-timeline. // If those values are changed that package should be rebuilt and redeployed. @@ -753,10 +755,14 @@ export function markRootPinged(root: FiberRoot, pingedLanes: Lanes) { export function markRootFinished( root: FiberRoot, + finishedLanes: Lanes, remainingLanes: Lanes, spawnedLane: Lane, + updatedLanes: Lanes, + suspendedRetryLanes: Lanes, ) { - const noLongerPendingLanes = root.pendingLanes & ~remainingLanes; + const previouslyPendingLanes = root.pendingLanes; + const noLongerPendingLanes = previouslyPendingLanes & ~remainingLanes; root.pendingLanes = remainingLanes; @@ -812,6 +818,37 @@ export function markRootFinished( NoLanes, ); } + + // suspendedRetryLanes represents the retry lanes spawned by new Suspense + // boundaries during this render that were not later pinged. + // + // These lanes were marked as pending on their associated Suspense boundary + // fiber during the render phase so that we could start rendering them + // before new data streams in. As soon as the fallback commits, we can try + // to render them again. + // + // But since we know they're still suspended, we can skip straight to the + // "prerender" mode (i.e. don't skip over siblings after something + // suspended) instead of the regular mode (i.e. unwind and skip the siblings + // as soon as something suspends to unblock the rest of the update). + if ( + suspendedRetryLanes !== NoLanes && + // Note that we only do this if there were no updates since we started + // rendering. This mirrors the logic in markRootUpdated — whenever we + // receive an update, we reset all the suspended and pinged lanes. + updatedLanes === NoLanes && + !(disableLegacyMode && root.tag === LegacyRoot) + ) { + // We also need to avoid marking a retry lane as suspended if it was already + // pending before this render. We can't say these are now suspended if they + // weren't included in our attempt. + const freshlySpawnedRetryLanes = + suspendedRetryLanes & + // Remove any retry lane that was already pending before our just-finished + // attempt, and also wasn't included in that attempt. + ~(previouslyPendingLanes & ~finishedLanes); + root.suspendedLanes |= freshlySpawnedRetryLanes; + } } function markSpawnedDeferredLane( diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index b729d53bc7fe0..bdba41f0e8d2e 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -128,6 +128,7 @@ import { DidDefer, ShouldSuspendCommit, MaySuspendCommit, + ScheduleRetry, } from './ReactFiberFlags'; import { NoLanes, @@ -365,8 +366,11 @@ let workInProgressRootInterleavedUpdatedLanes: Lanes = NoLanes; let workInProgressRootRenderPhaseUpdatedLanes: Lanes = NoLanes; // Lanes that were pinged (in an interleaved event) during this render. let workInProgressRootPingedLanes: Lanes = NoLanes; -// If this lane scheduled deferred work, this is the lane of the deferred task. +// If this render scheduled deferred work, this is the lane of the deferred task. let workInProgressDeferredLane: Lane = NoLane; +// Represents the retry lanes that were spawned by this render and have not +// been pinged since, implying that they are still suspended. +let workInProgressSuspendedRetryLanes: Lanes = NoLanes; // Errors that are thrown during the render phase. let workInProgressRootConcurrentErrors: Array> | null = null; @@ -1146,6 +1150,8 @@ function finishConcurrentRender( workInProgressTransitions, workInProgressRootDidIncludeRecursiveRenderUpdate, workInProgressDeferredLane, + workInProgressRootInterleavedUpdatedLanes, + workInProgressSuspendedRetryLanes, ); } else { if ( @@ -1188,6 +1194,8 @@ function finishConcurrentRender( workInProgressRootDidIncludeRecursiveRenderUpdate, lanes, workInProgressDeferredLane, + workInProgressRootInterleavedUpdatedLanes, + workInProgressSuspendedRetryLanes, workInProgressRootDidSkipSuspendedSiblings, ), msUntilTimeout, @@ -1203,6 +1211,8 @@ function finishConcurrentRender( workInProgressRootDidIncludeRecursiveRenderUpdate, lanes, workInProgressDeferredLane, + workInProgressRootInterleavedUpdatedLanes, + workInProgressSuspendedRetryLanes, workInProgressRootDidSkipSuspendedSiblings, ); } @@ -1216,6 +1226,8 @@ function commitRootWhenReady( didIncludeRenderPhaseUpdate: boolean, lanes: Lanes, spawnedLane: Lane, + updatedLanes: Lanes, + suspendedRetryLanes: Lanes, didSkipSuspendedSiblings: boolean, ) { // TODO: Combine retry throttling with Suspensey commits. Right now they run @@ -1254,6 +1266,9 @@ function commitRootWhenReady( recoverableErrors, transitions, didIncludeRenderPhaseUpdate, + spawnedLane, + updatedLanes, + suspendedRetryLanes, ), ); markRootSuspended(root, lanes, spawnedLane, didSkipSuspendedSiblings); @@ -1261,13 +1276,15 @@ function commitRootWhenReady( } } - // Otherwise, commit immediately. + // Otherwise, commit immediately.; commitRoot( root, recoverableErrors, transitions, didIncludeRenderPhaseUpdate, spawnedLane, + updatedLanes, + suspendedRetryLanes, ); } @@ -1470,6 +1487,8 @@ export function performSyncWorkOnRoot(root: FiberRoot, lanes: Lanes): null { workInProgressTransitions, workInProgressRootDidIncludeRecursiveRenderUpdate, workInProgressDeferredLane, + workInProgressRootInterleavedUpdatedLanes, + workInProgressSuspendedRetryLanes, ); // Before exiting, make sure there's a callback scheduled for the next @@ -1697,6 +1716,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { workInProgressRootRenderPhaseUpdatedLanes = NoLanes; workInProgressRootPingedLanes = NoLanes; workInProgressDeferredLane = NoLane; + workInProgressSuspendedRetryLanes = NoLanes; workInProgressRootConcurrentErrors = null; workInProgressRootRecoverableErrors = null; workInProgressRootDidIncludeRecursiveRenderUpdate = false; @@ -2701,6 +2721,28 @@ function throwAndUnwindWorkLoop( // separate prerender will be scheduled for later. skipSiblings = true; workInProgressRootDidSkipSuspendedSiblings = true; + + // Because we're skipping the siblings, schedule an immediate retry of + // this boundary. + // + // The reason we do this is because a prerender is only scheduled when + // the root is blocked from committing, i.e. RootSuspendedWithDelay. + // When the root is not blocked, as in the case when we render a + // fallback, the original lane is considered to be finished, and + // therefore no longer in need of being prerendered. However, there's + // still a pending retry that will happen once the data streams in. + // We should start rendering that even before the data streams in so we + // can prerender the siblings. + if ( + suspendedReason === SuspendedOnData || + suspendedReason === SuspendedOnImmediate || + suspendedReason === SuspendedOnDeprecatedThrowPromise + ) { + const boundary = getSuspenseHandler(); + if (boundary !== null && boundary.tag === SuspenseComponent) { + boundary.flags |= ScheduleRetry; + } + } } else { // This is a prerender. Don't skip the siblings. skipSiblings = false; @@ -2721,6 +2763,16 @@ function throwAndUnwindWorkLoop( } } +export function markSpawnedRetryLane(lane: Lane): void { + // Keep track of the retry lanes that were spawned by a fallback during the + // current render and were not later pinged. This will represent the lanes + // that are known to still be suspended. + workInProgressSuspendedRetryLanes = mergeLanes( + workInProgressSuspendedRetryLanes, + lane, + ); +} + function panicOnRootError(root: FiberRoot, error: mixed) { // There's no ancestor that can handle this exception. This should never // happen because the root is supposed to capture all errors that weren't @@ -2899,6 +2951,8 @@ function commitRoot( transitions: Array | null, didIncludeRenderPhaseUpdate: boolean, spawnedLane: Lane, + updatedLanes: Lanes, + suspendedRetryLanes: Lanes, ) { // TODO: This no longer makes any sense. We already wrap the mutation and // layout phases. Should be able to remove. @@ -2914,6 +2968,8 @@ function commitRoot( didIncludeRenderPhaseUpdate, previousUpdateLanePriority, spawnedLane, + updatedLanes, + suspendedRetryLanes, ); } finally { ReactSharedInternals.T = prevTransition; @@ -2930,6 +2986,8 @@ function commitRootImpl( didIncludeRenderPhaseUpdate: boolean, renderPriorityLevel: EventPriority, spawnedLane: Lane, + updatedLanes: Lanes, + suspendedRetryLanes: Lanes, ) { do { // `flushPassiveEffects` will call `flushSyncUpdateQueue` at the end, which @@ -3006,7 +3064,14 @@ function commitRootImpl( const concurrentlyUpdatedLanes = getConcurrentlyUpdatedLanes(); remainingLanes = mergeLanes(remainingLanes, concurrentlyUpdatedLanes); - markRootFinished(root, remainingLanes, spawnedLane); + markRootFinished( + root, + lanes, + remainingLanes, + spawnedLane, + updatedLanes, + suspendedRetryLanes, + ); // Reset this before firing side effects so we can detect recursive updates. didIncludeCommitPhaseUpdate = false; @@ -3712,6 +3777,17 @@ function pingSuspendedRoot( pingedLanes, ); } + + // If something pings the work-in-progress render, any work that suspended + // up to this point may now be unblocked; in other words, no + // longer suspended. + // + // Unlike the broader check above, we only need do this if the lanes match + // exactly. If the lanes don't exactly match, that implies the promise + // was created by an older render. + if (workInProgressSuspendedRetryLanes === workInProgressRootRenderLanes) { + workInProgressSuspendedRetryLanes = NoLanes; + } } ensureRootIsScheduled(root); diff --git a/packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js b/packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js index a8b8d286dc6aa..85365d3e708fc 100644 --- a/packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js +++ b/packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js @@ -240,6 +240,11 @@ describe('Activity StrictMode', () => { 'Parent mount', 'Parent unmount', 'Parent mount', + + ...(gate('enableSiblingPrerendering') + ? ['Child rendered', 'Child suspended'] + : []), + '------------------------------', 'Child rendered', 'Child rendered', diff --git a/packages/react-reconciler/src/__tests__/ReactActWarnings-test.js b/packages/react-reconciler/src/__tests__/ReactActWarnings-test.js index 03e04e8b1dc40..561741c108622 100644 --- a/packages/react-reconciler/src/__tests__/ReactActWarnings-test.js +++ b/packages/react-reconciler/src/__tests__/ReactActWarnings-test.js @@ -313,13 +313,23 @@ describe('act warnings', () => { act(() => { root.render(); }); - assertLog(['Suspend! [Async]', 'Loading...']); + assertLog([ + 'Suspend! [Async]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Async]'] : []), + ]); expect(root).toMatchRenderedOutput('Loading...'); // This is a retry, not a ping, because we already showed a fallback. expect(() => resolveText('Async')).toErrorDev( - 'A suspended resource finished loading inside a test, but the event ' + - 'was not wrapped in act(...)', + [ + 'A suspended resource finished loading inside a test, but the event ' + + 'was not wrapped in act(...)', + + ...(gate('enableSiblingPrerendering') ? ['not wrapped in act'] : []), + ], + {withoutStack: true}, ); }); diff --git a/packages/react-reconciler/src/__tests__/ReactBatching-test.internal.js b/packages/react-reconciler/src/__tests__/ReactBatching-test.internal.js index 7f68dcc1b6158..b11e78bdec526 100644 --- a/packages/react-reconciler/src/__tests__/ReactBatching-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactBatching-test.internal.js @@ -109,7 +109,13 @@ describe('ReactBlockingMode', () => { , ); - await waitForAll(['A', 'Suspend! [B]', 'Loading...']); + await waitForAll([ + 'A', + 'Suspend! [B]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['A', 'Suspend! [B]', 'C'] : []), + ]); // In Legacy Mode, A and B would mount in a hidden primary tree. In // Concurrent Mode, nothing in the primary tree should mount. But the // fallback should mount immediately. diff --git a/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js b/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js index f6ebd3c57494e..340e973b2f0b3 100644 --- a/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js +++ b/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js @@ -399,7 +399,13 @@ describe('ReactLazyContextPropagation', () => { // the fallback displays despite this being a refresh. setContext('B'); }); - assertLog(['Suspend! [B]', 'Loading...', 'B']); + assertLog([ + 'Suspend! [B]', + 'Loading...', + 'B', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [B]'] : []), + ]); expect(root).toMatchRenderedOutput('Loading...B'); await act(async () => { @@ -479,7 +485,13 @@ describe('ReactLazyContextPropagation', () => { // the fallback displays despite this being a refresh. setContext('B'); }); - assertLog(['Suspend! [B]', 'Loading...', 'B']); + assertLog([ + 'Suspend! [B]', + 'Loading...', + 'B', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [B]'] : []), + ]); expect(root).toMatchRenderedOutput('Loading...B'); await act(async () => { @@ -812,7 +824,12 @@ describe('ReactLazyContextPropagation', () => { await act(() => { setContext('B'); }); - assertLog(['Suspend! [B]', 'Loading...']); + assertLog([ + 'Suspend! [B]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [B]'] : []), + ]); expect(root).toMatchRenderedOutput('Loading...'); 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..fd03ba7310f83 100644 --- a/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js +++ b/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js @@ -499,6 +499,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('Fallback'); @@ -630,6 +632,8 @@ describe('ReactDeferredValue', () => { // go straight to attempting the final value. 'Suspend! [Content]', 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [Content]'] : []), ]); // The content suspended, so we show a Suspense fallback expect(root).toMatchRenderedOutput('Loading...'); diff --git a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js index 9b714458c74d5..556e0559728e7 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js @@ -1864,13 +1864,15 @@ describe('ReactHooks', () => { it('does not fire a false positive warning when suspending memo', async () => { const {Suspense, useState} = React; - let wasSuspended = false; + let isSuspended = true; let resolve; function trySuspend() { - if (!wasSuspended) { - throw new Promise(r => { - wasSuspended = true; - resolve = r; + if (isSuspended) { + throw new Promise(res => { + resolve = () => { + isSuspended = false; + res(); + }; }); } } @@ -1900,13 +1902,15 @@ describe('ReactHooks', () => { it('does not fire a false positive warning when suspending forwardRef', async () => { const {Suspense, useState} = React; - let wasSuspended = false; + let isSuspended = true; let resolve; function trySuspend() { - if (!wasSuspended) { - throw new Promise(r => { - wasSuspended = true; - resolve = r; + if (isSuspended) { + throw new Promise(res => { + resolve = () => { + isSuspended = false; + res(); + }; }); } } @@ -1936,13 +1940,15 @@ describe('ReactHooks', () => { it('does not fire a false positive warning when suspending memo(forwardRef)', async () => { const {Suspense, useState} = React; - let wasSuspended = false; + let isSuspended = true; let resolve; function trySuspend() { - if (!wasSuspended) { - throw new Promise(r => { - wasSuspended = true; - resolve = r; + if (isSuspended) { + throw new Promise(res => { + resolve = () => { + isSuspended = false; + res(); + }; }); } } diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js index e62852f2a3ddc..89a07e8e30312 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js @@ -3544,7 +3544,13 @@ describe('ReactHooksWithNoopRenderer', () => { ReactNoop.render(); }); - assertLog(['A', 'Suspend! [A]', 'Loading']); + assertLog([ + 'A', + 'Suspend! [A]', + 'Loading', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [A]'] : []), + ]); expect(ReactNoop).toMatchRenderedOutput( <> diff --git a/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js b/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js index 6eec3112e464b..32667704b8a6f 100644 --- a/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js @@ -313,7 +313,12 @@ describe('ReactLazy', () => { unstable_isConcurrent: true, }); - await waitForAll(['Suspend! [LazyChildA]', 'Loading...']); + await waitForAll([ + 'Suspend! [LazyChildA]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [LazyChildB]'] : []), + ]); expect(root).not.toMatchRenderedOutput('AB'); await act(async () => { @@ -322,9 +327,23 @@ describe('ReactLazy', () => { // B suspends even though it happens to share the same import as A. // TODO: React.lazy should implement the `status` and `value` fields, so // we can unwrap the result synchronously if it already loaded. Like `use`. - await waitFor(['A', 'Suspend! [LazyChildB]']); + await waitFor([ + 'A', + + // When enableSiblingPrerendering is on, LazyChildB was already + // initialized. So it also already resolved when we called + // resolveFakeImport above. So it doesn't suspend again. + ...(gate('enableSiblingPrerendering') + ? ['B'] + : ['Suspend! [LazyChildB]']), + ]); }); - assertLog(['A', 'B', 'Did mount: A', 'Did mount: B']); + assertLog([ + ...(gate('enableSiblingPrerendering') ? [] : ['A', 'B']), + + 'Did mount: A', + 'Did mount: B', + ]); expect(root).toMatchRenderedOutput('AB'); // Swap the position of A and B @@ -1388,15 +1407,20 @@ describe('ReactLazy', () => { unstable_isConcurrent: true, }); - await waitForAll(['Init A', 'Loading...']); + await waitForAll([ + 'Init A', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Init B'] : []), + ]); expect(root).not.toMatchRenderedOutput('AB'); await act(() => resolveFakeImport(ChildA)); assertLog([ 'A', - 'Init B', - ...(gate('enableSiblingPrerendering') ? ['A'] : []), + // When enableSiblingPrerendering is on, B was already initialized. + ...(gate('enableSiblingPrerendering') ? ['A'] : ['Init B']), ]); await act(() => resolveFakeImport(ChildB)); diff --git a/packages/react-reconciler/src/__tests__/ReactSiblingPrerendering-test.js b/packages/react-reconciler/src/__tests__/ReactSiblingPrerendering-test.js index 6cc24f9844b2f..902584b2851a4 100644 --- a/packages/react-reconciler/src/__tests__/ReactSiblingPrerendering-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSiblingPrerendering-test.js @@ -26,6 +26,22 @@ describe('ReactSiblingPrerendering', () => { textCache = new Map(); }); + // function resolveText(text) { + // const record = textCache.get(text); + // if (record === undefined) { + // const newRecord = { + // status: 'resolved', + // value: text, + // }; + // textCache.set(text, newRecord); + // } else if (record.status === 'pending') { + // const thenable = record.value; + // record.status = 'resolved'; + // record.value = text; + // thenable.pings.forEach(t => t()); + // } + // } + function readText(text) { const record = textCache.get(text); if (record !== undefined) { @@ -61,6 +77,37 @@ describe('ReactSiblingPrerendering', () => { } } + // function getText(text) { + // const record = textCache.get(text); + // if (record === undefined) { + // const thenable = { + // pings: [], + // then(resolve) { + // if (newRecord.status === 'pending') { + // thenable.pings.push(resolve); + // } else { + // Promise.resolve().then(() => resolve(newRecord.value)); + // } + // }, + // }; + // const newRecord = { + // status: 'pending', + // value: thenable, + // }; + // textCache.set(text, newRecord); + // return thenable; + // } else { + // switch (record.status) { + // case 'pending': + // return record.value; + // case 'rejected': + // return Promise.reject(record.value); + // case 'resolved': + // return Promise.resolve(record.value); + // } + // } + // } + function Text({text}) { Scheduler.log(text); return text; @@ -163,4 +210,32 @@ describe('ReactSiblingPrerendering', () => { expect(root).toMatchRenderedOutput('A'); }); }); + + it('start prerendering retries right after the fallback commits', async () => { + function App() { + return ( + }> + + + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => root.render()); + + // On the first attempt, A suspends. Unwind and show a fallback, without + // attempting B. + await waitForPaint(['Suspend! [A]', 'Loading...']); + expect(root).toMatchRenderedOutput('Loading...'); + + // Immediately after the fallback commits, retry the boundary again. This + // time we include B, since we're not blocking the fallback from showing. + if (gate('enableSiblingPrerendering')) { + await waitForPaint(['Suspend! [A]', 'Suspend! [B]']); + } + }); + expect(root).toMatchRenderedOutput('Loading...'); + }); }); diff --git a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js index 76b4ead3c4186..18dd6820e3f85 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js @@ -169,13 +169,23 @@ describe('ReactSuspense', () => { 'Loading A...', 'Suspend! [B]', 'Loading B...', + + ...(gate('enableSiblingPrerendering') + ? ['Suspend! [A]', 'Suspend! [B]'] + : []), ]); expect(container.innerHTML).toEqual('Loading A...Loading B...'); // Resolve first Suspense's promise and switch back to the normal view. The // second Suspense should still show the placeholder await act(() => resolveText('A')); - assertLog(['A']); + assertLog([ + 'A', + + ...(gate('enableSiblingPrerendering') + ? ['Suspend! [B]', 'A', 'Suspend! [B]'] + : []), + ]); expect(container.textContent).toEqual('ALoading B...'); // Resolve the second Suspense's promise resolves and switche back to the @@ -274,7 +284,15 @@ describe('ReactSuspense', () => { root.render(); }); - assertLog(['Foo', 'Suspend! [A]', 'Loading...']); + assertLog([ + 'Foo', + 'Suspend! [A]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') + ? ['Suspend! [A]', 'Suspend! [B]', 'Loading more...'] + : []), + ]); expect(container.textContent).toEqual('Loading...'); await resolveText('A'); @@ -327,7 +345,15 @@ describe('ReactSuspense', () => { // Render an empty shell const root = ReactDOMClient.createRoot(container); root.render(); - await waitForAll(['Foo', 'Suspend! [A]', 'Loading...']); + await waitForAll([ + 'Foo', + 'Suspend! [A]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') + ? ['Suspend! [A]', 'Suspend! [B]', 'Loading more...'] + : []), + ]); expect(container.textContent).toEqual('Loading...'); // Now resolve A @@ -375,7 +401,15 @@ describe('ReactSuspense', () => { await act(() => { root.render(); }); - assertLog(['Foo', 'Suspend! [A]', 'Loading...']); + assertLog([ + 'Foo', + 'Suspend! [A]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') + ? ['Suspend! [A]', 'Suspend! [B]', 'Loading more...'] + : []), + ]); expect(container.textContent).toEqual('Loading...'); await resolveText('A'); @@ -468,14 +502,24 @@ describe('ReactSuspense', () => { await act(() => { root.render(); }); - assertLog(['Suspend! [default]', 'Loading...']); + assertLog([ + 'Suspend! [default]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [default]'] : []), + ]); await act(() => resolveText('default')); assertLog(['default']); expect(container.textContent).toEqual('default'); await act(() => setValue('new value')); - assertLog(['Suspend! [new value]', 'Loading...']); + assertLog([ + 'Suspend! [new value]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [new value]'] : []), + ]); await act(() => resolveText('new value')); assertLog(['new value']); @@ -515,14 +559,24 @@ describe('ReactSuspense', () => { await act(() => { root.render(); }); - assertLog(['Suspend! [default]', 'Loading...']); + assertLog([ + 'Suspend! [default]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [default]'] : []), + ]); await act(() => resolveText('default')); assertLog(['default']); expect(container.textContent).toEqual('default'); await act(() => setValue('new value')); - assertLog(['Suspend! [new value]', 'Loading...']); + assertLog([ + 'Suspend! [new value]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [new value]'] : []), + ]); await act(() => resolveText('new value')); assertLog(['new value']); @@ -559,14 +613,24 @@ describe('ReactSuspense', () => { , ); }); - assertLog(['Suspend! [default]', 'Loading...']); + assertLog([ + 'Suspend! [default]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [default]'] : []), + ]); await act(() => resolveText('default')); assertLog(['default']); expect(container.textContent).toEqual('default'); await act(() => setValue('new value')); - assertLog(['Suspend! [new value]', 'Loading...']); + assertLog([ + 'Suspend! [new value]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [new value]'] : []), + ]); await act(() => resolveText('new value')); assertLog(['new value']); @@ -603,14 +667,24 @@ describe('ReactSuspense', () => { , ); }); - assertLog(['Suspend! [default]', 'Loading...']); + assertLog([ + 'Suspend! [default]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [default]'] : []), + ]); await act(() => resolveText('default')); assertLog(['default']); expect(container.textContent).toEqual('default'); await act(() => setValue('new value')); - assertLog(['Suspend! [new value]', 'Loading...']); + assertLog([ + 'Suspend! [new value]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') ? ['Suspend! [new value]'] : []), + ]); await act(() => resolveText('new value')); assertLog(['new value']); @@ -657,6 +731,10 @@ describe('ReactSuspense', () => { 'Suspend! [Child 2]', 'Loading...', 'destroy layout', + + ...(gate('enableSiblingPrerendering') + ? ['Child 1', 'Suspend! [Child 2]'] + : []), ]); await act(() => resolveText('Child 2')); @@ -679,7 +757,14 @@ describe('ReactSuspense', () => { root.render(); }); - assertLog(['Suspend! [Child 1]', 'Loading...']); + assertLog([ + 'Suspend! [Child 1]', + 'Loading...', + + ...(gate('enableSiblingPrerendering') + ? ['Suspend! [Child 1]', 'Suspend! [Child 2]'] + : []), + ]); await resolveText('Child 1'); await waitForAll([ 'Child 1', diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js index 688ff38a74c0a..4ade3c6ff79cf 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js @@ -274,6 +274,14 @@ describe('ReactSuspenseEffectsSemantics', () => { 'Text:Fallback create passive', 'Text:Outside create passive', 'App create passive', + + ...(gate('enableSiblingPrerendering') + ? [ + 'Text:Inside:Before render', + 'Suspend:Async', + 'ClassText:Inside:After render', + ] + : []), ]); expect(ReactNoop).toMatchRenderedOutput( <> @@ -646,7 +654,17 @@ describe('ReactSuspenseEffectsSemantics', () => { 'Text:Inside:After destroy layout', 'Text:Fallback create layout', ]); - await waitForAll(['Text:Fallback create passive']); + await waitForAll([ + 'Text:Fallback create passive', + + ...(gate('enableSiblingPrerendering') + ? [ + 'Text:Inside:Before render', + 'Suspend:Async', + 'Text:Inside:After render', + ] + : []), + ]); expect(ReactNoop).toMatchRenderedOutput( <>