From 5a04619f60221d9132f64f25b5a87b04c78fc7dc Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 24 Jul 2025 19:38:31 -0700 Subject: [PATCH] [Flight] Properly close stream when no chunks need to be written after prerender (#33982) There is an edge case when prerendering where if you have nothing to write you can end up in a state where the prerender is in status closed before you can provide a destination. In this case the destination is never closed becuase it assumes it already would have been. This condition can happen now because of the introduction of the deubg stream. Before this a request would never entere closed status if there was no active destination. When a destination was added it would perform a flush and possibly close the stream. Now, it is possible to flush without a destination because you might have debug chunks to stream and you can end up closing the stream independent of an active destination. There are a number of ways we can solve this but the one that seems to adhere best to the original design is to only set the status to CLOSED when a destination is active. This means that if you don't have an active destination when the pendingChunks count hits zero it will not enter CLOSED status until you startFlowing. --- .../src/__tests__/ReactFlightDOMNode-test.js | 42 +++++++++++++++++++ .../react-server/src/ReactFlightServer.js | 3 +- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index 4c06d93bed6f9..049fa39d417a5 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -863,4 +863,46 @@ describe('ReactFlightDOMNode', () => { expect(ownerStack).toBeNull(); } }); + + // @gate experimental + // @gate enableHalt + it('can handle an empty prelude when prerendering', async () => { + function App() { + return null; + } + + const serverAbortController = new AbortController(); + serverAbortController.abort(); + const errors = []; + const {pendingResult} = await serverAct(async () => { + // destructure trick to avoid the act scope from awaiting the returned value + return { + pendingResult: ReactServerDOMStaticServer.unstable_prerender( + ReactServer.createElement(App, null), + webpackMap, + { + signal: serverAbortController.signal, + onError(error) { + errors.push(error); + }, + }, + ), + }; + }); + + expect(errors).toEqual([]); + + const {prelude} = await pendingResult; + + const reader = prelude.getReader(); + while (true) { + const {done} = await reader.read(); + if (done) { + break; + } + } + + // We don't really have an assertion other than to make sure + // the stream doesn't hang. + }); }); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 688a9ef0c574a..30698b8311acc 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -5762,6 +5762,7 @@ function flushCompletedChunks(request: Request): void { // TODO: If this destination is not currently flowing we'll not close it when it resumes flowing. // We should keep a separate status for this. if (request.destination !== null) { + request.status = CLOSED; close(request.destination); request.destination = null; } @@ -5779,8 +5780,8 @@ function flushCompletedChunks(request: Request): void { ); request.cacheController.abort(abortReason); } - request.status = CLOSED; if (request.destination !== null) { + request.status = CLOSED; close(request.destination); request.destination = null; }