diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
index 27c3dcaf61126..44f6f2e64c349 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
@@ -6261,6 +6261,59 @@ describe('ReactDOMFizzServer', () => {
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',
@@ -6830,4 +6883,94 @@ describe('ReactDOMFizzServer', () => {
],
);
});
+
+ // @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'}
+
,
+ );
+ });
});
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
index cc1b8c4f7b320..58e34316b0782 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
@@ -870,8 +870,6 @@ describe('ReactDOMFizzStaticBrowser', () => {
prerendering = false;
- console.log(JSON.stringify(prerendered.postponed, null, 2));
-
const resumed = await ReactDOMFizzServer.resume(
,
JSON.parse(JSON.stringify(prerendered.postponed)),
@@ -887,4 +885,100 @@ describe('ReactDOMFizzStaticBrowser', () => {
{['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 ReactDOMFizzStatic.prerender();
+ expect(prerendered.postponed).not.toBe(null);
+
+ prerendering = false;
+
+ const resumed = await 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 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 ReactDOMFizzServer.resume(
+ ,
+ JSON.parse(JSON.stringify(prerendered.postponed)),
+ );
+
+ await readIntoContainer(resumed);
+
+ expect(getVisibleChildren(container)).toEqual(Hello
);
+ });
});
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index 82d713bb2a415..7a0011445bf95 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -170,8 +170,9 @@ type ResumeSlots =
type ReplaySuspenseBoundary = [
string | null /* name */,
string | number /* key */,
- Array /* keyed children */,
- ResumeSlots /* resumable slots */,
+ Array /* content keyed children */,
+ ResumeSlots /* content resumable slots */,
+ null | ReplayNode /* fallback content */,
number /* rootSegmentID */,
];
@@ -208,7 +209,8 @@ type SuspenseBoundary = {
byteSize: number, // used to determine whether to inline children boundaries.
fallbackAbortableTasks: Set, // used to cancel task on the fallback if the boundary completes or gets canceled.
resources: BoundaryResources,
- keyPath: Root | KeyNode,
+ trackedContentKeyPath: null | KeyNode, // used to track the path for replay nodes
+ trackedFallbackNode: null | ReplayNode, // used to track the fallback for replay nodes
};
type RenderTask = {
@@ -549,7 +551,6 @@ function pingTask(request: Request, task: Task): void {
function createSuspenseBoundary(
request: Request,
fallbackAbortableTasks: Set,
- keyPath: Root | KeyNode,
): SuspenseBoundary {
return {
status: PENDING,
@@ -561,7 +562,8 @@ function createSuspenseBoundary(
fallbackAbortableTasks,
errorDigest: null,
resources: createBoundaryResources(),
- keyPath,
+ trackedContentKeyPath: null,
+ trackedFallbackNode: null,
};
}
@@ -823,11 +825,10 @@ function renderSuspenseBoundary(
const content: ReactNodeList = props.children;
const fallbackAbortSet: Set = new Set();
- const newBoundary = createSuspenseBoundary(
- request,
- fallbackAbortSet,
- keyPath,
- );
+ const newBoundary = createSuspenseBoundary(request, fallbackAbortSet);
+ if (request.trackedPostpones !== null) {
+ newBoundary.trackedContentKeyPath = keyPath;
+ }
const insertionIndex = parentSegment.chunks.length;
// The children of the boundary segment is actually the fallback.
const boundarySegment = createPendingSegment(
@@ -930,6 +931,28 @@ function renderSuspenseBoundary(
task.keyPath = prevKeyPath;
}
+ const fallbackKeyPath = [keyPath[0], 'Suspense Fallback', keyPath[2]];
+ const trackedPostpones = request.trackedPostpones;
+ if (trackedPostpones !== null) {
+ // We create a detached replay node to track any postpones inside the fallback.
+ const fallbackReplayNode: ReplayNode = [
+ fallbackKeyPath[1],
+ fallbackKeyPath[2],
+ ([]: Array),
+ null,
+ ];
+ trackedPostpones.workingMap.set(fallbackKeyPath, fallbackReplayNode);
+ if (newBoundary.status === POSTPONED) {
+ // This must exist now.
+ const boundaryReplayNode: ReplaySuspenseBoundary =
+ (trackedPostpones.workingMap.get(keyPath): any);
+ boundaryReplayNode[4] = fallbackReplayNode;
+ } else {
+ // We might not inject it into the postponed tree, unless the content actually
+ // postpones too. We need to keep track of it until that happpens.
+ newBoundary.trackedFallbackNode = fallbackReplayNode;
+ }
+ }
// We create suspended task for the fallback because we don't want to actually work
// on it yet in case we finish the main content, so we queue for later.
const suspendedFallbackTask = createRenderTask(
@@ -940,8 +963,7 @@ function renderSuspenseBoundary(
parentBoundary,
boundarySegment,
fallbackAbortSet,
- // TODO: Should distinguish key path of fallback and primary tasks
- keyPath,
+ fallbackKeyPath,
task.formatContext,
task.legacyContext,
task.context,
@@ -965,6 +987,8 @@ function replaySuspenseBoundary(
id: number,
childNodes: Array,
childSlots: ResumeSlots,
+ fallbackNodes: Array,
+ fallbackSlots: ResumeSlots,
): void {
pushBuiltInComponentStackInDEV(task, 'Suspense');
@@ -974,13 +998,10 @@ function replaySuspenseBoundary(
const parentBoundary = task.blockedBoundary;
const content: ReactNodeList = props.children;
+ const fallback: ReactNodeList = props.fallback;
const fallbackAbortSet: Set = new Set();
- const resumedBoundary = createSuspenseBoundary(
- request,
- fallbackAbortSet,
- task.keyPath,
- );
+ const resumedBoundary = createSuspenseBoundary(request, fallbackAbortSet);
resumedBoundary.parentFlushed = true;
// We restore the same id of this boundary as was used during prerender.
resumedBoundary.rootSegmentID = id;
@@ -1003,13 +1024,6 @@ function replaySuspenseBoundary(
} else {
renderNode(request, task, content, -1);
}
- if (
- resumedBoundary.pendingTasks === 0 &&
- resumedBoundary.status === PENDING
- ) {
- resumedBoundary.status = COMPLETED;
- request.completedBoundaries.push(resumedBoundary);
- }
if (task.replay.pendingTasks === 1 && task.replay.nodes.length > 0) {
throw new Error(
"Couldn't find all resumable slots by key/index during replaying. " +
@@ -1017,6 +1031,18 @@ function replaySuspenseBoundary(
);
}
task.replay.pendingTasks--;
+ if (
+ resumedBoundary.pendingTasks === 0 &&
+ resumedBoundary.status === PENDING
+ ) {
+ resumedBoundary.status = COMPLETED;
+ request.completedBoundaries.push(resumedBoundary);
+ // This must have been the last segment we were waiting on. This boundary is now complete.
+ // Therefore we won't need the fallback. We early return so that we don't have to create
+ // the fallback.
+ popComponentStackInDEV(task);
+ return;
+ }
} catch (error) {
resumedBoundary.status = CLIENT_RENDERED;
let errorDigest;
@@ -1057,6 +1083,66 @@ function replaySuspenseBoundary(
task.replay = previousReplaySet;
task.keyPath = prevKeyPath;
}
+
+ const fallbackKeyPath = [keyPath[0], 'Suspense Fallback', keyPath[2]];
+
+ let suspendedFallbackTask;
+ // We create suspended task for the fallback because we don't want to actually work
+ // on it yet in case we finish the main content, so we queue for later.
+ if (typeof fallbackSlots === 'number') {
+ // Resuming directly in the fallback.
+ const resumedSegment = createPendingSegment(
+ request,
+ 0,
+ null,
+ task.formatContext,
+ false,
+ false,
+ );
+ resumedSegment.id = fallbackSlots;
+ resumedSegment.parentFlushed = true;
+ suspendedFallbackTask = createRenderTask(
+ request,
+ null,
+ fallback,
+ -1,
+ parentBoundary,
+ resumedSegment,
+ fallbackAbortSet,
+ fallbackKeyPath,
+ task.formatContext,
+ task.legacyContext,
+ task.context,
+ task.treeContext,
+ );
+ } else {
+ const fallbackReplay = {
+ nodes: fallbackNodes,
+ slots: fallbackSlots,
+ pendingTasks: 0,
+ };
+ suspendedFallbackTask = createReplayTask(
+ request,
+ null,
+ fallbackReplay,
+ fallback,
+ -1,
+ parentBoundary,
+ fallbackAbortSet,
+ fallbackKeyPath,
+ task.formatContext,
+ task.legacyContext,
+ task.context,
+ task.treeContext,
+ );
+ }
+ if (__DEV__) {
+ suspendedFallbackTask.componentStack = task.componentStack;
+ }
+ // TODO: This should be queued at a separate lower priority queue so that we only work
+ // on preparing fallbacks if we don't have any more main content to task on.
+ request.pingedTasks.push(suspendedFallbackTask);
+
// TODO: Should this be in the finally?
popComponentStackInDEV(task);
}
@@ -2025,9 +2111,11 @@ function replayElement(
task,
keyPath,
props,
- node[4],
+ node[5],
node[2],
node[3],
+ node[4] === null ? [] : node[4][2],
+ node[4] === null ? null : node[4][3],
);
}
// We finished rendering this node, so now we can consume this
@@ -2467,13 +2555,15 @@ function trackPostpone(
// it before flushing and we know that we can't inline it.
boundary.rootSegmentID = request.nextSegmentId++;
- const boundaryKeyPath = boundary.keyPath;
+ const boundaryKeyPath = boundary.trackedContentKeyPath;
if (boundaryKeyPath === null) {
throw new Error(
'It should not be possible to postpone at the root. This is a bug in React.',
);
}
+ const fallbackReplayNode = boundary.trackedFallbackNode;
+
const children: Array = [];
if (boundaryKeyPath === keyPath && task.childIndex === -1) {
// Since we postponed directly in the Suspense boundary we can't have written anything
@@ -2485,8 +2575,10 @@ function trackPostpone(
boundaryKeyPath[2],
children,
boundary.rootSegmentID,
+ fallbackReplayNode,
boundary.rootSegmentID,
];
+ trackedPostpones.workingMap.set(boundaryKeyPath, boundaryNode);
addToReplayParent(boundaryNode, boundaryKeyPath[0], trackedPostpones);
return;
} else {
@@ -2498,14 +2590,16 @@ function trackPostpone(
boundaryKeyPath[2],
children,
null,
+ fallbackReplayNode,
boundary.rootSegmentID,
];
trackedPostpones.workingMap.set(boundaryKeyPath, boundaryNode);
addToReplayParent(boundaryNode, boundaryKeyPath[0], trackedPostpones);
} else {
// Upgrade to ReplaySuspenseBoundary.
- ((boundaryNode: any): ReplaySuspenseBoundary)[4] =
- boundary.rootSegmentID;
+ const suspenseBoundary: ReplaySuspenseBoundary = (boundaryNode: any);
+ suspenseBoundary[4] = fallbackReplayNode;
+ suspenseBoundary[5] = boundary.rootSegmentID;
}
// Fall through to add the child node.
}
@@ -2528,13 +2622,19 @@ function trackPostpone(
if (keyPath === null) {
trackedPostpones.rootSlots = segment.id;
} else {
- const resumableElement: ReplayNode = [
- keyPath[1],
- keyPath[2],
- ([]: Array),
- segment.id,
- ];
- addToReplayParent(resumableElement, keyPath[0], trackedPostpones);
+ const workingMap = trackedPostpones.workingMap;
+ let resumableNode = workingMap.get(keyPath);
+ if (resumableNode === undefined) {
+ resumableNode = [
+ keyPath[1],
+ keyPath[2],
+ ([]: Array),
+ segment.id,
+ ];
+ addToReplayParent(resumableNode, keyPath[0], trackedPostpones);
+ } else {
+ resumableNode[3] = segment.id;
+ }
}
} else {
let slots;
@@ -2963,11 +3063,7 @@ function abortRemainingSuspenseBoundary(
error: mixed,
errorDigest: ?string,
): void {
- const resumedBoundary = createSuspenseBoundary(
- request,
- new Set(),
- null, // The keyPath doesn't matter at this point so we don't bother rebuilding it.
- );
+ const resumedBoundary = createSuspenseBoundary(request, new Set());
resumedBoundary.parentFlushed = true;
// We restore the same id of this boundary as was used during prerender.
resumedBoundary.rootSegmentID = rootSegmentID;
@@ -3017,7 +3113,7 @@ function abortRemainingReplayNodes(
);
} else {
const boundaryNode: ReplaySuspenseBoundary = node;
- const rootSegmentID = boundaryNode[4];
+ const rootSegmentID = boundaryNode[5];
abortRemainingSuspenseBoundary(
request,
rootSegmentID,
@@ -3835,9 +3931,7 @@ function flushCompletedQueues(
destination,
request.resumableState,
request.renderState,
- request.allPendingTasks === 0 &&
- (request.trackedPostpones === null ||
- request.trackedPostpones.workingMap.size === 0),
+ request.allPendingTasks === 0 && request.trackedPostpones === null,
);
}
@@ -3932,13 +4026,7 @@ function flushCompletedQueues(
if (enableFloat) {
// We write the trailing tags but only if don't have any data to resume.
// If we need to resume we'll write the postamble in the resume instead.
- if (
- !enablePostpone ||
- request.trackedPostpones === null ||
- // We check the working map instead of the root because the root could've
- // been mutated at this point if it was passed straight through to resume().
- request.trackedPostpones.workingMap.size === 0
- ) {
+ if (!enablePostpone || request.trackedPostpones === null) {
writePostamble(destination, request.resumableState);
}
}
@@ -4090,6 +4178,8 @@ export function getPostponedState(request: Request): null | PostponedState {
(trackedPostpones.rootNodes.length === 0 &&
trackedPostpones.rootSlots === null)
) {
+ // Reset. Let the flushing behave as if we completed the whole document.
+ request.trackedPostpones = null;
return null;
}
return {