From 52221d4539964e0340219b2aff26d63c6bc16ea6 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 24 Jul 2025 12:29:17 -0700 Subject: [PATCH] [Flight] Properly close stream when no chunks need to be written after prerender 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 4c06d93bed6f..049fa39d417a 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 688a9ef0c574..30698b8311ac 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; }