From 79f07a0817944fd04c4b601874274182aafda05f Mon Sep 17 00:00:00 2001 From: Remi Oduyemi Date: Wed, 6 May 2026 10:36:49 +0100 Subject: [PATCH 1/2] fix(router-core): flush buffered scripts before closing SSR stream --- packages/router-core/src/ssr/ssr-server.ts | 21 +++++++------------ .../src/ssr/transformStreamWithRouter.ts | 17 ++++++++++++++- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index 2bca7009d9e..42fb0855a98 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -24,6 +24,7 @@ import type { Manifest, RouterManagedTag } from '../manifest' declare module '../router' { interface ServerSsr { setRenderFinished: () => void + flushScripts: () => void cleanup: () => void } interface RouterEvents { @@ -88,25 +89,16 @@ class ScriptBuffer { enqueue(script: string) { if (this._cleanedUp) return this._queue.push(script) - // If barrier is lifted, schedule injection (if not already scheduled) - if (this._scriptBarrierLifted && !this._pendingMicrotask) { - this._pendingMicrotask = true - queueMicrotask(() => { - this._pendingMicrotask = false - this.injectBufferedScripts() - }) + if (this._scriptBarrierLifted) { + this.injectBufferedScripts() } } liftBarrier() { if (this._scriptBarrierLifted || this._cleanedUp) return this._scriptBarrierLifted = true - if (this._queue.length > 0 && !this._pendingMicrotask) { - this._pendingMicrotask = true - queueMicrotask(() => { - this._pendingMicrotask = false - this.injectBufferedScripts() - }) + if (this._queue.length > 0) { + this.injectBufferedScripts() } } @@ -512,6 +504,9 @@ export function attachRouterServerSsrUtils({ liftScriptBarrier() { scriptBuffer.liftBarrier() }, + flushScripts() { + scriptBuffer.flush() + }, takeBufferedHtml() { if (!injectedHtmlBuffer) { return undefined diff --git a/packages/router-core/src/ssr/transformStreamWithRouter.ts b/packages/router-core/src/ssr/transformStreamWithRouter.ts index 753a1809acd..cf42e00b6a3 100644 --- a/packages/router-core/src/ssr/transformStreamWithRouter.ts +++ b/packages/router-core/src/ssr/transformStreamWithRouter.ts @@ -189,12 +189,20 @@ export function transformStreamWithRouter( if (cleanedUp || isStreamClosed) return router.serverSsr?.setRenderFinished() + const finalHtml = router.serverSsr?.takeBufferedHtml() + if (finalHtml) { + controller?.enqueue(textEncoder.encode(finalHtml)) + } safeClose() cleanup() } catch (error) { if (cleanedUp) return console.error('Error reading appStream:', error) router.serverSsr?.setRenderFinished() + const finalHtmlErr = router.serverSsr?.takeBufferedHtml() + if (finalHtmlErr) { + controller?.enqueue(textEncoder.encode(finalHtmlErr)) + } safeError(error) cleanup() } finally { @@ -331,6 +339,8 @@ export function transformStreamWithRouter( serializationTimeoutHandle = undefined } + router.serverSsr?.flushScripts() + // Flush any remaining bytes in the TextDecoder const decoderRemainder = textDecoder.decode() @@ -403,7 +413,6 @@ export function transformStreamWithRouter( if (!streamBarrierLifted) { if (chunkString.includes(TSR_SCRIPT_BARRIER_ID)) { streamBarrierLifted = true - router.serverSsr?.liftScriptBarrier() } } @@ -425,6 +434,9 @@ export function transformStreamWithRouter( pendingClosingTags = chunkString.slice(bodyEndIndex) safeEnqueue(chunkString.slice(0, bodyEndIndex)) flushPendingRouterHtml() + if (streamBarrierLifted) { + router.serverSsr?.liftScriptBarrier() + } leftover = '' continue } @@ -434,6 +446,9 @@ export function transformStreamWithRouter( if (lastClosingTagEnd > 0) { safeEnqueue(chunkString.slice(0, lastClosingTagEnd)) flushPendingRouterHtml() + if (streamBarrierLifted) { + router.serverSsr?.liftScriptBarrier() + } leftover = chunkString.slice(lastClosingTagEnd) if (leftover.length > MAX_LEFTOVER_CHARS) { From 1e2ff8d1acd7d19033c6f7afb2005bf723e11d88 Mon Sep 17 00:00:00 2001 From: Remi Oduyemi Date: Wed, 6 May 2026 11:04:46 +0100 Subject: [PATCH 2/2] chore(tests): add ScriptBuffer streaming scripts tests --- .../router-core/tests/script-buffer.test.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 packages/router-core/tests/script-buffer.test.ts diff --git a/packages/router-core/tests/script-buffer.test.ts b/packages/router-core/tests/script-buffer.test.ts new file mode 100644 index 00000000000..c57c8647080 --- /dev/null +++ b/packages/router-core/tests/script-buffer.test.ts @@ -0,0 +1,95 @@ +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' +import { createTestRouter } from './routerTestUtils' +import { describe, expect, test } from 'vitest' + +function buildRouter(loaderData?: Record) { + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => null, + loader: loaderData ? () => loaderData : undefined, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + + return createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: true, + }) +} + +describe('ScriptBuffer: streaming scripts are not dropped', () => { + test('scripts injected synchronously when barrier lifts after serialization completes', async () => { + // When serialization finishes before the stream, + // setRenderFinished() must inject scripts synchronously (not via queueMicrotask). + + const deferredPromise = new Promise((r) => + setTimeout(() => r('resolved-value'), 10), + ) + const router = buildRouter({ deferred: deferredPromise }) + attachRouterServerSsrUtils({ router, manifest: undefined }) + await router.load() + + const injectedChunks: Array = [] + router.subscribe('onInjectedHtml', () => { + const html = router.serverSsr?.takeBufferedHtml() + if (html) injectedChunks.push(html) + }) + + await router.serverSsr!.dehydrate() + router.serverSsr!.takeBufferedScripts() + + // Wait for deferred promise + crossSerializeStream.onDone + await new Promise((r) => setTimeout(r, 50)) + expect(router.serverSsr!.isSerializationFinished()).toBe(true) + + // Lift barrier — scripts injected synchronously (no microtask) + router.serverSsr!.setRenderFinished() + + // Check immediately — no awaiting + const allInjected = injectedChunks.join('') + expect( + allInjected, + 'Scripts lost: $_TSR.e() not found. ' + + 'ScriptBuffer must inject synchronously when barrier is lifted.', + ).toContain('$_TSR.e()') + }) + + test('scripts not dropped when cleanup runs immediately after setRenderFinished', async () => { + // Simulates fast-exit path: setRenderFinished → takeBufferedHtml → cleanup + + const deferredPromise = new Promise((r) => + setTimeout(() => r('resolved-value'), 10), + ) + const router = buildRouter({ deferred: deferredPromise }) + attachRouterServerSsrUtils({ router, manifest: undefined }) + await router.load() + + const injectedChunks: Array = [] + router.subscribe('onInjectedHtml', () => { + const html = router.serverSsr?.takeBufferedHtml() + if (html) injectedChunks.push(html) + }) + + await router.serverSsr!.dehydrate() + router.serverSsr!.takeBufferedScripts() + + // Wait for serialization to complete + await new Promise((r) => setTimeout(r, 50)) + + // Fast-exit path: setRenderFinished → grab HTML → cleanup (NO drain) + router.serverSsr!.setRenderFinished() + const finalHtml = router.serverSsr!.takeBufferedHtml() + router.serverSsr!.cleanup() + + const allHtml = injectedChunks.join('') + (finalHtml ?? '') + expect( + allHtml, + 'Scripts dropped by cleanup: $_TSR.e() missing.', + ).toContain('$_TSR.e()') + }) +})