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
6 changes: 6 additions & 0 deletions .changeset/calm-adapters-serialize.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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()
})
55 changes: 55 additions & 0 deletions e2e/react-start/serialization-adapters/src/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/// <reference types="vite/client" />
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,
<StrictMode>
<StartClient />
</StrictMode>,
)
})
20 changes: 20 additions & 0 deletions e2e/react-start/serialization-adapters/tests/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions packages/router-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ export {
isPlainArray,
deepEqual,
createControlledPromise,
isPromise,
isModuleNotFoundError,
DEFAULT_PROTOCOL_ALLOWLIST,
escapeHtml,
Expand Down
14 changes: 9 additions & 5 deletions packages/start-client-core/src/client-rpc/createClientRpc.ts
Original file line number Diff line number Diff line change
@@ -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<any>) => {
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,
})
}
12 changes: 6 additions & 6 deletions packages/start-client-core/src/client-rpc/serverFnFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SerovalPlugin<any, any>> | null = null
Expand Down Expand Up @@ -110,19 +111,18 @@ function hasOwnProperties(obj: object): boolean {
export async function serverFnFetcher(
url: string,
args: Array<any>,
handler: (url: string, requestInit: RequestInit) => Promise<Response>,
startOptions: AnyStartInstanceOptions | undefined,
) {
if (!serovalPlugins) {
serovalPlugins = getDefaultSerovalPlugins()
}
serovalPlugins ||= getDefaultSerovalPlugins(startOptions)
Comment on lines +114 to +116

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don't freeze client seroval plugins on the first call.

serovalPlugins ||= ... snapshots the adapters from the first invocation. packages/start-client-core/src/client/hydrateStart.ts:19-32 later mutates startOptions.serializationAdapters, so any pre-hydration call can leave this fetch path permanently missing adapters added during hydration. Since this function is async, the module-level cache also creates cross-call leakage if different startOptions are observed concurrently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/start-client-core/src/client-rpc/serverFnFetcher.ts` around lines
114 - 116, The seroval plugin cache in serverFnFetcher should not be initialized
with a one-time fallback because it can lock in stale adapters from the first
call. Update the logic around getDefaultSerovalPlugins and serovalPlugins so it
recomputes or refreshes based on the current startOptions instead of using ||=,
and avoid module-level shared state that can leak across concurrent calls. Make
sure the behavior still works with hydrateStart mutating serializationAdapters
before later fetches.


const _first = args[0]

const first = _first as FunctionMiddlewareClientFnOptions<any, any, any> & {
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'

Expand Down
32 changes: 5 additions & 27 deletions packages/start-client-core/src/client/hydrateStart.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -23,27 +18,9 @@ declare global {

async function hydrateStart(): Promise<AnyRouter> {
const router = await getRouter()
const startOptions = (await initStartOptions()) as AnyStartInstanceOptions
const serializationAdapters = startOptions.serializationAdapters

let serializationAdapters: Array<AnySerializationAdapter>
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)
}
Expand All @@ -52,6 +29,7 @@ async function hydrateStart(): Promise<AnyRouter> {
basepath: process.env.TSS_ROUTER_BASEPATH,
...{ serializationAdapters },
})
router.options.defaultSsr = startOptions.defaultSsr
if (!router.stores.matchesId.get().length) {
await hydrate(router)
}
Expand Down
32 changes: 20 additions & 12 deletions packages/start-client-core/src/createServerFn.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -152,16 +152,23 @@ export const createServerFn: CreateServerFn<Register> = (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) {
Expand Down Expand Up @@ -202,6 +209,7 @@ export const createServerFn: CreateServerFn<Register> = (options, __opts) => {
resolvedMiddleware,
'server',
ctx,
startContext.startOptions.functionMiddleware,
).then((d) => ({
// Only send the result and sendContext back to the client
result: d.result,
Expand Down Expand Up @@ -229,8 +237,8 @@ export async function executeMiddleware(
middlewares: Array<AnyFunctionMiddleware | AnyRequestMiddleware>,
env: 'client' | 'server',
opts: ServerFnMiddlewareOptions,
globalMiddlewares: Array<AnyFunctionMiddleware> = [],
): Promise<ServerFnMiddlewareResult> {
const globalMiddlewares = getStartOptions()?.functionMiddleware || []
let flattenedMiddlewares = flattenMiddlewares([
...globalMiddlewares,
...middlewares,
Expand Down
7 changes: 4 additions & 3 deletions packages/start-client-core/src/getDefaultSerovalPlugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Plugin<any, any>> {
const start = getStartOptions()
export function getDefaultSerovalPlugins(
start: AnyStartInstanceOptions | undefined,
): Array<Plugin<any, any>> {
Comment on lines +9 to +11

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n --type=ts '\bgetDefaultSerovalPlugins\s*\(' packages

Repository: TanStack/router

Length of output: 773


🏁 Script executed:

#!/bin/bash
sed -n '1,80p' packages/start-client-core/src/getDefaultSerovalPlugins.ts
printf '\n---\n'
sed -n '88,132p' packages/start-static-server-functions/src/staticFunctionMiddleware.ts
printf '\n---\n'
sed -n '100,130p' packages/start-client-core/src/client-rpc/serverFnFetcher.ts
printf '\n---\n'
sed -n '60,90p' packages/start-server-core/src/server-functions-handler.ts

Repository: TanStack/router

Length of output: 3520


Make start optional. AnyStartInstanceOptions | undefined still requires an argument, so the zero-arg calls in packages/start-static-server-functions/src/staticFunctionMiddleware.ts:100 and :123 don’t match this signature. start?: AnyStartInstanceOptions aligns with the existing call sites.

Proposed fix
 export function getDefaultSerovalPlugins(
-  start: AnyStartInstanceOptions | undefined,
+  start?: AnyStartInstanceOptions,
 ): Array<Plugin<any, any>> {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function getDefaultSerovalPlugins(
start: AnyStartInstanceOptions | undefined,
): Array<Plugin<any, any>> {
export function getDefaultSerovalPlugins(
start?: AnyStartInstanceOptions,
): Array<Plugin<any, any>> {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/start-client-core/src/getDefaultSerovalPlugins.ts` around lines 9 -
11, `getDefaultSerovalPlugins` currently declares `start` as
`AnyStartInstanceOptions | undefined`, which still forces callers to pass an
argument and breaks the zero-arg uses in `staticFunctionMiddleware`. Update the
function signature in `getDefaultSerovalPlugins` to make `start` optional with
`start?: AnyStartInstanceOptions`, and keep the existing handling inside the
function compatible with an omitted value.

const adapters = start?.serializationAdapters as
| Array<AnySerializationAdapter>
| undefined
Expand Down
63 changes: 62 additions & 1 deletion packages/start-client-core/src/getStartOptions.ts
Original file line number Diff line number Diff line change
@@ -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<AnyStartInstanceOptions> | undefined

const setStartOptions = (options: AnyStartInstanceOptions) => {
const serializationAdapters = (options.serializationAdapters ??=
[]) as Array<AnySerializationAdapter>

// 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)
9 changes: 0 additions & 9 deletions packages/start-client-core/src/global.ts

This file was deleted.

1 change: 1 addition & 0 deletions packages/start-server-core/src/createStartHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,7 @@ export function createStartHandler<TRegister = Register>(
request,
context: requestOpts?.context,
serverFnId,
startOptions: requestStartOptions,
}),
)
}
Expand Down
Loading
Loading