From 986323f8c65927490036183357d644974a14b8a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 4 Nov 2025 22:11:33 -0500 Subject: [PATCH 1/3] [Fiber] SuspenseList with "hidden" tail row should "catch" suspense (#35042) Normally if you suspend in a SuspenseList row above a Suspense boundary in that row, it'll suspend the parent. Which can itself delay the commit or resuspend a parent boundary. That's because SuspenseList mostly just coordinates the state of the inner boundaries and isn't a boundary itself. However, for tail "hidden" and "collapsed" this is not quite the case because the rows themselves can avoid being rendered. In the case of "collapsed" we require at least one Suspense boundary above to have successfully rendered before committing the list because the idea of this mode is that you should at least always show some indicator that things are still loading. Since we'd never try the next one after that at all, this just works. Expect there was an unrelated bug that meant that "suspend with delay" on a Retry didn't suspend the commit. This caused a scenario were it'd allow a commit proceed when it shouldn't. So I fixed that too. The counter intuitive thing here is that we won't actually show a previous completed row if the loading state of the next row is still loading. For tail "hidden" it's a little different because we don't actually require any loading indicator at all to be shown while it's loading. If we attempt a row and it suspends, we can just hide it (and the rest) and move to commit. Therefore this implements a path where if all the rest of the tail are new mounts (we wouldn't be required to unmount any existing boundaries) then we can treat the SuspenseList boundary itself as "catching" the suspense. This is more coherent semantics since any future row that we didn't attempt also wouldn't resuspend the parent. This allows simple cases like `{list}` to stream in each row without any indicator and no need for Suspense boundaries. --- .../src/ReactFiberBeginWork.js | 15 ++ .../src/ReactFiberCompleteWork.js | 36 ++- .../src/ReactFiberSuspenseContext.js | 36 ++- .../react-reconciler/src/ReactFiberThrow.js | 11 +- .../src/ReactFiberUnwindWork.js | 28 ++- .../src/ReactFiberWorkLoop.js | 2 +- .../src/__tests__/ReactSuspenseList-test.js | 221 ++++++++++++++++++ 7 files changed, 337 insertions(+), 12 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 33fadfd5396..25918e8b387 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -3397,6 +3397,13 @@ function updateSuspenseListComponent( let suspenseContext: SuspenseContext = suspenseStackCursor.current; + if (workInProgress.flags & DidCapture) { + // This is the second pass after having suspended in a row. Proceed directly + // to the complete phase. + pushSuspenseListContext(workInProgress, suspenseContext); + return null; + } + const shouldForceFallback = hasSuspenseListContext( suspenseContext, (ForceSuspenseFallback: SuspenseContext), @@ -4011,6 +4018,14 @@ function attemptEarlyBailoutIfNoScheduledUpdate( break; } case SuspenseListComponent: { + if (workInProgress.flags & DidCapture) { + // Second pass caught. + return updateSuspenseListComponent( + current, + workInProgress, + renderLanes, + ); + } const didSuspendBefore = (current.flags & DidCapture) !== NoFlags; let hasChildWork = includesSomeLane( diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 0b1f1e4366e..dab1b2272bd 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -138,6 +138,7 @@ import { popSuspenseListContext, popSuspenseHandler, pushSuspenseListContext, + pushSuspenseListCatch, setShallowSuspenseListContext, ForceSuspenseFallback, setDefaultShallowSuspenseListContext, @@ -765,6 +766,17 @@ function cutOffTailIfNeeded( } } +function isOnlyNewMounts(tail: Fiber): boolean { + let fiber: null | Fiber = tail; + while (fiber !== null) { + if (fiber.alternate !== null) { + return false; + } + fiber = fiber.sibling; + } + return true; +} + function bubbleProperties(completedWork: Fiber) { const didBailout = completedWork.alternate !== null && @@ -1855,7 +1867,10 @@ function completeWork( if (renderState.tail !== null) { // We still have tail rows to render. // Pop a row. + // TODO: Consider storing the first of the new mount tail in the state so + // that we don't have to recompute this for every row in the list. const next = renderState.tail; + const onlyNewMounts = isOnlyNewMounts(next); renderState.rendering = next; renderState.tail = next.sibling; renderState.renderingStartTime = now(); @@ -1874,7 +1889,26 @@ function completeWork( suspenseContext = setDefaultShallowSuspenseListContext(suspenseContext); } - pushSuspenseListContext(workInProgress, suspenseContext); + if ( + renderState.tailMode === 'visible' || + renderState.tailMode === 'collapsed' || + !onlyNewMounts || + // TODO: While hydrating, we still let it suspend the parent. Tail mode hidden has broken + // hydration anyway right now but this preserves the previous semantics out of caution. + // Once proper hydration is implemented, this special case should be removed as it should + // never be needed. + getIsHydrating() + ) { + pushSuspenseListContext(workInProgress, suspenseContext); + } else { + // If we are rendering in 'hidden' (default) tail mode, then we if we suspend in the + // tail itself, we can delete it rather than suspend the parent. So we act as a catch in that + // case. For 'collapsed' we need to render at least one in suspended state, after which we'll + // have cut off the rest to never attempt it so it never hits this case. + // If this is an updated node, we cannot delete it from the tail so it's effectively visible. + // As a consequence, if it resuspends it actually suspends the parent by taking the other path. + pushSuspenseListCatch(workInProgress, suspenseContext); + } // Do a pass over the next row. if (getIsHydrating()) { // Re-apply tree fork since we popped the tree fork context in the beginning of this function. diff --git a/packages/react-reconciler/src/ReactFiberSuspenseContext.js b/packages/react-reconciler/src/ReactFiberSuspenseContext.js index 3177b9d1b35..aec47af4656 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseContext.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseContext.js @@ -48,9 +48,10 @@ export function pushPrimaryTreeSuspenseHandler(handler: Fiber): void { // Shallow Suspense context fields, like ForceSuspenseFallback, should only be // propagated a single level. For example, when ForceSuspenseFallback is set, // it should only force the nearest Suspense boundary into fallback mode. - pushSuspenseListContext( - handler, + push( + suspenseStackCursor, setDefaultShallowSuspenseListContext(suspenseStackCursor.current), + handler, ); // Experimental feature: Some Suspense boundaries are marked as having an @@ -113,7 +114,7 @@ export function pushDehydratedActivitySuspenseHandler(fiber: Fiber): void { // Reuse the current value on the stack. // TODO: We can avoid needing to push here by by forking popSuspenseHandler // into separate functions for Activity, Suspense and Offscreen. - pushSuspenseListContext(fiber, suspenseStackCursor.current); + push(suspenseStackCursor, suspenseStackCursor.current, fiber); push(suspenseHandlerStackCursor, fiber, fiber); if (shellBoundary === null) { // We can contain any suspense inside the Activity boundary. @@ -127,7 +128,7 @@ export function pushOffscreenSuspenseHandler(fiber: Fiber): void { // Reuse the current value on the stack. // TODO: We can avoid needing to push here by by forking popSuspenseHandler // into separate functions for Activity, Suspense and Offscreen. - pushSuspenseListContext(fiber, suspenseStackCursor.current); + push(suspenseStackCursor, suspenseStackCursor.current, fiber); push(suspenseHandlerStackCursor, fiber, fiber); if (shellBoundary === null) { // We're rendering hidden content. If it suspends, we can handle it by @@ -141,7 +142,7 @@ export function pushOffscreenSuspenseHandler(fiber: Fiber): void { } export function reuseSuspenseHandlerOnStack(fiber: Fiber) { - pushSuspenseListContext(fiber, suspenseStackCursor.current); + push(suspenseStackCursor, suspenseStackCursor.current, fiber); push(suspenseHandlerStackCursor, getSuspenseHandler(), fiber); } @@ -155,7 +156,7 @@ export function popSuspenseHandler(fiber: Fiber): void { // Popping back into the shell. shellBoundary = null; } - popSuspenseListContext(fiber); + pop(suspenseStackCursor, fiber); } // SuspenseList context @@ -201,9 +202,32 @@ export function pushSuspenseListContext( fiber: Fiber, newContext: SuspenseContext, ): void { + // Push the current handler in this case since we're not catching at the SuspenseList + // for typical rows. + const handlerOnStack = suspenseHandlerStackCursor.current; + push(suspenseHandlerStackCursor, handlerOnStack, fiber); + push(suspenseStackCursor, newContext, fiber); +} + +export function pushSuspenseListCatch( + fiber: Fiber, + newContext: SuspenseContext, +): void { + // In this case we do want to handle catching suspending on the actual boundary itself. + // This is used for rows that are allowed to be hidden anyway. + push(suspenseHandlerStackCursor, fiber, fiber); push(suspenseStackCursor, newContext, fiber); + if (shellBoundary === null) { + // We can contain the effects to hiding the current row. + shellBoundary = fiber; + } } export function popSuspenseListContext(fiber: Fiber): void { pop(suspenseStackCursor, fiber); + pop(suspenseHandlerStackCursor, fiber); + if (shellBoundary === fiber) { + // Popping back into the shell. + shellBoundary = null; + } } diff --git a/packages/react-reconciler/src/ReactFiberThrow.js b/packages/react-reconciler/src/ReactFiberThrow.js index 92540176fe8..8ff61b02697 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.js +++ b/packages/react-reconciler/src/ReactFiberThrow.js @@ -27,6 +27,7 @@ import { ActivityComponent, SuspenseComponent, OffscreenComponent, + SuspenseListComponent, } from './ReactWorkTags'; import { DidCapture, @@ -400,7 +401,8 @@ function throwException( if (suspenseBoundary !== null) { switch (suspenseBoundary.tag) { case ActivityComponent: - case SuspenseComponent: { + case SuspenseComponent: + case SuspenseListComponent: { // If this suspense/activity boundary is not already showing a fallback, mark // the in-progress render as suspended. We try to perform this logic // as soon as soon as possible during the render phase, so the work @@ -561,6 +563,13 @@ function throwException( // Instead of surfacing the error, find the nearest Suspense boundary // and render it again without hydration. if (hydrationBoundary !== null) { + if (__DEV__) { + if (hydrationBoundary.tag === SuspenseListComponent) { + console.error( + 'SuspenseList should never catch while hydrating. This is a bug in React.', + ); + } + } if ((hydrationBoundary.flags & ShouldCapture) === NoFlags) { // Set a flag to indicate that we should try rendering the normal // children again, not the fallback. diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.js b/packages/react-reconciler/src/ReactFiberUnwindWork.js index efe08a43ac7..b2954c41f5a 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.js @@ -11,7 +11,10 @@ import type {ReactContext} from 'shared/ReactTypes'; import type {Fiber, FiberRoot} from './ReactInternalTypes'; import type {Lanes} from './ReactFiberLane'; import type {ActivityState} from './ReactFiberActivityComponent'; -import type {SuspenseState} from './ReactFiberSuspenseComponent'; +import type { + SuspenseState, + SuspenseListRenderState, +} from './ReactFiberSuspenseComponent'; import type {Cache} from './ReactFiberCacheComponent'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent'; @@ -31,7 +34,7 @@ import { CacheComponent, TracingMarkerComponent, } from './ReactWorkTags'; -import {DidCapture, NoFlags, ShouldCapture} from './ReactFiberFlags'; +import {DidCapture, NoFlags, ShouldCapture, Update} from './ReactFiberFlags'; import {NoMode, ProfileMode} from './ReactTypeOfMode'; import { enableProfilerTimer, @@ -180,8 +183,27 @@ function unwindWork( } case SuspenseListComponent: { popSuspenseListContext(workInProgress); - // SuspenseList doesn't actually catch anything. It should've been + // SuspenseList doesn't normally catch anything. It should've been // caught by a nested boundary. If not, it should bubble through. + const flags = workInProgress.flags; + if (flags & ShouldCapture) { + workInProgress.flags = (flags & ~ShouldCapture) | DidCapture; + // If we caught something on the SuspenseList itself it's because + // we want to ignore something. Re-enter the cycle and handle it + // in the complete phase. + const renderState: null | SuspenseListRenderState = + workInProgress.memoizedState; + if (renderState !== null) { + // Cut off any remaining tail work and don't commit the rendering one. + // This assumes that we have already confirmed that none of these are + // already mounted. + renderState.rendering = null; + renderState.tail = null; + } + // Schedule the commit phase to attach retry listeners. + workInProgress.flags |= Update; + return workInProgress; + } return null; } case HostPortal: diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 3de2ab6b16f..f7200458be1 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -1355,7 +1355,7 @@ function finishConcurrentRender( throw new Error('Root did not complete. This is a bug in React.'); } case RootSuspendedWithDelay: { - if (!includesOnlyTransitions(lanes)) { + if (!includesOnlyTransitions(lanes) && !includesOnlyRetries(lanes)) { // Commit the placeholder. break; } diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js index ba5b668652f..c7d4de1421e 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js @@ -2346,6 +2346,227 @@ describe('ReactSuspenseList', () => { ); }); + // @gate enableSuspenseList + it('reveals "hidden" rows one by one without suspense boundaries', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + const C = createAsyncText('C'); + + function Foo() { + return ( + +
+ +
+ + +
+ ); + } + + ReactNoop.render( + + + , + ); + + await waitForAll(['Suspend! [A]']); + + // We can commit without any rows at all leaving empty. + expect(ReactNoop).toMatchRenderedOutput(null); + + await act(() => A.resolve()); + assertLog(['A', 'Suspend! [B]']); + + expect(ReactNoop).toMatchRenderedOutput( +
+ A +
, + ); + + await act(() => B.resolve()); + assertLog(['B', 'Suspend! [C]']); + + // Incremental loading is suspended. + jest.advanceTimersByTime(500); + + expect(ReactNoop).toMatchRenderedOutput( + <> +
+ A +
+ B + , + ); + + await act(() => C.resolve()); + assertLog(['C']); + + expect(ReactNoop).toMatchRenderedOutput( + <> +
+ A +
+ B + C + , + ); + }); + + // @gate enableSuspenseList + it('preserves already mounted rows when a new hidden on is inserted in the tail', async () => { + const B = createAsyncText('B'); + const C = createAsyncText('C'); + + let count = 0; + function MountCount({children}) { + // This component should only mount once. + React.useLayoutEffect(() => { + count++; + }, []); + return children; + } + + function Foo({insert}) { + return ( + + + {insert ? : null} + + }> + + + + + ); + } + + await act(() => { + ReactNoop.render(); + }); + assertLog(['A', 'Suspend! [C]', 'Loading C', 'Suspend! [C]']); + + expect(count).toBe(1); + + expect(ReactNoop).toMatchRenderedOutput( + <> + A + Loading C + , + ); + + await act(() => { + ReactNoop.render(); + }); + + assertLog(['A', 'Suspend! [B]', 'A', 'Suspend! [B]']); + + expect(count).toBe(1); + + expect(ReactNoop).toMatchRenderedOutput( + <> + A + Loading C + , + ); + + await act(async () => { + await B.resolve(); + await C.resolve(); + }); + + assertLog(['A', 'B', 'C']); + + expect(count).toBe(1); + + expect(ReactNoop).toMatchRenderedOutput( + <> + A + B + C + , + ); + }); + + // @gate enableSuspenseList + it('reveals "collapsed" rows one by one after the first without boundaries', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + const C = createAsyncText('C'); + + function Foo() { + return ( + +
+ }> + + + + + ); + } + + await act(async () => { + ReactNoop.render( + + + , + ); + await waitForAll(['Suspend! [A]', 'Suspend! [A]']); + }); + + // The root is still blocked on the first row. + expect(ReactNoop).toMatchRenderedOutput('Loading root'); + + await A.resolve(); + + await waitForAll(['A', 'Suspend! [B]', 'Loading B']); + + // Incremental loading is suspended. + jest.advanceTimersByTime(500); + + // Because we have a Suspense boundary that can commit we can now unblock the rest. + // If it wasn't a boundary then we couldn't make progress because it would commit + // without any loading state. + expect(ReactNoop).toMatchRenderedOutput( + <> + A + Loading B + , + ); + + await act(() => B.resolve()); + assertLog(['B', 'Suspend! [C]', 'B', 'Suspend! [C]']); + + // Incremental loading is suspended. + jest.advanceTimersByTime(500); + + // Surprisingly unsuspending B actually causes the parent to resuspend + // because C is now unblocked which resuspends the parent. Preventing the + // Retry from committing. That's because we don't want to commit into a + // state that doesn't have any loading indicators at all. That's what + // "collapsed" is for. To ensure there's always a loading indicator. + expect(ReactNoop).toMatchRenderedOutput( + <> + A + Loading B + , + ); + + await act(() => C.resolve()); + assertLog(['B', 'C']); + + expect(ReactNoop).toMatchRenderedOutput( + <> + A + B + C + , + ); + }); + // @gate enableSuspenseList it('eventually resolves a nested forwards suspense list', async () => { const B = createAsyncText('B'); From c308cb590598b61a7fc1766e15edf454d758d226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 4 Nov 2025 23:23:25 -0500 Subject: [PATCH 2/3] Disable enablePostpone flag in experimental (#31042) I don't think we're ready to land this yet since we're using it to run other experiments and our tests. I'm opening this PR to indicate intent to disable and to ensure tests in other combinations still work. Such as enableHalt without enablePostpone. I think we'll also need to rewrite some tests that depend on enablePostpone to preserve some coverage. The conclusion after this experiment is that try/catch around these are too likely to block these signals and consider them error. Throwing works for Hooks and `use()` because the lint rule can ensure that they're not wrapped in try/catch. Throwing in arbitrary functions not quite ecosystem compatible. It's also why there's `use()` and not just throwing a Promise. This might also affect the Catch proposal. The "prerender" for SSR that's supporting "Partial Prerendering" is still there. This just disables the `React.postpone()` API for creating the holes. --- .../src/__tests__/ReactDOMFizzStatic-test.js | 66 ------------------- .../react/index.experimental.development.js | 1 - packages/react/index.experimental.js | 1 - packages/react/src/ReactClient.js | 2 - .../ReactServer.experimental.development.js | 2 - .../react/src/ReactServer.experimental.js | 2 - packages/shared/ReactFeatureFlags.js | 2 +- 7 files changed, 1 insertion(+), 75 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js index 65012023420..a4ac87c92c4 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js @@ -400,72 +400,6 @@ describe('ReactDOMFizzStatic', () => { ); }); - // @gate enablePostpone - it('does not fatally error when aborting with a postpone during a prerender', async () => { - let postponedValue; - try { - React.unstable_postpone('aborting with postpone'); - } catch (e) { - postponedValue = e; - } - - const controller = new AbortController(); - const infinitePromise = new Promise(() => {}); - function App() { - React.use(infinitePromise); - return
aborted
; - } - - const errors = []; - const pendingResult = ReactDOMFizzStatic.prerenderToNodeStream(, { - onError: error => { - errors.push(error); - }, - signal: controller.signal, - }); - pendingResult.catch(() => {}); - - await Promise.resolve(); - controller.abort(postponedValue); - - const result = await pendingResult; - - await act(async () => { - result.prelude.pipe(writable); - }); - expect(getVisibleChildren(container)).toEqual(undefined); - expect(errors).toEqual([]); - }); - - // @gate enablePostpone - it('does not fatally error when aborting with a postpone during a prerender from within', async () => { - let postponedValue; - try { - React.unstable_postpone('aborting with postpone'); - } catch (e) { - postponedValue = e; - } - - const controller = new AbortController(); - function App() { - controller.abort(postponedValue); - return
aborted
; - } - - const errors = []; - const result = await ReactDOMFizzStatic.prerenderToNodeStream(, { - onError: error => { - errors.push(error); - }, - signal: controller.signal, - }); - await act(async () => { - result.prelude.pipe(writable); - }); - expect(getVisibleChildren(container)).toEqual(undefined); - expect(errors).toEqual([]); - }); - // @gate enableHalt it('will halt a prerender when aborting with an error during a render', async () => { const controller = new AbortController(); diff --git a/packages/react/index.experimental.development.js b/packages/react/index.experimental.development.js index b2374493073..9b0e301a8c9 100644 --- a/packages/react/index.experimental.development.js +++ b/packages/react/index.experimental.development.js @@ -30,7 +30,6 @@ export { cacheSignal, startTransition, Activity, - unstable_postpone, unstable_getCacheForType, unstable_SuspenseList, ViewTransition, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 37800ede07a..0145e9137e9 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -31,7 +31,6 @@ export { startTransition, Activity, Activity as unstable_Activity, - unstable_postpone, unstable_getCacheForType, unstable_SuspenseList, ViewTransition, diff --git a/packages/react/src/ReactClient.js b/packages/react/src/ReactClient.js index 9f7c5dce36d..d881030b7d0 100644 --- a/packages/react/src/ReactClient.js +++ b/packages/react/src/ReactClient.js @@ -34,7 +34,6 @@ import {lazy} from './ReactLazy'; import {forwardRef} from './ReactForwardRef'; import {memo} from './ReactMemo'; import {cache, cacheSignal} from './ReactCacheClient'; -import {postpone} from './ReactPostpone'; import { getCacheForType, useCallback, @@ -84,7 +83,6 @@ export { memo, cache, cacheSignal, - postpone as unstable_postpone, useCallback, useContext, useEffect, diff --git a/packages/react/src/ReactServer.experimental.development.js b/packages/react/src/ReactServer.experimental.development.js index df8b3d79b3a..10d0123843d 100644 --- a/packages/react/src/ReactServer.experimental.development.js +++ b/packages/react/src/ReactServer.experimental.development.js @@ -38,7 +38,6 @@ import {lazy} from './ReactLazy'; import {memo} from './ReactMemo'; import {cache, cacheSignal} from './ReactCacheServer'; import {startTransition} from './ReactStartTransition'; -import {postpone} from './ReactPostpone'; import {captureOwnerStack} from './ReactOwnerStack'; import version from 'shared/ReactVersion'; @@ -76,7 +75,6 @@ export { cacheSignal, startTransition, getCacheForType as unstable_getCacheForType, - postpone as unstable_postpone, useId, useCallback, useDebugValue, diff --git a/packages/react/src/ReactServer.experimental.js b/packages/react/src/ReactServer.experimental.js index 06c0a9bf896..9fc26341314 100644 --- a/packages/react/src/ReactServer.experimental.js +++ b/packages/react/src/ReactServer.experimental.js @@ -38,7 +38,6 @@ import {lazy} from './ReactLazy'; import {memo} from './ReactMemo'; import {cache, cacheSignal} from './ReactCacheServer'; import {startTransition} from './ReactStartTransition'; -import {postpone} from './ReactPostpone'; import version from 'shared/ReactVersion'; const Children = { @@ -75,7 +74,6 @@ export { cacheSignal, startTransition, getCacheForType as unstable_getCacheForType, - postpone as unstable_postpone, useId, useCallback, useDebugValue, diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 6d45a30718a..1881d3fc289 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -80,7 +80,7 @@ export const enableAsyncIterableChildren = __EXPERIMENTAL__; export const enableTaint = __EXPERIMENTAL__; -export const enablePostpone = __EXPERIMENTAL__; +export const enablePostpone: boolean = false; // Probably won't ship in this form. export const enableHalt: boolean = true; From dd048c3b2d8b5760dec718fb0926ca0b68660922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 5 Nov 2025 00:05:59 -0500 Subject: [PATCH 3/3] Clean up enablePostpone Experiment (#35048) We're not shipping this and it's a lot of code to maintain that is blocking my refactor of Fizz for SuspenseList. --- .../react-client/src/ReactFlightClient.js | 105 -- .../ReactDOMFizzDeferredValue-test.js | 37 - .../src/__tests__/ReactDOMFizzServer-test.js | 1569 +---------------- .../ReactDOMFizzServerBrowser-test.js | 41 - .../src/__tests__/ReactDOMFizzStatic-test.js | 40 +- .../ReactDOMFizzStaticBrowser-test.js | 1230 +------------ .../__tests__/ReactDOMFizzStaticFloat-test.js | 283 --- .../__tests__/ReactDOMFizzStaticNode-test.js | 28 +- .../src/server/ReactDOMFizzServerBrowser.js | 10 +- .../src/server/ReactDOMFizzServerBun.js | 4 +- .../src/server/ReactDOMFizzServerEdge.js | 10 +- .../src/server/ReactDOMFizzServerNode.js | 7 - .../src/server/ReactDOMFizzStaticBrowser.js | 29 +- .../src/server/ReactDOMFizzStaticEdge.js | 29 +- .../src/server/ReactDOMFizzStaticNode.js | 48 +- .../react-markup/src/ReactMarkupClient.js | 1 - .../react-markup/src/ReactMarkupServer.js | 2 - .../src/ReactNoopFlightServer.js | 2 - .../src/ReactFiberBeginWork.js | 38 +- .../src/ReactFiberCommitWork.js | 4 +- .../react-reconciler/src/ReactFiberThrow.js | 6 - .../src/server/ReactFlightDOMServerNode.js | 4 - .../src/server/ReactFlightDOMServerBrowser.js | 3 - .../src/server/ReactFlightDOMServerEdge.js | 3 - .../src/server/ReactFlightDOMServerNode.js | 6 - .../src/server/ReactFlightDOMServerBrowser.js | 3 - .../src/server/ReactFlightDOMServerEdge.js | 3 - .../src/server/ReactFlightDOMServerNode.js | 6 - .../src/__tests__/ReactFlightDOM-test.js | 89 +- .../__tests__/ReactFlightDOMBrowser-test.js | 116 +- .../src/__tests__/ReactFlightDOMEdge-test.js | 14 +- .../src/__tests__/ReactFlightDOMNode-test.js | 4 +- .../src/server/ReactFlightDOMServerBrowser.js | 3 - .../src/server/ReactFlightDOMServerEdge.js | 3 - .../src/server/ReactFlightDOMServerNode.js | 6 - packages/react-server/README.md | 2 - packages/react-server/src/ReactFizzServer.js | 358 +--- .../react-server/src/ReactFlightServer.js | 151 +- packages/react/src/ReactPostpone.js | 23 - packages/shared/ReactFeatureFlags.js | 2 - packages/shared/ReactSymbols.js | 2 - .../forks/ReactFeatureFlags.native-fb.js | 1 - .../forks/ReactFeatureFlags.native-oss.js | 1 - .../forks/ReactFeatureFlags.test-renderer.js | 1 - ...actFeatureFlags.test-renderer.native-fb.js | 1 - .../ReactFeatureFlags.test-renderer.www.js | 1 - .../shared/forks/ReactFeatureFlags.www.js | 2 - 47 files changed, 216 insertions(+), 4115 deletions(-) delete mode 100644 packages/react-dom/src/__tests__/ReactDOMFizzStaticFloat-test.js delete mode 100644 packages/react/src/ReactPostpone.js diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index f8cc0a80f39..7b635636157 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -39,12 +39,9 @@ import type { EncodeFormActionCallback, } from './ReactFlightReplyClient'; -import type {Postpone} from 'react/src/ReactPostpone'; - import type {TemporaryReferenceSet} from './ReactFlightTemporaryReferences'; import { - enablePostpone, enableProfilerTimer, enableComponentPerformanceTrack, enableAsyncDebugInfo, @@ -89,7 +86,6 @@ import { import { REACT_LAZY_TYPE, REACT_ELEMENT_TYPE, - REACT_POSTPONE_TYPE, ASYNC_ITERATOR, REACT_FRAGMENT_TYPE, } from 'shared/ReactSymbols'; @@ -3460,88 +3456,6 @@ function resolveErrorDev( return error; } -function resolvePostponeProd( - response: Response, - id: number, - streamState: StreamState, -): void { - if (__DEV__) { - // These errors should never make it into a build so we don't need to encode them in codes.json - // eslint-disable-next-line react-internal/prod-error-codes - throw new Error( - 'resolvePostponeProd should never be called in development mode. Use resolvePostponeDev instead. This is a bug in React.', - ); - } - const error = new Error( - 'A Server Component was postponed. The reason is omitted in production' + - ' builds to avoid leaking sensitive details.', - ); - const postponeInstance: Postpone = (error: any); - postponeInstance.$$typeof = REACT_POSTPONE_TYPE; - postponeInstance.stack = 'Error: ' + error.message; - const chunks = response._chunks; - const chunk = chunks.get(id); - if (!chunk) { - const newChunk: ErroredChunk = createErrorChunk( - response, - postponeInstance, - ); - chunks.set(id, newChunk); - } else { - triggerErrorOnChunk(response, chunk, postponeInstance); - } -} - -function resolvePostponeDev( - response: Response, - id: number, - reason: string, - stack: ReactStackTrace, - env: string, - streamState: StreamState, -): void { - if (!__DEV__) { - // These errors should never make it into a build so we don't need to encode them in codes.json - // eslint-disable-next-line react-internal/prod-error-codes - throw new Error( - 'resolvePostponeDev should never be called in production mode. Use resolvePostponeProd instead. This is a bug in React.', - ); - } - let postponeInstance: Postpone; - const callStack = buildFakeCallStack( - response, - stack, - env, - false, - // $FlowFixMe[incompatible-use] - Error.bind(null, reason || ''), - ); - const rootTask = response._debugRootTask; - if (rootTask != null) { - postponeInstance = rootTask.run(callStack); - } else { - postponeInstance = callStack(); - } - postponeInstance.$$typeof = REACT_POSTPONE_TYPE; - const chunks = response._chunks; - const chunk = chunks.get(id); - if (!chunk) { - const newChunk: ErroredChunk = createErrorChunk( - response, - postponeInstance, - ); - if (__DEV__) { - resolveChunkDebugInfo(response, streamState, newChunk); - } - chunks.set(id, newChunk); - } else { - if (__DEV__) { - resolveChunkDebugInfo(response, streamState, chunk); - } - triggerErrorOnChunk(response, chunk, postponeInstance); - } -} - function resolveErrorModel( response: Response, id: number, @@ -4893,25 +4807,6 @@ function processFullStringRow( return; } // Fallthrough - case 80 /* "P" */: { - if (enablePostpone) { - if (__DEV__) { - const postponeInfo = JSON.parse(row); - resolvePostponeDev( - response, - id, - postponeInfo.reason, - postponeInfo.stack, - postponeInfo.env, - streamState, - ); - } else { - resolvePostponeProd(response, id, streamState); - } - return; - } - } - // Fallthrough default: /* """ "{" "[" "t" "f" "n" "0" - "9" */ { if (__DEV__ && row === '') { resolveDebugHalt(response, id); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js index 529e3be3132..805bf89c4f1 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js @@ -90,43 +90,6 @@ describe('ReactDOMFizzForm', () => { expect(container.textContent).toEqual('Final'); }); - // @gate enablePostpone - it( - 'if initial value postpones during hydration, it will switch to the ' + - 'final value instead', - async () => { - function Content() { - const isInitial = useDeferredValue(false, true); - if (isInitial) { - React.unstable_postpone(); - } - return ; - } - - function App() { - return ( -
- }> - - -
- ); - } - - const stream = await serverAct(() => - ReactDOMServer.renderToReadableStream(), - ); - await readIntoContainer(stream); - expect(container.textContent).toEqual('Loading...'); - - assertLog(['Loading...']); - // After hydration, it's updated to the final value - await act(() => ReactDOMClient.hydrateRoot(container, )); - expect(container.textContent).toEqual('Final'); - assertLog(['Loading...', 'Final']); - }, - ); - it( 'useDeferredValue during hydration has higher priority than remaining ' + 'incremental hydration', diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index b988bd72caf..18cf6124444 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -6665,150 +6665,6 @@ describe('ReactDOMFizzServer', () => { ]); }); - // @gate enablePostpone - it('client renders postponed boundaries without erroring', async () => { - function Postponed({isClient}) { - if (!isClient) { - React.unstable_postpone('testing postpone'); - } - return 'client only'; - } - - function App({isClient}) { - return ( -
- - - -
- ); - } - - const errors = []; - - await act(() => { - const {pipe} = renderToPipeableStream(, { - onError(error) { - errors.push(error.message); - }, - }); - pipe(writable); - }); - - expect(getVisibleChildren(container)).toEqual(
loading...
); - - ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(error) { - errors.push(error.message); - }, - }); - await waitForAll([]); - // Postponing should not be logged as a recoverable error since it's intentional. - expect(errors).toEqual([]); - expect(getVisibleChildren(container)).toEqual(
client only
); - }); - - // @gate enablePostpone - it('errors if trying to postpone outside a Suspense boundary', async () => { - function Postponed() { - React.unstable_postpone('testing postpone'); - return 'client only'; - } - - function App() { - return ( -
- -
- ); - } - - const errors = []; - const fatalErrors = []; - const postponed = []; - let written = false; - - const testWritable = new Stream.Writable(); - testWritable._write = (chunk, encoding, next) => { - written = true; - }; - - await act(() => { - const {pipe} = renderToPipeableStream(, { - onPostpone(reason) { - postponed.push(reason); - }, - onError(error) { - errors.push(error.message); - }, - onShellError(error) { - fatalErrors.push(error.message); - }, - }); - pipe(testWritable); - }); - - expect(written).toBe(false); - // Postponing is not logged as an error but as a postponed reason. - expect(errors).toEqual([]); - expect(postponed).toEqual(['testing postpone']); - // However, it does error the shell. - expect(fatalErrors).toEqual(['testing postpone']); - }); - - // @gate enablePostpone - it('can postpone in a fallback', async () => { - function Postponed({isClient}) { - if (!isClient) { - React.unstable_postpone('testing postpone'); - } - return 'loading...'; - } - - const lazyText = React.lazy(async () => { - await 0; // causes the fallback to start work - return {default: 'Hello'}; - }); - - function App({isClient}) { - return ( -
- - }> - {lazyText} - - -
- ); - } - - const errors = []; - - await act(() => { - const {pipe} = renderToPipeableStream(, { - onError(error) { - errors.push(error.message); - }, - }); - pipe(writable); - }); - - // TODO: This should actually be fully resolved because the value could eventually - // resolve on the server even though the fallback couldn't so we should have been - // able to render it. - expect(getVisibleChildren(container)).toEqual(
Outer
); - - ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(error) { - errors.push(error.message); - }, - }); - await waitForAll([]); - // Postponing should not be logged as a recoverable error since it's intentional. - expect(errors).toEqual([]); - expect(getVisibleChildren(container)).toEqual(
Hello
); - }); - it( 'a transition that flows into a dehydrated boundary should not suspend ' + 'if the boundary is showing a fallback', @@ -6860,37 +6716,63 @@ describe('ReactDOMFizzServer', () => { }, ); - // @gate enablePostpone - it('supports postponing in prerender and resuming later', async () => { + // @gate enableHalt + it('can resume a prerender that was aborted', async () => { + const promise = new Promise(r => {}); + let prerendering = true; - function Postpone() { + + function Wait() { if (prerendering) { - React.unstable_postpone(); + return React.use(promise); + } else { + return 'Hello'; } - return 'Hello'; } function App() { return (
- +

+ + + + + +

+

+ + + + + +

); } - const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(); - expect(prerendered.postponed).not.toBe(null); + const controller = new AbortController(); + const signal = controller.signal; - prerendering = false; + const errors = []; + function onError(error) { + errors.push(error); + } + let pendingPrerender; + await act(() => { + pendingPrerender = ReactDOMFizzStatic.prerenderToNodeStream(, { + signal, + onError, + }); + }); + controller.abort('boom'); - const resumed = ReactDOMFizzServer.resumeToPipeableStream( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - ); + const prerendered = await pendingPrerender; + + expect(errors).toEqual(['boom', 'boom']); - // Create a separate stream so it doesn't close the writable. I.e. simple concat. const preludeWritable = new Stream.PassThrough(); preludeWritable.setEncoding('utf8'); preludeWritable.on('data', chunk => { @@ -6901,1378 +6783,45 @@ describe('ReactDOMFizzServer', () => { prerendered.prelude.pipe(preludeWritable); }); - expect(getVisibleChildren(container)).toEqual(
Loading...
); - - await act(() => { - resumed.pipe(writable); - }); - - expect(getVisibleChildren(container)).toEqual(
Hello
); - }); - - // @gate enablePostpone - it('client renders a component if it errors during resuming', async () => { - let prerendering = true; - let ssr = true; - function PostponeAndError() { - if (prerendering) { - React.unstable_postpone(); - } - if (ssr) { - throw new Error('server error'); - } - return 'Hello'; - } - - function Postpone() { - if (prerendering) { - React.unstable_postpone(); - } - return 'Hello'; - } - - const lazyPostponeAndError = React.lazy(async () => { - return {default: }; - }); - - function ReplayError() { - if (prerendering) { - return ; - } - if (ssr) { - throw new Error('replay error'); - } - return 'Hello'; - } - - function App() { - return ( -
- - - - - - {lazyPostponeAndError} - - - - -
- ); - } - - const prerenderErrors = []; - const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream( - , - { - onError(x) { - prerenderErrors.push(x.message); - }, - }, + expect(getVisibleChildren(container)).toEqual( +
+

+ Loading again... +

+

+ Loading again too... +

+
, ); - expect(prerendered.postponed).not.toBe(null); prerendering = false; - const ssrErrors = []; - - const resumed = ReactDOMFizzServer.resumeToPipeableStream( + errors.length = 0; + const resumed = await ReactDOMFizzServer.resumeToPipeableStream( , JSON.parse(JSON.stringify(prerendered.postponed)), { - onError(x) { - ssrErrors.push(x.message); - }, + onError, }, ); - // Create a separate stream so it doesn't close the writable. I.e. simple concat. - const preludeWritable = new Stream.PassThrough(); - preludeWritable.setEncoding('utf8'); - preludeWritable.on('data', chunk => { - writable.write(chunk); - }); - - await act(() => { - prerendered.prelude.pipe(preludeWritable); - }); - - expect(getVisibleChildren(container)).toEqual( -
- {'Loading1'} - {'Loading2'} - {'Loading4'} -
, - ); - await act(() => { resumed.pipe(writable); }); - expect(prerenderErrors).toEqual([]); - - expect(ssrErrors).toEqual(['server error', 'server error', 'replay error']); - - // Still loading... - expect(getVisibleChildren(container)).toEqual( -
- {'Loading1'} - {'Hello'} - {'Loading3'} - {'Loading4'} -
, - ); - - const recoverableErrors = []; - - ssr = false; - - await clientAct(() => { - ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(x) { - recoverableErrors.push(x.message); - }, - }); - }); - - expect(recoverableErrors).toEqual( - __DEV__ - ? [ - 'Switched to client rendering because the server rendering errored:\n\n' + - 'server error', - 'Switched to client rendering because the server rendering errored:\n\n' + - 'replay error', - 'Switched to client rendering because the server rendering errored:\n\n' + - 'server error', - ] - : [ - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', - ], - ); + expect(errors).toEqual([]); expect(getVisibleChildren(container)).toEqual(
- {'Hello'} - {'Hello'} - {'Hello'} - {'Hello'} +

+ Hello +

+

+ Hello +

, ); }); - // @gate enablePostpone - it('client renders a component if we abort before resuming', async () => { - let prerendering = true; - let ssr = true; - const promise = new Promise(() => {}); - function PostponeAndSuspend() { - if (prerendering) { - React.unstable_postpone(); - } - if (ssr) { - React.use(promise); - } - return 'Hello'; - } - - function Postpone() { - if (prerendering) { - React.unstable_postpone(); - } - return 'Hello'; - } - - function DelayedBoundary() { - if (!prerendering && ssr) { - // We delay discovery of the boundary so we can abort before finding it. - React.use(promise); - } - return ( - - - - ); - } - - function App() { - return ( -
- - - - - - - - - -
- ); - } - - const prerenderErrors = []; - const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream( - , - { - onError(x) { - prerenderErrors.push(x.message); - }, - }, - ); - expect(prerendered.postponed).not.toBe(null); - - prerendering = false; - - const ssrErrors = []; - - const resumed = ReactDOMFizzServer.resumeToPipeableStream( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - { - onError(x) { - ssrErrors.push(x.message); - }, - }, - ); - - // Create a separate stream so it doesn't close the writable. I.e. simple concat. - const preludeWritable = new Stream.PassThrough(); - preludeWritable.setEncoding('utf8'); - preludeWritable.on('data', chunk => { - writable.write(chunk); - }); - - await act(() => { - prerendered.prelude.pipe(preludeWritable); - }); - - expect(getVisibleChildren(container)).toEqual( -
- {'Loading1'} - {'Loading2'} - {'Loading3'} -
, - ); - - await act(() => { - resumed.pipe(writable); - }); - - const recoverableErrors = []; - - ssr = false; - - await clientAct(() => { - ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(x) { - recoverableErrors.push(x.message); - }, - }); - }); - - expect(recoverableErrors).toEqual([]); - expect(prerenderErrors).toEqual([]); - expect(ssrErrors).toEqual([]); - - // Still loading... - expect(getVisibleChildren(container)).toEqual( -
- {'Loading1'} - {/* - This used to show "Hello" in this slot because the boundary was able to be flushed - early but we now prevent flushing while pendingRootTasks is not zero. This is how Edge - would work anyway because you don't get the stream until the root is unblocked on a resume - so Node now aligns with edge bevavior - {'Hello'} - */} - {'Loading2'} - {'Loading3'} -
, - ); - - await clientAct(async () => { - await act(() => { - resumed.abort(new Error('aborted')); - }); - }); - - expect(getVisibleChildren(container)).toEqual( -
- {'Hello'} - {'Hello'} - {'Hello'} -
, - ); - - expect(prerenderErrors).toEqual([]); - expect(ssrErrors).toEqual(['aborted', 'aborted']); - expect(recoverableErrors).toEqual( - __DEV__ - ? [ - 'Switched to client rendering because the server rendering aborted due to:\n\n' + - 'aborted', - 'Switched to client rendering because the server rendering aborted due to:\n\n' + - 'aborted', - ] - : [ - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', - ], - ); - }); - - // @gate enablePostpone - it('client renders remaining boundaries below the error in shell', async () => { - let prerendering = true; - let ssr = true; - function Postpone() { - if (prerendering) { - React.unstable_postpone(); - } - return 'Hello'; - } - - function ReplayError({children}) { - if (!prerendering && ssr) { - throw new Error('replay error'); - } - return children; - } - - function App() { - return ( -
-
- - - - - - - - - - - -
- -
- - - -
-
- - - - - - - - -
- ); - } - - const prerenderErrors = []; - const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream( - , - { - onError(x) { - prerenderErrors.push(x.message); - }, - }, - ); - expect(prerendered.postponed).not.toBe(null); - - prerendering = false; - - const ssrErrors = []; - - const resumed = ReactDOMFizzServer.resumeToPipeableStream( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - { - onError(x) { - ssrErrors.push(x.message); - }, - }, - ); - - // Create a separate stream so it doesn't close the writable. I.e. simple concat. - const preludeWritable = new Stream.PassThrough(); - preludeWritable.setEncoding('utf8'); - preludeWritable.on('data', chunk => { - writable.write(chunk); - }); - - await act(() => { - prerendered.prelude.pipe(preludeWritable); - }); - - expect(getVisibleChildren(container)).toEqual( -
-
- {'Loading1'} - {'Loading2'} - {'Loading3'} -
-
{'Loading4'}
- {'Loading5'} -
, - ); - - await act(() => { - resumed.pipe(writable); - }); - - expect(getVisibleChildren(container)).toEqual( -
-
- {'Hello' /* This was matched and completed before the error */} - { - 'Loading2' /* This will be client rendered because its parent errored during replay */ - } - { - 'Hello' /* This should be renderable since we matched which previous sibling errored */ - } -
-
- { - 'Hello' /* This should be able to resume because it's in a different parent. */ - } -
- {'Hello'} - {'Loading6' /* The parent could resolve even if the child didn't */} -
, - ); - - const recoverableErrors = []; - - ssr = false; - - await clientAct(() => { - ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(x) { - recoverableErrors.push(x.message); - }, - }); - }); - - expect(getVisibleChildren(container)).toEqual( -
-
- {'Hello'} - {'Hello'} - {'Hello'} -
-
{'Hello'}
- {'Hello'} - {'Hello'} -
, - ); - - // We should've logged once for each boundary that this affected. - expect(prerenderErrors).toEqual([]); - expect(ssrErrors).toEqual([ - // This error triggered in two replay components. - 'replay error', - 'replay error', - ]); - expect(recoverableErrors).toEqual( - // It surfaced in two different suspense boundaries. - __DEV__ - ? [ - 'Switched to client rendering because the server rendering errored:\n\n' + - 'replay error', - 'Switched to client rendering because the server rendering errored:\n\n' + - 'replay error', - ] - : [ - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', - ], - ); - }); - - // @gate enablePostpone - it('can client render a boundary after having already postponed', async () => { - let prerendering = true; - let ssr = true; - - function Postpone() { - if (prerendering) { - React.unstable_postpone(); - } - return 'Hello'; - } - - function ServerError() { - if (ssr) { - throw new Error('server error'); - } - return 'World'; - } - - function App() { - return ( -
- - - - - - - -
- ); - } - - const prerenderErrors = []; - const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream( - , - { - onError(x) { - prerenderErrors.push(x.message); - }, - }, - ); - expect(prerendered.postponed).not.toBe(null); - - prerendering = false; - - const ssrErrors = []; - - const resumed = ReactDOMFizzServer.resumeToPipeableStream( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - { - onError(x) { - ssrErrors.push(x.message); - }, - }, - ); - - const windowErrors = []; - function globalError(e) { - windowErrors.push(e.message); - } - window.addEventListener('error', globalError); - - // Create a separate stream so it doesn't close the writable. I.e. simple concat. - const preludeWritable = new Stream.PassThrough(); - preludeWritable.setEncoding('utf8'); - preludeWritable.on('data', chunk => { - writable.write(chunk); - }); - - await act(() => { - prerendered.prelude.pipe(preludeWritable); - }); - - expect(windowErrors).toEqual([]); - - expect(getVisibleChildren(container)).toEqual( -
- {'Loading1'} - {'Loading2'} -
, - ); - - await act(() => { - resumed.pipe(writable); - }); - - expect(prerenderErrors).toEqual(['server error']); - - // Since this errored, we shouldn't have to replay it. - expect(ssrErrors).toEqual([]); - - expect(windowErrors).toEqual([]); - - // Still loading... - expect(getVisibleChildren(container)).toEqual( -
- {'Loading1'} - {'Hello'} -
, - ); - - const recoverableErrors = []; - - ssr = false; - - await clientAct(() => { - ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(x) { - recoverableErrors.push(x.message); - }, - }); - }); - - expect(recoverableErrors).toEqual( - __DEV__ - ? [ - 'Switched to client rendering because the server rendering errored:\n\n' + - 'server error', - ] - : [ - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', - ], - ); - expect(getVisibleChildren(container)).toEqual( -
- {'Hello'} - {'World'} - {'Hello'} -
, - ); - - expect(windowErrors).toEqual([]); - - window.removeEventListener('error', globalError); - }); - - // @gate enablePostpone - it('can postpone in fallback', async () => { - let prerendering = true; - function Postpone() { - if (prerendering) { - React.unstable_postpone(); - } - return 'Hello'; - } - - let resolve; - const promise = new Promise(r => (resolve = r)); - - function PostponeAndDelay() { - if (prerendering) { - React.unstable_postpone(); - } - return React.use(promise); - } - - const Lazy = React.lazy(async () => { - await 0; - return {default: Postpone}; - }); - - function App() { - return ( -
- - }> - World - - }> - - - -
- ); - } - - const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(); - expect(prerendered.postponed).not.toBe(null); - - prerendering = false; - - // Create a separate stream so it doesn't close the writable. I.e. simple concat. - const preludeWritable = new Stream.PassThrough(); - preludeWritable.setEncoding('utf8'); - preludeWritable.on('data', chunk => { - writable.write(chunk); - }); - - await act(() => { - prerendered.prelude.pipe(preludeWritable); - }); - - const resumed = await ReactDOMFizzServer.resumeToPipeableStream( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - ); - - expect(getVisibleChildren(container)).toEqual(
Outer
); - - // Read what we've completed so far - await act(() => { - resumed.pipe(writable); - }); - - // Should have now resolved the postponed loading state, but not the promise - expect(getVisibleChildren(container)).toEqual( -
- {'Hello'} - {'Hello'} -
, - ); - - // Resolve the final promise - await act(() => { - resolve('Hi'); - }); - - expect(getVisibleChildren(container)).toEqual( -
- {'Hi'} - {' World'} - {'Hello'} -
, - ); - }); - - // @gate enablePostpone - it('can discover new suspense boundaries in the resume', async () => { - let prerendering = true; - let resolveA; - const promiseA = new Promise(r => (resolveA = r)); - let resolveB; - const promiseB = new Promise(r => (resolveB = r)); - - function WaitA() { - return React.use(promiseA); - } - function WaitB() { - return React.use(promiseB); - } - function Postpone() { - if (prerendering) { - React.unstable_postpone(); - } - return ( - - - - - - - ); - } - - function App() { - return ( -
- -

- -

-
-
- ); - } - - const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(); - expect(prerendered.postponed).not.toBe(null); - - prerendering = false; - - // Create a separate stream so it doesn't close the writable. I.e. simple concat. - const preludeWritable = new Stream.PassThrough(); - preludeWritable.setEncoding('utf8'); - preludeWritable.on('data', chunk => { - writable.write(chunk); - }); - - await act(() => { - prerendered.prelude.pipe(preludeWritable); - }); - - const resumed = await ReactDOMFizzServer.resumeToPipeableStream( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - ); - - expect(getVisibleChildren(container)).toEqual(
Loading...
); - - // Read what we've completed so far - await act(() => { - resumed.pipe(writable); - }); - - // Still blocked - expect(getVisibleChildren(container)).toEqual(
Loading...
); - - // Resolve the first promise, this unblocks the inner boundary - await act(() => { - resolveA('Hello'); - }); - - // Still blocked - expect(getVisibleChildren(container)).toEqual(
Loading...
); - - // Resolve the second promise, this unblocks the outer boundary - await act(() => { - resolveB('World'); - }); - - expect(getVisibleChildren(container)).toEqual( -
-

- - {'Hello'} - {'World'} - -

-
, - ); - }); - - // @gate enablePostpone - it('does not call onError when you abort with a postpone instance during prerender', async () => { - const promise = new Promise(r => {}); - - function Wait() { - return React.use(promise); - } - - function App() { - return ( -
- -

- - - - - -

-

- - - - - -

-
-
- ); - } - - let postponeInstance; - try { - React.unstable_postpone('manufactured'); - } catch (p) { - postponeInstance = p; - } - - const controller = new AbortController(); - const signal = controller.signal; - - const errors = []; - function onError(error) { - errors.push(error); - } - const postpones = []; - function onPostpone(reason) { - postpones.push(reason); - } - let pendingPrerender; - await act(() => { - pendingPrerender = ReactDOMFizzStatic.prerenderToNodeStream(, { - signal, - onError, - onPostpone, - }); - }); - controller.abort(postponeInstance); - - const prerendered = await pendingPrerender; - - expect(errors).toEqual([]); - expect(postpones).toEqual(['manufactured', 'manufactured']); - - await act(() => { - prerendered.prelude.pipe(writable); - }); - - expect(getVisibleChildren(container)).toEqual( -
-

- Loading again... -

-

- Loading again too... -

-
, - ); - }); - - // @gate enableHalt - it('can resume a prerender that was aborted', async () => { - const promise = new Promise(r => {}); - - let prerendering = true; - - function Wait() { - if (prerendering) { - return React.use(promise); - } else { - return 'Hello'; - } - } - - function App() { - return ( -
- -

- - - - - -

-

- - - - - -

-
-
- ); - } - - const controller = new AbortController(); - const signal = controller.signal; - - const errors = []; - function onError(error) { - errors.push(error); - } - let pendingPrerender; - await act(() => { - pendingPrerender = ReactDOMFizzStatic.prerenderToNodeStream(, { - signal, - onError, - }); - }); - controller.abort('boom'); - - const prerendered = await pendingPrerender; - - expect(errors).toEqual(['boom', 'boom']); - - const preludeWritable = new Stream.PassThrough(); - preludeWritable.setEncoding('utf8'); - preludeWritable.on('data', chunk => { - writable.write(chunk); - }); - - await act(() => { - prerendered.prelude.pipe(preludeWritable); - }); - - expect(getVisibleChildren(container)).toEqual( -
-

- Loading again... -

-

- Loading again too... -

-
, - ); - - prerendering = false; - - errors.length = 0; - const resumed = await ReactDOMFizzServer.resumeToPipeableStream( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - { - onError, - }, - ); - - await act(() => { - resumed.pipe(writable); - }); - - expect(errors).toEqual([]); - expect(getVisibleChildren(container)).toEqual( -
-

- Hello -

-

- Hello -

-
, - ); - }); - - // @gate enablePostpone - it('does not call onError when you abort with a postpone instance during resume', async () => { - let prerendering = true; - const promise = new Promise(r => {}); - - function Wait() { - return React.use(promise); - } - function Postpone() { - if (prerendering) { - React.unstable_postpone(); - } - return ( - - - - - - ); - } - - function App() { - return ( -
- -

- -

-

- -

-
-
- ); - } - - const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(); - expect(prerendered.postponed).not.toBe(null); - - prerendering = false; - - // Create a separate stream so it doesn't close the writable. I.e. simple concat. - const preludeWritable = new Stream.PassThrough(); - preludeWritable.setEncoding('utf8'); - preludeWritable.on('data', chunk => { - writable.write(chunk); - }); - - await act(() => { - prerendered.prelude.pipe(preludeWritable); - }); - - expect(getVisibleChildren(container)).toEqual(
Loading...
); - - let postponeInstance; - try { - React.unstable_postpone('manufactured'); - } catch (p) { - postponeInstance = p; - } - - const errors = []; - function onError(error) { - errors.push(error); - } - const postpones = []; - function onPostpone(reason) { - postpones.push(reason); - } - - prerendering = false; - - const resumed = await ReactDOMFizzServer.resumeToPipeableStream( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - { - onError, - onPostpone, - }, - ); - - await act(() => { - resumed.pipe(writable); - }); - await act(() => { - resumed.abort(postponeInstance); - }); - - expect(getVisibleChildren(container)).toEqual( -
-

- Loading again... -

-

- Loading again... -

-
, - ); - - expect(errors).toEqual([]); - expect(postpones).toEqual(['manufactured', 'manufactured']); - }); - - // @gate enablePostpone - it('does not call onError when you abort with a postpone instance during a render', async () => { - const promise = new Promise(r => {}); - - function Wait() { - return React.use(promise); - } - - function App() { - return ( -
- -

- - - - - -

-

- - - - - -

-
-
- ); - } - - const errors = []; - function onError(error) { - errors.push(error); - } - const postpones = []; - function onPostpone(reason) { - postpones.push(reason); - } - const result = await renderToPipeableStream(, {onError, onPostpone}); - await act(() => { - result.pipe(writable); - }); - - expect(getVisibleChildren(container)).toEqual( -
-

- Loading again... -

-

- Loading again... -

-
, - ); - - let postponeInstance; - try { - React.unstable_postpone('manufactured'); - } catch (p) { - postponeInstance = p; - } - await act(() => { - result.abort(postponeInstance); - }); - - expect(getVisibleChildren(container)).toEqual( -
-

- Loading again... -

-

- Loading again... -

-
, - ); - - expect(errors).toEqual([]); - expect(postpones).toEqual(['manufactured', 'manufactured']); - }); - - // @gate enablePostpone - it('fatally errors if you abort with a postpone in the shell during resume', async () => { - let prerendering = true; - const promise = new Promise(r => {}); - - function Wait() { - return React.use(promise); - } - function Postpone() { - if (prerendering) { - React.unstable_postpone(); - } - return ( - - - - - - ); - } - - function PostponeInShell() { - if (prerendering) { - React.unstable_postpone(); - } - return in shell; - } - - function App() { - return ( -
- - -

- -

-

- -

-
-
- ); - } - - const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(); - expect(prerendered.postponed).not.toBe(null); - - prerendering = false; - - // Create a separate stream so it doesn't close the writable. I.e. simple concat. - const preludeWritable = new Stream.PassThrough(); - preludeWritable.setEncoding('utf8'); - preludeWritable.on('data', chunk => { - writable.write(chunk); - }); - - await act(() => { - prerendered.prelude.pipe(preludeWritable); - }); - - expect(getVisibleChildren(container)).toEqual(undefined); - - let postponeInstance; - try { - React.unstable_postpone('manufactured'); - } catch (p) { - postponeInstance = p; - } - - const errors = []; - function onError(error) { - errors.push(error); - } - const shellErrors = []; - function onShellError(error) { - shellErrors.push(error); - } - const postpones = []; - function onPostpone(reason) { - postpones.push(reason); - } - - prerendering = false; - - const resumed = ReactDOMFizzServer.resumeToPipeableStream( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - { - onError, - onShellError, - onPostpone, - }, - ); - await act(() => { - resumed.abort(postponeInstance); - }); - expect(errors).toEqual([ - new Error( - 'The render was aborted with postpone when the shell is incomplete. Reason: manufactured', - ), - ]); - expect(shellErrors).toEqual([ - new Error( - 'The render was aborted with postpone when the shell is incomplete. Reason: manufactured', - ), - ]); - expect(postpones).toEqual([]); - }); - - // @gate enablePostpone - it('fatally errors if you abort with a postpone in the shell during render', async () => { - const promise = new Promise(r => {}); - - function Wait() { - return React.use(promise); - } - - function App() { - return ( -
- -

- - - - - -

-

- - - - - -

-
-
- ); - } - - const errors = []; - function onError(error) { - errors.push(error); - } - const shellErrors = []; - function onShellError(error) { - shellErrors.push(error); - } - const postpones = []; - function onPostpone(reason) { - postpones.push(reason); - } - const result = renderToPipeableStream(, { - onError, - onShellError, - onPostpone, - }); - - let postponeInstance; - try { - React.unstable_postpone('manufactured'); - } catch (p) { - postponeInstance = p; - } - await act(() => { - result.abort(postponeInstance); - }); - - expect(getVisibleChildren(container)).toEqual(undefined); - - expect(errors).toEqual([ - new Error( - 'The render was aborted with postpone when the shell is incomplete. Reason: manufactured', - ), - ]); - expect(shellErrors).toEqual([ - new Error( - 'The render was aborted with postpone when the shell is incomplete. Reason: manufactured', - ), - ]); - expect(postpones).toEqual([]); - }); - it('should NOT warn for using generator functions as components', async () => { function* Foo() { yield

Hello

; diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js index bc96750d460..27e74750c45 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js @@ -551,45 +551,4 @@ describe('ReactDOMFizzServerBrowser', () => { `"
hello world
"`, ); }); - - // @gate enablePostpone - it('errors if trying to postpone outside a Suspense boundary', async () => { - function Postponed() { - React.unstable_postpone('testing postpone'); - return 'client only'; - } - - function App() { - return ( -
- -
- ); - } - - const errors = []; - const postponed = []; - - let caughtError = null; - try { - await serverAct(() => - ReactDOMFizzServer.renderToReadableStream(, { - onError(error) { - errors.push(error.message); - }, - onPostpone(reason) { - postponed.push(reason); - }, - }), - ); - } catch (error) { - caughtError = error; - } - - // Postponing is not logged as an error but as a postponed reason. - expect(errors).toEqual([]); - expect(postponed).toEqual(['testing postpone']); - // However, it does error the shell. - expect(caughtError.message).toEqual('testing postpone'); - }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js index a4ac87c92c4..4542ca618ba 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js @@ -231,9 +231,7 @@ describe('ReactDOMFizzStatic', () => { const result = await promise; expect(result.postponed).toBe( - gate(flags => flags.enableHalt || flags.enablePostpone) - ? null - : undefined, + gate(flags => flags.enableHalt) ? null : undefined, ); await act(async () => { @@ -302,42 +300,6 @@ describe('ReactDOMFizzStatic', () => { expect(getVisibleChildren(container)).toEqual('hello'); }); - // @gate enablePostpone - it('includes stylesheet preloads in onHeaders when postponing in the Shell', async () => { - let headers; - function onHeaders(x) { - headers = x; - } - - function App() { - ReactDOM.preload('image', {as: 'image', fetchPriority: 'high'}); - ReactDOM.preinit('style', {as: 'style'}); - React.unstable_postpone(); - return ( - - hello - - ); - } - - const result = await ReactDOMFizzStatic.prerenderToNodeStream(, { - onHeaders, - }); - expect(headers).toEqual({ - Link: ` -; rel=preload; as="image"; fetchpriority="high", - - - - ); + it('logs an error if onHeaders throws but continues the prerender', async () => { + const errors = []; + function onError(error) { + errors.push(error.message); } - function App() { - ReactDOM.preconnect('example.com'); - ReactDOM.preload('my-font', {as: 'font', type: 'font/woff2'}); - ReactDOM.preload('my-style0', {as: 'style'}); - // This should transfer the props in to the style that loads later. - ReactDOM.preload('my-style2', { - as: 'style', - crossOrigin: 'use-credentials', - }); - return ( -
- - - - - - Hello World -
- ); + function onHeaders(x) { + throw new Error('bad onHeaders'); } - let calledInit = false; - jest.mock( - 'init.js', - () => { - calledInit = true; - }, - {virtual: true}, - ); - const prerendered = await serverAct(() => - ReactDOMFizzStatic.prerender(, { - bootstrapScripts: ['init.js'], + ReactDOMFizzStatic.prerender(
hello
, { + onHeaders, + onError, }), ); - expect(prerendered.postponed).not.toBe(null); - - await readIntoContainer(prerendered.prelude); - - expect(getVisibleChildren(container)).toEqual([ - , - , - , - , - , - , - , - Hello World, -
Loading...
, - ]); - - prerendering = false; - const content = await serverAct(() => - ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - ), - ); - - await readIntoContainer(content); - - expect(calledInit).toBe(true); - - // Dispatch load event to injected stylesheet - const link = document.querySelector( - 'link[rel="stylesheet"][href="my-style2"]', - ); - const event = document.createEvent('Events'); - event.initEvent('load', true, true); - link.dispatchEvent(event); - - // Wait for the instruction microtasks to flush. - await 0; - await 0; - jest.runAllTimers(); - - expect(getVisibleChildren(container)).toEqual([ - , - , - , - , - , - , - , - , - , - Hello World, -
- - -
, - ]); - }); - - // @gate enablePostpone - it('can postpone a boundary after it has already been added', async () => { - let prerendering = true; - function Postpone() { - if (prerendering) { - React.unstable_postpone(); - } - return 'Hello'; - } - - function App() { - return ( -
- - - - - - - -
- ); - } - - const prerendered = await serverAct(() => - ReactDOMFizzStatic.prerender(), - ); - expect(prerendered.postponed).not.toBe(null); - - prerendering = false; - - const resumed = await serverAct(() => - ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - ), + expect(prerendered.postponed).toBe( + gate(flags => flags.enableHalt) ? null : undefined, ); + expect(errors).toEqual(['bad onHeaders']); await readIntoContainer(prerendered.prelude); - - expect(getVisibleChildren(container)).toEqual(
Loading...
); - - await readIntoContainer(resumed); - - expect(getVisibleChildren(container)).toEqual( -
{['Hello', 'Hello', 'Hello']}
, - ); - }); - - // @gate enablePostpone - it('can postpone in fallback', async () => { - let prerendering = true; - function Postpone() { - if (prerendering) { - React.unstable_postpone(); - } - return 'Hello'; - } - - const Lazy = React.lazy(async () => { - await 0; - return {default: Postpone}; - }); - - function App() { - return ( -
- - }> - World - - }> - - - -
- ); - } - - const prerendered = await serverAct(() => - ReactDOMFizzStatic.prerender(), - ); - expect(prerendered.postponed).not.toBe(null); - - prerendering = false; - - const resumed = await serverAct(() => - ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - ), - ); - - await readIntoContainer(prerendered.prelude); - - expect(getVisibleChildren(container)).toEqual(
Outer
); - - await readIntoContainer(resumed); - - expect(getVisibleChildren(container)).toEqual( -
- {'Hello'} - {' World'} - {'Hello'} -
, - ); - }); - - // @gate enablePostpone - it('can postpone in fallback without postponing the tree', async () => { - function Postpone() { - React.unstable_postpone(); - } - - const lazyText = React.lazy(async () => { - await 0; // causes the fallback to start work - return {default: 'Hello'}; - }); - - function App() { - return ( -
- - }>{lazyText} - -
- ); - } - - const prerendered = await serverAct(() => - ReactDOMFizzStatic.prerender(), - ); - // TODO: This should actually be null because we should've been able to fully - // resolve the render on the server eventually, even though the fallback postponed. - // So we should not need to resume. - expect(prerendered.postponed).not.toBe(null); - - await readIntoContainer(prerendered.prelude); - - expect(getVisibleChildren(container)).toEqual(
Outer
); - - const resumed = await serverAct(() => - ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - ), - ); - - await readIntoContainer(resumed); - - expect(getVisibleChildren(container)).toEqual(
Hello
); - }); - - // @gate enablePostpone - it('errors if the replay does not line up', async () => { - let prerendering = true; - function Postpone() { - if (prerendering) { - React.unstable_postpone(); - } - return 'Hello'; - } - - function Wrapper({children}) { - return children; - } - - const lazySpan = React.lazy(async () => { - await 0; - return {default: }; - }); - - function App() { - const children = ( - - - - ); - return ( - <> -
{prerendering ? {children} : children}
-
- {prerendering ? ( - -
- -
-
- ) : ( - lazySpan - )} -
- - ); - } - - const prerendered = await serverAct(() => - ReactDOMFizzStatic.prerender(), - ); - expect(prerendered.postponed).not.toBe(null); - - await readIntoContainer(prerendered.prelude); - - expect(getVisibleChildren(container)).toEqual([ -
Loading...
, -
Loading...
, - ]); - - prerendering = false; - - const errors = []; - const resumed = await serverAct(() => - ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - { - onError(x) { - errors.push(x.message); - }, - }, - ), - ); - - expect(errors).toEqual([ - 'Expected the resume to render in this slot but instead it rendered . ' + - "The tree doesn't match so React will fallback to client rendering.", - 'Expected the resume to render in this slot but instead it rendered . ' + - "The tree doesn't match so React will fallback to client rendering.", - ]); - - // TODO: Test the component stack but we don't expose it to the server yet. - - await readIntoContainer(resumed); - - // Client rendered - expect(getVisibleChildren(container)).toEqual([ -
Loading...
, -
Loading...
, - ]); - }); - - // @gate enablePostpone - it('can abort the resume', async () => { - let prerendering = true; - const infinitePromise = new Promise(() => {}); - function Postpone() { - if (prerendering) { - React.unstable_postpone(); - } - return 'Hello'; - } - - function App() { - if (!prerendering) { - React.use(infinitePromise); - } - return ( -
- - - -
- ); - } - - const prerendered = await serverAct(() => - ReactDOMFizzStatic.prerender(), - ); - expect(prerendered.postponed).not.toBe(null); - - await readIntoContainer(prerendered.prelude); - - expect(getVisibleChildren(container)).toEqual(
Loading...
); - - prerendering = false; - - const controller = new AbortController(); - - const errors = []; - - const resumedPromise = serverAct(() => - ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - { - signal: controller.signal, - onError(x) { - errors.push(x); - }, - }, - ), - ); - - controller.abort('abort'); - - const resumed = await resumedPromise; - await resumed.allReady; - - expect(errors).toEqual(['abort']); - - await readIntoContainer(resumed); - - // Client rendered - expect(getVisibleChildren(container)).toEqual(
Loading...
); - }); - - // @gate enablePostpone - it('can suspend in a replayed component several layers deep', async () => { - let prerendering = true; - function Postpone() { - if (prerendering) { - React.unstable_postpone(); - } - return 'Hello'; - } - - let resolve; - const promise = new Promise(r => (resolve = r)); - function Delay({children}) { - if (!prerendering) { - React.use(promise); - } - return children; - } - - // This wrapper will cause us to do one destructive render past this. - function Outer({children}) { - return children; - } - - function App() { - return ( -
- - - - - - - -
- ); - } - - const prerendered = await serverAct(() => - ReactDOMFizzStatic.prerender(), - ); - expect(prerendered.postponed).not.toBe(null); - - await readIntoContainer(prerendered.prelude); - - prerendering = false; - - const resumedPromise = serverAct(() => - ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - ), - ); - - await jest.runAllTimers(); - - expect(getVisibleChildren(container)).toEqual(
Loading...
); - - await resolve(); - - await readIntoContainer(await resumedPromise); - - expect(getVisibleChildren(container)).toEqual(
Hello
); - }); - - // @gate enablePostpone - it('emits an empty prelude and resumes at the root if we postpone in the shell', async () => { - let prerendering = true; - function Postpone() { - if (prerendering) { - React.unstable_postpone(); - } - return 'Hello'; - } - - function App() { - return ( - - - - - - - ); - } - - const prerendered = await serverAct(() => - ReactDOMFizzStatic.prerender(), - ); - expect(prerendered.postponed).not.toBe(null); - - prerendering = false; - - expect(await readContent(prerendered.prelude)).toBe(''); - - const content = await serverAct(() => - ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - ), - ); - - expect(await readContent(content)).toBe( - '' + - '' + - (gate(flags => flags.enableFizzBlockingRender) - ? '' - : '') + - '' + - 'Hello' + - (gate(flags => flags.enableFizzBlockingRender) - ? '' - : '') + - '', - ); - }); - - // @gate enablePostpone - it('emits an empty prelude if we have not rendered html or head tags yet', async () => { - let prerendering = true; - function Postpone() { - if (prerendering) { - React.unstable_postpone(); - } - return ( - - Hello - - ); - } - - function App() { - return ( - <> - - - - ); - } - - const prerendered = await serverAct(() => - ReactDOMFizzStatic.prerender(), - ); - expect(prerendered.postponed).not.toBe(null); - - prerendering = false; - - expect(await readContent(prerendered.prelude)).toBe(''); - - const content = await serverAct(() => - ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - ), - ); - - expect(await readContent(content)).toBe( - '' + - '' + - '' + - 'Hello', - ); - }); - - // @gate enablePostpone - it('emits an empty prelude if a postpone in a promise in the shell', async () => { - let prerendering = true; - function Postpone() { - if (prerendering) { - React.unstable_postpone(); - } - return 'Hello'; - } - - const Lazy = React.lazy(async () => { - await 0; - return {default: Postpone}; - }); - - function App() { - return ( - - - -
- -
- - - ); - } - - const prerendered = await serverAct(() => - ReactDOMFizzStatic.prerender(), - ); - expect(prerendered.postponed).not.toBe(null); - - prerendering = false; - - expect(await readContent(prerendered.prelude)).toBe(''); - - const content = await serverAct(() => - ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - ), - ); - - expect(await readContent(content)).toBe( - '' + - '' + - '' + - '
Hello
', - ); - }); - - // @gate enablePostpone - it('does not emit preloads during resume for Resources preloaded through onHeaders', async () => { - let prerendering = true; - - let hasLoaded = false; - let resolve; - const promise = new Promise(r => (resolve = r)); - function WaitIfResuming({children}) { - if (!prerendering && !hasLoaded) { - throw promise; - } - return children; - } - - function Postpone() { - if (prerendering) { - React.unstable_postpone(); - } - return null; - } - - let headers; - function onHeaders(x) { - headers = x; - } - - function App() { - ReactDOM.preload('image', {as: 'image', fetchPriority: 'high'}); - return ( - - - hello - - - world - - - - - - - ); - } - - const prerendered = await serverAct(() => - ReactDOMFizzStatic.prerender(, { - onHeaders, - }), - ); - expect(prerendered.postponed).not.toBe(null); - - prerendering = false; - - expect(await readContent(prerendered.prelude)).toBe(''); - expect(headers).toEqual( - new Headers({ - Link: ` -; rel=preload; as="image"; fetchpriority="high", -