From 84f3c5a61dfe14a88ffcdab73990d99d46d96d2f Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Thu, 20 Aug 2026 10:38:43 -0700 Subject: [PATCH 1/4] perf(ssr-query): Batch streamed query dehydration Streaming each completed query separately repeatedly scanned the whole query cache and emitted one payload per query. Collect same-turn query hashes and dehydrate them in one pass. Queries that finish later still stream separately. Co-Authored-By: OpenAI Codex --- packages/router-ssr-query-core/src/index.ts | 69 ++++++++++++++----- .../router-ssr-query-core/tests/index.test.ts | 56 +++++++++++++++ 2 files changed, 106 insertions(+), 19 deletions(-) diff --git a/packages/router-ssr-query-core/src/index.ts b/packages/router-ssr-query-core/src/index.ts index 685653dea0c..f5383c33730 100644 --- a/packages/router-ssr-query-core/src/index.ts +++ b/packages/router-ssr-query-core/src/index.ts @@ -48,6 +48,7 @@ export function setupCoreRouterSsrQueryIntegration({ let unsubscribe: (() => void) | undefined = undefined let cleanupRegistered = false let tornDown = false + let pendingQueryHashes: Set | undefined const teardown = () => { if (tornDown) return @@ -78,6 +79,8 @@ export function setupCoreRouterSsrQueryIntegration({ // ignore } sentQueries.clear() + pendingQueryHashes?.clear() + pendingQueryHashes = undefined } // Register teardown as soon as SSR attaches. attachRouterServerSsrUtils() @@ -100,6 +103,7 @@ export function setupCoreRouterSsrQueryIntegration({ router.options.dehydrate = async (): Promise => { router.serverSsr!.onRenderFinished(() => { + flushPendingQueries() if (!queryStream.isClosed()) queryStream.close() unsubscribe?.() unsubscribe = undefined @@ -135,6 +139,43 @@ export function setupCoreRouterSsrQueryIntegration({ }, }) + const flushPendingQueries = () => { + const queryHashes = pendingQueryHashes + pendingQueryHashes = undefined + if ( + tornDown || + queryStream.isClosed() || + queryHashes === undefined || + queryHashes.size === 0 + ) { + return + } + + const dehydratedQuery = queryDehydrate(queryClient, { + ...dehydrateOptions, + shouldDehydrateQuery: (query) => { + if (!queryHashes.has(query.queryHash)) { + return false + } + + return ( + (ogClientOptions.dehydrate?.shouldDehydrateQuery?.(query) ?? + true) && + (dehydrateOptions?.shouldDehydrateQuery?.(query) ?? true) + ) + }, + }) + + if (dehydratedQuery.queries.length === 0) { + return + } + + dehydratedQuery.queries.forEach((query) => { + sentQueries.add(query.queryHash) + }) + queryStream.enqueue(dehydratedQuery) + } + unsubscribe = queryClient.getQueryCache().subscribe((event) => { // before rendering starts, we do not stream individual queries // instead we dehydrate the entire query client in router's dehydrate() @@ -155,27 +196,17 @@ export function setupCoreRouterSsrQueryIntegration({ ) return } - const dehydratedQuery = queryDehydrate(queryClient, { - ...dehydrateOptions, - shouldDehydrateQuery: (query) => { - if (query.queryHash !== event.query.queryHash) { - return false + if (!pendingQueryHashes) { + pendingQueryHashes = new Set() + queueMicrotask(() => { + try { + flushPendingQueries() + } catch (err) { + queryStream.error(err) } - - return ( - (ogClientOptions.dehydrate?.shouldDehydrateQuery?.(query) ?? - true) && - (dehydrateOptions?.shouldDehydrateQuery?.(query) ?? true) - ) - }, - }) - - if (dehydratedQuery.queries.length === 0) { - return + }) } - - sentQueries.add(event.query.queryHash) - queryStream.enqueue(dehydratedQuery) + pendingQueryHashes.add(event.query.queryHash) }) // on the client } else { diff --git a/packages/router-ssr-query-core/tests/index.test.ts b/packages/router-ssr-query-core/tests/index.test.ts index a167dbd03e1..f56d34bc104 100644 --- a/packages/router-ssr-query-core/tests/index.test.ts +++ b/packages/router-ssr-query-core/tests/index.test.ts @@ -261,6 +261,62 @@ describe('setupCoreRouterSsrQueryIntegration', () => { expect(queryClient.getQueryData(['initial'])).toBe('initial-hydrated') expect(queryClient.getQueryData(['streamed'])).toBe('stream-hydrated') }) + + it('streams queries created together in one chunk and keeps later queries', async () => { + const queryClient = track(new QueryClient()) + const { router, finishRender, attachServerSsr, setDehydrated } = + createServerRouter() + + router.serverSsr = undefined + setupCoreRouterSsrQueryIntegration({ + router: router as any, + queryClient, + }) + attachServerSsr() + + const dehydrated = (await router.options.dehydrate?.()) as { + queryStream: ReadableStream<{ + queries: Array<{ queryKey: Array }> + }> + } + const streamedQueriesPromise = readStream(dehydrated.queryStream) + const firstDeferred = createDeferred() + const secondDeferred = createDeferred() + const laterDeferred = createDeferred() + + setDehydrated(true) + const firstPromise = queryClient.fetchQuery({ + queryKey: ['first'], + queryFn: () => firstDeferred.promise, + }) + const secondPromise = queryClient.fetchQuery({ + queryKey: ['second'], + queryFn: () => secondDeferred.promise, + }) + + firstDeferred.resolve('first-data') + secondDeferred.resolve('second-data') + await Promise.all([firstPromise, secondPromise]) + + const laterPromise = queryClient.fetchQuery({ + queryKey: ['later'], + queryFn: () => laterDeferred.promise, + }) + laterDeferred.resolve('later-data') + await laterPromise + finishRender() + + const streamedQueries = await streamedQueriesPromise + + expect(streamedQueries).toHaveLength(2) + expect(streamedQueries[0]?.queries.map((query) => query.queryKey)).toEqual([ + ['first'], + ['second'], + ]) + expect(streamedQueries[1]?.queries.map((query) => query.queryKey)).toEqual([ + ['later'], + ]) + }) }) // GC reclamation tests are non-deterministic by nature (V8 makes no From dc21dacd55a8df74491eda1e560a2228202fc6a6 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Thu, 20 Aug 2026 11:35:40 -0700 Subject: [PATCH 2/4] perf(ssr-query): Widen streamed query batches Use Query's scheduler so independent I/O callbacks can join the same dehydration batch. Queries that finish in a later scheduler turn still stream separately.\n\nCo-Authored-By: OpenAI Codex --- packages/router-ssr-query-core/src/index.ts | 3 ++- packages/router-ssr-query-core/tests/index.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/router-ssr-query-core/src/index.ts b/packages/router-ssr-query-core/src/index.ts index f5383c33730..ac3884af141 100644 --- a/packages/router-ssr-query-core/src/index.ts +++ b/packages/router-ssr-query-core/src/index.ts @@ -1,4 +1,5 @@ import { + notifyManager, dehydrate as queryDehydrate, hydrate as queryHydrate, } from '@tanstack/query-core' @@ -198,7 +199,7 @@ export function setupCoreRouterSsrQueryIntegration({ } if (!pendingQueryHashes) { pendingQueryHashes = new Set() - queueMicrotask(() => { + notifyManager.schedule(() => { try { flushPendingQueries() } catch (err) { diff --git a/packages/router-ssr-query-core/tests/index.test.ts b/packages/router-ssr-query-core/tests/index.test.ts index f56d34bc104..340e0a416c2 100644 --- a/packages/router-ssr-query-core/tests/index.test.ts +++ b/packages/router-ssr-query-core/tests/index.test.ts @@ -262,7 +262,7 @@ describe('setupCoreRouterSsrQueryIntegration', () => { expect(queryClient.getQueryData(['streamed'])).toBe('stream-hydrated') }) - it('streams queries created together in one chunk and keeps later queries', async () => { + it('streams queries that resolve together in one chunk', async () => { const queryClient = track(new QueryClient()) const { router, finishRender, attachServerSsr, setDehydrated } = createServerRouter() @@ -297,6 +297,7 @@ describe('setupCoreRouterSsrQueryIntegration', () => { firstDeferred.resolve('first-data') secondDeferred.resolve('second-data') await Promise.all([firstPromise, secondPromise]) + await new Promise((resolve) => setTimeout(resolve, 0)) const laterPromise = queryClient.fetchQuery({ queryKey: ['later'], From 2b895e425082c63099051f53e843fc198a82d96a Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Thu, 20 Aug 2026 12:00:11 -0700 Subject: [PATCH 3/4] test(ssr-query): Cover batching lifecycle Keep the historical once-only streaming behavior covered when queries refetch, and verify request cleanup wins over a scheduled batch. Co-Authored-By: OpenAI Codex --- packages/router-ssr-query-core/src/index.ts | 2 + .../router-ssr-query-core/tests/index.test.ts | 46 ++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/router-ssr-query-core/src/index.ts b/packages/router-ssr-query-core/src/index.ts index ac3884af141..49bdd5edf05 100644 --- a/packages/router-ssr-query-core/src/index.ts +++ b/packages/router-ssr-query-core/src/index.ts @@ -199,6 +199,8 @@ export function setupCoreRouterSsrQueryIntegration({ } if (!pendingQueryHashes) { pendingQueryHashes = new Set() + // QueryCache listeners run inside notifyManager.batch. Scheduling the + // flush collects queries completed during the same scheduler turn. notifyManager.schedule(() => { try { flushPendingQueries() diff --git a/packages/router-ssr-query-core/tests/index.test.ts b/packages/router-ssr-query-core/tests/index.test.ts index 340e0a416c2..84abfc3f772 100644 --- a/packages/router-ssr-query-core/tests/index.test.ts +++ b/packages/router-ssr-query-core/tests/index.test.ts @@ -262,7 +262,7 @@ describe('setupCoreRouterSsrQueryIntegration', () => { expect(queryClient.getQueryData(['streamed'])).toBe('stream-hydrated') }) - it('streams queries that resolve together in one chunk', async () => { + it('batches queries by scheduler turn and only streams them once', async () => { const queryClient = track(new QueryClient()) const { router, finishRender, attachServerSsr, setDehydrated } = createServerRouter() @@ -299,6 +299,12 @@ describe('setupCoreRouterSsrQueryIntegration', () => { await Promise.all([firstPromise, secondPromise]) await new Promise((resolve) => setTimeout(resolve, 0)) + await queryClient.fetchQuery({ + queryKey: ['first'], + queryFn: () => 'first-data-updated', + }) + expect(queryClient.getQueryData(['first'])).toBe('first-data-updated') + const laterPromise = queryClient.fetchQuery({ queryKey: ['later'], queryFn: () => laterDeferred.promise, @@ -318,6 +324,44 @@ describe('setupCoreRouterSsrQueryIntegration', () => { ['later'], ]) }) + + it('does not stream pending queries after request cleanup', async () => { + const queryClient = track(new QueryClient()) + const { + router, + triggerCleanup, + attachServerSsr, + setDehydrated, + } = createServerRouter() + + router.serverSsr = undefined + setupCoreRouterSsrQueryIntegration({ + router: router as any, + queryClient, + }) + attachServerSsr() + + const dehydrated = (await router.options.dehydrate?.()) as { + queryStream: ReadableStream<{ + queries: Array<{ queryKey: Array }> + }> + } + const streamedQueriesPromise = readStream(dehydrated.queryStream) + const deferred = createDeferred() + + setDehydrated(true) + const queryPromise = queryClient.fetchQuery({ + queryKey: ['pending'], + queryFn: () => deferred.promise, + }) + + deferred.resolve('pending-data') + await queryPromise + triggerCleanup() + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(await streamedQueriesPromise).toEqual([]) + }) }) // GC reclamation tests are non-deterministic by nature (V8 makes no From 7a934055503cf3ac1d9fac72c0ea93f6b99bea72 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Thu, 20 Aug 2026 13:20:07 -0700 Subject: [PATCH 4/4] test(ssr-query): Use a typed router fixture Build the integration fixture with RouterCore so test calls stay type-checked when router members change. Co-Authored-By: OpenAI Codex --- .../router-ssr-query-core/tests/index.test.ts | 144 +++++++++--------- 1 file changed, 72 insertions(+), 72 deletions(-) diff --git a/packages/router-ssr-query-core/tests/index.test.ts b/packages/router-ssr-query-core/tests/index.test.ts index 84abfc3f772..075debf94f4 100644 --- a/packages/router-ssr-query-core/tests/index.test.ts +++ b/packages/router-ssr-query-core/tests/index.test.ts @@ -1,25 +1,40 @@ import { QueryClient } from '@tanstack/query-core' +import { + BaseRootRoute, + RouterCore, + createNonReactiveMutableStore, + createNonReactiveReadonlyStore, +} from '@tanstack/router-core' +import { attachRouterServerSsrUtils } from '@tanstack/router-core/ssr/server' import { afterEach, describe, expect, it, vi } from 'vitest' import { setupCoreRouterSsrQueryIntegration } from '../src' +import type { GetStoreConfig } from '@tanstack/router-core' -type TestRouter = { - isServer: boolean - options: { - dehydrate?: () => unknown | Promise - hydrate?: (dehydrated: any) => unknown | Promise - } - serverSsr?: { - isDehydrated: () => boolean - onRenderFinished: (listener: () => void) => void - onCleanup: (listener: () => void) => void - } - serverSsrLifecycle?: { - onServerSsrAttach: Array< - (serverSsr: NonNullable) => void - > +const getStoreConfig: GetStoreConfig = () => ({ + createMutableStore: createNonReactiveMutableStore, + createReadonlyStore: createNonReactiveReadonlyStore, + batch: (fn) => fn(), +}) + +function createTestRouter(isServer: boolean) { + const router = new RouterCore( + { + routeTree: new BaseRootRoute({}), + isServer, + }, + getStoreConfig, + ) + + // RouterCore exposes test routers globally in jsdom, defeating the GC tests. + if (Reflect.get(globalThis, '__TSR_ROUTER__') === router) { + Reflect.deleteProperty(globalThis, '__TSR_ROUTER__') } + + return router } +type TestRouter = ReturnType + type ServerRouterFixture = { router: TestRouter finishRender: () => void @@ -30,46 +45,48 @@ type ServerRouterFixture = { } function createServerRouter(): ServerRouterFixture { - const renderFinishedListeners = new Array<() => void>() - const cleanupListeners = new Array<() => void>() + const router = createTestRouter(true) + let serverSsr: NonNullable | undefined let dehydrated = false - const serverSsr = { - isDehydrated: () => dehydrated, - onRenderFinished: (listener: () => void) => { - renderFinishedListeners.push(listener) - }, - onCleanup: (listener: () => void) => { - cleanupListeners.push(listener) - }, + let cleanupListenerCount = 0 + + router.serverSsrLifecycle = { + onServerSsrAttach: [ + (attachedServerSsr) => { + serverSsr = attachedServerSsr + attachedServerSsr.isDehydrated = () => dehydrated + + const onCleanup = attachedServerSsr.onCleanup + attachedServerSsr.onCleanup = (listener) => { + cleanupListenerCount++ + onCleanup(() => { + try { + listener() + } finally { + cleanupListenerCount-- + } + }) + } + }, + ], } - const result: ServerRouterFixture = { - router: { - isServer: true, - options: {}, - serverSsr, - }, + return { + router, finishRender: () => { - renderFinishedListeners.splice(0).forEach((listener) => listener()) + serverSsr?.setRenderFinished() }, triggerCleanup: () => { - cleanupListeners.splice(0).forEach((listener) => listener()) + serverSsr?.cleanup() }, attachServerSsr: () => { - result.router.serverSsr = serverSsr - result.router.serverSsrLifecycle?.onServerSsrAttach.forEach( - (listener) => { - listener(serverSsr) - }, - ) + attachRouterServerSsrUtils({ router, manifest: undefined }) }, setDehydrated: (value: boolean) => { dehydrated = value }, - cleanupListenerCount: () => cleanupListeners.length, + cleanupListenerCount: () => cleanupListenerCount, } - - return result } async function readStream(stream: ReadableStream): Promise> { @@ -149,9 +166,8 @@ describe('setupCoreRouterSsrQueryIntegration', () => { const { router, finishRender, attachServerSsr, setDehydrated } = createServerRouter() - router.serverSsr = undefined setupCoreRouterSsrQueryIntegration({ - router: router as any, + router, queryClient, dehydrateOptions: { serializeData: (data) => `${data}-serialized`, @@ -210,13 +226,10 @@ describe('setupCoreRouterSsrQueryIntegration', () => { it('uses custom hydrate options for the initial payload and streamed queries', async () => { const queryClient = track(new QueryClient()) - const router: TestRouter = { - isServer: false, - options: {}, - } + const router = createTestRouter(false) setupCoreRouterSsrQueryIntegration({ - router: router as any, + router, queryClient, hydrateOptions: { defaultOptions: { @@ -267,9 +280,8 @@ describe('setupCoreRouterSsrQueryIntegration', () => { const { router, finishRender, attachServerSsr, setDehydrated } = createServerRouter() - router.serverSsr = undefined setupCoreRouterSsrQueryIntegration({ - router: router as any, + router, queryClient, }) attachServerSsr() @@ -327,16 +339,11 @@ describe('setupCoreRouterSsrQueryIntegration', () => { it('does not stream pending queries after request cleanup', async () => { const queryClient = track(new QueryClient()) - const { - router, - triggerCleanup, - attachServerSsr, - setDehydrated, - } = createServerRouter() + const { router, triggerCleanup, attachServerSsr, setDehydrated } = + createServerRouter() - router.serverSsr = undefined setupCoreRouterSsrQueryIntegration({ - router: router as any, + router, queryClient, }) attachServerSsr() @@ -394,9 +401,8 @@ describe.runIf(gcTestsEnabled)('SSR memory: GC reclamation', () => { let serverRouter: ReturnType | null = createServerRouter() - serverRouter.router.serverSsr = undefined setupCoreRouterSsrQueryIntegration({ - router: serverRouter.router as any, + router: serverRouter.router, queryClient, }) serverRouter.attachServerSsr() @@ -435,9 +441,8 @@ describe.runIf(gcTestsEnabled)('SSR memory: GC reclamation', () => { let serverRouter: ReturnType | null = createServerRouter() - serverRouter.router.serverSsr = undefined setupCoreRouterSsrQueryIntegration({ - router: serverRouter.router as any, + router: serverRouter.router, queryClient, }) serverRouter.attachServerSsr() @@ -477,9 +482,8 @@ describe('SSR cleanup: deterministic behavior', () => { const { router, triggerCleanup, attachServerSsr, setDehydrated } = createServerRouter() - router.serverSsr = undefined setupCoreRouterSsrQueryIntegration({ - router: router as any, + router, queryClient, }) attachServerSsr() @@ -505,9 +509,8 @@ describe('SSR cleanup: deterministic behavior', () => { const { router, triggerCleanup, attachServerSsr, setDehydrated } = createServerRouter() - router.serverSsr = undefined setupCoreRouterSsrQueryIntegration({ - router: router as any, + router, queryClient, }) attachServerSsr() @@ -551,9 +554,8 @@ describe('SSR cleanup: deterministic behavior', () => { cleanupListenerCount, } = createServerRouter() - router.serverSsr = undefined setupCoreRouterSsrQueryIntegration({ - router: router as any, + router, queryClient, }) attachServerSsr() @@ -591,10 +593,8 @@ describe('SSR cleanup: deterministic behavior', () => { cleanupListenerCount, } = createServerRouter() // Detach to simulate pre-attach state. - router.serverSsr = undefined - setupCoreRouterSsrQueryIntegration({ - router: router as any, + router, queryClient, })