From 436d3731027e9b534db5eab38db9a3d3566ab3c7 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Tue, 23 Jun 2026 12:22:10 -0700 Subject: [PATCH 1/9] add support for setting IDs in createServerFn before handler and middleware --- .../start-client-core/src/createServerFn.ts | 149 +++++++++++++++++- .../src/tests/createServerFn.test-d.ts | 60 +++++++ 2 files changed, 207 insertions(+), 2 deletions(-) diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index bfa48039cf..a8e21230fc 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -97,6 +97,13 @@ export const createServerFn: CreateServerFn = (options, __opts) => { const res: ServerFnBuilder = { options: resolvedOptions, + id: (id) => { + const newOptions = { + ...resolvedOptions, + id, + } + return createServerFn(undefined, newOptions) as any + }, middleware: (middleware) => { // multiple calls to `middleware()` merge the middlewares with the previously supplied ones // this is primarily useful for letting users create their own abstractions on top of `createServerFn` @@ -216,8 +223,10 @@ export const createServerFn: CreateServerFn = (options, __opts) => { }, } as ServerFnBuilder const fun = (options?: ServerFnOptions) => { + const inheritedOptions = { ...resolvedOptions } + delete inheritedOptions.id const newOptions = { - ...resolvedOptions, + ...inheritedOptions, ...options, } return createServerFn(undefined, newOptions) @@ -500,6 +509,7 @@ export type ServerFnBaseOptions< TInputValidator = unknown, TStrict extends ServerFnStrict = true, > = { + id?: string method: TMethod strict?: TStrict middleware?: Constrain< @@ -586,6 +596,138 @@ export type AppendMiddlewares = : TMiddlewares : TNewMiddlewares +export interface ServerFnId< + TRegister, + TMethod extends Method, + TMiddlewares, + TInputValidator, + TStrict extends ServerFnStrict, +> { + id: (id: string) => ServerFnAfterId< + TRegister, + TMethod, + TMiddlewares, + TInputValidator, + TStrict + > +} + +export interface ServerFnAfterId< + TRegister, + TMethod extends Method, + TMiddlewares, + TInputValidator, + TStrict extends ServerFnStrict, +> + extends + ServerFnWithTypes< + TRegister, + TMethod, + TMiddlewares, + TInputValidator, + undefined, + TStrict + >, + ServerFnMiddlewareAfterId< + TRegister, + TMethod, + TMiddlewares, + TInputValidator, + TStrict + >, + ServerFnValidatorAfterId, + ServerFnHandler< + TRegister, + TMethod, + TMiddlewares, + TInputValidator, + TStrict + > {} + +export interface ServerFnMiddlewareAfterId< + TRegister, + TMethod extends Method, + TMiddlewares, + TInputValidator, + TStrict extends ServerFnStrict, +> { + middleware: ( + middlewares: Constrain< + TNewMiddlewares, + ReadonlyArray + >, + ) => ServerFnAfterId< + TRegister, + TMethod, + AppendMiddlewares, + TInputValidator, + TStrict + > +} + +export type ValidatorFnAfterId< + TRegister, + TMethod extends Method, + TMiddlewares, + TStrict extends ServerFnStrict, +> = ( + validator: ConstrainValidator, +) => ServerFnAfterIdAfterValidator< + TRegister, + TMethod, + TMiddlewares, + TInputValidator, + TStrict +> + +export interface ServerFnValidatorAfterId< + TRegister, + TMethod extends Method, + TMiddlewares, + TStrict extends ServerFnStrict, +> { + validator: ValidatorFnAfterId + // TODO remove upon stable + /** @deprecated Use `validator` instead. */ + inputValidator: ValidatorFnAfterId< + TRegister, + TMethod, + TMiddlewares, + TStrict + > +} + +export interface ServerFnAfterIdAfterValidator< + TRegister, + TMethod extends Method, + TMiddlewares, + TInputValidator, + TStrict extends ServerFnStrict, +> + extends + ServerFnWithTypes< + TRegister, + TMethod, + TMiddlewares, + TInputValidator, + undefined, + TStrict + >, + ServerFnMiddlewareAfterId< + TRegister, + TMethod, + TMiddlewares, + TInputValidator, + TStrict + >, + ServerFnHandler< + TRegister, + TMethod, + TMiddlewares, + TInputValidator, + TStrict + > {} + export interface ServerFnMiddleware< TRegister, TMethod extends Method, @@ -625,6 +767,7 @@ export interface ServerFnAfterMiddleware< >, ServerFnMiddleware, ServerFnValidator, + ServerFnId, ServerFnHandler< TRegister, TMethod, @@ -702,7 +845,8 @@ export interface ServerFnAfterValidator< TMiddlewares, TInputValidator, TStrict - > {} + >, + ServerFnId {} export interface ServerFnAfterTyper< TRegister, @@ -764,6 +908,7 @@ export interface ServerFnBuilder< >, ServerFnMiddleware, ServerFnValidator, + ServerFnId, ServerFnHandler { < TNewMethod extends Method = TMethod, 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..a6dbe140d8 100644 --- a/packages/start-client-core/src/tests/createServerFn.test-d.ts +++ b/packages/start-client-core/src/tests/createServerFn.test-d.ts @@ -578,6 +578,66 @@ test('createServerFn strict false factory preserves strictness', () => { >() }) +test('createServerFn id can be set before handler', () => { + const builder = createServerFn().id('get-user') + + expectTypeOf(builder).toHaveProperty('handler') + expectTypeOf(builder).toHaveProperty('middleware') + expectTypeOf(builder).toHaveProperty('validator') + expectTypeOf(builder).not.toHaveProperty('id') + + const fn = builder.handler(() => ({})) + + expectTypeOf(fn).not.toHaveProperty('id') + + const builderAfterMiddleware = createServerFn().id('list-users').middleware([]) + + expectTypeOf(builderAfterMiddleware).toHaveProperty('handler') + expectTypeOf(builderAfterMiddleware).not.toHaveProperty('id') + + const builderWithIdAfterMiddleware = createServerFn() + .middleware([]) + .id('update-user') + + expectTypeOf(builderWithIdAfterMiddleware).toHaveProperty('handler') + expectTypeOf(builderWithIdAfterMiddleware).not.toHaveProperty('id') + + const builderWithIdAfterValidator = createServerFn() + .validator((input: { id: string }) => input) + .id('delete-user') + + expectTypeOf(builderWithIdAfterValidator).toHaveProperty('handler') + expectTypeOf(builderWithIdAfterValidator).not.toHaveProperty('id') +}) + +test('createServerFn factory children set their own ids', () => { + const middleware = createMiddleware({ type: 'function' }).server( + ({ next }) => { + return next({ context: { user: 'alice' } as const }) + }, + ) + + const createAuthedServerFn = createServerFn({ method: 'POST' }).middleware([ + middleware, + ]) + + const builder = createAuthedServerFn().id('save-user') + + expectTypeOf(builder).toHaveProperty('handler') + expectTypeOf(builder).not.toHaveProperty('id') + + builder.handler((options) => { + expectTypeOf(options).toEqualTypeOf<{ + context: { + readonly user: 'alice' + } + data: undefined + method: 'POST' + serverFnMeta: ServerFnMeta + }>() + }) +}) + test('createServerFn strict input false can validate function', () => { const fn = createServerFn({ strict: { input: false } }) .validator((input: { func: () => 'input' }) => ({ From 2cca4b1e350a6ba381ab62c7edf29e96d7f7a15c Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Tue, 23 Jun 2026 12:46:52 -0700 Subject: [PATCH 2/9] add support for manual server function IDs with validation --- .../src/start-compiler/compiler.ts | 5 + .../start-compiler/handleCreateServerFn.ts | 65 +++++++- .../src/start-compiler/types.ts | 1 + .../createServerFn/createServerFn.test.ts | 150 ++++++++++++++++++ 4 files changed, 214 insertions(+), 7 deletions(-) diff --git a/packages/start-plugin-core/src/start-compiler/compiler.ts b/packages/start-plugin-core/src/start-compiler/compiler.ts index d6b36fe6e8..7860fa1aa7 100644 --- a/packages/start-plugin-core/src/start-compiler/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler/compiler.ts @@ -1295,6 +1295,7 @@ export class StartCompiler { // Collect method chain paths by walking DOWN from root through the chain const methodChain: MethodChainPaths = { + id: null, middleware: null, validator: null, // TODO remove upon stable @@ -1319,6 +1320,10 @@ export class StartCompiler { if (t.isIdentifier(callee.property)) { const name = callee.property.name as keyof MethodChainPaths if (name in methodChain) { + if (kind === 'ServerFn' && name === 'id' && methodChain.id) { + throw new Error('createServerFn().id() can only be called once') + } + // Get first argument path const args = currentPath.get('arguments') const firstArgPath = diff --git a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts index 3d4a8a2542..3d53a4cf74 100644 --- a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts +++ b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts @@ -13,6 +13,7 @@ import type { import type { CompileStartFrameworkOptions } from '../types' const TSS_SERVERFN_SPLIT_PARAM = 'tss-serverfn-split' +const MANUAL_SERVER_FN_ID_REGEX = /^[A-Za-z0-9_-]+$/ const providerHmrAcceptTemplate = babel.template.statements( ` @@ -155,6 +156,34 @@ function buildServerFnMetaObject( ]) } +function getManualServerFnId( + context: CompilationContext, + id: MethodCallInfo | null, +): string | undefined { + if (!id) { + return undefined + } + + const idArg = id.callPath.node.arguments[0] + if (!idArg || !t.isStringLiteral(idArg)) { + throw codeFrameError( + context.code, + id.callPath.node.loc!, + 'createServerFn().id() must be called with a string literal', + ) + } + + if (!MANUAL_SERVER_FN_ID_REGEX.test(idArg.value)) { + throw codeFrameError( + context.code, + idArg.loc!, + 'createServerFn().id() must be a non-empty URL-safe path segment using only letters, numbers, "_", or "-"', + ) + } + + return idArg.value +} + /** * Generates the RPC stub expression for provider files. * Uses pre-compiled template for performance. @@ -241,7 +270,7 @@ export function handleCreateServerFn( for (const candidate of candidates) { const { path: candidatePath, methodChain } = candidate - const { validator, inputValidator, handler } = methodChain + const { id, validator, inputValidator, handler } = methodChain const candidateVariableDeclarator = getVariableDeclaratorForExpressionPath( candidatePath as babel.NodePath, @@ -271,12 +300,30 @@ export function handleCreateServerFn( } functionNameSet.add(functionName) - // Generate function ID using pre-computed relative filename - const functionId = context.generateFunctionId({ - filename: relativeFilename, - functionName, - extractedFilename, - }) + const manualFunctionId = getManualServerFnId(context, id) + const functionId = + manualFunctionId ?? + context.generateFunctionId({ + filename: relativeFilename, + functionName, + extractedFilename, + }) + + if (manualFunctionId && !isProviderFile) { + const existingFn = + serverFnsById[manualFunctionId] ?? knownFns[manualFunctionId] + const isSameKnownFn = + existingFn?.functionName === functionName && + existingFn.extractedFilename === extractedFilename + + if (existingFn && !isSameKnownFn) { + throw codeFrameError( + context.code, + id!.callPath.node.loc!, + `Duplicate manual server function id: ${manualFunctionId}`, + ) + } + } // Check if this function was already discovered by the client build const knownFn = knownFns[functionId] @@ -294,6 +341,10 @@ export function handleCreateServerFn( const canonicalExtractedFilename = knownFn?.extractedFilename ?? extractedFilename + if (id) { + stripMethodCall(id.callPath) + } + // TODO remove upon stable if (inputValidator) { warnInputValidatorDeprecation(context, inputValidator) diff --git a/packages/start-plugin-core/src/start-compiler/types.ts b/packages/start-plugin-core/src/start-compiler/types.ts index 1371e7a973..b1dd580467 100644 --- a/packages/start-plugin-core/src/start-compiler/types.ts +++ b/packages/start-plugin-core/src/start-compiler/types.ts @@ -47,6 +47,7 @@ export interface MethodCallInfo { * This avoids needing to traverse the AST again in handlers. */ export interface MethodChainPaths { + id: MethodCallInfo | null middleware: MethodCallInfo | null validator: MethodCallInfo | null // TODO remove upon stable diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index c4009657c4..64dd6bad68 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -118,6 +118,156 @@ describe('createServerFn compiles correctly', async () => { `) }) + test('should compile server functions with manual ids', async () => { + const code = ` + import { createServerFn } from '@tanstack/react-start' + const myServerFn = createServerFn() + .middleware([]) + .id('get-user') + .handler(() => 'server')` + + const compiledResultClient = await compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }) + + const compiledResultServerCaller = await compile({ + code, + env: 'server', + isProviderFile: false, + mode: 'build', + }) + + const compiledResultServerProvider = await compile({ + code, + env: 'server', + isProviderFile: true, + mode: 'build', + }) + + expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') + expect(compiledResultClient!.code).not.toContain('.id(') + + expect(compiledResultServerCaller!.code).toContain( + 'createSsrRpc("get-user")', + ) + expect(compiledResultServerCaller!.code).not.toContain('.id(') + + expect(compiledResultServerProvider!.code).toContain('id: "get-user"') + expect(compiledResultServerProvider!.code).not.toContain('.id(') + }) + + test('should register server functions by manual ids', async () => { + const serverFnsById: Record< + string, + { + functionName: string + functionId: string + extractedFilename: string + filename: string + isClientReferenced?: boolean + } + > = {} + + const compiler = new StartCompiler({ + env: 'client', + ...getDefaultTestOptions('client'), + mode: 'build', + loadModule: async () => {}, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + resolveId: async (id) => id, + getKnownServerFns: () => ({}), + onServerFnsById: (discovered) => { + Object.assign(serverFnsById, discovered) + }, + }) + + await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + const myServerFn = createServerFn() + .id('get-user') + .handler(() => 'server') + `, + id: '/test/src/test.ts', + }) + + expect(serverFnsById['get-user']).toMatchObject({ + functionId: 'get-user', + functionName: 'myServerFn_createServerFn_handler', + filename: '/test/src/test.ts', + isClientReferenced: true, + }) + }) + + test('should reject dynamic manual server function ids', async () => { + const code = ` + import { createServerFn } from '@tanstack/react-start' + const id = 'get-user' + const myServerFn = createServerFn() + .id(id) + .handler(() => 'server')` + + await expect( + compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }), + ).rejects.toThrow( + 'createServerFn().id() must be called with a string literal', + ) + }) + + test('should reject invalid manual server function ids', async () => { + const code = ` + import { createServerFn } from '@tanstack/react-start' + const myServerFn = createServerFn() + .id('users/get') + .handler(() => 'server')` + + await expect( + compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }), + ).rejects.toThrow( + 'createServerFn().id() must be a non-empty URL-safe path segment', + ) + }) + + test('should reject duplicate manual server function ids', async () => { + const code = ` + import { createServerFn } from '@tanstack/react-start' + const getUser = createServerFn() + .id('user-action') + .handler(() => 'get') + const updateUser = createServerFn() + .id('user-action') + .handler(() => 'update')` + + await expect( + compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }), + ).rejects.toThrow('Duplicate manual server function id: user-action') + }) + // TODO remove upon stable test('should warn for deprecated inputValidator method', async () => { const warn = vi.fn() From 994c55732e03fc60e4ebd75adb7f7e43660386d8 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Tue, 23 Jun 2026 13:22:30 -0700 Subject: [PATCH 3/9] add getViteDevServerFnImport function and related tests for server function ID resolution --- .../start-compiler-plugin/module-specifier.ts | 37 +++++++++++++++- .../src/vite/start-compiler-plugin/plugin.ts | 13 +++--- .../tests/vite/start-compiler-utils.test.ts | 42 +++++++++++++++++++ 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/packages/start-plugin-core/src/vite/start-compiler-plugin/module-specifier.ts b/packages/start-plugin-core/src/vite/start-compiler-plugin/module-specifier.ts index 1324894f68..07f281fe62 100644 --- a/packages/start-plugin-core/src/vite/start-compiler-plugin/module-specifier.ts +++ b/packages/start-plugin-core/src/vite/start-compiler-plugin/module-specifier.ts @@ -1,4 +1,12 @@ -import type { DevServerFnModuleSpecifierEncoder } from '../../start-compiler/types' +import type { + DevServerFnModuleSpecifierEncoder, + ServerFn, +} from '../../start-compiler/types' + +export type ViteDevServerFnImport = { + file: string + export: string +} export function createViteDevServerFnModuleSpecifierEncoder( root: string, @@ -39,3 +47,30 @@ export function decodeViteDevServerModuleSpecifier( return queryIndex === -1 ? sourceFile : sourceFile.slice(0, queryIndex) } + +export function getViteDevServerFnImport( + id: string, + serverFnsById: Record, +): ViteDevServerFnImport { + const registeredServerFn = serverFnsById[id] + if (registeredServerFn) { + return { + file: registeredServerFn.extractedFilename, + export: registeredServerFn.functionName, + } + } + + try { + const decoded = JSON.parse(Buffer.from(id, 'base64url').toString('utf8')) + if (typeof decoded.file === 'string' && typeof decoded.export === 'string') { + return { + file: decoded.file, + export: decoded.export, + } + } + } catch { + // Manual IDs are not encoded module references; fall through to registry lookup. + } + + throw new Error(`Invalid server function ID: ${id}`) +} diff --git a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts index 6309b76eb2..1efe2f0d77 100644 --- a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts @@ -24,6 +24,7 @@ import { resolveViteId } from '../../utils' import { createViteDevServerFnModuleSpecifierEncoder, decodeViteDevServerModuleSpecifier, + getViteDevServerFnImport, } from './module-specifier' import { mergeHotUpdateModules } from './hot-update' import type { @@ -189,9 +190,7 @@ function getDevServerFnValidatorModule(): string { return ` export async function getServerFnById(id, _access) { const validateIdImport = ${JSON.stringify(validateServerFnIdVirtualModule)} + '?id=' + id - await import(/* @vite-ignore */ '/@id/__x00__' + validateIdImport) - const decoded = Buffer.from(id, 'base64url').toString('utf8') - const devServerFn = JSON.parse(decoded) + const { devServerFn } = await import(/* @vite-ignore */ '/@id/__x00__' + validateIdImport) const mod = await import(/* @vite-ignore */ devServerFn.file) return mod[devServerFn.export] } @@ -551,7 +550,9 @@ export function startCompilerPlugin( const parsed = parseIdQuery(id) const fnId = parsed.query.id if (fnId && serverFnsById[fnId]) { - return `export {}` + return `export const devServerFn = ${JSON.stringify( + getViteDevServerFnImport(fnId, serverFnsById), + )}` } // ID not yet registered — the source file may not have been @@ -592,7 +593,9 @@ export function startCompilerPlugin( // Re-check after lazy compilation if (serverFnsById[fnId]) { - return `export {}` + return `export const devServerFn = ${JSON.stringify( + getViteDevServerFnImport(fnId, serverFnsById), + )}` } } } diff --git a/packages/start-plugin-core/tests/vite/start-compiler-utils.test.ts b/packages/start-plugin-core/tests/vite/start-compiler-utils.test.ts index bf028a483e..56aa7ff922 100644 --- a/packages/start-plugin-core/tests/vite/start-compiler-utils.test.ts +++ b/packages/start-plugin-core/tests/vite/start-compiler-utils.test.ts @@ -2,11 +2,53 @@ import { describe, expect, test } from 'vitest' import { createViteDevServerFnModuleSpecifierEncoder, decodeViteDevServerModuleSpecifier, + getViteDevServerFnImport, } from '../../src/vite/start-compiler-plugin/module-specifier' import { mergeHotUpdateModules } from '../../src/vite/start-compiler-plugin/hot-update' import type { EnvironmentModuleNode } from 'vite' describe('Vite dev server module specifiers', () => { + test('resolves registered server function ids before decoding them', () => { + const generatedIdShapedManualId = Buffer.from( + JSON.stringify({ + file: '/src/routes/wrong.tsx?tss-serverfn-split', + export: 'wrong_createServerFn_handler', + }), + 'utf8', + ).toString('base64url') + + expect( + getViteDevServerFnImport(generatedIdShapedManualId, { + [generatedIdShapedManualId]: { + functionName: 'getUser_createServerFn_handler', + functionId: generatedIdShapedManualId, + filename: '/repo/app/src/routes/users.tsx', + extractedFilename: + '/repo/app/src/routes/users.tsx?tss-serverfn-split', + isClientReferenced: true, + }, + }), + ).toEqual({ + file: '/repo/app/src/routes/users.tsx?tss-serverfn-split', + export: 'getUser_createServerFn_handler', + }) + }) + + test('falls back to decoding generated dev server function ids', () => { + const encodedId = Buffer.from( + JSON.stringify({ + file: '/src/routes/users.tsx?tss-serverfn-split', + export: 'getUser_createServerFn_handler', + }), + 'utf8', + ).toString('base64url') + + expect(getViteDevServerFnImport(encodedId, {})).toEqual({ + file: '/src/routes/users.tsx?tss-serverfn-split', + export: 'getUser_createServerFn_handler', + }) + }) + test('encodes app files as root-relative dev server paths', () => { const encode = createViteDevServerFnModuleSpecifierEncoder('/repo/app') From 39c64e5ff5d1b906c6fd2e64e5c64d1ff522659d Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Tue, 23 Jun 2026 13:42:46 -0700 Subject: [PATCH 4/9] add function ID reservation and deduplication for manual server function IDs --- .../src/start-compiler/compiler.ts | 10 ++++ .../start-compiler/handleCreateServerFn.ts | 8 +++ .../src/start-compiler/types.ts | 2 + .../start-compiler-plugin/module-specifier.ts | 13 ++++- .../src/vite/start-compiler-plugin/plugin.ts | 12 +++- .../createServerFn/createServerFn.test.ts | 56 +++++++++++++++++++ .../tests/vite/start-compiler-utils.test.ts | 32 +++++++---- 7 files changed, 117 insertions(+), 16 deletions(-) diff --git a/packages/start-plugin-core/src/start-compiler/compiler.ts b/packages/start-plugin-core/src/start-compiler/compiler.ts index 7860fa1aa7..c8f7ee735a 100644 --- a/packages/start-plugin-core/src/start-compiler/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler/compiler.ts @@ -672,6 +672,15 @@ export class StartCompiler { return functionId } + private reserveFunctionId(functionId: string): boolean { + if (this.functionIds.has(functionId)) { + return false + } + + this.functionIds.add(functionId) + return true + } + private get mode(): 'dev' | 'build' { return this.options.mode ?? 'dev' } @@ -1373,6 +1382,7 @@ export class StartCompiler { warn: warnFn, generateFunctionId: (opts) => this.generateFunctionId(opts), + reserveFunctionId: (id) => this.reserveFunctionId(id), 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 3d53a4cf74..1b090b2a22 100644 --- a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts +++ b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts @@ -323,6 +323,14 @@ export function handleCreateServerFn( `Duplicate manual server function id: ${manualFunctionId}`, ) } + + if (!isSameKnownFn && !context.reserveFunctionId(manualFunctionId)) { + throw codeFrameError( + context.code, + id!.callPath.node.loc!, + `Duplicate manual server function id: ${manualFunctionId}`, + ) + } } // Check if this function was already discovered by the client build diff --git a/packages/start-plugin-core/src/start-compiler/types.ts b/packages/start-plugin-core/src/start-compiler/types.ts index b1dd580467..dbd6806c09 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 function ID so later generated IDs can deduplicate against it. */ + reserveFunctionId: (id: string) => boolean /** Get known server functions from previous builds (e.g., client build) */ getKnownServerFns: () => Record /** Module-level directives to add to extracted server function provider files. */ diff --git a/packages/start-plugin-core/src/vite/start-compiler-plugin/module-specifier.ts b/packages/start-plugin-core/src/vite/start-compiler-plugin/module-specifier.ts index 07f281fe62..f3f6f15871 100644 --- a/packages/start-plugin-core/src/vite/start-compiler-plugin/module-specifier.ts +++ b/packages/start-plugin-core/src/vite/start-compiler-plugin/module-specifier.ts @@ -51,25 +51,32 @@ export function decodeViteDevServerModuleSpecifier( export function getViteDevServerFnImport( id: string, serverFnsById: Record, + encodeModuleSpecifier: DevServerFnModuleSpecifierEncoder, ): ViteDevServerFnImport { const registeredServerFn = serverFnsById[id] if (registeredServerFn) { return { - file: registeredServerFn.extractedFilename, + file: encodeModuleSpecifier({ + extractedFilename: registeredServerFn.extractedFilename, + root: '', + }), export: registeredServerFn.functionName, } } try { const decoded = JSON.parse(Buffer.from(id, 'base64url').toString('utf8')) - if (typeof decoded.file === 'string' && typeof decoded.export === 'string') { + if ( + typeof decoded.file === 'string' && + typeof decoded.export === 'string' + ) { return { file: decoded.file, export: decoded.export, } } } catch { - // Manual IDs are not encoded module references; fall through to registry lookup. + // Manual IDs are not encoded module references. } throw new Error(`Invalid server function ID: ${id}`) diff --git a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts index 1efe2f0d77..967aea5463 100644 --- a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts @@ -551,7 +551,11 @@ export function startCompilerPlugin( const fnId = parsed.query.id if (fnId && serverFnsById[fnId]) { return `export const devServerFn = ${JSON.stringify( - getViteDevServerFnImport(fnId, serverFnsById), + getViteDevServerFnImport( + fnId, + serverFnsById, + createViteDevServerFnModuleSpecifierEncoder(root), + ), )}` } @@ -594,7 +598,11 @@ export function startCompilerPlugin( // Re-check after lazy compilation if (serverFnsById[fnId]) { return `export const devServerFn = ${JSON.stringify( - getViteDevServerFnImport(fnId, serverFnsById), + getViteDevServerFnImport( + fnId, + serverFnsById, + createViteDevServerFnModuleSpecifierEncoder(root), + ), )}` } } diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index 64dd6bad68..91a0235acd 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -268,6 +268,62 @@ describe('createServerFn compiles correctly', async () => { ).rejects.toThrow('Duplicate manual server function id: user-action') }) + test('should dedupe generated ids that collide with manual ids', async () => { + const serverFnsById: Record< + string, + { + functionName: string + functionId: string + extractedFilename: string + filename: string + isClientReferenced?: boolean + } + > = {} + + const compiler = new StartCompiler({ + env: 'client', + ...getDefaultTestOptions('client'), + mode: 'build', + loadModule: async () => {}, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + resolveId: async (id) => id, + generateFunctionId: ({ functionName }) => + functionName === 'generatedFn_createServerFn_handler' + ? 'user-action' + : undefined, + getKnownServerFns: () => ({}), + onServerFnsById: (discovered) => { + Object.assign(serverFnsById, discovered) + }, + }) + + const result = await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + const manualFn = createServerFn() + .id('user-action') + .handler(() => 'manual') + const generatedFn = createServerFn() + .handler(() => 'generated') + `, + id: '/test/src/test.ts', + }) + + expect(result!.code).toContain('createClientRpc("user-action")') + expect(result!.code).toContain('createClientRpc("user-action_1")') + expect(Object.keys(serverFnsById).sort()).toEqual([ + 'user-action', + 'user-action_1', + ]) + }) + // TODO remove upon stable test('should warn for deprecated inputValidator method', async () => { const warn = vi.fn() diff --git a/packages/start-plugin-core/tests/vite/start-compiler-utils.test.ts b/packages/start-plugin-core/tests/vite/start-compiler-utils.test.ts index 56aa7ff922..b22318734d 100644 --- a/packages/start-plugin-core/tests/vite/start-compiler-utils.test.ts +++ b/packages/start-plugin-core/tests/vite/start-compiler-utils.test.ts @@ -18,18 +18,22 @@ describe('Vite dev server module specifiers', () => { ).toString('base64url') expect( - getViteDevServerFnImport(generatedIdShapedManualId, { - [generatedIdShapedManualId]: { - functionName: 'getUser_createServerFn_handler', - functionId: generatedIdShapedManualId, - filename: '/repo/app/src/routes/users.tsx', - extractedFilename: - '/repo/app/src/routes/users.tsx?tss-serverfn-split', - isClientReferenced: true, + getViteDevServerFnImport( + generatedIdShapedManualId, + { + [generatedIdShapedManualId]: { + functionName: 'getUser_createServerFn_handler', + functionId: generatedIdShapedManualId, + filename: '/repo/app/src/routes/users.tsx', + extractedFilename: + '/repo/app/src/routes/users.tsx?tss-serverfn-split', + isClientReferenced: true, + }, }, - }), + createViteDevServerFnModuleSpecifierEncoder('/repo/app'), + ), ).toEqual({ - file: '/repo/app/src/routes/users.tsx?tss-serverfn-split', + file: '/src/routes/users.tsx?tss-serverfn-split', export: 'getUser_createServerFn_handler', }) }) @@ -43,7 +47,13 @@ describe('Vite dev server module specifiers', () => { 'utf8', ).toString('base64url') - expect(getViteDevServerFnImport(encodedId, {})).toEqual({ + expect( + getViteDevServerFnImport( + encodedId, + {}, + createViteDevServerFnModuleSpecifierEncoder('/repo/app'), + ), + ).toEqual({ file: '/src/routes/users.tsx?tss-serverfn-split', export: 'getUser_createServerFn_handler', }) From 45c430bd28b9e7fb3a9b236088604cc47ef61553 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Tue, 23 Jun 2026 14:02:36 -0700 Subject: [PATCH 5/9] docs --- .../framework/react/guide/server-functions.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/start/framework/react/guide/server-functions.md b/docs/start/framework/react/guide/server-functions.md index 903e5d7b86..bb724068d8 100644 --- a/docs/start/framework/react/guide/server-functions.md +++ b/docs/start/framework/react/guide/server-functions.md @@ -414,6 +414,55 @@ Server functions can return Server Components - server-rendered React components Handle request cancellation with `AbortSignal` for long-running operations. +### Manual function IDs + +TanStack Start gives every server function an internal ID. By default, that ID is generated from the source file and extracted function name. + +Add `.id()` when a function needs to keep the same identity across file moves or variable renames: + +```tsx +import { createServerFn } from '@tanstack/react-start' + +export const getUser = createServerFn({ method: 'GET' }) + .id('get-user') + .handler(async () => { + return { id: '123' } + }) +``` + +The value must be a static string literal before `.handler()`. It can contain letters, numbers, `_`, and `-`. Manual IDs must be unique. Duplicate manual IDs fail at compile time. If a generated or plugin-generated ID collides with one, the generated ID gets a suffix such as `_1`. + +> [!WARNING] +> IDs are public. They can show up in browser network requests, logs, proxies, and analytics, so keep them short and non-sensitive. Do not include secrets, private file paths, tenant IDs, user IDs, or sensitive business data. + +An ID only locates the function. It does not authenticate the caller, authorize the action, validate input, or provide CSRF protection. Put those checks in middleware or in the handler. + +A per-function `.id()` takes precedence over `serverFns.generateFunctionId` for that server function. + +#### Manual IDs with factories + +Set the ID on the server function produced by the factory, not on the reusable factory itself: + +```tsx +const authedServerFn = createServerFn().middleware([authMiddleware]) + +export const getUser = authedServerFn() + .id('get-user') + .handler(async () => { + return { id: '123' } + }) + +export const updateUser = authedServerFn({ method: 'POST' }) + .id('update-user') + .handler(async () => { + return { ok: true } + }) +``` + +Factory calls do not inherit IDs so each function produced by the factory should declare its own ID. + +Keep the same `.id()` value to preserve function identity across file moves and variable renames. Keep the same `method` to preserve call compatibility. + ### Function ID generation for production build Server functions are addressed by a generated, stable function ID under the hood. These IDs are embedded into the client/SSR builds and used by the server to locate and import the correct module at runtime. From 270b7c80fd0db5077e3fa82ea45830eeb52d79a9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:11:35 +0000 Subject: [PATCH 6/9] ci: apply automated fixes --- .../start-client-core/src/createServerFn.ts | 19 +++++-------------- .../src/tests/createServerFn.test-d.ts | 4 +++- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index a8e21230fc..f78a5acc3c 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -603,7 +603,9 @@ export interface ServerFnId< TInputValidator, TStrict extends ServerFnStrict, > { - id: (id: string) => ServerFnAfterId< + id: ( + id: string, + ) => ServerFnAfterId< TRegister, TMethod, TMiddlewares, @@ -689,12 +691,7 @@ export interface ServerFnValidatorAfterId< validator: ValidatorFnAfterId // TODO remove upon stable /** @deprecated Use `validator` instead. */ - inputValidator: ValidatorFnAfterId< - TRegister, - TMethod, - TMiddlewares, - TStrict - > + inputValidator: ValidatorFnAfterId } export interface ServerFnAfterIdAfterValidator< @@ -839,13 +836,7 @@ export interface ServerFnAfterValidator< TInputValidator, TStrict >, - ServerFnHandler< - TRegister, - TMethod, - TMiddlewares, - TInputValidator, - TStrict - >, + ServerFnHandler, ServerFnId {} export interface ServerFnAfterTyper< 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 a6dbe140d8..85bca19272 100644 --- a/packages/start-client-core/src/tests/createServerFn.test-d.ts +++ b/packages/start-client-core/src/tests/createServerFn.test-d.ts @@ -590,7 +590,9 @@ test('createServerFn id can be set before handler', () => { expectTypeOf(fn).not.toHaveProperty('id') - const builderAfterMiddleware = createServerFn().id('list-users').middleware([]) + const builderAfterMiddleware = createServerFn() + .id('list-users') + .middleware([]) expectTypeOf(builderAfterMiddleware).toHaveProperty('handler') expectTypeOf(builderAfterMiddleware).not.toHaveProperty('id') From e01824c20e7b56468727c1b7198e874be9041640 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Tue, 23 Jun 2026 15:50:17 -0700 Subject: [PATCH 7/9] enhance function ID generation with deduplication for known and manual IDs --- .../src/start-compiler/compiler.ts | 147 +++++++++++------- .../createServerFn/createServerFn.test.ts | 136 ++++++++++++++++ 2 files changed, 230 insertions(+), 53 deletions(-) diff --git a/packages/start-plugin-core/src/start-compiler/compiler.ts b/packages/start-plugin-core/src/start-compiler/compiler.ts index c8f7ee735a..69e4ea9769 100644 --- a/packages/start-plugin-core/src/start-compiler/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler/compiler.ts @@ -44,6 +44,12 @@ type StartCompilerAstPlugin = StartCompilerPlugin & { transformAst: NonNullable } +type GenerateFunctionIdOpts = { + filename: string + functionName: string + extractedFilename: string +} + export type BuiltInLookupKind = | 'ServerFn' | 'Middleware' @@ -606,77 +612,112 @@ export class StartCompiler { * In dev mode, uses a base64-encoded JSON with file path and export name. * In build mode, uses SHA256 hash or custom generator. */ - private generateFunctionId(opts: { - filename: string - functionName: string - 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') - } - - // Production build: use custom generator or hash + private generateFunctionId(opts: GenerateFunctionIdOpts): string { const entryId = `${opts.filename}--${opts.functionName}` let functionId = this.entryIdToFunctionId.get(entryId) if (functionId === undefined) { - const knownFn = Object.values(this.options.getKnownServerFns()).find( - (serverFn) => - serverFn.functionName === opts.functionName && - serverFn.extractedFilename === opts.extractedFilename, - ) + const knownFn = this.getKnownServerFn(opts) if (knownFn) { functionId = knownFn.functionId + } else if (this.mode === 'dev') { + functionId = this.generateDevFunctionId(opts) + } else { + functionId = this.generateBuildFunctionId(entryId, opts) } - if (this.options.generateFunctionId) { - functionId ??= this.options.generateFunctionId({ - filename: opts.filename, - functionName: opts.functionName, - }) - } - 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)) { - let deduplicatedId - let iteration = 0 - do { - deduplicatedId = `${functionId}_${++iteration}` - } while (this.functionIds.has(deduplicatedId)) - functionId = deduplicatedId - } this.entryIdToFunctionId.set(entryId, functionId) this.functionIds.add(functionId) } return functionId } - private reserveFunctionId(functionId: string): boolean { + private getKnownServerFn(opts: GenerateFunctionIdOpts): ServerFn | undefined { + return Object.values(this.options.getKnownServerFns()).find( + (serverFn) => + serverFn.functionName === opts.functionName && + serverFn.extractedFilename === opts.extractedFilename, + ) + } + + private isFunctionIdAvailable( + functionId: string, + opts: GenerateFunctionIdOpts, + ): boolean { if (this.functionIds.has(functionId)) { return false } + const knownFn = this.options.getKnownServerFns()[functionId] + return ( + !knownFn || + (knownFn.functionName === opts.functionName && + knownFn.extractedFilename === opts.extractedFilename) + ) + } + + private hasKnownFunctionId(functionId: string): boolean { + return !!this.options.getKnownServerFns()[functionId] + } + + private generateDevFunctionId(opts: GenerateFunctionIdOpts): string { + 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 createFunctionId = (collision?: number) => + Buffer.from( + JSON.stringify({ + file, + export: opts.functionName, + ...(collision ? { collision } : undefined), + }), + 'utf8', + ).toString('base64url') + + let functionId = createFunctionId() + let iteration = 0 + while (!this.isFunctionIdAvailable(functionId, opts)) { + functionId = createFunctionId(++iteration) + } + return functionId + } + + private generateBuildFunctionId( + entryId: string, + opts: GenerateFunctionIdOpts, + ): string { + let functionId = this.options.generateFunctionId?.({ + filename: opts.filename, + functionName: opts.functionName, + }) + if (!functionId) { + functionId = crypto.createHash('sha256').update(entryId).digest('hex') + } + + const baseFunctionId = functionId + let iteration = 0 + while (!this.isFunctionIdAvailable(functionId, opts)) { + functionId = `${baseFunctionId}_${++iteration}` + } + return functionId + } + + private reserveFunctionId(functionId: string): boolean { + if ( + this.functionIds.has(functionId) || + this.hasKnownFunctionId(functionId) + ) { + return false + } + this.functionIds.add(functionId) return true } diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index 91a0235acd..2b05743d4d 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -324,6 +324,142 @@ describe('createServerFn compiles correctly', async () => { ]) }) + test('should dedupe generated ids that collide with known ids', async () => { + const serverFnsById: Record< + string, + { + functionName: string + functionId: string + extractedFilename: string + filename: string + isClientReferenced?: boolean + } + > = { + 'known-action': { + functionName: 'knownFn_createServerFn_handler', + functionId: 'known-action', + extractedFilename: '/test/src/known.ts?tss-serverfn-split', + filename: '/test/src/known.ts', + isClientReferenced: true, + }, + } + + const compiler = new StartCompiler({ + env: 'client', + ...getDefaultTestOptions('client'), + mode: 'build', + loadModule: async () => {}, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + resolveId: async (id) => id, + generateFunctionId: ({ functionName }) => + functionName === 'generatedFn_createServerFn_handler' + ? 'known-action' + : undefined, + getKnownServerFns: () => serverFnsById, + onServerFnsById: (discovered) => { + Object.assign(serverFnsById, discovered) + }, + }) + + const result = await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + const generatedFn = createServerFn() + .handler(() => 'generated') + `, + id: '/test/src/generated.ts', + }) + + expect(result!.code).toContain('createClientRpc("known-action_1")') + expect(serverFnsById['known-action']).toMatchObject({ + functionName: 'knownFn_createServerFn_handler', + functionId: 'known-action', + }) + expect(serverFnsById['known-action_1']).toMatchObject({ + functionName: 'generatedFn_createServerFn_handler', + functionId: 'known-action_1', + }) + }) + + test('should dedupe dev generated ids that collide with manual ids', async () => { + const devServerFnPayload = { + file: '/test/src/test.ts?tss-serverfn-split', + export: 'generatedFn_createServerFn_handler', + } + const manualFunctionId = Buffer.from( + JSON.stringify(devServerFnPayload), + 'utf8', + ).toString('base64url') + const serverFnsById: Record< + string, + { + functionName: string + functionId: string + extractedFilename: string + filename: string + isClientReferenced?: boolean + } + > = {} + + 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, + devServerFnModuleSpecifierEncoder: ({ extractedFilename }) => + extractedFilename, + getKnownServerFns: () => ({}), + onServerFnsById: (discovered) => { + Object.assign(serverFnsById, discovered) + }, + }) + + const result = await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + const manualFn = createServerFn() + .id('${manualFunctionId}') + .handler(() => 'manual') + const generatedFn = createServerFn() + .handler(() => 'generated') + `, + id: '/test/src/test.ts', + }) + + const functionIds = Object.keys(serverFnsById).sort() + const generatedFunctionId = functionIds.find( + (functionId) => functionId !== manualFunctionId, + ) + const generatedPayload = JSON.parse( + Buffer.from(generatedFunctionId!, 'base64url').toString('utf8'), + ) + + expect(result!.code).toContain(`createClientRpc("${manualFunctionId}")`) + expect(result!.code).toContain(`createClientRpc("${generatedFunctionId}")`) + expect(functionIds).toHaveLength(2) + expect(functionIds).toContain(manualFunctionId) + expect(generatedPayload).toEqual({ + ...devServerFnPayload, + collision: 1, + }) + }) + // TODO remove upon stable test('should warn for deprecated inputValidator method', async () => { const warn = vi.fn() From b8bdb89e5b7d211da852c209ccb5e54a2d287ecc Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Tue, 23 Jun 2026 17:01:08 -0700 Subject: [PATCH 8/9] enhance function ID management with reservation and deduplication for manual IDs --- .../src/start-compiler/compiler.ts | 14 ++- .../start-compiler/handleCreateServerFn.ts | 81 ++++++++++++--- .../src/vite/start-compiler-plugin/plugin.ts | 21 ++++ .../createServerFn/createServerFn.test.ts | 99 +++++++++++++++++++ 4 files changed, 198 insertions(+), 17 deletions(-) diff --git a/packages/start-plugin-core/src/start-compiler/compiler.ts b/packages/start-plugin-core/src/start-compiler/compiler.ts index 69e4ea9769..b2434f3843 100644 --- a/packages/start-plugin-core/src/start-compiler/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler/compiler.ts @@ -528,6 +528,7 @@ export class StartCompiler { // For generating unique function IDs in production builds private entryIdToFunctionId = new Map() private functionIds = new Set() + private reservedFunctionIdOwners = new Map() constructor( private options: { @@ -710,7 +711,7 @@ export class StartCompiler { return functionId } - private reserveFunctionId(functionId: string): boolean { + private reserveFunctionId(functionId: string, moduleId: string): boolean { if ( this.functionIds.has(functionId) || this.hasKnownFunctionId(functionId) @@ -719,6 +720,7 @@ export class StartCompiler { } this.functionIds.add(functionId) + this.reservedFunctionIdOwners.set(functionId, cleanId(moduleId)) return true } @@ -918,6 +920,13 @@ export class StartCompiler { return deletedModuleIds } + for (const [functionId, moduleId] of this.reservedFunctionIdOwners) { + if (normalizedIds.has(moduleId)) { + this.reservedFunctionIdOwners.delete(functionId) + this.functionIds.delete(functionId) + } + } + for (const moduleId of Array.from(this.moduleCache.keys())) { const normalizedModuleId = cleanId(moduleId) if (normalizedIds.has(normalizedModuleId)) { @@ -1423,7 +1432,8 @@ export class StartCompiler { warn: warnFn, generateFunctionId: (opts) => this.generateFunctionId(opts), - reserveFunctionId: (id) => this.reserveFunctionId(id), + reserveFunctionId: (functionId) => + this.reserveFunctionId(functionId, id), 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 1b090b2a22..669c755069 100644 --- a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts +++ b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts @@ -268,10 +268,19 @@ export function handleCreateServerFn( const knownFns = context.getKnownServerFns() const cleanedContextId = cleanId(context.id) + const candidateInfos: Array<{ + candidate: RewriteCandidate + candidateVariableDeclarator: NonNullable< + ReturnType + > + existingVariableName: string + functionName: string + manualFunctionId: string | undefined + variableDeclarator: t.VariableDeclarator + }> = [] + for (const candidate of candidates) { const { path: candidatePath, methodChain } = candidate - const { id, validator, inputValidator, handler } = methodChain - const candidateVariableDeclarator = getVariableDeclaratorForExpressionPath( candidatePath as babel.NodePath, ) @@ -300,18 +309,41 @@ export function handleCreateServerFn( } functionNameSet.add(functionName) - const manualFunctionId = getManualServerFnId(context, id) - const functionId = - manualFunctionId ?? - context.generateFunctionId({ - filename: relativeFilename, - functionName, - extractedFilename, - }) + const manualFunctionId = getManualServerFnId(context, methodChain.id) - if (manualFunctionId && !isProviderFile) { - const existingFn = - serverFnsById[manualFunctionId] ?? knownFns[manualFunctionId] + candidateInfos.push({ + candidate, + candidateVariableDeclarator, + existingVariableName, + functionName, + manualFunctionId, + variableDeclarator, + }) + } + + const manualFunctionIds = new Map() + if (!isProviderFile) { + for (const { functionName, manualFunctionId, variableDeclarator } of + candidateInfos) { + if (!manualFunctionId) { + continue + } + + const existingManualFunctionName = + manualFunctionIds.get(manualFunctionId) + if ( + existingManualFunctionName && + existingManualFunctionName !== functionName + ) { + throw codeFrameError( + context.code, + variableDeclarator.loc!, + `Duplicate manual server function id: ${manualFunctionId}`, + ) + } + manualFunctionIds.set(manualFunctionId, functionName) + + const existingFn = knownFns[manualFunctionId] const isSameKnownFn = existingFn?.functionName === functionName && existingFn.extractedFilename === extractedFilename @@ -319,7 +351,7 @@ export function handleCreateServerFn( if (existingFn && !isSameKnownFn) { throw codeFrameError( context.code, - id!.callPath.node.loc!, + variableDeclarator.loc!, `Duplicate manual server function id: ${manualFunctionId}`, ) } @@ -327,11 +359,30 @@ export function handleCreateServerFn( if (!isSameKnownFn && !context.reserveFunctionId(manualFunctionId)) { throw codeFrameError( context.code, - id!.callPath.node.loc!, + variableDeclarator.loc!, `Duplicate manual server function id: ${manualFunctionId}`, ) } } + } + + for (const candidateInfo of candidateInfos) { + const { + candidate, + candidateVariableDeclarator, + existingVariableName, + functionName, + manualFunctionId, + } = candidateInfo + const { path: candidatePath, methodChain } = candidate + const { id, validator, inputValidator, handler } = methodChain + const 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/vite/start-compiler-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts index 967aea5463..40e4a35f7f 100644 --- a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts @@ -157,6 +157,25 @@ function invalidateCompilerVirtualModules( }) } +function deleteServerFnsByModuleIds( + serverFnsById: Record, + ids: Iterable, +): void { + const moduleIds = new Set(Array.from(ids, cleanId)) + if (moduleIds.size === 0) { + return + } + + for (const [functionId, serverFn] of Object.entries(serverFnsById)) { + if ( + moduleIds.has(cleanId(serverFn.filename)) || + moduleIds.has(cleanId(serverFn.extractedFilename)) + ) { + delete serverFnsById[functionId] + } + } +} + function getServerFnProviderIds(ids: Iterable) { const providerIds = new Set() @@ -446,6 +465,8 @@ export function startCompilerPlugin( compiler.invalidateModules(seenImporters) } + deleteServerFnsByModuleIds(serverFnsById, idsToInvalidate) + invalidateModuleNodes(this.environment, importerModulesToInvalidate) invalidateServerFnLookupModules(this.environment, idsToInvalidate) const compilerVirtualModules = invalidateCompilerVirtualModules( diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index 2b05743d4d..d2b4ad6214 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -324,6 +324,105 @@ describe('createServerFn compiles correctly', async () => { ]) }) + test('should dedupe generated ids that collide with later manual ids', async () => { + const serverFnsById: Record< + string, + { + functionName: string + functionId: string + extractedFilename: string + filename: string + isClientReferenced?: boolean + } + > = {} + + const compiler = new StartCompiler({ + env: 'client', + ...getDefaultTestOptions('client'), + mode: 'build', + loadModule: async () => {}, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + resolveId: async (id) => id, + generateFunctionId: ({ functionName }) => + functionName === 'generatedFn_createServerFn_handler' + ? 'user-action' + : undefined, + getKnownServerFns: () => ({}), + onServerFnsById: (discovered) => { + Object.assign(serverFnsById, discovered) + }, + }) + + const result = await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + const generatedFn = createServerFn() + .handler(() => 'generated') + const manualFn = createServerFn() + .id('user-action') + .handler(() => 'manual') + `, + id: '/test/src/test.ts', + }) + + expect(result!.code).toContain('createClientRpc("user-action_1")') + expect(result!.code).toContain('createClientRpc("user-action")') + expect(Object.keys(serverFnsById).sort()).toEqual([ + 'user-action', + 'user-action_1', + ]) + }) + + test('should release manual id reservations when a module is invalidated', async () => { + const compiler = new StartCompiler({ + env: 'client', + ...getDefaultTestOptions('client'), + mode: 'build', + loadModule: async () => {}, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + resolveId: async (id) => id, + getKnownServerFns: () => ({}), + }) + + await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + const firstFn = createServerFn() + .id('user-action') + .handler(() => 'first') + `, + id: '/test/src/first.ts', + }) + + compiler.invalidateModule('/test/src/first.ts') + + const result = await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + const secondFn = createServerFn() + .id('user-action') + .handler(() => 'second') + `, + id: '/test/src/second.ts', + }) + + expect(result!.code).toContain('createClientRpc("user-action")') + }) + test('should dedupe generated ids that collide with known ids', async () => { const serverFnsById: Record< string, From 599f31bf291a642a9a66594b500a3cf16888a4b9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:10:54 +0000 Subject: [PATCH 9/9] ci: apply automated fixes --- .../src/start-compiler/handleCreateServerFn.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts index 669c755069..8334656230 100644 --- a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts +++ b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts @@ -323,14 +323,16 @@ export function handleCreateServerFn( const manualFunctionIds = new Map() if (!isProviderFile) { - for (const { functionName, manualFunctionId, variableDeclarator } of - candidateInfos) { + for (const { + functionName, + manualFunctionId, + variableDeclarator, + } of candidateInfos) { if (!manualFunctionId) { continue } - const existingManualFunctionName = - manualFunctionIds.get(manualFunctionId) + const existingManualFunctionName = manualFunctionIds.get(manualFunctionId) if ( existingManualFunctionName && existingManualFunctionName !== functionName