diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
index eb739eb00ba5..d0dcb2f71474 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
@@ -7178,6 +7178,141 @@ describe('ReactDOMFizzServer', () => {
);
});
+ // @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__
+ ? ['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;
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index fe9d7b283b2d..16357649bbb3 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -994,6 +994,8 @@ function renderSuspenseBoundary(
}
encodeErrorForBoundary(newBoundary, errorDigest, error, thrownInfo);
+ untrackBoundary(request, newBoundary);
+
// We don't need to decrement any task numbers because we didn't spawn any new task.
// We don't need to schedule any task because we know the parent has written yet.
// We do need to fallthrough to create the fallback though.
@@ -2672,6 +2674,34 @@ function trackPostpone(
}
}
+// In case a boundary errors, we need to stop tracking it because we won't
+// resume it.
+function untrackBoundary(request: Request, boundary: SuspenseBoundary) {
+ const trackedPostpones = request.trackedPostpones;
+ if (trackedPostpones === null) {
+ return;
+ }
+ const boundaryKeyPath = boundary.trackedContentKeyPath;
+ if (boundaryKeyPath === null) {
+ return;
+ }
+ const boundaryNode: void | ReplayNode =
+ trackedPostpones.workingMap.get(boundaryKeyPath);
+ if (boundaryNode === undefined) {
+ return;
+ }
+
+ // Downgrade to plain ReplayNode since we won't replay through it.
+ // $FlowFixMe[cannot-write]: We intentionally downgrade this to the other tuple.
+ boundaryNode.length = 4;
+ // Remove any resumable slots.
+ boundaryNode[2] = [];
+ boundaryNode[3] = null;
+
+ // TODO: We should really just remove the boundary from all parent paths too so
+ // we don't replay the path to it.
+}
+
function injectPostponedHole(
request: Request,
task: RenderTask,
@@ -3007,6 +3037,7 @@ function erroredTask(
if (boundary.status !== CLIENT_RENDERED) {
boundary.status = CLIENT_RENDERED;
encodeErrorForBoundary(boundary, errorDigest, error, errorInfo);
+ untrackBoundary(request, boundary);
// Regardless of what happens next, this boundary won't be displayed,
// so we can flush it, if the parent already flushed.
@@ -3192,6 +3223,8 @@ function abortTask(task: Task, request: Request, error: mixed): void {
}
encodeErrorForBoundary(boundary, errorDigest, errorMessage, errorInfo);
+ untrackBoundary(request, boundary);
+
if (boundary.parentFlushed) {
request.clientRenderedBoundaries.push(boundary);
}