Skip to content

Commit 3c0c9cc

Browse files
fix: Add support for customizable functionId generation. (#5373)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 9416eba commit 3c0c9cc

File tree

12 files changed

+216
-39
lines changed

12 files changed

+216
-39
lines changed

docs/start/framework/react/server-functions.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,52 @@ Cache server function results at build time for static generation. See [Static S
224224

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

227+
### Function ID generation
228+
229+
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.
230+
231+
Defaults:
232+
233+
- In development, IDs are URL-safe strings derived from `${filename}--${functionName}` to aid debugging.
234+
- In production, IDs are SHA256 hashes of the same seed to keep bundles compact and avoid leaking file paths.
235+
- 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.
236+
- IDs are stable for a given file/function tuple for the lifetime of the process (hot updates keep the same mapping).
237+
238+
Customization:
239+
240+
You can customize function ID generation by providing a `generateFunctionId` function when configuring the TanStack Start Vite plugin:
241+
242+
Example:
243+
244+
```ts
245+
// vite.config.ts
246+
import { defineConfig } from 'vite'
247+
import react from '@vitejs/plugin-react'
248+
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
249+
250+
export default defineConfig({
251+
plugins: [
252+
tanstackStart({
253+
serverFns: {
254+
generateFunctionId: ({ filename, functionName }) => {
255+
// Return a custom ID string. If you return undefined, the default is used.
256+
// For example, always hash (even in dev):
257+
// return createHash('sha256').update(`${filename}--${functionName}`).digest('hex')
258+
return undefined
259+
},
260+
},
261+
}),
262+
react(),
263+
],
264+
})
265+
```
266+
267+
Tips:
268+
269+
- Prefer deterministic inputs (filename + functionName) so IDs remain stable between builds.
270+
- If you don’t want file paths in dev IDs, return a hash in all environments.
271+
- Ensure the returned ID is **URL-safe**.
272+
227273
---
228274

229275
> **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.

e2e/react-start/custom-basepath/tests/navigation.spec.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,7 @@ test('Server function URLs correctly include app basepath', async ({
4141
const form = page.locator('form')
4242
const actionUrl = await form.getAttribute('action')
4343

44-
expect(actionUrl).toBe(
45-
'/custom/basepath/_serverFn/src_routes_logout_tsx--logoutFn_createServerFn_handler',
46-
)
44+
expect(actionUrl).toMatch(/^\/custom\/basepath\/_serverFn\//)
4745
})
4846

4947
test('client-side redirect', async ({ page, baseURL }) => {

e2e/react-start/server-functions/tests/server-functions.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ import type { Page } from '@playwright/test'
66

77
const PORT = await getTestServerPort(packageJson.name)
88

9+
test('Server function URLs correctly include constant ids', async ({
10+
page,
11+
}) => {
12+
for (const currentPage of ['/submit-post-formdata', '/formdata-redirect']) {
13+
await page.goto(currentPage)
14+
await page.waitForLoadState('networkidle')
15+
16+
const form = page.locator('form')
17+
const actionUrl = await form.getAttribute('action')
18+
19+
expect(actionUrl).toMatch(/^\/_serverFn\/constant_id/)
20+
}
21+
})
22+
923
test('invoking a server function with custom response status code', async ({
1024
page,
1125
}) => {

e2e/react-start/server-functions/vite.config.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,25 @@ import tsConfigPaths from 'vite-tsconfig-paths'
33
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
44
import viteReact from '@vitejs/plugin-react'
55

6+
const FUNCTIONS_WITH_CONSTANT_ID = [
7+
'src/routes/submit-post-formdata.tsx/greetUser_createServerFn_handler',
8+
'src/routes/formdata-redirect/index.tsx/greetUser_createServerFn_handler',
9+
]
10+
611
export default defineConfig({
712
plugins: [
813
tsConfigPaths({
914
projects: ['./tsconfig.json'],
1015
}),
11-
tanstackStart(),
16+
tanstackStart({
17+
serverFns: {
18+
generateFunctionId: (opts) => {
19+
const id = `${opts.filename}/${opts.functionName}`
20+
if (FUNCTIONS_WITH_CONSTANT_ID.includes(id)) return 'constant_id'
21+
else return undefined
22+
},
23+
},
24+
}),
1225
viteReact(),
1326
],
1427
})

packages/directive-functions-plugin/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"@babel/types": "^7.27.7",
7272
"@tanstack/router-utils": "workspace:*",
7373
"babel-dead-code-elimination": "^1.0.10",
74+
"pathe": "^2.0.3",
7475
"tiny-invariant": "^1.3.3"
7576
},
7677
"devDependencies": {

packages/directive-functions-plugin/src/compilers.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
deadCodeElimination,
66
findReferencedIdentifiers,
77
} from 'babel-dead-code-elimination'
8+
import path from 'pathe'
89
import { generateFromAst, parseAst } from '@tanstack/router-utils'
910
import type { GeneratorResult, ParseAstOptions } from '@tanstack/router-utils'
1011

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

26+
export type GenerateFunctionIdFn = (opts: {
27+
filename: string
28+
functionName: string
29+
}) => string
30+
2531
export type ReplacerFn = (opts: {
2632
fn: string
2733
extractedFilename: string
@@ -38,6 +44,7 @@ export type CompileDirectivesOpts = ParseAstOptions & {
3844
getRuntimeCode?: (opts: {
3945
directiveFnsById: Record<string, DirectiveFn>
4046
}) => string
47+
generateFunctionId: GenerateFunctionIdFn
4148
replacer: ReplacerFn
4249
// devSplitImporter: string
4350
filename: string
@@ -198,14 +205,6 @@ function findNearestVariableName(
198205
return nameParts.length > 0 ? nameParts.join('_') : 'anonymous'
199206
}
200207

201-
function makeFileLocationUrlSafe(location: string): string {
202-
return location
203-
.replace(/[^a-zA-Z0-9-_]/g, '_') // Replace unsafe chars with underscore
204-
.replace(/_{2,}/g, '_') // Collapse multiple underscores
205-
.replace(/^_|_$/g, '') // Trim leading/trailing underscores
206-
.replace(/_--/g, '--') // Clean up the joiner
207-
}
208-
209208
function makeIdentifierSafe(identifier: string): string {
210209
return identifier
211210
.replace(/[^a-zA-Z0-9_$]/g, '_') // Replace unsafe chars with underscore
@@ -221,6 +220,7 @@ export function findDirectives(
221220
directive: string
222221
directiveLabel: string
223222
replacer?: ReplacerFn
223+
generateFunctionId: GenerateFunctionIdFn
224224
directiveSplitParam: string
225225
filename: string
226226
root: string
@@ -460,16 +460,22 @@ export function findDirectives(
460460
`body.${topParentIndex}.declarations.0.init`,
461461
) as SupportedFunctionPath
462462

463-
const [baseFilename, ..._searchParams] = opts.filename.split('?')
463+
const [baseFilename, ..._searchParams] = opts.filename.split('?') as [
464+
string,
465+
...Array<string>,
466+
]
464467
const searchParams = new URLSearchParams(_searchParams.join('&'))
465468
searchParams.set(opts.directiveSplitParam, '')
466469

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

469-
const functionId = makeFileLocationUrlSafe(
470-
`${baseFilename}--${functionName}`.replace(opts.root, ''),
471-
)
472-
472+
// Relative to have constant functionId regardless of the machine
473+
// that we are executing
474+
const relativeFilename = path.relative(opts.root, baseFilename)
475+
const functionId = opts.generateFunctionId({
476+
filename: relativeFilename,
477+
functionName: functionName,
478+
})
473479
// If a replacer is provided, replace the function with the replacer
474480
if (opts.replacer) {
475481
const replacer = opts.replacer({

packages/directive-functions-plugin/src/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
22

33
import { logDiff } from '@tanstack/router-utils'
44
import { compileDirectives } from './compilers'
5-
import type { CompileDirectivesOpts, DirectiveFn } from './compilers'
5+
import type {
6+
CompileDirectivesOpts,
7+
DirectiveFn,
8+
GenerateFunctionIdFn,
9+
} from './compilers'
610
import type { Plugin } from 'vite'
711

812
const debug =
@@ -13,6 +17,7 @@ export type {
1317
DirectiveFn,
1418
CompileDirectivesOpts,
1519
ReplacerFn,
20+
GenerateFunctionIdFn,
1621
} from './compilers'
1722

1823
export type DirectiveFunctionsViteEnvOptions = Pick<
@@ -28,6 +33,7 @@ export type DirectiveFunctionsViteOptions = Pick<
2833
> &
2934
DirectiveFunctionsViteEnvOptions & {
3035
onDirectiveFnsById?: (directiveFnsById: Record<string, DirectiveFn>) => void
36+
generateFunctionId: GenerateFunctionIdFn
3137
}
3238

3339
const createDirectiveRx = (directive: string) =>
@@ -61,6 +67,7 @@ export type DirectiveFunctionsVitePluginEnvOptions = Pick<
6167
server: DirectiveFunctionsViteEnvOptions & { envName?: string }
6268
}
6369
onDirectiveFnsById?: (directiveFnsById: Record<string, DirectiveFn>) => void
70+
generateFunctionId: GenerateFunctionIdFn
6471
}
6572

6673
export function TanStackDirectiveFunctionsPluginEnv(
@@ -131,6 +138,7 @@ function transformCode({
131138
directive,
132139
directiveLabel,
133140
getRuntimeCode,
141+
generateFunctionId,
134142
replacer,
135143
onDirectiveFnsById,
136144
root,
@@ -155,6 +163,7 @@ function transformCode({
155163
directive,
156164
directiveLabel,
157165
getRuntimeCode,
166+
generateFunctionId,
158167
replacer,
159168
code,
160169
root,

packages/directive-functions-plugin/tests/compiler.test.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,47 @@ import { describe, expect, test } from 'vitest'
33
import { compileDirectives } from '../src/compilers'
44
import type { CompileDirectivesOpts } from '../src/compilers'
55

6+
function makeFunctionIdUrlSafe(location: string): string {
7+
return location
8+
.replace(/[^a-zA-Z0-9-_]/g, '_') // Replace unsafe chars with underscore
9+
.replace(/_{2,}/g, '_') // Collapse multiple underscores
10+
.replace(/^_|_$/g, '') // Trim leading/trailing underscores
11+
.replace(/_--/g, '--') // Clean up the joiner
12+
}
13+
14+
const generateFunctionId: CompileDirectivesOpts['generateFunctionId'] = (
15+
opts,
16+
) => {
17+
return makeFunctionIdUrlSafe(`${opts.filename}--${opts.functionName}`)
18+
}
19+
620
const clientConfig: Omit<CompileDirectivesOpts, 'code'> = {
721
directive: 'use server',
822
directiveLabel: 'Server function',
923
root: './test-files',
10-
filename: 'test.ts',
24+
filename: './test-files/test.ts',
1125
getRuntimeCode: () => 'import { createClientRpc } from "my-rpc-lib-client"',
26+
generateFunctionId,
1227
replacer: (opts) => `createClientRpc(${JSON.stringify(opts.functionId)})`,
1328
}
1429

1530
const ssrConfig: Omit<CompileDirectivesOpts, 'code'> = {
1631
directive: 'use server',
1732
directiveLabel: 'Server function',
1833
root: './test-files',
19-
filename: 'test.ts',
34+
filename: './test-files/test.ts',
2035
getRuntimeCode: () => 'import { createSsrRpc } from "my-rpc-lib-server"',
36+
generateFunctionId,
2137
replacer: (opts) => `createSsrRpc(${JSON.stringify(opts.functionId)})`,
2238
}
2339

2440
const serverConfig: Omit<CompileDirectivesOpts, 'code'> = {
2541
directive: 'use server',
2642
directiveLabel: 'Server function',
2743
root: './test-files',
28-
filename: 'test.ts',
44+
filename: './test-files/test.ts',
2945
getRuntimeCode: () => 'import { createServerRpc } from "my-rpc-lib-server"',
46+
generateFunctionId,
3047
replacer: (opts) =>
3148
// On the server build, we need different code for the split function
3249
// vs any other server functions the split function may reference

0 commit comments

Comments
 (0)