From 85cabb9ea980f7648982c0870cc84f2c93808142 Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Sat, 27 Jun 2026 19:06:39 +0200
Subject: [PATCH] fix(start): initialize serialization adapters for early
client server functions
fixes #7706
---
.changeset/calm-adapters-serialize.md | 6 ++
.../src/client-entry-server-functions.ts | 10 +++
.../serialization-adapters/src/client.tsx | 55 ++++++++++++++++
.../serialization-adapters/tests/app.spec.ts | 20 ++++++
packages/router-core/src/index.ts | 1 +
.../src/client-rpc/createClientRpc.ts | 14 +++--
.../src/client-rpc/serverFnFetcher.ts | 12 ++--
.../src/client/hydrateStart.ts | 32 ++--------
.../start-client-core/src/createServerFn.ts | 32 ++++++----
.../src/getDefaultSerovalPlugins.ts | 7 ++-
.../start-client-core/src/getStartOptions.ts | 63 ++++++++++++++++++-
packages/start-client-core/src/global.ts | 9 ---
.../src/createStartHandler.ts | 1 +
.../src/server-functions-handler.ts | 5 +-
14 files changed, 203 insertions(+), 64 deletions(-)
create mode 100644 .changeset/calm-adapters-serialize.md
create mode 100644 e2e/react-start/serialization-adapters/src/client-entry-server-functions.ts
create mode 100644 e2e/react-start/serialization-adapters/src/client.tsx
delete mode 100644 packages/start-client-core/src/global.ts
diff --git a/.changeset/calm-adapters-serialize.md b/.changeset/calm-adapters-serialize.md
new file mode 100644
index 0000000000..6c24af7220
--- /dev/null
+++ b/.changeset/calm-adapters-serialize.md
@@ -0,0 +1,6 @@
+---
+'@tanstack/start-client-core': patch
+'@tanstack/start-server-core': patch
+---
+
+Fix custom serialization adapters for server functions called from client entry modules before hydration. Start options are now initialized once and reused by early client-side server-function calls, so custom adapters and Start-level server-function fetch options are available before the app hydrates.
diff --git a/e2e/react-start/serialization-adapters/src/client-entry-server-functions.ts b/e2e/react-start/serialization-adapters/src/client-entry-server-functions.ts
new file mode 100644
index 0000000000..41cb786736
--- /dev/null
+++ b/e2e/react-start/serialization-adapters/src/client-entry-server-functions.ts
@@ -0,0 +1,10 @@
+import { createServerFn } from '@tanstack/react-start'
+import { makeNested } from './data'
+
+export const getClientEntryPing = createServerFn().handler(() => {
+ return 'ready'
+})
+
+export const getClientEntryNested = createServerFn().handler(() => {
+ return makeNested()
+})
diff --git a/e2e/react-start/serialization-adapters/src/client.tsx b/e2e/react-start/serialization-adapters/src/client.tsx
new file mode 100644
index 0000000000..8e4eb1fe0d
--- /dev/null
+++ b/e2e/react-start/serialization-adapters/src/client.tsx
@@ -0,0 +1,55 @@
+///
+import { StrictMode, startTransition } from 'react'
+import { hydrateRoot } from 'react-dom/client'
+import { StartClient } from '@tanstack/react-start/client'
+import {
+ getClientEntryNested,
+ getClientEntryPing,
+} from './client-entry-server-functions'
+
+type ClientEntryServerFnResult =
+ | {
+ status: 'success'
+ ping: string
+ shout: string
+ whisper: string
+ }
+ | {
+ status: 'error'
+ name: string
+ message: string
+ }
+
+declare global {
+ interface Window {
+ __serializationAdapterClientEntryResult?: ClientEntryServerFnResult
+ }
+}
+
+if (new URL(window.location.href).searchParams.has('client-entry-server-fn')) {
+ void Promise.all([getClientEntryPing(), getClientEntryNested()])
+ .then(([ping, nested]) => {
+ window.__serializationAdapterClientEntryResult = {
+ status: 'success',
+ ping,
+ shout: nested.inner.shout(),
+ whisper: nested.whisper(),
+ }
+ })
+ .catch((error) => {
+ window.__serializationAdapterClientEntryResult = {
+ status: 'error',
+ name: error?.name ?? 'Error',
+ message: error?.message ?? String(error),
+ }
+ })
+}
+
+startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+ ,
+ )
+})
diff --git a/e2e/react-start/serialization-adapters/tests/app.spec.ts b/e2e/react-start/serialization-adapters/tests/app.spec.ts
index 26f70e81b0..f9decbd6db 100644
--- a/e2e/react-start/serialization-adapters/tests/app.spec.ts
+++ b/e2e/react-start/serialization-adapters/tests/app.spec.ts
@@ -77,6 +77,26 @@ test.describe('SSR serialization adapters', () => {
})
test.describe('server functions serialization adapters', () => {
+ test('client entry server functions use custom serialization adapters', async ({
+ page,
+ }) => {
+ await page.goto('/server-function/nested?client-entry-server-fn')
+ await awaitPageLoaded(page)
+
+ const result = await page
+ .waitForFunction(
+ () => (window as any).__serializationAdapterClientEntryResult,
+ )
+ .then((handle) => handle.jsonValue())
+
+ expect(result).toEqual({
+ status: 'success',
+ ping: 'ready',
+ shout: 'HELLO WORLD',
+ whisper: 'hello world',
+ })
+ })
+
test('custom error', async ({ page }) => {
await page.goto('/server-function/custom-error')
await awaitPageLoaded(page)
diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts
index fd673ca410..c19be6c4db 100644
--- a/packages/router-core/src/index.ts
+++ b/packages/router-core/src/index.ts
@@ -316,6 +316,7 @@ export {
isPlainArray,
deepEqual,
createControlledPromise,
+ isPromise,
isModuleNotFoundError,
DEFAULT_PROTOCOL_ALLOWLIST,
escapeHtml,
diff --git a/packages/start-client-core/src/client-rpc/createClientRpc.ts b/packages/start-client-core/src/client-rpc/createClientRpc.ts
index 0ab3c73e76..85eb62dc7e 100644
--- a/packages/start-client-core/src/client-rpc/createClientRpc.ts
+++ b/packages/start-client-core/src/client-rpc/createClientRpc.ts
@@ -1,20 +1,24 @@
+import { isPromise } from '@tanstack/router-core'
import { TSS_SERVER_FUNCTION } from '../constants'
-import { getStartOptions } from '../getStartOptions'
+import { initStartOptions } from '../getStartOptions'
import { serverFnFetcher } from './serverFnFetcher'
import type { ClientFnMeta } from '../constants'
export function createClientRpc(functionId: string) {
const url = process.env.TSS_SERVER_FN_BASE + functionId
- const serverFnMeta: ClientFnMeta = { id: functionId }
const clientFn = (...args: Array) => {
- const startFetch = getStartOptions()?.serverFns?.fetch
- return serverFnFetcher(url, args, startFetch ?? fetch)
+ const startOptions = initStartOptions()
+ return isPromise(startOptions)
+ ? startOptions.then((resolvedOptions) =>
+ serverFnFetcher(url, args, resolvedOptions),
+ )
+ : serverFnFetcher(url, args, startOptions)
}
return Object.assign(clientFn, {
url,
- serverFnMeta,
+ serverFnMeta: { id: functionId } satisfies ClientFnMeta,
[TSS_SERVER_FUNCTION]: true,
})
}
diff --git a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts
index 2e993205bc..5b41628bc1 100644
--- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts
+++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts
@@ -16,6 +16,7 @@ import {
} from '../constants'
import { createFrameDecoder } from './frame-decoder'
import type { FunctionMiddlewareClientFnOptions } from '../createMiddleware'
+import type { AnyStartInstanceOptions } from '../createStart'
import type { Plugin as SerovalPlugin } from 'seroval'
let serovalPlugins: Array> | null = null
@@ -110,19 +111,18 @@ function hasOwnProperties(obj: object): boolean {
export async function serverFnFetcher(
url: string,
args: Array,
- handler: (url: string, requestInit: RequestInit) => Promise,
+ startOptions: AnyStartInstanceOptions | undefined,
) {
- if (!serovalPlugins) {
- serovalPlugins = getDefaultSerovalPlugins()
- }
+ serovalPlugins ||= getDefaultSerovalPlugins(startOptions)
+
const _first = args[0]
const first = _first as FunctionMiddlewareClientFnOptions & {
headers?: HeadersInit
}
- // Use custom fetch if provided, otherwise fall back to the passed handler (global fetch)
- const fetchImpl = first.fetch ?? handler
+ // Use custom fetch if provided, otherwise fall back to the Start/global fetch.
+ const fetchImpl = first.fetch ?? startOptions?.serverFns?.fetch ?? fetch
const type = first.data instanceof FormData ? 'formData' : 'payload'
diff --git a/packages/start-client-core/src/client/hydrateStart.ts b/packages/start-client-core/src/client/hydrateStart.ts
index 83b9c468af..cb16fa9a11 100644
--- a/packages/start-client-core/src/client/hydrateStart.ts
+++ b/packages/start-client-core/src/client/hydrateStart.ts
@@ -1,12 +1,7 @@
import { hydrate } from '@tanstack/router-core/ssr/client'
-import { startInstance } from '#tanstack-start-entry'
-import {
- hasPluginAdapters,
- pluginSerializationAdapters,
-} from '#tanstack-start-plugin-adapters'
import { getRouter } from '#tanstack-router-entry'
-import { ServerFunctionSerializationAdapter } from './ServerFunctionSerializationAdapter'
-import type { AnyRouter, AnySerializationAdapter } from '@tanstack/router-core'
+import { initStartOptions } from '../getStartOptions'
+import type { AnyRouter } from '@tanstack/router-core'
import type { AnyStartInstanceOptions } from '../createStart'
type HotContext = {
@@ -23,27 +18,9 @@ declare global {
async function hydrateStart(): Promise {
const router = await getRouter()
+ const startOptions = (await initStartOptions()) as AnyStartInstanceOptions
+ const serializationAdapters = startOptions.serializationAdapters
- let serializationAdapters: Array
- if (startInstance) {
- const startOptions = await startInstance.getOptions()
- startOptions.serializationAdapters =
- startOptions.serializationAdapters ?? []
- window.__TSS_START_OPTIONS__ = startOptions as AnyStartInstanceOptions
- serializationAdapters = startOptions.serializationAdapters
- router.options.defaultSsr = startOptions.defaultSsr
- } else {
- serializationAdapters = []
- window.__TSS_START_OPTIONS__ = {
- serializationAdapters,
- } as AnyStartInstanceOptions
- }
-
- // Only spread plugin adapters if any are configured (this will tree-shake away otherwise)
- if (hasPluginAdapters) {
- serializationAdapters.push(...pluginSerializationAdapters)
- }
- serializationAdapters.push(ServerFunctionSerializationAdapter)
if (router.options.serializationAdapters) {
serializationAdapters.push(...router.options.serializationAdapters)
}
@@ -52,6 +29,7 @@ async function hydrateStart(): Promise {
basepath: process.env.TSS_ROUTER_BASEPATH,
...{ serializationAdapters },
})
+ router.options.defaultSsr = startOptions.defaultSsr
if (!router.stores.matchesId.get().length) {
await hydrate(router)
}
diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts
index bfa48039cf..96a0b69a47 100644
--- a/packages/start-client-core/src/createServerFn.ts
+++ b/packages/start-client-core/src/createServerFn.ts
@@ -1,8 +1,8 @@
import { mergeHeaders } from '@tanstack/router-core/ssr/client'
-import { isRedirect, parseRedirect } from '@tanstack/router-core'
+import { isPromise, isRedirect, parseRedirect } from '@tanstack/router-core'
import { TSS_SERVER_FUNCTION_FACTORY } from './constants'
-import { getStartOptions } from './getStartOptions'
+import { initStartOptions } from './getStartOptions'
import { getStartContextServerOnly } from './getStartContextServerOnly'
import { createNullProtoObject, safeObjectMerge } from './safeObjectMerge'
import type {
@@ -152,16 +152,23 @@ export const createServerFn: CreateServerFn = (options, __opts) => {
return Object.assign(
async (opts?: CompiledFetcherFnOptions) => {
+ const startOptions = initStartOptions()
// Start by executing the client-side middleware chain
- const result = await executeMiddleware(resolvedMiddleware, 'client', {
- ...extractedFn,
- ...newOptions,
- data: opts?.data,
- headers: opts?.headers,
- signal: opts?.signal,
- fetch: opts?.fetch,
- context: createNullProtoObject(),
- })
+ const result = await executeMiddleware(
+ resolvedMiddleware,
+ 'client',
+ {
+ ...extractedFn,
+ ...newOptions,
+ data: opts?.data,
+ headers: opts?.headers,
+ signal: opts?.signal,
+ fetch: opts?.fetch,
+ context: createNullProtoObject(),
+ },
+ (isPromise(startOptions) ? await startOptions : startOptions)
+ ?.functionMiddleware,
+ )
const redirect = parseRedirect(result.error)
if (redirect) {
@@ -202,6 +209,7 @@ export const createServerFn: CreateServerFn = (options, __opts) => {
resolvedMiddleware,
'server',
ctx,
+ startContext.startOptions.functionMiddleware,
).then((d) => ({
// Only send the result and sendContext back to the client
result: d.result,
@@ -229,8 +237,8 @@ export async function executeMiddleware(
middlewares: Array,
env: 'client' | 'server',
opts: ServerFnMiddlewareOptions,
+ globalMiddlewares: Array = [],
): Promise {
- const globalMiddlewares = getStartOptions()?.functionMiddleware || []
let flattenedMiddlewares = flattenMiddlewares([
...globalMiddlewares,
...middlewares,
diff --git a/packages/start-client-core/src/getDefaultSerovalPlugins.ts b/packages/start-client-core/src/getDefaultSerovalPlugins.ts
index 8070137c20..bd97500a99 100644
--- a/packages/start-client-core/src/getDefaultSerovalPlugins.ts
+++ b/packages/start-client-core/src/getDefaultSerovalPlugins.ts
@@ -2,12 +2,13 @@ import {
makeSerovalPlugin,
defaultSerovalPlugins as routerDefaultSerovalPlugins,
} from '@tanstack/router-core'
-import { getStartOptions } from './getStartOptions'
import type { AnySerializationAdapter } from '@tanstack/router-core'
+import type { AnyStartInstanceOptions } from './createStart'
import type { Plugin } from 'seroval'
-export function getDefaultSerovalPlugins(): Array> {
- const start = getStartOptions()
+export function getDefaultSerovalPlugins(
+ start: AnyStartInstanceOptions | undefined,
+): Array> {
const adapters = start?.serializationAdapters as
| Array
| undefined
diff --git a/packages/start-client-core/src/getStartOptions.ts b/packages/start-client-core/src/getStartOptions.ts
index d290934b5f..a0d89bd88f 100644
--- a/packages/start-client-core/src/getStartOptions.ts
+++ b/packages/start-client-core/src/getStartOptions.ts
@@ -1,8 +1,69 @@
+import { invariant, isPromise } from '@tanstack/router-core'
import { getStartContext } from '@tanstack/start-storage-context'
import { createIsomorphicFn } from '@tanstack/start-fn-stubs'
+import { startInstance } from '#tanstack-start-entry'
+import {
+ hasPluginAdapters,
+ pluginSerializationAdapters,
+} from '#tanstack-start-plugin-adapters'
+import { ServerFunctionSerializationAdapter } from './client/ServerFunctionSerializationAdapter'
+import type { AnySerializationAdapter, Awaitable } from '@tanstack/router-core'
import type { AnyStartInstanceOptions } from './createStart'
+let startOptions: Awaitable | undefined
+
+const setStartOptions = (options: AnyStartInstanceOptions) => {
+ const serializationAdapters = (options.serializationAdapters ??=
+ []) as Array
+
+ // Only spread plugin adapters if any are configured (this will tree-shake away otherwise)
+ if (hasPluginAdapters) {
+ serializationAdapters.push(...pluginSerializationAdapters)
+ }
+ serializationAdapters.push(ServerFunctionSerializationAdapter)
+
+ return (startOptions = options)
+}
+
export const getStartOptions: () => AnyStartInstanceOptions | undefined =
createIsomorphicFn()
- .client(() => window.__TSS_START_OPTIONS__)
+ .client(() => {
+ if (!startOptions || isPromise(startOptions)) {
+ if (process.env.NODE_ENV !== 'production') {
+ throw new Error(
+ 'Start options have not been initialized yet. Await initStartOptions() before calling getStartOptions() on the client.',
+ )
+ }
+
+ invariant()
+ }
+
+ return startOptions
+ })
.server(() => getStartContext().startOptions)
+
+export const initStartOptions: () => Awaitable<
+ AnyStartInstanceOptions | undefined
+> = createIsomorphicFn()
+ .client(function initClientStartOptions(): Awaitable<
+ AnyStartInstanceOptions | undefined
+ > {
+ if (startOptions) {
+ return startOptions
+ }
+
+ if (!startInstance) {
+ return setStartOptions({} as AnyStartInstanceOptions)
+ }
+
+ const options = startInstance.getOptions()
+ if (isPromise(options)) {
+ startOptions = options.then((resolvedOptions) =>
+ setStartOptions(resolvedOptions as AnyStartInstanceOptions),
+ )
+ return startOptions
+ }
+
+ return setStartOptions(options as AnyStartInstanceOptions)
+ })
+ .server(() => getStartContext().startOptions)
diff --git a/packages/start-client-core/src/global.ts b/packages/start-client-core/src/global.ts
deleted file mode 100644
index c2179938f4..0000000000
--- a/packages/start-client-core/src/global.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import type { AnyStartInstanceOptions } from './createStart'
-
-declare global {
- interface Window {
- __TSS_START_OPTIONS__?: AnyStartInstanceOptions
- }
-}
-
-export {}
diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts
index 90d4e706f9..1c359a837d 100644
--- a/packages/start-server-core/src/createStartHandler.ts
+++ b/packages/start-server-core/src/createStartHandler.ts
@@ -514,6 +514,7 @@ export function createStartHandler(
request,
context: requestOpts?.context,
serverFnId,
+ startOptions: requestStartOptions,
}),
)
}
diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts
index e7d961decf..4fd1cad0b4 100644
--- a/packages/start-server-core/src/server-functions-handler.ts
+++ b/packages/start-server-core/src/server-functions-handler.ts
@@ -19,6 +19,7 @@ import {
createMultiplexedStream,
} from './frame-protocol'
import type { LateStreamRegistration } from './frame-protocol'
+import type { AnyStartInstanceOptions } from '@tanstack/start-client-core'
import type { Plugin as SerovalPlugin } from 'seroval'
// Cache serovalPlugins at module level to avoid repeated calls
@@ -37,10 +38,12 @@ export const handleServerAction = async ({
request,
context,
serverFnId,
+ startOptions,
}: {
request: Request
context: any
serverFnId: string
+ startOptions: AnyStartInstanceOptions
}) => {
const method = request.method
const methodUpper = method.toUpperCase()
@@ -66,7 +69,7 @@ export const handleServerAction = async ({
// Initialize serovalPlugins lazily (cached at module level)
if (!serovalPlugins) {
- serovalPlugins = getDefaultSerovalPlugins()
+ serovalPlugins = getDefaultSerovalPlugins(startOptions)
}
const contentType = request.headers.get('Content-Type')