Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/start/framework/react/server-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,52 @@ Cache server function results at build time for static generation. See [Static S

Handle request cancellation with `AbortSignal` for long-running operations.

### Function ID generation

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.

Defaults:

- In development, IDs are URL-safe strings derived from `${filename}--${functionName}` to aid debugging.
- In production, IDs are SHA256 hashes of the same seed to keep bundles compact and avoid leaking file paths.
- If two server functions end up with the same ID (including when using a custom generator), the system de-duplicates by appending an incrementing suffix like `_1`, `_2`, etc.
- IDs are stable for a given file/function tuple for the lifetime of the process (hot updates keep the same mapping).

Customization:

You can customize function ID generation by providing a `generateFunctionId` function when configuring the TanStack Start Vite plugin:

Example:

```ts
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'

export default defineConfig({
plugins: [
tanstackStart({
serverFns: {
generateFunctionId: ({ filename, functionName }) => {
// Return a custom ID string. If you return undefined, the default is used.
// For example, always hash (even in dev):
// return createHash('sha256').update(`${filename}--${functionName}`).digest('hex')
return undefined
},
},
}),
react(),
],
})
```

Tips:

- Prefer deterministic inputs (filename + functionName) so IDs remain stable between builds.
- If you don’t want file paths in dev IDs, return a hash in all environments.
- Ensure the returned ID is **URL-safe**.

---

> **Note**: Server functions use a compilation process that extracts server code from client bundles while maintaining seamless calling patterns. On the client, calls become `fetch` requests to the server.
4 changes: 1 addition & 3 deletions e2e/react-start/custom-basepath/tests/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,7 @@ test('Server function URLs correctly include app basepath', async ({
const form = page.locator('form')
const actionUrl = await form.getAttribute('action')

expect(actionUrl).toBe(
'/custom/basepath/_serverFn/src_routes_logout_tsx--logoutFn_createServerFn_handler',
)
expect(actionUrl).toMatch(/^\/custom\/basepath\/_serverFn\//)
})

test('client-side redirect', async ({ page, baseURL }) => {
Expand Down
14 changes: 14 additions & 0 deletions e2e/react-start/server-functions/tests/server-functions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ import type { Page } from '@playwright/test'

const PORT = await getTestServerPort(packageJson.name)

test('Server function URLs correctly include constant ids', async ({
page,
}) => {
for (const currentPage of ['/submit-post-formdata', '/formdata-redirect']) {
await page.goto(currentPage)
await page.waitForLoadState('networkidle')

const form = page.locator('form')
const actionUrl = await form.getAttribute('action')

expect(actionUrl).toMatch(/^\/_serverFn\/constant_id/)
}
})

test('invoking a server function with custom response status code', async ({
page,
}) => {
Expand Down
15 changes: 14 additions & 1 deletion e2e/react-start/server-functions/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,25 @@ import tsConfigPaths from 'vite-tsconfig-paths'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'

const FUNCTIONS_WITH_CONSTANT_ID = [
'src/routes/submit-post-formdata.tsx/greetUser_createServerFn_handler',
'src/routes/formdata-redirect/index.tsx/greetUser_createServerFn_handler',
]

export default defineConfig({
plugins: [
tsConfigPaths({
projects: ['./tsconfig.json'],
}),
tanstackStart(),
tanstackStart({
serverFns: {
generateFunctionId: (opts) => {
const id = `${opts.filename}/${opts.functionName}`
if (FUNCTIONS_WITH_CONSTANT_ID.includes(id)) return 'constant_id'
else return undefined
},
},
}),
viteReact(),
],
})
1 change: 1 addition & 0 deletions packages/directive-functions-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"@babel/types": "^7.27.7",
"@tanstack/router-utils": "workspace:*",
"babel-dead-code-elimination": "^1.0.10",
"pathe": "^2.0.3",
"tiny-invariant": "^1.3.3"
},
"devDependencies": {
Expand Down
32 changes: 19 additions & 13 deletions packages/directive-functions-plugin/src/compilers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
deadCodeElimination,
findReferencedIdentifiers,
} from 'babel-dead-code-elimination'
import path from 'pathe'
import { generateFromAst, parseAst } from '@tanstack/router-utils'
import type { GeneratorResult, ParseAstOptions } from '@tanstack/router-utils'

Expand All @@ -22,6 +23,11 @@ export type SupportedFunctionPath =
| babel.NodePath<babel.types.FunctionExpression>
| babel.NodePath<babel.types.ArrowFunctionExpression>

export type GenerateFunctionIdFn = (opts: {
filename: string
functionName: string
}) => string

export type ReplacerFn = (opts: {
fn: string
extractedFilename: string
Expand All @@ -38,6 +44,7 @@ export type CompileDirectivesOpts = ParseAstOptions & {
getRuntimeCode?: (opts: {
directiveFnsById: Record<string, DirectiveFn>
}) => string
generateFunctionId: GenerateFunctionIdFn
replacer: ReplacerFn
// devSplitImporter: string
filename: string
Expand Down Expand Up @@ -198,14 +205,6 @@ function findNearestVariableName(
return nameParts.length > 0 ? nameParts.join('_') : 'anonymous'
}

function makeFileLocationUrlSafe(location: string): string {
return location
.replace(/[^a-zA-Z0-9-_]/g, '_') // Replace unsafe chars with underscore
.replace(/_{2,}/g, '_') // Collapse multiple underscores
.replace(/^_|_$/g, '') // Trim leading/trailing underscores
.replace(/_--/g, '--') // Clean up the joiner
}

function makeIdentifierSafe(identifier: string): string {
return identifier
.replace(/[^a-zA-Z0-9_$]/g, '_') // Replace unsafe chars with underscore
Expand All @@ -221,6 +220,7 @@ export function findDirectives(
directive: string
directiveLabel: string
replacer?: ReplacerFn
generateFunctionId: GenerateFunctionIdFn
directiveSplitParam: string
filename: string
root: string
Expand Down Expand Up @@ -460,16 +460,22 @@ export function findDirectives(
`body.${topParentIndex}.declarations.0.init`,
) as SupportedFunctionPath

const [baseFilename, ..._searchParams] = opts.filename.split('?')
const [baseFilename, ..._searchParams] = opts.filename.split('?') as [
string,
...Array<string>,
]
const searchParams = new URLSearchParams(_searchParams.join('&'))
searchParams.set(opts.directiveSplitParam, '')

const extractedFilename = `${baseFilename}?${searchParams.toString()}`

const functionId = makeFileLocationUrlSafe(
`${baseFilename}--${functionName}`.replace(opts.root, ''),
)

// Relative to have constant functionId regardless of the machine
// that we are executing
const relativeFilename = path.relative(opts.root, baseFilename)
const functionId = opts.generateFunctionId({
filename: relativeFilename,
functionName: functionName,
})
// If a replacer is provided, replace the function with the replacer
if (opts.replacer) {
const replacer = opts.replacer({
Expand Down
11 changes: 10 additions & 1 deletion packages/directive-functions-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { fileURLToPath, pathToFileURL } from 'node:url'

import { logDiff } from '@tanstack/router-utils'
import { compileDirectives } from './compilers'
import type { CompileDirectivesOpts, DirectiveFn } from './compilers'
import type {
CompileDirectivesOpts,
DirectiveFn,
GenerateFunctionIdFn,
} from './compilers'
import type { Plugin } from 'vite'

const debug =
Expand All @@ -13,6 +17,7 @@ export type {
DirectiveFn,
CompileDirectivesOpts,
ReplacerFn,
GenerateFunctionIdFn,
} from './compilers'

export type DirectiveFunctionsViteEnvOptions = Pick<
Expand All @@ -28,6 +33,7 @@ export type DirectiveFunctionsViteOptions = Pick<
> &
DirectiveFunctionsViteEnvOptions & {
onDirectiveFnsById?: (directiveFnsById: Record<string, DirectiveFn>) => void
generateFunctionId: GenerateFunctionIdFn
}

const createDirectiveRx = (directive: string) =>
Expand Down Expand Up @@ -61,6 +67,7 @@ export type DirectiveFunctionsVitePluginEnvOptions = Pick<
server: DirectiveFunctionsViteEnvOptions & { envName?: string }
}
onDirectiveFnsById?: (directiveFnsById: Record<string, DirectiveFn>) => void
generateFunctionId: GenerateFunctionIdFn
}

export function TanStackDirectiveFunctionsPluginEnv(
Expand Down Expand Up @@ -131,6 +138,7 @@ function transformCode({
directive,
directiveLabel,
getRuntimeCode,
generateFunctionId,
replacer,
onDirectiveFnsById,
root,
Expand All @@ -155,6 +163,7 @@ function transformCode({
directive,
directiveLabel,
getRuntimeCode,
generateFunctionId,
replacer,
code,
root,
Expand Down
23 changes: 20 additions & 3 deletions packages/directive-functions-plugin/tests/compiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,47 @@ import { describe, expect, test } from 'vitest'
import { compileDirectives } from '../src/compilers'
import type { CompileDirectivesOpts } from '../src/compilers'

function makeFunctionIdUrlSafe(location: string): string {
return location
.replace(/[^a-zA-Z0-9-_]/g, '_') // Replace unsafe chars with underscore
.replace(/_{2,}/g, '_') // Collapse multiple underscores
.replace(/^_|_$/g, '') // Trim leading/trailing underscores
.replace(/_--/g, '--') // Clean up the joiner
}

const generateFunctionId: CompileDirectivesOpts['generateFunctionId'] = (
opts,
) => {
return makeFunctionIdUrlSafe(`${opts.filename}--${opts.functionName}`)
}

const clientConfig: Omit<CompileDirectivesOpts, 'code'> = {
directive: 'use server',
directiveLabel: 'Server function',
root: './test-files',
filename: 'test.ts',
filename: './test-files/test.ts',
getRuntimeCode: () => 'import { createClientRpc } from "my-rpc-lib-client"',
generateFunctionId,
replacer: (opts) => `createClientRpc(${JSON.stringify(opts.functionId)})`,
}

const ssrConfig: Omit<CompileDirectivesOpts, 'code'> = {
directive: 'use server',
directiveLabel: 'Server function',
root: './test-files',
filename: 'test.ts',
filename: './test-files/test.ts',
getRuntimeCode: () => 'import { createSsrRpc } from "my-rpc-lib-server"',
generateFunctionId,
replacer: (opts) => `createSsrRpc(${JSON.stringify(opts.functionId)})`,
}

const serverConfig: Omit<CompileDirectivesOpts, 'code'> = {
directive: 'use server',
directiveLabel: 'Server function',
root: './test-files',
filename: 'test.ts',
filename: './test-files/test.ts',
getRuntimeCode: () => 'import { createServerRpc } from "my-rpc-lib-server"',
generateFunctionId,
replacer: (opts) =>
// On the server build, we need different code for the split function
// vs any other server functions the split function may reference
Expand Down
Loading
Loading