From b26da8afc415f22b32afd40fceac5f6673cd42d3 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 25 Jun 2026 09:52:19 -0700 Subject: [PATCH 01/17] add manual server fn id option and literal extraction --- .../start-client-core/src/createServerFn.ts | 2 + .../src/tests/createServerFn.test-d.ts | 2 + .../start-compiler/handleCreateServerFn.ts | 120 +++++++++++++++++- .../createServerFn/createServerFn.test.ts | 46 +++++++ 4 files changed, 164 insertions(+), 6 deletions(-) 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/handleCreateServerFn.ts b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts index 3d4a8a2542..cc18b6ae20 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_PATTERN = /^[a-zA-Z0-9_-]+$/ const providerHmrAcceptTemplate = babel.template.statements( ` @@ -139,6 +140,109 @@ function getEnvConfig( } } +function extractManualServerFnId( + candidatePath: babel.NodePath, + code: string, +): string | undefined { + const [optionsArg] = candidatePath.node.arguments + if (!optionsArg || !t.isObjectExpression(optionsArg)) { + return undefined + } + + if (optionsArg.loc) { + const optionsSource = getSourceTextForNode(code, optionsArg.loc) + if (optionsSource.includes('...')) { + throw codeFrameError( + code, + optionsArg.loc, + 'createServerFn({ ...opts }) is not supported for manual ids.', + ) + } + + if (/(^|[,{])\s*\[[^\]]+\]\s*:/.test(optionsSource)) { + throw codeFrameError( + code, + optionsArg.loc, + 'createServerFn({ [key]: value }) is not supported for manual ids.', + ) + } + } + + for (const prop of optionsArg.properties) { + if (!t.isObjectProperty(prop)) { + continue + } + + const isIdKey = + (t.isIdentifier(prop.key) && prop.key.name === 'id') || + (t.isStringLiteral(prop.key) && prop.key.value === 'id') + + if (!isIdKey) { + continue + } + + if (t.isStringLiteral(prop.value)) { + if (!MANUAL_SERVER_FN_ID_PATTERN.test(prop.value.value)) { + throw codeFrameError( + code, + prop.value.loc!, + 'createServerFn({ id }) must use a URL-safe id: [a-zA-Z0-9_-]+', + ) + } + + return prop.value.value + } + + if (t.isIdentifier(prop.value)) { + const binding = candidatePath.scope.getBinding(prop.value.name) + const bindingPath = binding?.path + const bindingInit = + bindingPath && bindingPath.isVariableDeclarator() + ? bindingPath.node.init + : undefined + + if (binding?.constant && bindingInit && t.isStringLiteral(bindingInit)) { + if (!MANUAL_SERVER_FN_ID_PATTERN.test(bindingInit.value)) { + throw codeFrameError( + code, + bindingInit.loc!, + 'createServerFn({ id }) must use a URL-safe id: [a-zA-Z0-9_-]+', + ) + } + + return bindingInit.value + } + } + + throw codeFrameError( + code, + prop.loc!, + 'createServerFn({ id }) must use a static string literal or a constant string binding.', + ) + } + + return undefined +} + +function getSourceTextForNode( + code: string, + loc: { + start: { line: number; column: number } + end: { line: number; column: number } + }, +): string { + const lineStarts = [0] + for (let index = 0; index < code.length; index++) { + if (code[index] === '\n') { + lineStarts.push(index + 1) + } + } + + const startOffset = lineStarts[loc.start.line - 1]! + loc.start.column + const endOffset = lineStarts[loc.end.line - 1]! + loc.end.column + return code.slice(startOffset, endOffset) +} + /** * Builds the serverFnMeta object literal AST node. * The object contains: { id, name, filename } @@ -271,12 +375,16 @@ 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 ?? + 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/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index c4009657c4..397de0ede0 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -118,6 +118,52 @@ 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('should use a shorthand constant string id', async () => { + const code = ` + import { createServerFn } from '@tanstack/react-start' + + const manualId = 'get-user' + + export const getUser = createServerFn({ method: 'GET', id: manualId }) + .handler(async () => ({ id: '123' })) + ` + + const compiledResultClient = await compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }) + + expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') + }) + // TODO remove upon stable test('should warn for deprecated inputValidator method', async () => { const warn = vi.fn() From f9c10d6bd16247b3047027564a770de71b831846 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 25 Jun 2026 10:05:30 -0700 Subject: [PATCH 02/17] add manual server fn ids with reservation and validation --- .../src/start-compiler/compiler.ts | 52 +++++++- .../start-compiler/handleCreateServerFn.ts | 44 ++++++- .../src/start-compiler/types.ts | 7 + .../createServerFn/createServerFn.test.ts | 123 +++++++++--------- 4 files changed, 149 insertions(+), 77 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..2de43aacc1 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 reservedManualFunctionIds = new Set() + 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 @@ -658,13 +661,11 @@ export class StartCompiler { 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 + if ( + this.functionIds.has(functionId) || + this.reservedManualFunctionIds.has(functionId) + ) { + throw new Error(`Duplicate server function id: ${functionId}`) } this.entryIdToFunctionId.set(entryId, functionId) this.functionIds.add(functionId) @@ -672,6 +673,26 @@ export class StartCompiler { return functionId } + private reserveFunctionId(opts: { filename: string; functionId: string }) { + if ( + this.functionIds.has(opts.functionId) || + this.reservedManualFunctionIds.has(opts.functionId) + ) { + throw new Error(`Duplicate server function id: ${opts.functionId}`) + } + + this.reservedManualFunctionIds.add(opts.functionId) + + let reservedIds = this.reservedManualFunctionIdsByFilename.get(opts.filename) + if (!reservedIds) { + reservedIds = new Set() + this.reservedManualFunctionIdsByFilename.set(opts.filename, reservedIds) + } + reservedIds.add(opts.functionId) + + return opts.functionId + } + private get mode(): 'dev' | 'build' { return this.options.mode ?? 'dev' } @@ -871,6 +892,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 +917,21 @@ 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.reservedManualFunctionIds.delete(functionId) + } + this.reservedManualFunctionIdsByFilename.delete(relativeFilename) + } + public async getTransitiveImporters( ids: string | Iterable, ): Promise> { @@ -1368,6 +1405,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 cc18b6ae20..85a0695c00 100644 --- a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts +++ b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts @@ -144,7 +144,12 @@ function extractManualServerFnId( candidatePath: babel.NodePath, code: string, ): string | undefined { - const [optionsArg] = candidatePath.node.arguments + const createServerFnCall = getCreateServerFnCallExpression(candidatePath) + if (!createServerFnCall) { + return undefined + } + + const [optionsArg] = createServerFnCall.arguments if (!optionsArg || !t.isObjectExpression(optionsArg)) { return undefined } @@ -224,6 +229,27 @@ function extractManualServerFnId( return undefined } +function getCreateServerFnCallExpression( + candidatePath: babel.NodePath, +): t.CallExpression | undefined { + const { callee } = candidatePath.node + if (!t.isMemberExpression(callee)) { + return undefined + } + + const rootCall = callee.object + if (!t.isCallExpression(rootCall)) { + return undefined + } + + const rootCallee = rootCall.callee + if (!t.isIdentifier(rootCallee) || rootCallee.name !== 'createServerFn') { + return undefined + } + + return rootCall +} + function getSourceTextForNode( code: string, loc: { @@ -379,12 +405,16 @@ export function handleCreateServerFn( // Generate function ID using pre-computed relative filename unless the user supplied one. const functionId = - manualFunctionId ?? - context.generateFunctionId({ - filename: relativeFilename, - functionName, - extractedFilename, - }) + manualFunctionId !== undefined + ? context.reserveFunctionId({ + filename: relativeFilename, + 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/types.ts b/packages/start-plugin-core/src/start-compiler/types.ts index 1371e7a973..3a43fb89ae 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,11 @@ export type GenerateFunctionIdFn = (opts: { extractedFilename: string }) => string +export type ReserveFunctionIdFn = (opts: { + filename: 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 397de0ede0..0a2c993e24 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -617,46 +617,26 @@ 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 - } - > = {} - - function createCompiler() { - return 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: ({ functionName }) => - functionName === 'greetUser_createServerFn_handler' - ? 'constant_id' - : undefined, - getKnownServerFns: () => serverFnsById, - onServerFnsById: (discovered) => { - Object.assign(serverFnsById, discovered) + test('fails when custom IDs collide 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: () => ({}), + }) - const firstCompiler = createCompiler() - await firstCompiler.compile({ + await compiler.compile({ code: ` import { createServerFn } from '@tanstack/react-start' export const greetUser = createServerFn().handler(async () => 'first') @@ -664,39 +644,56 @@ describe('createServerFn compiles correctly', async () => { id: '/test/src/submit-post-formdata.tsx', }) - await firstCompiler.compile({ - code: ` - import { createServerFn } from '@tanstack/react-start' - export const greetUser = createServerFn().handler(async () => 'second') - `, - id: '/test/src/formdata-redirect/index.tsx', + await expect( + compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + export const greetUser = createServerFn().handler(async () => 'second') + `, + id: '/test/src/formdata-redirect/index.tsx', + }), + ).rejects.toThrow('Duplicate server function id: constant_id') + }) + + 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: () => ({}), }) - expect( - Object.values(serverFnsById) - .map((serverFn) => serverFn.functionId) - .sort(), - ).toEqual(['constant_id', 'constant_id_1']) + const source = ` + import { createServerFn } from '@tanstack/react-start' + export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'first') + ` - const secondCompiler = createCompiler() - const firstResult = await secondCompiler.compile({ - code: ` - import { createServerFn } from '@tanstack/react-start' - export const greetUser = createServerFn().handler(async () => 'first') - `, + const firstResult = await compiler.compile({ + code: source, id: '/test/src/submit-post-formdata.tsx', }) - const secondResult = await secondCompiler.compile({ - code: ` - import { createServerFn } from '@tanstack/react-start' - export const greetUser = createServerFn().handler(async () => 'second') - `, - id: '/test/src/formdata-redirect/index.tsx', + expect(firstResult!.code).toContain('createSsrRpc("get-user")') + expect(compiler.invalidateModule('/test/src/submit-post-formdata.tsx')).toBe( + true, + ) + + const secondResult = await compiler.compile({ + code: source, + id: '/test/src/submit-post-formdata.tsx', }) - expect(firstResult!.code).toContain('createSsrRpc("constant_id"') - expect(secondResult!.code).toContain('createSsrRpc("constant_id_1"') + expect(secondResult!.code).toContain('createSsrRpc("get-user")') }) test('should resolve createServerFn from the same binding as a known root export', async () => { From da14d36a4cb5f7429ac6dfa08afe923d758319f8 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 25 Jun 2026 10:30:14 -0700 Subject: [PATCH 03/17] stabalize manual server fn ids across chains and shared compilers --- .../src/start-compiler/compiler.ts | 84 ++++++++++------- .../start-compiler/handleCreateServerFn.ts | 31 ++++--- .../src/start-compiler/types.ts | 2 + .../createServerFn/createServerFn.test.ts | 89 +++++++++++++++++++ 4 files changed, 162 insertions(+), 44 deletions(-) diff --git a/packages/start-plugin-core/src/start-compiler/compiler.ts b/packages/start-plugin-core/src/start-compiler/compiler.ts index 2de43aacc1..d4007427d8 100644 --- a/packages/start-plugin-core/src/start-compiler/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler/compiler.ts @@ -516,6 +516,7 @@ export class StartCompiler { Map >() private reservedManualFunctionIds = new Set() + 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) @@ -615,26 +616,7 @@ 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 @@ -673,24 +655,61 @@ export class StartCompiler { return functionId } - private reserveFunctionId(opts: { filename: string; functionId: string }) { - if ( - this.functionIds.has(opts.functionId) || - this.reservedManualFunctionIds.has(opts.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) + + if (existingOwner && existingOwner !== entryId) { throw new Error(`Duplicate server function id: ${opts.functionId}`) } - this.reservedManualFunctionIds.add(opts.functionId) + if (!existingOwner && this.functionIds.has(opts.functionId)) { + throw new Error(`Duplicate server function id: ${opts.functionId}`) + } - let reservedIds = this.reservedManualFunctionIdsByFilename.get(opts.filename) - if (!reservedIds) { - reservedIds = new Set() - this.reservedManualFunctionIdsByFilename.set(opts.filename, reservedIds) + if (!existingOwner) { + this.reservedManualFunctionIdOwners.set(opts.functionId, entryId) + this.reservedManualFunctionIds.add(opts.functionId) + + let reservedIds = this.reservedManualFunctionIdsByFilename.get(opts.filename) + if (!reservedIds) { + reservedIds = new Set() + this.reservedManualFunctionIdsByFilename.set(opts.filename, reservedIds) + } + reservedIds.add(opts.functionId) } - reservedIds.add(opts.functionId) - return 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' { @@ -928,6 +947,7 @@ export class StartCompiler { for (const functionId of reservedIds) { this.reservedManualFunctionIds.delete(functionId) + this.reservedManualFunctionIdOwners.delete(functionId) } this.reservedManualFunctionIdsByFilename.delete(relativeFilename) } diff --git a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts index 85a0695c00..7487bcc19d 100644 --- a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts +++ b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts @@ -232,22 +232,27 @@ function extractManualServerFnId( function getCreateServerFnCallExpression( candidatePath: babel.NodePath, ): t.CallExpression | undefined { - const { callee } = candidatePath.node - if (!t.isMemberExpression(callee)) { - return undefined - } + let currentCall: t.CallExpression | undefined = candidatePath.node - const rootCall = callee.object - if (!t.isCallExpression(rootCall)) { - return undefined - } + while (currentCall) { + const callee: t.CallExpression['callee'] = currentCall.callee + if (!t.isMemberExpression(callee)) { + return undefined + } - const rootCallee = rootCall.callee - if (!t.isIdentifier(rootCallee) || rootCallee.name !== 'createServerFn') { - return undefined + const innerCall: t.MemberExpression['object'] = callee.object + if (!t.isCallExpression(innerCall)) { + return undefined + } + + if (t.isIdentifier(innerCall.callee, { name: 'createServerFn' })) { + return innerCall + } + + currentCall = innerCall } - return rootCall + return undefined } function getSourceTextForNode( @@ -408,6 +413,8 @@ export function handleCreateServerFn( manualFunctionId !== undefined ? context.reserveFunctionId({ filename: relativeFilename, + functionName, + extractedFilename, functionId: manualFunctionId, }) : context.generateFunctionId({ diff --git a/packages/start-plugin-core/src/start-compiler/types.ts b/packages/start-plugin-core/src/start-compiler/types.ts index 3a43fb89ae..c87ab2aa54 100644 --- a/packages/start-plugin-core/src/start-compiler/types.ts +++ b/packages/start-plugin-core/src/start-compiler/types.ts @@ -99,6 +99,8 @@ export type GenerateFunctionIdFn = (opts: { export type ReserveFunctionIdFn = (opts: { filename: string + functionName: string + extractedFilename: string functionId: string }) => string diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index 0a2c993e24..0ced979a12 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') { @@ -164,6 +165,25 @@ describe('createServerFn compiles correctly', async () => { expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') }) + test('should preserve a manual id through validator chaining', async () => { + const 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 })) + ` + + const compiledResultClient = await compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }) + + expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') + }) + // TODO remove upon stable test('should warn for deprecated inputValidator method', async () => { const warn = vi.fn() @@ -696,6 +716,75 @@ describe('createServerFn compiles correctly', async () => { 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 getUser = createServerFn({ id: 'get-user' }).handler(async () => 'first') + `, + id: '/test/src/example.tsx', + }) + + 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 () => { const virtualModules: Record = { '@tanstack/start-client-core': ` From b0be51f4ca0b6f039f2dedf57defd684738d776b Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 25 Jun 2026 11:28:05 -0700 Subject: [PATCH 04/17] deduplicate generated custom IDs and enforce manual ID constraints in createServerFn --- .../src/start-compiler/compiler.ts | 13 +- .../start-compiler/handleCreateServerFn.ts | 74 ++++++----- .../createServerFn/createServerFn.test.ts | 119 ++++++++++++++++-- 3 files changed, 160 insertions(+), 46 deletions(-) diff --git a/packages/start-plugin-core/src/start-compiler/compiler.ts b/packages/start-plugin-core/src/start-compiler/compiler.ts index d4007427d8..e721bf8b1f 100644 --- a/packages/start-plugin-core/src/start-compiler/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler/compiler.ts @@ -642,12 +642,21 @@ export class StartCompiler { if (!functionId) { functionId = crypto.createHash('sha256').update(entryId).digest('hex') } - // Deduplicate in case the generated id conflicts with an existing id + // Deduplicate generated/custom IDs so manual reservations stay exact + // without making the older generateFunctionId hook a breaking change. if ( this.functionIds.has(functionId) || this.reservedManualFunctionIds.has(functionId) ) { - throw new Error(`Duplicate server function id: ${functionId}`) + let deduplicatedId + let iteration = 0 + do { + deduplicatedId = `${functionId}_${++iteration}` + } while ( + this.functionIds.has(deduplicatedId) || + this.reservedManualFunctionIds.has(deduplicatedId) + ) + functionId = deduplicatedId } this.entryIdToFunctionId.set(entryId, functionId) this.functionIds.add(functionId) diff --git a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts index 7487bcc19d..46c5632d2e 100644 --- a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts +++ b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts @@ -154,30 +154,25 @@ function extractManualServerFnId( return undefined } - if (optionsArg.loc) { - const optionsSource = getSourceTextForNode(code, optionsArg.loc) - if (optionsSource.includes('...')) { - throw codeFrameError( - code, - optionsArg.loc, - 'createServerFn({ ...opts }) is not supported for manual ids.', - ) - } - - if (/(^|[,{])\s*\[[^\]]+\]\s*:/.test(optionsSource)) { - throw codeFrameError( - code, - optionsArg.loc, - 'createServerFn({ [key]: value }) is not supported for manual ids.', - ) - } - } - for (const prop of optionsArg.properties) { if (!t.isObjectProperty(prop)) { continue } + if (prop.computed) { + const keyValue = resolveStaticString(candidatePath, prop.key) + + if (keyValue === 'id') { + throw codeFrameError( + code, + prop.loc!, + 'createServerFn({ [key]: value }) is not supported for manual ids.', + ) + } + + continue + } + const isIdKey = (t.isIdentifier(prop.key) && prop.key.name === 'id') || (t.isStringLiteral(prop.key) && prop.key.value === 'id') @@ -255,23 +250,34 @@ function getCreateServerFnCallExpression( return undefined } -function getSourceTextForNode( - code: string, - loc: { - start: { line: number; column: number } - end: { line: number; column: number } - }, -): string { - const lineStarts = [0] - for (let index = 0; index < code.length; index++) { - if (code[index] === '\n') { - lineStarts.push(index + 1) - } +function resolveStaticString( + candidatePath: babel.NodePath, + value: t.Expression | t.PrivateName, +): string | undefined { + if (t.isStringLiteral(value)) { + return value.value + } + + if (t.isTemplateLiteral(value) && value.expressions.length === 0) { + return value.quasis[0]?.value.cooked ?? undefined + } + + if (!t.isIdentifier(value)) { + return undefined } - const startOffset = lineStarts[loc.start.line - 1]! + loc.start.column - const endOffset = lineStarts[loc.end.line - 1]! + loc.end.column - return code.slice(startOffset, endOffset) + const binding = candidatePath.scope.getBinding(value.name) + const bindingPath = binding?.path + const bindingInit = + bindingPath && bindingPath.isVariableDeclarator() + ? bindingPath.node.init + : undefined + + if (binding?.constant && bindingInit && t.isStringLiteral(bindingInit)) { + return bindingInit.value + } + + return undefined } /** diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index 0ced979a12..1ef6ada3f4 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -184,6 +184,66 @@ describe('createServerFn compiles correctly', async () => { expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') }) + test('should ignore unrelated spreads when no manual id is present', async () => { + const code = ` + import { createServerFn } from '@tanstack/react-start' + + const baseOptions = { method: 'GET' as const } + + export const getUser = createServerFn({ ...baseOptions }) + .handler(async () => ({ id: '123' })) + ` + + const compiledResultClient = await compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }) + + expect(compiledResultClient!.code).toContain('createClientRpc(') + }) + + test('should ignore unrelated computed keys when no manual id is present', async () => { + const code = ` + import { createServerFn } from '@tanstack/react-start' + + const key = 'method' + + export const getUser = createServerFn({ [key]: 'GET' as const }) + .handler(async () => ({ id: '123' })) + ` + + const compiledResultClient = await compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }) + + expect(compiledResultClient!.code).toContain('createClientRpc(') + }) + + test('should reject computed manual id keys', async () => { + const code = ` + import { createServerFn } from '@tanstack/react-start' + + export const getUser = createServerFn({ ['id']: 'get-user' }) + .handler(async () => ({ id: '123' })) + ` + + await expect( + compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }), + ).rejects.toThrow( + 'createServerFn({ [key]: value }) is not supported for manual ids.', + ) + }) + // TODO remove upon stable test('should warn for deprecated inputValidator method', async () => { const warn = vi.fn() @@ -637,7 +697,7 @@ describe('createServerFn compiles correctly', async () => { ) }) - test('fails when custom IDs collide across compiler instances', async () => { + test('dedupes generated custom IDs across compiler instances', async () => { const compiler = new StartCompiler({ env: 'server', ...getDefaultTestOptions('server'), @@ -664,15 +724,54 @@ describe('createServerFn compiles correctly', async () => { id: '/test/src/submit-post-formdata.tsx', }) - await expect( - compiler.compile({ - code: ` - import { createServerFn } from '@tanstack/react-start' - export const greetUser = createServerFn().handler(async () => 'second') - `, - id: '/test/src/formdata-redirect/index.tsx', - }), - ).rejects.toThrow('Duplicate server function id: constant_id') + 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 around reserved manual IDs', 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', + }) + + const generatedResult = await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + export const getUser = createServerFn().handler(async () => 'generated') + `, + id: '/test/src/generated.tsx', + }) + + expect(manualResult!.code).toContain('createSsrRpc("get-user")') + expect(generatedResult!.code).toContain('createSsrRpc("get-user_1")') }) test('releases manual ids after module invalidation', async () => { From 6e015f7337d49604f126623a41b8f9fdd20e4776 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 25 Jun 2026 11:33:46 -0700 Subject: [PATCH 05/17] add tests to preserve static manual IDs with unrelated spreads and computed keys --- .../createServerFn/createServerFn.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index 1ef6ada3f4..fd9d30d895 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -244,6 +244,46 @@ describe('createServerFn compiles correctly', async () => { ) }) + test('should keep static manual id when options include unrelated spread', async () => { + const 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' })) + ` + + const compiledResultClient = await compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }) + + expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') + }) + + test('should keep static manual id when options include unrelated computed keys', async () => { + const 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' })) + ` + + const compiledResultClient = await compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }) + + expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') + }) + // TODO remove upon stable test('should warn for deprecated inputValidator method', async () => { const warn = vi.fn() From 9a32289aad3fb36876fac668d5add854265eebc8 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 25 Jun 2026 12:00:47 -0700 Subject: [PATCH 06/17] dedupe generated server fn ids against known ids and reject computed id aliases --- .../src/start-compiler/compiler.ts | 13 +++- .../start-compiler/handleCreateServerFn.ts | 9 +++ .../createServerFn/createServerFn.test.ts | 60 +++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/packages/start-plugin-core/src/start-compiler/compiler.ts b/packages/start-plugin-core/src/start-compiler/compiler.ts index e721bf8b1f..e5a05ba9d5 100644 --- a/packages/start-plugin-core/src/start-compiler/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler/compiler.ts @@ -623,11 +623,15 @@ export class StartCompiler { 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 @@ -642,11 +646,13 @@ export class StartCompiler { if (!functionId) { functionId = crypto.createHash('sha256').update(entryId).digest('hex') } + const isCanonicalKnownMatch = knownFn?.functionId === functionId // Deduplicate generated/custom IDs so manual reservations stay exact // without making the older generateFunctionId hook a breaking change. if ( this.functionIds.has(functionId) || - this.reservedManualFunctionIds.has(functionId) + this.reservedManualFunctionIds.has(functionId) || + (!isCanonicalKnownMatch && knownFunctionIds.has(functionId)) ) { let deduplicatedId let iteration = 0 @@ -654,7 +660,8 @@ export class StartCompiler { deduplicatedId = `${functionId}_${++iteration}` } while ( this.functionIds.has(deduplicatedId) || - this.reservedManualFunctionIds.has(deduplicatedId) + this.reservedManualFunctionIds.has(deduplicatedId) || + knownFunctionIds.has(deduplicatedId) ) functionId = deduplicatedId } diff --git a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts index 46c5632d2e..cf68578d25 100644 --- a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts +++ b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts @@ -277,6 +277,15 @@ function resolveStaticString( return bindingInit.value } + if ( + binding?.constant && + bindingInit && + t.isTemplateLiteral(bindingInit) && + bindingInit.expressions.length === 0 + ) { + return bindingInit.quasis[0]?.value.cooked ?? undefined + } + return undefined } diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index fd9d30d895..a1a24b309b 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -244,6 +244,28 @@ describe('createServerFn compiles correctly', async () => { ) }) + test('should reject computed manual id key aliases', async () => { + const code = ` + import { createServerFn } from '@tanstack/react-start' + + const key = \`id\` + + export const getUser = createServerFn({ [key]: 'get-user' }) + .handler(async () => ({ id: '123' })) + ` + + await expect( + compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }), + ).rejects.toThrow( + 'createServerFn({ [key]: value }) is not supported for manual ids.', + ) + }) + test('should keep static manual id when options include unrelated spread', async () => { const code = ` import { createServerFn } from '@tanstack/react-start' @@ -775,6 +797,44 @@ describe('createServerFn compiles correctly', async () => { 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', + 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('dedupes generated IDs around reserved manual IDs', async () => { const compiler = new StartCompiler({ env: 'server', From a8a2739a81b7e7c85590e8df8a347f08c53b961f Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 25 Jun 2026 12:10:29 -0700 Subject: [PATCH 07/17] add test to reuse canonical known server fn IDs without suffixing --- .../createServerFn/createServerFn.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index a1a24b309b..9e8158eaa9 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -835,6 +835,45 @@ describe('createServerFn compiles correctly', async () => { expect(result!.code).toContain('createSsrRpc("constant_id_1")') }) + test('reuses canonical known server fn IDs without suffixing', async () => { + const knownServerFns = { + knownFn: { + functionId: 'constant_id', + 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('dedupes generated IDs around reserved manual IDs', async () => { const compiler = new StartCompiler({ env: 'server', From e81f6d615d1617d5e7c6bc03aee1fca6c9e07df0 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 25 Jun 2026 12:50:52 -0700 Subject: [PATCH 08/17] docs --- .../framework/react/guide/server-functions.md | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/docs/start/framework/react/guide/server-functions.md b/docs/start/framework/react/guide/server-functions.md index 903e5d7b86..0d2ed9fa05 100644 --- a/docs/start/framework/react/guide/server-functions.md +++ b/docs/start/framework/react/guide/server-functions.md @@ -157,6 +157,70 @@ 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. + +```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, return value, and network request stay the same. + + +### 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. From 6bc295e592b641564c7de2029d3209bbc4d55757 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 25 Jun 2026 13:18:55 -0700 Subject: [PATCH 09/17] clarify behavior of manual IDs in server functions and update documentation on ID collisions --- docs/start/framework/react/guide/server-functions.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/start/framework/react/guide/server-functions.md b/docs/start/framework/react/guide/server-functions.md index 0d2ed9fa05..02f6825fc0 100644 --- a/docs/start/framework/react/guide/server-functions.md +++ b/docs/start/framework/react/guide/server-functions.md @@ -207,8 +207,9 @@ createServerFn({ [key]: 'create-user' }).handler(async () => { 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, return value, and network request stay the same. +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. If a generated or custom `generateFunctionId` value collides with a manual ID, the generated value is suffixed instead. If two manual IDs collide, compilation fails. ### Security Caveats From 20c9157ff4bb8c23c652a90a09865aba342b3010 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 25 Jun 2026 13:20:59 -0700 Subject: [PATCH 10/17] add validation to prevent duplicate manual server function IDs and enhance tests for ID handling --- .../src/start-compiler/compiler.ts | 9 + .../start-compiler/handleCreateServerFn.ts | 35 +-- .../createServerFn/createServerFn.test.ts | 265 ++++++++++++++++++ 3 files changed, 283 insertions(+), 26 deletions(-) diff --git a/packages/start-plugin-core/src/start-compiler/compiler.ts b/packages/start-plugin-core/src/start-compiler/compiler.ts index e5a05ba9d5..26981b6f2e 100644 --- a/packages/start-plugin-core/src/start-compiler/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler/compiler.ts @@ -679,6 +679,15 @@ export class StartCompiler { }) { 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}`) diff --git a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts index cf68578d25..a94f3a9695 100644 --- a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts +++ b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts @@ -181,8 +181,10 @@ function extractManualServerFnId( continue } - if (t.isStringLiteral(prop.value)) { - if (!MANUAL_SERVER_FN_ID_PATTERN.test(prop.value.value)) { + const idValue = resolveStaticString(candidatePath, prop.value) + + if (idValue !== undefined) { + if (!MANUAL_SERVER_FN_ID_PATTERN.test(idValue)) { throw codeFrameError( code, prop.value.loc!, @@ -190,28 +192,7 @@ function extractManualServerFnId( ) } - return prop.value.value - } - - if (t.isIdentifier(prop.value)) { - const binding = candidatePath.scope.getBinding(prop.value.name) - const bindingPath = binding?.path - const bindingInit = - bindingPath && bindingPath.isVariableDeclarator() - ? bindingPath.node.init - : undefined - - if (binding?.constant && bindingInit && t.isStringLiteral(bindingInit)) { - if (!MANUAL_SERVER_FN_ID_PATTERN.test(bindingInit.value)) { - throw codeFrameError( - code, - bindingInit.loc!, - 'createServerFn({ id }) must use a URL-safe id: [a-zA-Z0-9_-]+', - ) - } - - return bindingInit.value - } + return idValue } throw codeFrameError( @@ -228,22 +209,24 @@ function getCreateServerFnCallExpression( candidatePath: babel.NodePath, ): t.CallExpression | undefined { let currentCall: t.CallExpression | undefined = candidatePath.node + let sawMethodChain = false while (currentCall) { const callee: t.CallExpression['callee'] = currentCall.callee if (!t.isMemberExpression(callee)) { - return undefined + return sawMethodChain ? currentCall : undefined } const innerCall: t.MemberExpression['object'] = callee.object if (!t.isCallExpression(innerCall)) { - return undefined + return sawMethodChain ? currentCall : undefined } if (t.isIdentifier(innerCall.callee, { name: 'createServerFn' })) { return innerCall } + sawMethodChain = true currentCall = innerCall } diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index 9e8158eaa9..478d09009d 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -165,6 +165,44 @@ describe('createServerFn compiles correctly', async () => { expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') }) + test('should use a template literal manual id', 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', + }) + + expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') + }) + + test('should use a constant template literal manual id', async () => { + const code = ` + import { createServerFn } from '@tanstack/react-start' + + const manualId = \`get-user\` + + export const getUser = createServerFn({ method: 'GET', id: manualId }) + .handler(async () => ({ id: '123' })) + ` + + const compiledResultClient = await compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }) + + expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') + }) + test('should preserve a manual id through validator chaining', async () => { const code = ` import { createServerFn } from '@tanstack/react-start' @@ -695,6 +733,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' @@ -801,6 +883,7 @@ describe('createServerFn compiles correctly', async () => { const knownServerFns = { knownFn: { functionId: 'constant_id', + filename: '/test/src/known-fn.tsx', extractedFilename: '/test/src/known-fn.tsx', functionName: 'knownFn', }, @@ -839,6 +922,7 @@ describe('createServerFn compiles correctly', 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', }, @@ -913,6 +997,187 @@ describe('createServerFn compiles correctly', async () => { expect(generatedResult!.code).toContain('createSsrRpc("get-user_1")') }) + test('rejects duplicate manual IDs in the same file', 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 expect( + 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', + }), + ).rejects.toThrow('Duplicate server function id: get-user') + }) + + test('rejects duplicate manual IDs across files', 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 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', + }) + + await expect( + 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.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('reuses a matching known manual server fn ID', async () => { + const 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', + }, + } + + 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: () => knownServerFns, + }) + + const result = await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'manual') + `, + id: '/test/src/get-user.tsx', + }) + + expect(result!.code).toContain('createSsrRpc("get-user")') + }) + + test('rejects a manual ID with mismatched known server fn metadata', async () => { + const 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', + }, + } + + 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: () => knownServerFns, + }) + + await expect( + compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'manual') + `, + id: '/test/src/get-user.tsx', + }), + ).rejects.toThrow('Duplicate server function id: get-user') + }) + test('releases manual ids after module invalidation', async () => { const compiler = new StartCompiler({ env: 'server', From deb212f83aa8b8eb1bfcfaa733dff20f9f86db1f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:23:48 +0000 Subject: [PATCH 11/17] ci: apply automated fixes --- .../src/start-compiler/compiler.ts | 17 +++++++----- .../start-compiler/handleCreateServerFn.ts | 27 ++++++++++--------- .../createServerFn/createServerFn.test.ts | 6 ++--- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/packages/start-plugin-core/src/start-compiler/compiler.ts b/packages/start-plugin-core/src/start-compiler/compiler.ts index 26981b6f2e..86f02a8231 100644 --- a/packages/start-plugin-core/src/start-compiler/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler/compiler.ts @@ -678,7 +678,9 @@ export class StartCompiler { functionId: string }) { const entryId = `${opts.filename}--${opts.functionName}` - const existingOwner = this.reservedManualFunctionIdOwners.get(opts.functionId) + const existingOwner = this.reservedManualFunctionIdOwners.get( + opts.functionId, + ) const knownFn = this.options.getKnownServerFns()[opts.functionId] if ( @@ -701,7 +703,9 @@ export class StartCompiler { this.reservedManualFunctionIdOwners.set(opts.functionId, entryId) this.reservedManualFunctionIds.add(opts.functionId) - let reservedIds = this.reservedManualFunctionIdsByFilename.get(opts.filename) + let reservedIds = this.reservedManualFunctionIdsByFilename.get( + opts.filename, + ) if (!reservedIds) { reservedIds = new Set() this.reservedManualFunctionIdsByFilename.set(opts.filename, reservedIds) @@ -723,7 +727,9 @@ export class StartCompiler { // 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.') + throw new Error( + 'devServerFnModuleSpecifierEncoder is required in dev mode.', + ) } const file = encodeModuleSpecifier({ extractedFilename: opts.extractedFilename, @@ -963,9 +969,8 @@ export class StartCompiler { private clearReservedManualFunctionIdsForModule(moduleId: string): void { const relativeFilename = path.relative(this.options.root, moduleId) - const reservedIds = this.reservedManualFunctionIdsByFilename.get( - relativeFilename, - ) + const reservedIds = + this.reservedManualFunctionIdsByFilename.get(relativeFilename) if (!reservedIds) { return } diff --git a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts index a94f3a9695..db6074db2a 100644 --- a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts +++ b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts @@ -159,20 +159,20 @@ function extractManualServerFnId( continue } - if (prop.computed) { - const keyValue = resolveStaticString(candidatePath, prop.key) - - if (keyValue === 'id') { - throw codeFrameError( - code, - prop.loc!, - 'createServerFn({ [key]: value }) is not supported for manual ids.', - ) - } + if (prop.computed) { + const keyValue = resolveStaticString(candidatePath, prop.key) - continue + if (keyValue === 'id') { + throw codeFrameError( + code, + prop.loc!, + 'createServerFn({ [key]: value }) is not supported for manual ids.', + ) } + continue + } + const isIdKey = (t.isIdentifier(prop.key) && prop.key.name === 'id') || (t.isStringLiteral(prop.key) && prop.key.value === 'id') @@ -404,7 +404,10 @@ export function handleCreateServerFn( } functionNameSet.add(functionName) - const manualFunctionId = extractManualServerFnId(candidatePath, context.code) + const manualFunctionId = extractManualServerFnId( + candidatePath, + context.code, + ) // Generate function ID using pre-computed relative filename unless the user supplied one. const functionId = diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index 478d09009d..c01af9ffc6 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -1207,9 +1207,9 @@ describe('createServerFn compiles correctly', async () => { }) expect(firstResult!.code).toContain('createSsrRpc("get-user")') - expect(compiler.invalidateModule('/test/src/submit-post-formdata.tsx')).toBe( - true, - ) + expect( + compiler.invalidateModule('/test/src/submit-post-formdata.tsx'), + ).toBe(true) const secondResult = await compiler.compile({ code: source, From cb51cfb318c830241949f0d47cb1f9042ebd9a59 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 25 Jun 2026 13:44:17 -0700 Subject: [PATCH 12/17] remove redundant tests for template literal manual IDs in createServerFn --- .../createServerFn/createServerFn.test.ts | 38 ------------------- 1 file changed, 38 deletions(-) diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index 478d09009d..3ea0ef4383 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -165,44 +165,6 @@ describe('createServerFn compiles correctly', async () => { expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') }) - test('should use a template literal manual id', 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', - }) - - expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') - }) - - test('should use a constant template literal manual id', async () => { - const code = ` - import { createServerFn } from '@tanstack/react-start' - - const manualId = \`get-user\` - - export const getUser = createServerFn({ method: 'GET', id: manualId }) - .handler(async () => ({ id: '123' })) - ` - - const compiledResultClient = await compile({ - code, - env: 'client', - isProviderFile: false, - mode: 'build', - }) - - expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') - }) - test('should preserve a manual id through validator chaining', async () => { const code = ` import { createServerFn } from '@tanstack/react-start' From d59e3c8807c1c70c56dfec69ad26132b142fd2cb Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 25 Jun 2026 13:48:36 -0700 Subject: [PATCH 13/17] add validation to ensure static string literals or constant bindings for manual server function IDs --- .../src/start-compiler/handleCreateServerFn.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts index a94f3a9695..8eeee0df46 100644 --- a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts +++ b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts @@ -181,6 +181,14 @@ function extractManualServerFnId( continue } + 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) { From 7bd7dd2971f1673c1073f4f172004472b9c12e17 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 25 Jun 2026 13:52:42 -0700 Subject: [PATCH 14/17] refactor tests for createServerFn to use parameterized test cases for manual IDs --- .../createServerFn/createServerFn.test.ts | 100 +++++++----------- 1 file changed, 38 insertions(+), 62 deletions(-) diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index 3ea0ef4383..1b4d62f6c0 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -145,35 +145,51 @@ describe('createServerFn compiles correctly', async () => { expect(compiledResultServerProvider!.code).toContain('id: "get-user"') }) - test('should use a shorthand constant string id', async () => { - const code = ` - import { createServerFn } from '@tanstack/react-start' + test.each([ + { + name: 'constant binding', + code: ` + import { createServerFn } from '@tanstack/react-start' - const manualId = 'get-user' + const manualId = 'get-user' - export const getUser = createServerFn({ method: 'GET', id: manualId }) - .handler(async () => ({ id: '123' })) - ` + export const getUser = createServerFn({ method: 'GET', id: manualId }) + .handler(async () => ({ id: '123' })) + `, + }, + { + name: 'validator chain', + code: ` + import { createServerFn } from '@tanstack/react-start' - const compiledResultClient = await compile({ - code, - env: 'client', - isProviderFile: false, - mode: 'build', - }) + 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' - expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') - }) + const baseOptions = { method: 'GET' as const } - test('should preserve a manual id through validator chaining', async () => { - const code = ` - import { createServerFn } from '@tanstack/react-start' + export const getUser = createServerFn({ ...baseOptions, id: 'get-user' }) + .handler(async () => ({ id: '123' })) + `, + }, + { + name: 'unrelated computed key', + 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 })) - ` + 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', @@ -266,46 +282,6 @@ describe('createServerFn compiles correctly', async () => { ) }) - test('should keep static manual id when options include unrelated spread', async () => { - const 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' })) - ` - - const compiledResultClient = await compile({ - code, - env: 'client', - isProviderFile: false, - mode: 'build', - }) - - expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') - }) - - test('should keep static manual id when options include unrelated computed keys', async () => { - const 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' })) - ` - - const compiledResultClient = await compile({ - code, - env: 'client', - isProviderFile: false, - mode: 'build', - }) - - expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') - }) - // TODO remove upon stable test('should warn for deprecated inputValidator method', async () => { const warn = vi.fn() From 7947990ef4f1261d37ce29b9ac85b58de6930cac Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 25 Jun 2026 14:03:36 -0700 Subject: [PATCH 15/17] cleanup tests --- .../createServerFn/createServerFn.test.ts | 258 ++++++++---------- 1 file changed, 110 insertions(+), 148 deletions(-) diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index 1b4d62f6c0..c35fcb5ea9 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -200,36 +200,30 @@ describe('createServerFn compiles correctly', async () => { expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') }) - test('should ignore unrelated spreads when no manual id is present', async () => { - const code = ` - import { createServerFn } from '@tanstack/react-start' - - const baseOptions = { method: 'GET' as const } - - export const getUser = createServerFn({ ...baseOptions }) - .handler(async () => ({ id: '123' })) - ` - - const compiledResultClient = await compile({ - code, - env: 'client', - isProviderFile: false, - mode: 'build', - }) - - expect(compiledResultClient!.code).toContain('createClientRpc(') - }) + test.each([ + { + name: 'unrelated spread', + code: ` + import { createServerFn } from '@tanstack/react-start' - test('should ignore unrelated computed keys when no manual id is present', async () => { - const code = ` - import { createServerFn } from '@tanstack/react-start' + const baseOptions = { method: 'GET' as const } - const key = 'method' + export const getUser = createServerFn({ ...baseOptions }) + .handler(async () => ({ id: '123' })) + `, + }, + { + name: 'unrelated computed key', + code: ` + import { createServerFn } from '@tanstack/react-start' - export const getUser = createServerFn({ [key]: 'GET' as const }) - .handler(async () => ({ id: '123' })) - ` + 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', @@ -240,36 +234,28 @@ describe('createServerFn compiles correctly', async () => { expect(compiledResultClient!.code).toContain('createClientRpc(') }) - test('should reject computed manual id keys', async () => { - const code = ` - import { createServerFn } from '@tanstack/react-start' - - export const getUser = createServerFn({ ['id']: 'get-user' }) - .handler(async () => ({ id: '123' })) - ` - - await expect( - compile({ - code, - env: 'client', - isProviderFile: false, - mode: 'build', - }), - ).rejects.toThrow( - 'createServerFn({ [key]: value }) is not supported for manual ids.', - ) - }) - - test('should reject computed manual id key aliases', async () => { - const code = ` - import { createServerFn } from '@tanstack/react-start' + test.each([ + { + name: 'literal computed id key', + code: ` + import { createServerFn } from '@tanstack/react-start' - const key = \`id\` + export const getUser = createServerFn({ ['id']: 'get-user' }) + .handler(async () => ({ id: '123' })) + `, + }, + { + name: 'constant computed id key', + code: ` + import { createServerFn } from '@tanstack/react-start' - export const getUser = createServerFn({ [key]: 'get-user' }) - .handler(async () => ({ id: '123' })) - ` + const key = \`id\` + export const getUser = createServerFn({ [key]: 'get-user' }) + .handler(async () => ({ id: '123' })) + `, + }, + ])('should reject $name', async ({ code }) => { await expect( compile({ code, @@ -935,37 +921,40 @@ describe('createServerFn compiles correctly', async () => { expect(generatedResult!.code).toContain('createSsrRpc("get-user_1")') }) - test('rejects duplicate manual IDs in the same file', 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 expect( - 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', - }), - ).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', + }) - test('rejects duplicate manual IDs across files', async () => { + 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'), @@ -983,23 +972,9 @@ describe('createServerFn compiles correctly', async () => { getKnownServerFns: () => ({}), }) - 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', - }) - - await expect( - 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.toThrow('Duplicate server function id: get-user') + await expect(compileDuplicate(compiler)).rejects.toThrow( + 'Duplicate server function id: get-user', + ) }) test('rejects a manual ID that collides with a generated ID', async () => { @@ -1040,16 +1015,36 @@ describe('createServerFn compiles correctly', async () => { ).rejects.toThrow('Duplicate server function id: get-user') }) - test('reuses a matching known manual server fn ID', async () => { - const 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', + 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'), @@ -1067,7 +1062,7 @@ describe('createServerFn compiles correctly', async () => { getKnownServerFns: () => knownServerFns, }) - const result = await compiler.compile({ + const compileResult = compiler.compile({ code: ` import { createServerFn } from '@tanstack/react-start' export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'manual') @@ -1075,45 +1070,12 @@ describe('createServerFn compiles correctly', async () => { id: '/test/src/get-user.tsx', }) - expect(result!.code).toContain('createSsrRpc("get-user")') - }) - - test('rejects a manual ID with mismatched known server fn metadata', async () => { - const 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', - }, + if (expectedError) { + await expect(compileResult).rejects.toThrow(expectedError) + } else { + const result = await compileResult + expect(result!.code).toContain(expectedCode) } - - 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: () => knownServerFns, - }) - - await expect( - compiler.compile({ - code: ` - import { createServerFn } from '@tanstack/react-start' - export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'manual') - `, - id: '/test/src/get-user.tsx', - }), - ).rejects.toThrow('Duplicate server function id: get-user') }) test('releases manual ids after module invalidation', async () => { From b96a0c650d3a6530f4d7e7a5023dfb69af554bd0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:07:34 +0000 Subject: [PATCH 16/17] ci: apply automated fixes --- .../createServerFn/createServerFn.test.ts | 63 +++++++++---------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index 8aeb39e0d6..a553d278d0 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -1040,43 +1040,42 @@ describe('createServerFn compiles correctly', async () => { }, 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', - loadModule: async () => {}, - lookupKinds: new Set(['ServerFn']), - lookupConfigurations: [ - { - libName: '@tanstack/react-start', - rootExport: 'createServerFn', - kind: 'Root', - }, - ], - resolveId: async (id) => id, - getKnownServerFns: () => knownServerFns, - }) + ])( + '$name for manual ID', + async ({ knownServerFns, expectedCode, expectedError }) => { + 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: () => knownServerFns, + }) - const compileResult = compiler.compile({ - code: ` + const compileResult = compiler.compile({ + code: ` import { createServerFn } from '@tanstack/react-start' export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'manual') `, - id: '/test/src/get-user.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) - } - }) + 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({ From 230cdcce95caa4f4188b2f882778433c11e1fe61 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 25 Jun 2026 15:21:01 -0700 Subject: [PATCH 17/17] fix linting error --- .../start-compiler/handleCreateServerFn.ts | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts index 0d0bbb8a63..db0cc937c0 100644 --- a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts +++ b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts @@ -216,29 +216,23 @@ function extractManualServerFnId( function getCreateServerFnCallExpression( candidatePath: babel.NodePath, ): t.CallExpression | undefined { - let currentCall: t.CallExpression | undefined = candidatePath.node + let currentCall: t.CallExpression = candidatePath.node let sawMethodChain = false - while (currentCall) { - const callee: t.CallExpression['callee'] = currentCall.callee - if (!t.isMemberExpression(callee)) { - return sawMethodChain ? currentCall : undefined - } - - const innerCall: t.MemberExpression['object'] = callee.object - if (!t.isCallExpression(innerCall)) { - return sawMethodChain ? currentCall : undefined - } - + // 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 undefined + return sawMethodChain ? currentCall : undefined } function resolveStaticString(