Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/cool-streams-close.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion packages/router-core/src/ssr/ssr-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,10 @@ export function attachRouterServerSsrUtils({
return () => removeListener(injectedHtmlListeners, listener)
},
onRenderFinished: (listener) => {
if (cleanupStarted || streamFastPathReserved) return
if (cleanupStarted) return
Comment thread
radosek marked this conversation as resolved.
// 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) => {
Expand Down
30 changes: 30 additions & 0 deletions packages/router-core/tests/ssr-server-cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
const router = buildRouter({ value: value.promise })
Expand Down