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 })