Skip to content

Commit ea60ebe

Browse files
committed
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.
1 parent 6f1daf5 commit ea60ebe

3 files changed

Lines changed: 41 additions & 1 deletion

File tree

.changeset/cool-streams-close.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@tanstack/router-core': patch
3+
---
4+
5+
fix(router-core): run `onRenderFinished` listeners registered after the stream fast path is reserved
6+
7+
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.

packages/router-core/src/ssr/ssr-server.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -637,7 +637,10 @@ export function attachRouterServerSsrUtils({
637637
return () => removeListener(injectedHtmlListeners, listener)
638638
},
639639
onRenderFinished: (listener) => {
640-
if (cleanupStarted || streamFastPathReserved) return
640+
if (cleanupStarted) return
641+
// Register even when the fast path is reserved: it still calls
642+
// setRenderFinished() at the end of the app stream. Dropping listeners
643+
// here left router-ssr-query's query stream open, hanging SSR (#7529).
641644
renderFinishedListeners.push(listener)
642645
},
643646
onSerializationFinished: (listener) => {

packages/router-core/tests/ssr-server-cleanup.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,36 @@ describe('serverSsr.cleanup', () => {
190190
router.serverSsr?.cleanup()
191191
})
192192

193+
test('onRenderFinished listener registered after fast path reserve still fires', async () => {
194+
// Regression test for #7529: when the fast path is reserved before an
195+
// integration (e.g. router-ssr-query) registers its onRenderFinished
196+
// listener, the listener must not be dropped - otherwise the query stream
197+
// is never closed and the response hangs until the serialization timeout.
198+
// The fast path still calls setRenderFinished() at the end of the app
199+
// stream, so the listener fires at that point.
200+
const router = buildRouter()
201+
attachRouterServerSsrUtils({ router, manifest: undefined })
202+
203+
await router.load()
204+
await router.serverSsr!.dehydrate()
205+
router.serverSsr!.takeBufferedScripts()
206+
207+
expect(router.serverSsr!.reserveStreamFastPath()).toBe(true)
208+
209+
let renderFinishedCalls = 0
210+
router.serverSsr!.onRenderFinished(() => {
211+
renderFinishedCalls++
212+
})
213+
// Not invoked at registration time - the fast path defers to the app
214+
// stream end, mirrored here by an explicit setRenderFinished().
215+
expect(renderFinishedCalls).toBe(0)
216+
217+
router.serverSsr!.setRenderFinished()
218+
expect(renderFinishedCalls).toBe(1)
219+
220+
router.serverSsr?.cleanup()
221+
})
222+
193223
test('stream fast path rejects while SSR work is pending', async () => {
194224
const value = deferred<string>()
195225
const router = buildRouter({ value: value.promise })

0 commit comments

Comments
 (0)