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
21 changes: 8 additions & 13 deletions packages/router-core/src/ssr/ssr-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { Manifest, RouterManagedTag } from '../manifest'
declare module '../router' {
interface ServerSsr {
setRenderFinished: () => void
flushScripts: () => void
cleanup: () => void
}
interface RouterEvents {
Expand Down Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -512,6 +504,9 @@ export function attachRouterServerSsrUtils({
liftScriptBarrier() {
scriptBuffer.liftBarrier()
},
flushScripts() {
scriptBuffer.flush()
},
takeBufferedHtml() {
if (!injectedHtmlBuffer) {
return undefined
Expand Down
17 changes: 16 additions & 1 deletion packages/router-core/src/ssr/transformStreamWithRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -331,6 +339,8 @@ export function transformStreamWithRouter(
serializationTimeoutHandle = undefined
}

router.serverSsr?.flushScripts()

// Flush any remaining bytes in the TextDecoder
const decoderRemainder = textDecoder.decode()

Expand Down Expand Up @@ -403,7 +413,6 @@ export function transformStreamWithRouter(
if (!streamBarrierLifted) {
if (chunkString.includes(TSR_SCRIPT_BARRIER_ID)) {
streamBarrierLifted = true
router.serverSsr?.liftScriptBarrier()
}
}

Expand All @@ -425,6 +434,9 @@ export function transformStreamWithRouter(
pendingClosingTags = chunkString.slice(bodyEndIndex)
safeEnqueue(chunkString.slice(0, bodyEndIndex))
flushPendingRouterHtml()
if (streamBarrierLifted) {
router.serverSsr?.liftScriptBarrier()
}
leftover = ''
continue
}
Expand All @@ -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) {
Expand Down
95 changes: 95 additions & 0 deletions packages/router-core/tests/script-buffer.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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<string>((r) =>
setTimeout(() => r('resolved-value'), 10),
)
const router = buildRouter({ deferred: deferredPromise })
attachRouterServerSsrUtils({ router, manifest: undefined })
await router.load()

const injectedChunks: Array<string> = []
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<string>((r) =>
setTimeout(() => r('resolved-value'), 10),
)
const router = buildRouter({ deferred: deferredPromise })
attachRouterServerSsrUtils({ router, manifest: undefined })
await router.load()

const injectedChunks: Array<string> = []
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()')
})
})
Loading