diff --git a/.changeset/sour-dancers-buy.md b/.changeset/sour-dancers-buy.md
new file mode 100644
index 0000000000..1274f505a9
--- /dev/null
+++ b/.changeset/sour-dancers-buy.md
@@ -0,0 +1,6 @@
+---
+'@tanstack/start-client-core': minor
+'@tanstack/start-plugin-core': minor
+---
+
+Add support for stable, manually assigned server function IDs.
diff --git a/docs/start/framework/react/guide/server-functions.md b/docs/start/framework/react/guide/server-functions.md
index 903e5d7b86..ad36df0496 100644
--- a/docs/start/framework/react/guide/server-functions.md
+++ b/docs/start/framework/react/guide/server-functions.md
@@ -157,6 +157,75 @@ The build process replaces server function implementations with RPC stubs in cli
> const { getUser } = await import('~/utils/users.functions')
> ```
+## Manual Server Function IDs
+
+By default, TanStack Start generates a function ID for each server function. You can optionally provide a manual ID when you want a stable, explicit identifier under your control.
+
+Use manual IDs when you need predictable identifiers across refactors, clearer diagnostics, or explicit naming conventions.
+
+```tsx
+import { createServerFn } from '@tanstack/react-start'
+
+export const getUser = createServerFn({ method: 'GET', id: 'get-user' })
+ .validator((data: { userId: string }) => data)
+ .handler(async ({ data }) => {
+ return findUserById(data.userId)
+ })
+```
+
+### Rules for Manual IDs
+
+Manual IDs must follow these rules:
+
+1. Allowed characters are letters, numbers, underscore, and dash.
+2. The ID must match this pattern: `[a-zA-Z0-9_-]+`.
+3. The ID must be statically analyzable:
+ - String literal is allowed.
+ - Constant string binding is allowed.
+ - Computed ID keys are not supported.
+ - The `id` property must be declared directly in the inline options object.
+4. IDs inherited from `Object.prototype`, such as `__proto__` and `constructor`, are reserved.
+
+```tsx
+// ✅ literal string ID
+createServerFn({ id: 'create-user' }).handler(async () => {
+ return { ok: true }
+})
+
+// ✅ constant string binding ID
+const userLookupId = 'lookup-user'
+createServerFn({ id: userLookupId }).handler(async () => {
+ return { ok: true }
+})
+
+// ❌ computed ID key is not supported
+const key = 'id'
+createServerFn({ [key]: 'create-user' }).handler(async () => {
+ return { ok: true }
+})
+```
+
+### Automatic and Manual IDs
+
+Automatic IDs are the default and work for most apps. Provide a manual `id` only when you want a fixed, recognizable identifier (for example, stable values in logs or analytics).
+
+Switching between automatic and manual IDs does not change how you call a server function. The call signature, arguments, and return value stay the same, but the server function lookup ID and request URL change to use the manual ID.
+
+Manual IDs are exact reservations. Compilation fails if two manual IDs collide or if a generated or custom `generateFunctionId` value exactly matches a manual ID. Generated IDs are still suffixed when they collide with other generated IDs.
+
+In development, TanStack Start uses an encoded module identifier so the dev server can locate the server function module directly. The manual ID becomes the production lookup ID; it does not replace that development-only identifier.
+
+### Security Caveats
+
+Manual IDs improve stability and readability, but they are not a security feature.
+
+1. Do not treat function IDs as secrets.
+2. Do not rely on manual IDs for authorization.
+3. Authorize every server function in middleware or inside the handler.
+4. Keep same-origin and CSRF protections enabled for server function endpoints.
+
+Changing from automatic IDs to manual IDs does not change your security boundary. Your security boundary remains the server-side authorization and request protection checks.
+
## Parameters & Validation
Server functions accept a single `data` parameter. Since they cross the network boundary, validation ensures type safety and runtime correctness.
diff --git a/e2e/react-start/server-functions/src/routeTree.gen.ts b/e2e/react-start/server-functions/src/routeTree.gen.ts
index 249a9c42ce..65ff952c1b 100644
--- a/e2e/react-start/server-functions/src/routeTree.gen.ts
+++ b/e2e/react-start/server-functions/src/routeTree.gen.ts
@@ -17,6 +17,7 @@ import { Route as SerializeFormDataRouteImport } from './routes/serialize-form-d
import { Route as ReturnNullRouteImport } from './routes/return-null'
import { Route as RawResponseRouteImport } from './routes/raw-response'
import { Route as MultipartRouteImport } from './routes/multipart'
+import { Route as ManualIdRouteImport } from './routes/manual-id'
import { Route as IsomorphicFnsRouteImport } from './routes/isomorphic-fns'
import { Route as HeadersRouteImport } from './routes/headers'
import { Route as FormdataContextRouteImport } from './routes/formdata-context'
@@ -94,6 +95,11 @@ const MultipartRoute = MultipartRouteImport.update({
path: '/multipart',
getParentRoute: () => rootRouteImport,
} as any)
+const ManualIdRoute = ManualIdRouteImport.update({
+ id: '/manual-id',
+ path: '/manual-id',
+ getParentRoute: () => rootRouteImport,
+} as any)
const IsomorphicFnsRoute = IsomorphicFnsRouteImport.update({
id: '/isomorphic-fns',
path: '/isomorphic-fns',
@@ -295,6 +301,7 @@ export interface FileRoutesByFullPath {
'/formdata-context': typeof FormdataContextRoute
'/headers': typeof HeadersRoute
'/isomorphic-fns': typeof IsomorphicFnsRoute
+ '/manual-id': typeof ManualIdRoute
'/multipart': typeof MultipartRoute
'/raw-response': typeof RawResponseRoute
'/return-null': typeof ReturnNullRoute
@@ -341,6 +348,7 @@ export interface FileRoutesByTo {
'/formdata-context': typeof FormdataContextRoute
'/headers': typeof HeadersRoute
'/isomorphic-fns': typeof IsomorphicFnsRoute
+ '/manual-id': typeof ManualIdRoute
'/multipart': typeof MultipartRoute
'/raw-response': typeof RawResponseRoute
'/return-null': typeof ReturnNullRoute
@@ -388,6 +396,7 @@ export interface FileRoutesById {
'/formdata-context': typeof FormdataContextRoute
'/headers': typeof HeadersRoute
'/isomorphic-fns': typeof IsomorphicFnsRoute
+ '/manual-id': typeof ManualIdRoute
'/multipart': typeof MultipartRoute
'/raw-response': typeof RawResponseRoute
'/return-null': typeof ReturnNullRoute
@@ -436,6 +445,7 @@ export interface FileRouteTypes {
| '/formdata-context'
| '/headers'
| '/isomorphic-fns'
+ | '/manual-id'
| '/multipart'
| '/raw-response'
| '/return-null'
@@ -482,6 +492,7 @@ export interface FileRouteTypes {
| '/formdata-context'
| '/headers'
| '/isomorphic-fns'
+ | '/manual-id'
| '/multipart'
| '/raw-response'
| '/return-null'
@@ -528,6 +539,7 @@ export interface FileRouteTypes {
| '/formdata-context'
| '/headers'
| '/isomorphic-fns'
+ | '/manual-id'
| '/multipart'
| '/raw-response'
| '/return-null'
@@ -575,6 +587,7 @@ export interface RootRouteChildren {
FormdataContextRoute: typeof FormdataContextRoute
HeadersRoute: typeof HeadersRoute
IsomorphicFnsRoute: typeof IsomorphicFnsRoute
+ ManualIdRoute: typeof ManualIdRoute
MultipartRoute: typeof MultipartRoute
RawResponseRoute: typeof RawResponseRoute
ReturnNullRoute: typeof ReturnNullRoute
@@ -670,6 +683,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof MultipartRouteImport
parentRoute: typeof rootRouteImport
}
+ '/manual-id': {
+ id: '/manual-id'
+ path: '/manual-id'
+ fullPath: '/manual-id'
+ preLoaderRoute: typeof ManualIdRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/isomorphic-fns': {
id: '/isomorphic-fns'
path: '/isomorphic-fns'
@@ -935,6 +955,7 @@ const rootRouteChildren: RootRouteChildren = {
FormdataContextRoute: FormdataContextRoute,
HeadersRoute: HeadersRoute,
IsomorphicFnsRoute: IsomorphicFnsRoute,
+ ManualIdRoute: ManualIdRoute,
MultipartRoute: MultipartRoute,
RawResponseRoute: RawResponseRoute,
ReturnNullRoute: ReturnNullRoute,
diff --git a/e2e/react-start/server-functions/src/routes/manual-id.tsx b/e2e/react-start/server-functions/src/routes/manual-id.tsx
new file mode 100644
index 0000000000..f8eedf44b9
--- /dev/null
+++ b/e2e/react-start/server-functions/src/routes/manual-id.tsx
@@ -0,0 +1,31 @@
+import { useState } from 'react'
+import { createFileRoute } from '@tanstack/react-router'
+import { createServerFn, useServerFn } from '@tanstack/react-start'
+
+const manualIdServerFn = createServerFn({ id: 'manual-id-hello' }).handler(
+ () => 'manual id response',
+)
+
+export const Route = createFileRoute('/manual-id')({
+ component: ManualIdComponent,
+})
+
+function ManualIdComponent() {
+ const invokeManualIdServerFn = useServerFn(manualIdServerFn)
+ const [result, setResult] = useState('')
+
+ return (
+
+
+
+
+ )
+}
diff --git a/e2e/react-start/server-functions/tests/server-functions.spec.ts b/e2e/react-start/server-functions/tests/server-functions.spec.ts
index 5bb458d61c..e8e3eb4c9a 100644
--- a/e2e/react-start/server-functions/tests/server-functions.spec.ts
+++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts
@@ -45,6 +45,22 @@ test('invoking a server function with custom response status code', async ({
await requestPromise
})
+test('invokes a server function with a manual id', async ({ page }) => {
+ await page.goto('/manual-id')
+
+ const responsePromise = page.waitForResponse((response) =>
+ response.url().includes('/_serverFn/manual-id-hello'),
+ )
+
+ await page.getByTestId('invoke-manual-id-server-fn').click()
+
+ const response = await responsePromise
+ expect(response.status()).toBe(200)
+ await expect(page.getByTestId('manual-id-result')).toHaveText(
+ 'manual id response',
+ )
+})
+
test('Consistent server function returns both on client and server for GET and POST calls', async ({
page,
}) => {
diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts
index bfa48039cf..a74424ff35 100644
--- a/packages/start-client-core/src/createServerFn.ts
+++ b/packages/start-client-core/src/createServerFn.ts
@@ -37,6 +37,7 @@ export interface ServerFnOptions<
> {
method?: TMethod
strict?: TStrict
+ id?: string
}
export type ServerFnStrictInput =
@@ -502,6 +503,7 @@ export type ServerFnBaseOptions<
> = {
method: TMethod
strict?: TStrict
+ id?: string
middleware?: Constrain<
TMiddlewares,
ReadonlyArray
diff --git a/packages/start-client-core/src/tests/createServerFn.test-d.ts b/packages/start-client-core/src/tests/createServerFn.test-d.ts
index bfe96dfc5e..b8c7559668 100644
--- a/packages/start-client-core/src/tests/createServerFn.test-d.ts
+++ b/packages/start-client-core/src/tests/createServerFn.test-d.ts
@@ -24,6 +24,8 @@ test('createServerFn without middleware', () => {
// TODO remove upon stable
expectTypeOf(createServerFn()).toHaveProperty('inputValidator')
+ expectTypeOf(createServerFn({ id: 'get-user' })).toHaveProperty('handler')
+
createServerFn({ method: 'GET' }).handler((options) => {
expectTypeOf(options).toEqualTypeOf<{
context: undefined
diff --git a/packages/start-plugin-core/src/start-compiler/compiler.ts b/packages/start-plugin-core/src/start-compiler/compiler.ts
index d6b36fe6e8..c04bb89b57 100644
--- a/packages/start-plugin-core/src/start-compiler/compiler.ts
+++ b/packages/start-plugin-core/src/start-compiler/compiler.ts
@@ -1,5 +1,6 @@
import crypto from 'node:crypto'
import * as t from '@babel/types'
+import path from 'pathe'
import {
deadCodeElimination,
extractModuleInfoFromAst,
@@ -514,6 +515,8 @@ export class StartCompiler {
string,
Map
>()
+ private reservedManualFunctionIdOwners = new Map()
+ private reservedManualFunctionIdsByFilename = new Map>()
// Fast lookup for direct imports from known libraries (e.g., '@tanstack/react-start')
// Maps: libName → (exportName → Kind)
// This allows O(1) resolution for the common case without async resolveId calls
@@ -612,37 +615,22 @@ export class StartCompiler {
extractedFilename: string
}): string {
if (this.mode === 'dev') {
- // In dev, encode the file path and export name for direct lookup.
- // Each bundler adapter supplies its own strategy for encoding
- // module specifiers that work with its dev server runtime.
- const encodeModuleSpecifier =
- this.options.devServerFnModuleSpecifierEncoder
- if (!encodeModuleSpecifier) {
- throw new Error(
- 'devServerFnModuleSpecifierEncoder is required in dev mode.',
- )
- }
- const file = encodeModuleSpecifier({
- extractedFilename: opts.extractedFilename,
- root: this.options.root,
- })
-
- const serverFn = {
- file,
- export: opts.functionName,
- }
- return Buffer.from(JSON.stringify(serverFn), 'utf8').toString('base64url')
+ return this.generateDevFunctionId(opts)
}
// Production build: use custom generator or hash
const entryId = `${opts.filename}--${opts.functionName}`
let functionId = this.entryIdToFunctionId.get(entryId)
if (functionId === undefined) {
- const knownFn = Object.values(this.options.getKnownServerFns()).find(
+ const knownServerFns = this.options.getKnownServerFns()
+ const knownFn = Object.values(knownServerFns).find(
(serverFn) =>
serverFn.functionName === opts.functionName &&
serverFn.extractedFilename === opts.extractedFilename,
)
+ const knownFunctionIds = new Set(
+ Object.values(knownServerFns).map((serverFn) => serverFn.functionId),
+ )
if (knownFn) {
functionId = knownFn.functionId
@@ -657,13 +645,26 @@ export class StartCompiler {
if (!functionId) {
functionId = crypto.createHash('sha256').update(entryId).digest('hex')
}
- // Deduplicate in case the generated id conflicts with an existing id
- if (this.functionIds.has(functionId)) {
+ const isCanonicalKnownMatch = knownFn?.functionId === functionId
+ if (this.reservedManualFunctionIdOwners.has(functionId)) {
+ throw new Error(`Duplicate server function id: ${functionId}`)
+ }
+
+ // Deduplicate generated/custom IDs without making the older
+ // generateFunctionId hook a breaking change.
+ if (
+ this.functionIds.has(functionId) ||
+ (!isCanonicalKnownMatch && knownFunctionIds.has(functionId))
+ ) {
let deduplicatedId
let iteration = 0
do {
deduplicatedId = `${functionId}_${++iteration}`
- } while (this.functionIds.has(deduplicatedId))
+ } while (
+ this.functionIds.has(deduplicatedId) ||
+ this.reservedManualFunctionIdOwners.has(deduplicatedId) ||
+ knownFunctionIds.has(deduplicatedId)
+ )
functionId = deduplicatedId
}
this.entryIdToFunctionId.set(entryId, functionId)
@@ -672,6 +673,77 @@ export class StartCompiler {
return functionId
}
+ private reserveFunctionId(opts: {
+ filename: string
+ functionName: string
+ extractedFilename: string
+ functionId: string
+ }) {
+ const entryId = `${opts.filename}--${opts.functionName}`
+ const existingOwner = this.reservedManualFunctionIdOwners.get(
+ opts.functionId,
+ )
+ const knownFn = this.options.getKnownServerFns()[opts.functionId]
+
+ if (
+ knownFn &&
+ (knownFn.functionName !== opts.functionName ||
+ knownFn.extractedFilename !== opts.extractedFilename)
+ ) {
+ throw new Error(`Duplicate server function id: ${opts.functionId}`)
+ }
+
+ if (existingOwner && existingOwner !== entryId) {
+ throw new Error(`Duplicate server function id: ${opts.functionId}`)
+ }
+
+ if (!existingOwner && this.functionIds.has(opts.functionId)) {
+ throw new Error(`Duplicate server function id: ${opts.functionId}`)
+ }
+
+ if (!existingOwner) {
+ this.reservedManualFunctionIdOwners.set(opts.functionId, entryId)
+
+ let reservedIds = this.reservedManualFunctionIdsByFilename.get(
+ opts.filename,
+ )
+ if (!reservedIds) {
+ reservedIds = new Set()
+ this.reservedManualFunctionIdsByFilename.set(opts.filename, reservedIds)
+ }
+ reservedIds.add(opts.functionId)
+ }
+
+ return this.mode === 'dev'
+ ? this.generateDevFunctionId(opts)
+ : opts.functionId
+ }
+
+ private generateDevFunctionId(opts: {
+ functionName: string
+ extractedFilename: string
+ }): string {
+ // In dev, encode the file path and export name for direct lookup.
+ // Each bundler adapter supplies its own strategy for encoding
+ // module specifiers that work with its dev server runtime.
+ const encodeModuleSpecifier = this.options.devServerFnModuleSpecifierEncoder
+ if (!encodeModuleSpecifier) {
+ throw new Error(
+ 'devServerFnModuleSpecifierEncoder is required in dev mode.',
+ )
+ }
+ const file = encodeModuleSpecifier({
+ extractedFilename: opts.extractedFilename,
+ root: this.options.root,
+ })
+
+ const serverFn = {
+ file,
+ export: opts.functionName,
+ }
+ return Buffer.from(JSON.stringify(serverFn), 'utf8').toString('base64url')
+ }
+
private get mode(): 'dev' | 'build' {
return this.options.mode ?? 'dev'
}
@@ -871,6 +943,7 @@ export class StartCompiler {
for (const moduleId of Array.from(this.moduleCache.keys())) {
const normalizedModuleId = cleanId(moduleId)
if (normalizedIds.has(normalizedModuleId)) {
+ this.clearReservedManualFunctionIdsForModule(normalizedModuleId)
this.moduleCache.delete(moduleId)
deletedModuleIds.add(normalizedModuleId)
}
@@ -895,6 +968,20 @@ export class StartCompiler {
return deletedModuleIds
}
+ private clearReservedManualFunctionIdsForModule(moduleId: string): void {
+ const relativeFilename = path.relative(this.options.root, moduleId)
+ const reservedIds =
+ this.reservedManualFunctionIdsByFilename.get(relativeFilename)
+ if (!reservedIds) {
+ return
+ }
+
+ for (const functionId of reservedIds) {
+ this.reservedManualFunctionIdOwners.delete(functionId)
+ }
+ this.reservedManualFunctionIdsByFilename.delete(relativeFilename)
+ }
+
public async getTransitiveImporters(
ids: string | Iterable,
): Promise> {
@@ -1368,6 +1455,7 @@ export class StartCompiler {
warn: warnFn,
generateFunctionId: (opts) => this.generateFunctionId(opts),
+ reserveFunctionId: (opts) => this.reserveFunctionId(opts),
getKnownServerFns: this.options.getKnownServerFns,
serverFnProviderModuleDirectives:
this.options.serverFnProviderModuleDirectives,
diff --git a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts
index 3d4a8a2542..9d88f519ad 100644
--- a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts
+++ b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts
@@ -1,7 +1,10 @@
import * as t from '@babel/types'
import babel from '@babel/core'
import { hasKeys } from '@tanstack/router-core'
-import { getVariableDeclaratorForExpressionPath } from '@tanstack/router-utils'
+import {
+ getVariableDeclaratorForExpressionPath,
+ unwrapExpression,
+} from '@tanstack/router-utils'
import path from 'pathe'
import { cleanId, codeFrameError, stripMethodCall } from './utils'
import type {
@@ -13,6 +16,7 @@ import type {
import type { CompileStartFrameworkOptions } from '../types'
const TSS_SERVERFN_SPLIT_PARAM = 'tss-serverfn-split'
+const MANUAL_SERVER_FN_ID_PATTERN = /^[a-zA-Z0-9_-]+$/
const providerHmrAcceptTemplate = babel.template.statements(
`
@@ -139,6 +143,232 @@ function getEnvConfig(
}
}
+function extractManualServerFnId(
+ candidatePath: babel.NodePath,
+ code: string,
+): string | undefined {
+ const createServerFnCall = getCreateServerFnCallExpression(candidatePath)
+ if (!createServerFnCall) {
+ return undefined
+ }
+
+ const [optionsArg] = createServerFnCall.arguments
+ if (!optionsArg) {
+ return undefined
+ }
+
+ const unwrappedOptionsArg = t.isExpression(optionsArg)
+ ? unwrapExpression(optionsArg)
+ : optionsArg
+
+ if (t.isIdentifier(unwrappedOptionsArg)) {
+ const binding = candidatePath.scope.getBinding(unwrappedOptionsArg.name)
+ const bindingInit = binding?.path.isVariableDeclarator()
+ ? binding.path.node.init
+ : undefined
+ const unwrappedBindingInit =
+ bindingInit && t.isExpression(bindingInit)
+ ? unwrapExpression(bindingInit)
+ : undefined
+
+ if (
+ binding?.constant &&
+ unwrappedBindingInit &&
+ t.isObjectExpression(unwrappedBindingInit) &&
+ unwrappedBindingInit.properties.some(
+ (prop) =>
+ (t.isObjectProperty(prop) || t.isObjectMethod(prop)) &&
+ resolveObjectMemberKey(candidatePath, prop) === 'id',
+ )
+ ) {
+ throw codeFrameError(
+ code,
+ unwrappedOptionsArg.loc!,
+ 'createServerFn({ id }) must declare the manual id inline.',
+ )
+ }
+ }
+
+ if (!t.isObjectExpression(unwrappedOptionsArg)) {
+ return undefined
+ }
+
+ let manualFunctionId: string | undefined
+
+ for (const prop of unwrappedOptionsArg.properties) {
+ if (!t.isObjectProperty(prop)) {
+ if (
+ t.isObjectMethod(prop) &&
+ resolveObjectMemberKey(candidatePath, prop) === 'id'
+ ) {
+ throw codeFrameError(
+ code,
+ prop.loc!,
+ 'createServerFn({ id }) must use a static string literal or a constant string binding.',
+ )
+ }
+ continue
+ }
+
+ const keyValue = resolveObjectMemberKey(candidatePath, prop)
+ if (prop.computed) {
+ if (keyValue === 'id') {
+ throw codeFrameError(
+ code,
+ prop.loc!,
+ 'createServerFn({ [key]: value }) is not supported for manual ids.',
+ )
+ }
+
+ continue
+ }
+
+ if (keyValue !== 'id') {
+ continue
+ }
+
+ if (manualFunctionId !== undefined) {
+ throw codeFrameError(
+ code,
+ prop.loc!,
+ 'createServerFn options must not define more than one manual id.',
+ )
+ }
+
+ if (!t.isExpression(prop.value) && !t.isPrivateName(prop.value)) {
+ throw codeFrameError(
+ code,
+ prop.loc!,
+ 'createServerFn({ id }) must use a static string literal or a constant string binding.',
+ )
+ }
+
+ const idValue = resolveStaticString(candidatePath, prop.value)
+
+ if (idValue !== undefined) {
+ if (!MANUAL_SERVER_FN_ID_PATTERN.test(idValue)) {
+ throw codeFrameError(
+ code,
+ prop.value.loc!,
+ 'createServerFn({ id }) must use a URL-safe id: [a-zA-Z0-9_-]+',
+ )
+ }
+
+ if (idValue in Object.prototype) {
+ throw codeFrameError(
+ code,
+ prop.value.loc!,
+ `createServerFn({ id }) must not use the reserved id: ${idValue}`,
+ )
+ }
+
+ manualFunctionId = idValue
+ continue
+ }
+
+ throw codeFrameError(
+ code,
+ prop.loc!,
+ 'createServerFn({ id }) must use a static string literal or a constant string binding.',
+ )
+ }
+
+ return manualFunctionId
+}
+
+function getCreateServerFnCallExpression(
+ candidatePath: babel.NodePath,
+): t.CallExpression | undefined {
+ let currentCall: t.CallExpression = candidatePath.node
+ let sawMethodChain = false
+
+ // Walk inward through the `createServerFn()...method()...` chain.
+ while (
+ t.isMemberExpression(currentCall.callee) &&
+ t.isCallExpression(currentCall.callee.object)
+ ) {
+ const innerCall = currentCall.callee.object
+ if (t.isIdentifier(innerCall.callee, { name: 'createServerFn' })) {
+ return innerCall
+ }
+ sawMethodChain = true
+ currentCall = innerCall
+ }
+
+ return sawMethodChain ? currentCall : undefined
+}
+
+function resolveStaticString(
+ candidatePath: babel.NodePath,
+ value: t.Expression | t.PrivateName,
+): string | undefined {
+ const unwrappedValue = t.isExpression(value) ? unwrapExpression(value) : value
+
+ if (t.isStringLiteral(unwrappedValue)) {
+ return unwrappedValue.value
+ }
+
+ if (
+ t.isTemplateLiteral(unwrappedValue) &&
+ unwrappedValue.expressions.length === 0
+ ) {
+ return unwrappedValue.quasis[0]?.value.cooked ?? undefined
+ }
+
+ if (!t.isIdentifier(unwrappedValue)) {
+ return undefined
+ }
+
+ const binding = candidatePath.scope.getBinding(unwrappedValue.name)
+ const bindingPath = binding?.path
+ const bindingInit =
+ bindingPath && bindingPath.isVariableDeclarator()
+ ? bindingPath.node.init
+ : undefined
+ const unwrappedBindingInit =
+ bindingInit && t.isExpression(bindingInit)
+ ? unwrapExpression(bindingInit)
+ : bindingInit
+
+ if (
+ binding?.constant &&
+ unwrappedBindingInit &&
+ t.isStringLiteral(unwrappedBindingInit)
+ ) {
+ return unwrappedBindingInit.value
+ }
+
+ if (
+ binding?.constant &&
+ unwrappedBindingInit &&
+ t.isTemplateLiteral(unwrappedBindingInit) &&
+ unwrappedBindingInit.expressions.length === 0
+ ) {
+ return unwrappedBindingInit.quasis[0]?.value.cooked ?? undefined
+ }
+
+ return undefined
+}
+
+function resolveObjectMemberKey(
+ candidatePath: babel.NodePath,
+ prop: t.ObjectMethod | t.ObjectProperty,
+): string | undefined {
+ if (prop.computed) {
+ return resolveStaticString(candidatePath, prop.key)
+ }
+
+ if (t.isIdentifier(prop.key)) {
+ return prop.key.name
+ }
+
+ if (t.isStringLiteral(prop.key)) {
+ return prop.key.value
+ }
+
+ return undefined
+}
+
/**
* Builds the serverFnMeta object literal AST node.
* The object contains: { id, name, filename }
@@ -271,12 +501,25 @@ export function handleCreateServerFn(
}
functionNameSet.add(functionName)
- // Generate function ID using pre-computed relative filename
- const functionId = context.generateFunctionId({
- filename: relativeFilename,
- functionName,
- extractedFilename,
- })
+ const manualFunctionId = extractManualServerFnId(
+ candidatePath,
+ context.code,
+ )
+
+ // Generate function ID using pre-computed relative filename unless the user supplied one.
+ const functionId =
+ manualFunctionId !== undefined
+ ? context.reserveFunctionId({
+ filename: relativeFilename,
+ functionName,
+ extractedFilename,
+ functionId: manualFunctionId,
+ })
+ : context.generateFunctionId({
+ filename: relativeFilename,
+ functionName,
+ extractedFilename,
+ })
// Check if this function was already discovered by the client build
const knownFn = knownFns[functionId]
diff --git a/packages/start-plugin-core/src/start-compiler/host.ts b/packages/start-plugin-core/src/start-compiler/host.ts
index d6953f51f9..cc38fe10e6 100644
--- a/packages/start-plugin-core/src/start-compiler/host.ts
+++ b/packages/start-plugin-core/src/start-compiler/host.ts
@@ -68,6 +68,16 @@ export function mergeServerFnsById(
next: Record,
): void {
for (const [id, fn] of Object.entries(next)) {
+ for (const [existingId, existingFn] of Object.entries(current)) {
+ if (
+ existingId !== id &&
+ existingFn.filename === fn.filename &&
+ existingFn.functionName === fn.functionName
+ ) {
+ delete current[existingId]
+ }
+ }
+
const existing = current[id]
if (existing) {
diff --git a/packages/start-plugin-core/src/start-compiler/types.ts b/packages/start-plugin-core/src/start-compiler/types.ts
index 1371e7a973..c87ab2aa54 100644
--- a/packages/start-plugin-core/src/start-compiler/types.ts
+++ b/packages/start-plugin-core/src/start-compiler/types.ts
@@ -9,6 +9,8 @@ import type { StartCompilerTransformContext } from '../types'
export interface CompilationContext extends StartCompilerTransformContext {
/** Generate a unique function ID */
generateFunctionId: GenerateFunctionIdFn
+ /** Reserve a user-supplied function ID for the current module. */
+ reserveFunctionId: ReserveFunctionIdFn
/** Get known server functions from previous builds (e.g., client build) */
getKnownServerFns: () => Record
/** Module-level directives to add to extracted server function provider files. */
@@ -95,6 +97,13 @@ export type GenerateFunctionIdFn = (opts: {
extractedFilename: string
}) => string
+export type ReserveFunctionIdFn = (opts: {
+ filename: string
+ functionName: string
+ extractedFilename: string
+ functionId: string
+}) => string
+
/**
* Optional version that allows returning undefined to use default ID generation.
*/
diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts
index c4009657c4..f1cf26f8a8 100644
--- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts
+++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts
@@ -2,6 +2,7 @@ import { readFile, readdir } from 'node:fs/promises'
import path from 'node:path'
import { describe, expect, test, vi } from 'vitest'
import { StartCompiler } from '../../src/start-compiler/compiler'
+import { createViteDevServerFnModuleSpecifierEncoder } from '../../src/vite/start-compiler-plugin/module-specifier'
// Default test options for StartCompiler
function getDefaultTestOptions(env: 'client' | 'server') {
@@ -118,6 +119,212 @@ describe('createServerFn compiles correctly', async () => {
`)
})
+ test('should use a literal manual id from createServerFn options', async () => {
+ const code = `
+ import { createServerFn } from '@tanstack/react-start'
+
+ export const getUser = createServerFn({ method: 'GET', id: 'get-user' })
+ .handler(async () => ({ id: '123' }))
+ `
+
+ const compiledResultClient = await compile({
+ code,
+ env: 'client',
+ isProviderFile: false,
+ mode: 'build',
+ })
+
+ const compiledResultServerProvider = await compile({
+ code,
+ env: 'server',
+ isProviderFile: true,
+ mode: 'build',
+ })
+
+ expect(compiledResultClient!.code).toContain('createClientRpc("get-user")')
+ expect(compiledResultServerProvider!.code).toContain('id: "get-user"')
+ })
+
+ test.each([
+ {
+ name: 'constant binding',
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+
+ const manualId = 'get-user'
+
+ export const getUser = createServerFn({ method: 'GET', id: manualId })
+ .handler(async () => ({ id: '123' }))
+ `,
+ },
+ {
+ name: 'validator chain',
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+
+ export const getUser = createServerFn({ method: 'GET', id: 'get-user' })
+ .validator((input: string) => input)
+ .handler(async ({ data }) => ({ id: data }))
+ `,
+ },
+ {
+ name: 'unrelated spread',
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+
+ const baseOptions = { method: 'GET' as const }
+
+ export const getUser = createServerFn({ ...baseOptions, id: 'get-user' })
+ .handler(async () => ({ id: '123' }))
+ `,
+ },
+ {
+ name: 'unrelated computed key',
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+
+ const key = 'method'
+
+ export const getUser = createServerFn({ [key]: 'GET' as const, id: 'get-user' })
+ .handler(async () => ({ id: '123' }))
+ `,
+ },
+ ])('should use a manual id with $name', async ({ code }) => {
+ const compiledResultClient = await compile({
+ code,
+ env: 'client',
+ isProviderFile: false,
+ mode: 'build',
+ })
+
+ expect(compiledResultClient!.code).toContain('createClientRpc("get-user")')
+ })
+
+ test.each([
+ {
+ name: 'unrelated spread',
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+
+ const baseOptions = { method: 'GET' as const }
+
+ export const getUser = createServerFn({ ...baseOptions })
+ .handler(async () => ({ id: '123' }))
+ `,
+ },
+ {
+ name: 'unrelated computed key',
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+
+ const key = 'method'
+
+ export const getUser = createServerFn({ [key]: 'GET' as const })
+ .handler(async () => ({ id: '123' }))
+ `,
+ },
+ ])('should ignore $name when no manual id is present', async ({ code }) => {
+ const compiledResultClient = await compile({
+ code,
+ env: 'client',
+ isProviderFile: false,
+ mode: 'build',
+ })
+
+ expect(compiledResultClient!.code).toContain('createClientRpc(')
+ })
+
+ test.each([
+ {
+ name: 'literal computed id key',
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+
+ export const getUser = createServerFn({ ['id']: 'get-user' })
+ .handler(async () => ({ id: '123' }))
+ `,
+ },
+ {
+ name: 'constant computed id key',
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+
+ const key = \`id\`
+
+ export const getUser = createServerFn({ [key]: 'get-user' })
+ .handler(async () => ({ id: '123' }))
+ `,
+ },
+ ])('should reject $name', async ({ code }) => {
+ await expect(
+ compile({
+ code,
+ env: 'client',
+ isProviderFile: false,
+ mode: 'build',
+ }),
+ ).rejects.toThrow(
+ 'createServerFn({ [key]: value }) is not supported for manual ids.',
+ )
+ })
+
+ test('should reject duplicate manual id properties', async () => {
+ const code = `
+ import { createServerFn } from '@tanstack/react-start'
+
+ export const getUser = createServerFn({ id: 'get-user', 'id': 'get-other-user' })
+ .handler(async () => ({ id: '123' }))
+ `
+
+ await expect(
+ compile({
+ code,
+ env: 'client',
+ isProviderFile: false,
+ mode: 'build',
+ }),
+ ).rejects.toThrow(
+ 'createServerFn options must not define more than one manual id.',
+ )
+ })
+
+ test('should reject the reserved manual id __proto__', async () => {
+ await expect(
+ compile({
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+
+ export const getUser = createServerFn({ id: '__proto__' })
+ .handler(async () => ({ id: '123' }))
+ `,
+ env: 'client',
+ isProviderFile: false,
+ mode: 'build',
+ }),
+ ).rejects.toThrow(
+ 'createServerFn({ id }) must not use the reserved id: __proto__',
+ )
+ })
+
+ test('should reject a manual id hidden in an options binding', async () => {
+ await expect(
+ compile({
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+
+ const options = { method: 'GET', id: 'get-user' } as const
+ export const getUser = createServerFn(options)
+ .handler(async () => ({ id: '123' }))
+ `,
+ env: 'client',
+ isProviderFile: false,
+ mode: 'build',
+ }),
+ ).rejects.toThrow(
+ 'createServerFn({ id }) must declare the manual id inline.',
+ )
+ })
+
// TODO remove upon stable
test('should warn for deprecated inputValidator method', async () => {
const warn = vi.fn()
@@ -507,6 +714,50 @@ describe('createServerFn compiles correctly', async () => {
)
})
+ test('should use a manual id from a local named re-export of createServerFn', async () => {
+ const code = `
+ import { createFooServerFn } from './factory'
+ const myServerFn = createFooServerFn({ id: 'get-user' }).handler(async () => {
+ return 'hello'
+ })`
+
+ const resolveIdMock = vi.fn(async (id: string) => id)
+
+ const compiler = new StartCompiler({
+ env: 'client',
+ ...getDefaultTestOptions('client'),
+ loadModule: async (id) => {
+ if (id === './factory') {
+ compiler.ingestModule({
+ code: `
+ export { createServerFn as createFooServerFn } from '@tanstack/react-start'
+ `,
+ id: './factory',
+ })
+ }
+ },
+ lookupKinds: new Set(['ServerFn']),
+ lookupConfigurations: [
+ {
+ libName: '@tanstack/react-start',
+ rootExport: 'createServerFn',
+ kind: 'Root',
+ },
+ ],
+ getKnownServerFns: () => ({}),
+ resolveId: resolveIdMock,
+ mode: 'build',
+ })
+
+ const result = await compiler.compile({
+ code,
+ id: '/test/src/test.ts',
+ })
+
+ expect(result).not.toBeNull()
+ expect(result!.code).toContain('createClientRpc("get-user")')
+ })
+
test('should resolve export-star re-export chains of createServerFn', async () => {
const code = `
import { createFooServerFn } from './factory'
@@ -571,20 +822,286 @@ describe('createServerFn compiles correctly', async () => {
)
})
- test('reuses deduped custom IDs across compiler instances', async () => {
- const serverFnsById: Record<
- string,
- {
- functionName: string
- functionId: string
- extractedFilename: string
- filename: string
- isClientReferenced?: boolean
- }
- > = {}
+ test('dedupes generated custom IDs across compiler instances', async () => {
+ const compiler = new StartCompiler({
+ env: 'server',
+ ...getDefaultTestOptions('server'),
+ mode: 'build',
+ loadModule: async () => {},
+ lookupKinds: new Set(['ServerFn']),
+ lookupConfigurations: [
+ {
+ libName: '@tanstack/react-start',
+ rootExport: 'createServerFn',
+ kind: 'Root',
+ },
+ ],
+ resolveId: async (id) => id,
+ generateFunctionId: () => 'constant_id',
+ getKnownServerFns: () => ({}),
+ })
- function createCompiler() {
- return new StartCompiler({
+ await compiler.compile({
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+ export const greetUser = createServerFn().handler(async () => 'first')
+ `,
+ id: '/test/src/submit-post-formdata.tsx',
+ })
+
+ const secondResult = await compiler.compile({
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+ export const greetUser = createServerFn().handler(async () => 'second')
+ `,
+ id: '/test/src/formdata-redirect/index.tsx',
+ })
+
+ expect(secondResult!.code).toContain('createSsrRpc("constant_id_1")')
+ })
+
+ test('dedupes generated IDs when known server fn IDs collide', async () => {
+ const knownServerFns = {
+ knownFn: {
+ functionId: 'constant_id',
+ filename: '/test/src/known-fn.tsx',
+ extractedFilename: '/test/src/known-fn.tsx',
+ functionName: 'knownFn',
+ },
+ }
+
+ const compiler = new StartCompiler({
+ env: 'server',
+ ...getDefaultTestOptions('server'),
+ mode: 'build',
+ loadModule: async () => {},
+ lookupKinds: new Set(['ServerFn']),
+ lookupConfigurations: [
+ {
+ libName: '@tanstack/react-start',
+ rootExport: 'createServerFn',
+ kind: 'Root',
+ },
+ ],
+ resolveId: async (id) => id,
+ generateFunctionId: () => 'constant_id',
+ getKnownServerFns: () => knownServerFns,
+ })
+
+ const result = await compiler.compile({
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+ export const greetUser = createServerFn().handler(async () => 'next')
+ `,
+ id: '/test/src/new-fn.tsx',
+ })
+
+ expect(result!.code).toContain('createSsrRpc("constant_id_1")')
+ })
+
+ test('reuses canonical known server fn IDs without suffixing', async () => {
+ const knownServerFns = {
+ knownFn: {
+ functionId: 'constant_id',
+ filename: '/test/src/new-fn.tsx',
+ extractedFilename: '/test/src/new-fn.tsx?tss-serverfn-split',
+ functionName: 'greetUser_createServerFn_handler',
+ },
+ }
+
+ const compiler = new StartCompiler({
+ env: 'server',
+ ...getDefaultTestOptions('server'),
+ mode: 'build',
+ loadModule: async () => {},
+ lookupKinds: new Set(['ServerFn']),
+ lookupConfigurations: [
+ {
+ libName: '@tanstack/react-start',
+ rootExport: 'createServerFn',
+ kind: 'Root',
+ },
+ ],
+ resolveId: async (id) => id,
+ generateFunctionId: () => 'constant_id',
+ getKnownServerFns: () => knownServerFns,
+ })
+
+ const result = await compiler.compile({
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+ export const greetUser = createServerFn().handler(async () => 'next')
+ `,
+ id: '/test/src/new-fn.tsx',
+ })
+
+ expect(result!.code).toContain('createSsrRpc("constant_id")')
+ expect(result!.code).not.toContain('createSsrRpc("constant_id_1")')
+ })
+
+ test('rejects a generated ID that collides with a manual ID', async () => {
+ const compiler = new StartCompiler({
+ env: 'server',
+ ...getDefaultTestOptions('server'),
+ mode: 'build',
+ loadModule: async () => {},
+ lookupKinds: new Set(['ServerFn']),
+ lookupConfigurations: [
+ {
+ libName: '@tanstack/react-start',
+ rootExport: 'createServerFn',
+ kind: 'Root',
+ },
+ ],
+ resolveId: async (id) => id,
+ generateFunctionId: () => 'get-user',
+ getKnownServerFns: () => ({}),
+ })
+
+ const manualResult = await compiler.compile({
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+ export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'manual')
+ `,
+ id: '/test/src/manual.tsx',
+ })
+
+ expect(manualResult!.code).toContain('createSsrRpc("get-user")')
+
+ await expect(
+ compiler.compile({
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+ export const getUser = createServerFn().handler(async () => 'generated')
+ `,
+ id: '/test/src/generated.tsx',
+ }),
+ ).rejects.toThrow('Duplicate server function id: get-user')
+ })
+
+ test.each([
+ {
+ name: 'in the same file',
+ compileDuplicate: (compiler: StartCompiler) =>
+ compiler.compile({
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+ export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'first')
+ export const getOtherUser = createServerFn({ id: 'get-user' }).handler(async () => 'second')
+ `,
+ id: '/test/src/duplicates.tsx',
+ }),
+ },
+ {
+ name: 'across files',
+ compileDuplicate: async (compiler: StartCompiler) => {
+ await compiler.compile({
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+ export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'first')
+ `,
+ id: '/test/src/get-user.tsx',
+ })
+
+ return compiler.compile({
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+ export const getOtherUser = createServerFn({ id: 'get-user' }).handler(async () => 'second')
+ `,
+ id: '/test/src/get-other-user.tsx',
+ })
+ },
+ },
+ ])('rejects duplicate manual IDs $name', async ({ compileDuplicate }) => {
+ const compiler = new StartCompiler({
+ env: 'server',
+ ...getDefaultTestOptions('server'),
+ mode: 'build',
+ loadModule: async () => {},
+ lookupKinds: new Set(['ServerFn']),
+ lookupConfigurations: [
+ {
+ libName: '@tanstack/react-start',
+ rootExport: 'createServerFn',
+ kind: 'Root',
+ },
+ ],
+ resolveId: async (id) => id,
+ getKnownServerFns: () => ({}),
+ })
+
+ await expect(compileDuplicate(compiler)).rejects.toThrow(
+ 'Duplicate server function id: get-user',
+ )
+ })
+
+ test('rejects a manual ID that collides with a generated ID', async () => {
+ const compiler = new StartCompiler({
+ env: 'server',
+ ...getDefaultTestOptions('server'),
+ mode: 'build',
+ loadModule: async () => {},
+ lookupKinds: new Set(['ServerFn']),
+ lookupConfigurations: [
+ {
+ libName: '@tanstack/react-start',
+ rootExport: 'createServerFn',
+ kind: 'Root',
+ },
+ ],
+ resolveId: async (id) => id,
+ generateFunctionId: () => 'get-user',
+ getKnownServerFns: () => ({}),
+ })
+
+ await compiler.compile({
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+ export const getUser = createServerFn().handler(async () => 'generated')
+ `,
+ id: '/test/src/generated.tsx',
+ })
+
+ await expect(
+ compiler.compile({
+ code: `
+ import { createServerFn } from '@tanstack/react-start'
+ export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'manual')
+ `,
+ id: '/test/src/manual.tsx',
+ }),
+ ).rejects.toThrow('Duplicate server function id: get-user')
+ })
+
+ test.each([
+ {
+ name: 'reuses matching known metadata',
+ knownServerFns: {
+ 'get-user': {
+ functionId: 'get-user',
+ filename: '/test/src/get-user.tsx',
+ extractedFilename: '/test/src/get-user.tsx?tss-serverfn-split',
+ functionName: 'getUser_createServerFn_handler',
+ },
+ },
+ expectedCode: 'createSsrRpc("get-user")',
+ },
+ {
+ name: 'rejects mismatched known metadata',
+ knownServerFns: {
+ 'get-user': {
+ functionId: 'get-user',
+ filename: '/test/src/other-user.tsx',
+ extractedFilename: '/test/src/other-user.tsx?tss-serverfn-split',
+ functionName: 'getOtherUser_createServerFn_handler',
+ },
+ },
+ expectedError: 'Duplicate server function id: get-user',
+ },
+ ])(
+ '$name for manual ID',
+ async ({ knownServerFns, expectedCode, expectedError }) => {
+ const compiler = new StartCompiler({
env: 'server',
...getDefaultTestOptions('server'),
mode: 'build',
@@ -598,59 +1115,134 @@ describe('createServerFn compiles correctly', async () => {
},
],
resolveId: async (id) => id,
- generateFunctionId: ({ functionName }) =>
- functionName === 'greetUser_createServerFn_handler'
- ? 'constant_id'
- : undefined,
- getKnownServerFns: () => serverFnsById,
- onServerFnsById: (discovered) => {
- Object.assign(serverFnsById, discovered)
- },
+ getKnownServerFns: () => knownServerFns,
})
- }
- const firstCompiler = createCompiler()
- await firstCompiler.compile({
- code: `
+ const compileResult = compiler.compile({
+ code: `
import { createServerFn } from '@tanstack/react-start'
- export const greetUser = createServerFn().handler(async () => 'first')
+ export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'manual')
`,
- id: '/test/src/submit-post-formdata.tsx',
+ id: '/test/src/get-user.tsx',
+ })
+
+ if (expectedError) {
+ await expect(compileResult).rejects.toThrow(expectedError)
+ } else {
+ const result = await compileResult
+ expect(result!.code).toContain(expectedCode)
+ }
+ },
+ )
+
+ test('releases manual ids after module invalidation', async () => {
+ const compiler = new StartCompiler({
+ env: 'server',
+ ...getDefaultTestOptions('server'),
+ mode: 'build',
+ loadModule: async () => {},
+ lookupKinds: new Set(['ServerFn']),
+ lookupConfigurations: [
+ {
+ libName: '@tanstack/react-start',
+ rootExport: 'createServerFn',
+ kind: 'Root',
+ },
+ ],
+ resolveId: async (id) => id,
+ getKnownServerFns: () => ({}),
})
- await firstCompiler.compile({
- code: `
- import { createServerFn } from '@tanstack/react-start'
- export const greetUser = createServerFn().handler(async () => 'second')
- `,
- id: '/test/src/formdata-redirect/index.tsx',
+ const source = `
+ import { createServerFn } from '@tanstack/react-start'
+ export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'first')
+ `
+
+ const firstResult = await compiler.compile({
+ code: source,
+ id: '/test/src/submit-post-formdata.tsx',
})
+ expect(firstResult!.code).toContain('createSsrRpc("get-user")')
expect(
- Object.values(serverFnsById)
- .map((serverFn) => serverFn.functionId)
- .sort(),
- ).toEqual(['constant_id', 'constant_id_1'])
+ compiler.invalidateModule('/test/src/submit-post-formdata.tsx'),
+ ).toBe(true)
- const secondCompiler = createCompiler()
- const firstResult = await secondCompiler.compile({
- code: `
- import { createServerFn } from '@tanstack/react-start'
- export const greetUser = createServerFn().handler(async () => 'first')
- `,
+ const secondResult = await compiler.compile({
+ code: source,
id: '/test/src/submit-post-formdata.tsx',
})
- const secondResult = await secondCompiler.compile({
+ expect(secondResult!.code).toContain('createSsrRpc("get-user")')
+ })
+
+ test('reuses a manual id across caller and provider compiles in one compiler', async () => {
+ const compiler = new StartCompiler({
+ env: 'server',
+ ...getDefaultTestOptions('server'),
+ mode: 'build',
+ loadModule: async () => {},
+ lookupKinds: new Set(['ServerFn']),
+ lookupConfigurations: [
+ {
+ libName: '@tanstack/react-start',
+ rootExport: 'createServerFn',
+ kind: 'Root',
+ },
+ ],
+ resolveId: async (id) => id,
+ getKnownServerFns: () => ({}),
+ })
+
+ const code = `
+ import { createServerFn } from '@tanstack/react-start'
+ export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'first')
+ `
+
+ const callerResult = await compiler.compile({
+ code,
+ id: '/test/src/example.tsx',
+ })
+
+ const providerResult = await compiler.compile({
+ code,
+ id: '/test/src/example.tsx?tss-serverfn-split',
+ })
+
+ expect(callerResult!.code).toContain('createSsrRpc("get-user")')
+ expect(providerResult!.code).toContain('id: "get-user"')
+ })
+
+ test('keeps dev manual ids encoded for runtime lookup', async () => {
+ const compiler = new StartCompiler({
+ env: 'client',
+ ...getDefaultTestOptions('client'),
+ mode: 'dev',
+ loadModule: async () => {},
+ lookupKinds: new Set(['ServerFn']),
+ lookupConfigurations: [
+ {
+ libName: '@tanstack/react-start',
+ rootExport: 'createServerFn',
+ kind: 'Root',
+ },
+ ],
+ resolveId: async (id) => id,
+ getKnownServerFns: () => ({}),
+ devServerFnModuleSpecifierEncoder:
+ createViteDevServerFnModuleSpecifierEncoder('/test'),
+ })
+
+ const result = await compiler.compile({
code: `
import { createServerFn } from '@tanstack/react-start'
- export const greetUser = createServerFn().handler(async () => 'second')
+ export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'first')
`,
- id: '/test/src/formdata-redirect/index.tsx',
+ id: '/test/src/example.tsx',
})
- expect(firstResult!.code).toContain('createSsrRpc("constant_id"')
- expect(secondResult!.code).toContain('createSsrRpc("constant_id_1"')
+ expect(result!.code).toContain('createClientRpc("')
+ expect(result!.code).not.toContain('createClientRpc("get-user")')
})
test('should resolve createServerFn from the same binding as a known root export', async () => {
diff --git a/packages/start-plugin-core/tests/start-compiler-host.test.ts b/packages/start-plugin-core/tests/start-compiler-host.test.ts
new file mode 100644
index 0000000000..4a23d17cc8
--- /dev/null
+++ b/packages/start-plugin-core/tests/start-compiler-host.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, test } from 'vitest'
+import { mergeServerFnsById } from '../src/start-compiler/host'
+import type { ServerFn } from '../src/start-compiler/types'
+
+describe('mergeServerFnsById', () => {
+ test('replaces a stale ID for the same server function', () => {
+ const serverFnsById: Record = {
+ 'old-id': {
+ functionId: 'old-id',
+ functionName: 'getUser_createServerFn_handler',
+ filename: '/src/users.ts',
+ extractedFilename: '/src/users.ts?tss-serverfn-split',
+ },
+ }
+
+ mergeServerFnsById(serverFnsById, {
+ 'new-id': {
+ functionId: 'new-id',
+ functionName: 'getUser_createServerFn_handler',
+ filename: '/src/users.ts',
+ extractedFilename: '/src/users.ts?tss-serverfn-split',
+ },
+ })
+
+ expect(serverFnsById).toEqual({
+ 'new-id': {
+ functionId: 'new-id',
+ functionName: 'getUser_createServerFn_handler',
+ filename: '/src/users.ts',
+ extractedFilename: '/src/users.ts?tss-serverfn-split',
+ },
+ })
+ })
+
+ test('merges client references reported by multiple environments', () => {
+ const serverFnsById: Record = {
+ 'get-user': {
+ functionId: 'get-user',
+ functionName: 'getUser_createServerFn_handler',
+ filename: '/src/users.ts',
+ extractedFilename: '/src/users.ts?tss-serverfn-split',
+ isClientReferenced: false,
+ },
+ }
+
+ mergeServerFnsById(serverFnsById, {
+ 'get-user': {
+ functionId: 'get-user',
+ functionName: 'getUser_createServerFn_handler',
+ filename: '/src/users.ts',
+ extractedFilename: '/src/users.ts?tss-serverfn-split',
+ isClientReferenced: true,
+ },
+ })
+
+ expect(serverFnsById['get-user']?.isClientReferenced).toBe(true)
+ })
+})