-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
fix(start): initialize serialization adapters for early client server… #7708
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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() | ||
| }) |
| 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>, | ||
| ) | ||
| }) |
| 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, | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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*\(' packagesRepository: 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.tsRepository: TanStack/router Length of output: 3520 Make Proposed fix export function getDefaultSerovalPlugins(
- start: AnyStartInstanceOptions | undefined,
+ start?: AnyStartInstanceOptions,
): Array<Plugin<any, any>> {📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| const adapters = start?.serializationAdapters as | ||||||||||||||
| | Array<AnySerializationAdapter> | ||||||||||||||
| | undefined | ||||||||||||||
|
|
||||||||||||||
| 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) |
This file was deleted.
There was a problem hiding this comment.
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-32later mutatesstartOptions.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 differentstartOptionsare observed concurrently.🤖 Prompt for AI Agents