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')