Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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