From ea60ebeb697d4dd563a4196f38a243c659841b0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rado=C5=A1?= Date: Tue, 9 Jun 2026 13:59:29 +0200 Subject: [PATCH] fix(router-core): don't drop onRenderFinished listeners after fast path reserved When the stream fast path was already reserved, onRenderFinished silently dropped any listener registered afterwards. With @tanstack/react-router-ssr-query this meant the dehydration query stream was never closed, hanging the SSR response until the ~60s serialization timeout (#7529). The fast path still calls setRenderFinished() once the app stream ends, so the listener is now registered regardless and fires at the correct time instead of being discarded. --- .changeset/cool-streams-close.md | 7 +++++ packages/router-core/src/ssr/ssr-server.ts | 5 +++- .../tests/ssr-server-cleanup.test.ts | 30 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 .changeset/cool-streams-close.md diff --git a/.changeset/cool-streams-close.md b/.changeset/cool-streams-close.md new file mode 100644 index 0000000000..77575be52c --- /dev/null +++ b/.changeset/cool-streams-close.md @@ -0,0 +1,7 @@ +--- +'@tanstack/router-core': patch +--- + +fix(router-core): run `onRenderFinished` listeners registered after the stream fast path is reserved + +When `reserveStreamFastPath()` had already set `streamFastPathReserved = true`, a subsequently registered `onRenderFinished` listener was silently dropped. This broke SSR streaming with `@tanstack/react-router-ssr-query`: the dehydration query stream was never closed, so the response hung until the serialization timeout (~60s). The listener is now registered regardless; the fast path still calls `setRenderFinished()` when the app stream ends, so it fires at the correct time. diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index 31db2f5e41..d6c340f7e3 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -637,7 +637,10 @@ export function attachRouterServerSsrUtils({ return () => removeListener(injectedHtmlListeners, listener) }, onRenderFinished: (listener) => { - if (cleanupStarted || streamFastPathReserved) return + if (cleanupStarted) return + // Register even when the fast path is reserved: it still calls + // setRenderFinished() at the end of the app stream. Dropping listeners + // here left router-ssr-query's query stream open, hanging SSR (#7529). renderFinishedListeners.push(listener) }, onSerializationFinished: (listener) => { diff --git a/packages/router-core/tests/ssr-server-cleanup.test.ts b/packages/router-core/tests/ssr-server-cleanup.test.ts index 0cc8832abd..ceafcc8fab 100644 --- a/packages/router-core/tests/ssr-server-cleanup.test.ts +++ b/packages/router-core/tests/ssr-server-cleanup.test.ts @@ -190,6 +190,36 @@ describe('serverSsr.cleanup', () => { router.serverSsr?.cleanup() }) + test('onRenderFinished listener registered after fast path reserve still fires', async () => { + // Regression test for #7529: when the fast path is reserved before an + // integration (e.g. router-ssr-query) registers its onRenderFinished + // listener, the listener must not be dropped - otherwise the query stream + // is never closed and the response hangs until the serialization timeout. + // The fast path still calls setRenderFinished() at the end of the app + // stream, so the listener fires at that point. + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + + await router.load() + await router.serverSsr!.dehydrate() + router.serverSsr!.takeBufferedScripts() + + expect(router.serverSsr!.reserveStreamFastPath()).toBe(true) + + let renderFinishedCalls = 0 + router.serverSsr!.onRenderFinished(() => { + renderFinishedCalls++ + }) + // Not invoked at registration time - the fast path defers to the app + // stream end, mirrored here by an explicit setRenderFinished(). + expect(renderFinishedCalls).toBe(0) + + router.serverSsr!.setRenderFinished() + expect(renderFinishedCalls).toBe(1) + + router.serverSsr?.cleanup() + }) + test('stream fast path rejects while SSR work is pending', async () => { const value = deferred() const router = buildRouter({ value: value.promise })