Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -305,9 +305,17 @@ export function handleCreateServerFn(
// 1. Create an extractedFn that calls __executeServer
// 2. Modify .handler() to pass (extractedFn, serverFn) - two arguments
//
// To avoid TDZ (Temporal Dead Zone) errors in production builds, we use an
// assignment expression inside .handler() instead of declaring the handler
// variable before the serverFn. This ensures proper initialization order
// when the bundler processes exports.
//
// Expected output format:
// const extractedFn = createServerRpc("id", (opts) => varName.__executeServer(opts));
// const varName = createServerFn().handler(extractedFn, originalHandler);
// let extractedFn;
// const varName = createServerFn().handler(
// extractedFn = createServerRpc("id", (opts) => varName.__executeServer(opts)),
// originalHandler
// );

// Build the arrow function: (opts, signal) => varName.__executeServer(opts, signal)
// The signal parameter is passed through to allow abort signal propagation
Expand All @@ -328,9 +336,11 @@ export function handleCreateServerFn(
executeServerArrowFn,
)

// Build the extracted function statement
const extractedFnStatement = t.variableDeclaration('const', [
t.variableDeclarator(t.identifier(functionName), extractedFnInit),
// Build the extracted function declaration with 'let' (no initializer)
// Using 'let' without initialization avoids TDZ - the variable exists
// but is undefined until the assignment expression runs
const extractedFnDeclaration = t.variableDeclaration('let', [
t.variableDeclarator(t.identifier(functionName)),
])

// Find the variable declaration statement containing our createServerFn
Expand All @@ -341,16 +351,24 @@ export function handleCreateServerFn(
)
}

// Insert the extracted function statement before the variable declaration
variableDeclaration.insertBefore(extractedFnStatement)
// Insert the 'let' declaration before the variable declaration
variableDeclaration.insertBefore(extractedFnDeclaration)

// Create an assignment expression: extractedFn = createServerRpc(...)
// This assigns the RPC during the .handler() call, ensuring the serverFn
// is being constructed when the assignment happens
const assignmentExpression = t.assignmentExpression(
'=',
t.identifier(functionName),
extractedFnInit,
)

// Modify the .handler() call to pass two arguments: (extractedFn, serverFn)
// The handlerFnPath.node contains the original serverFn
const extractedFnIdentifier = t.identifier(functionName)
// Modify the .handler() call to pass two arguments:
// (extractedFn = createServerRpc(...), serverFn)
const serverFnNode = t.cloneNode(handlerFn, true)

// Replace handler's arguments with [extractedFn, serverFn]
handler.callPath.node.arguments = [extractedFnIdentifier, serverFnNode]
// Replace handler's arguments with [assignmentExpression, serverFn]
handler.callPath.node.arguments = [assignmentExpression, serverFnNode]

// Only export the extracted handler (e.g., myFn_createServerFn_handler)
// The manifest and all import paths only look up this suffixed name.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { writeFileSync } from 'node:fs'
import path from 'pathe'
import type { GeneratorPlugin } from '@tanstack/router-generator'
import type { TanStackStartVitePluginCoreOptions } from '../../types'

export interface ModuleDeclarationPluginOptions {
generatedRouteTreePath: string
routerFilePath: string
startFilePath: string | undefined
corePluginOpts: TanStackStartVitePluginCoreOptions
}

/**
* This plugin generates a separate .d.ts file for the module augmentation
* that registers the router type with TanStack Start.
*
* By generating this in a separate .d.ts file instead of in the main
* routeTree.gen.ts file, we avoid creating a circular dependency that
* causes TDZ (Temporal Dead Zone) errors in Vite SSR.
*
* The circular dependency was:
* - router.tsx imports routeTree from routeTree.gen.ts
* - routeTree.gen.ts had `import type { getRouter } from './router.tsx'`
*
* Now the type import only exists in the .d.ts file, which TypeScript
* uses for type checking but Vite doesn't process at runtime.
*/
export function moduleDeclarationPlugin(
getOptions: () => ModuleDeclarationPluginOptions,
): GeneratorPlugin {
return {
name: 'module-declaration-plugin',
onRouteTreeChanged: () => {
const options = getOptions()
const dtsContent = generateModuleDeclaration(options)
const dtsPath = getDtsPath(options.generatedRouteTreePath)

writeFileSync(dtsPath, dtsContent, 'utf-8')
},
}
}

function getDtsPath(generatedRouteTreePath: string): string {
// Replace .ts or .tsx extension with .d.ts
// e.g., routeTree.gen.ts -> routeTree.gen.d.ts
return generatedRouteTreePath.replace(/\.tsx?$/, '.d.ts')
}

function generateModuleDeclaration(
options: ModuleDeclarationPluginOptions,
): string {
const { generatedRouteTreePath, routerFilePath, startFilePath, corePluginOpts } =
options

function getImportPath(absolutePath: string) {
let relativePath = path.relative(
path.dirname(generatedRouteTreePath),
absolutePath,
)

if (!relativePath.startsWith('.')) {
relativePath = './' + relativePath
}

// convert to POSIX-style for ESM imports (important on Windows)
relativePath = relativePath.split(path.sep).join('/')
return relativePath
}

const lines: Array<string> = [
'// This file was automatically generated by TanStack Start.',
'// It provides type declarations for the router registration.',
'// This file is separate from routeTree.gen.ts to avoid circular dependencies',
'// that can cause TDZ errors in Vite SSR.',
'',
`import type { getRouter } from '${getImportPath(routerFilePath)}'`,
]

if (startFilePath) {
lines.push(
`import type { startInstance } from '${getImportPath(startFilePath)}'`,
)
} else {
// make sure we import something from start to get the server route declaration merge
lines.push(
`import type { createStart } from '@tanstack/${corePluginOpts.framework}-start'`,
)
}

lines.push('')
lines.push(`declare module '@tanstack/${corePluginOpts.framework}-start' {`)
lines.push(` interface Register {`)
lines.push(` ssr: true`)
lines.push(` router: Awaited<ReturnType<typeof getRouter>>`)

if (startFilePath) {
lines.push(
` config: Awaited<ReturnType<typeof startInstance.getOptions>>`,
)
}

lines.push(` }`)
lines.push(`}`)
lines.push('')

return lines.join('\n')
}
89 changes: 20 additions & 69 deletions packages/start-plugin-core/src/start-router-plugin/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import path from 'pathe'
import { VITE_ENVIRONMENT_NAMES } from '../constants'
import { routesManifestPlugin } from './generator-plugins/routes-manifest-plugin'
import { prerenderRoutesPlugin } from './generator-plugins/prerender-routes-plugin'
import { moduleDeclarationPlugin } from './generator-plugins/module-declaration-plugin'
import { pruneServerOnlySubtrees } from './pruneServerOnlySubtrees'
import { SERVER_PROP } from './constants'
import type { GetConfigFn, TanStackStartVitePluginCoreOptions } from '../types'
Expand All @@ -29,63 +30,6 @@ function isServerOnlyNode(node: RouteNode | undefined) {
)
}

function moduleDeclaration({
startFilePath,
routerFilePath,
corePluginOpts,
generatedRouteTreePath,
}: {
startFilePath: string | undefined
routerFilePath: string
corePluginOpts: TanStackStartVitePluginCoreOptions
generatedRouteTreePath: string
}): string {
function getImportPath(absolutePath: string) {
let relativePath = path.relative(
path.dirname(generatedRouteTreePath),
absolutePath,
)

if (!relativePath.startsWith('.')) {
relativePath = './' + relativePath
}

// convert to POSIX-style for ESM imports (important on Windows)
relativePath = relativePath.split(path.sep).join('/')
return relativePath
}

const result: Array<string> = [
`import type { getRouter } from '${getImportPath(routerFilePath)}'`,
]
if (startFilePath) {
result.push(
`import type { startInstance } from '${getImportPath(startFilePath)}'`,
)
}
// make sure we import something from start to get the server route declaration merge
else {
result.push(
`import type { createStart } from '@tanstack/${corePluginOpts.framework}-start'`,
)
}
result.push(
`declare module '@tanstack/${corePluginOpts.framework}-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>`,
)
if (startFilePath) {
result.push(
` config: Awaited<ReturnType<typeof startInstance.getOptions>>`,
)
}
result.push(` }
}`)

return result.join('\n')
}

export function tanStackStartRouter(
startPluginOpts: TanStackStartInputConfig,
getConfig: GetConfigFn,
Expand Down Expand Up @@ -131,7 +75,7 @@ export function tanStackStartRouter(
if (routeTreeFileFooter) {
return routeTreeFileFooter
}
const { startConfig, resolvedStartConfig } = getConfig()
const { startConfig } = getConfig()
const ogRouteTreeFileFooter = startConfig.router.routeTreeFileFooter
if (ogRouteTreeFileFooter) {
if (Array.isArray(ogRouteTreeFileFooter)) {
Expand All @@ -140,15 +84,10 @@ export function tanStackStartRouter(
routeTreeFileFooter = ogRouteTreeFileFooter()
}
}
routeTreeFileFooter = [
moduleDeclaration({
generatedRouteTreePath: getGeneratedRouteTreePath(),
corePluginOpts,
startFilePath: resolvedStartConfig.startFilePath,
routerFilePath: resolvedStartConfig.routerFilePath,
}),
...(routeTreeFileFooter ?? []),
]
// Note: Module declaration is now generated in a separate .d.ts file
// by the moduleDeclarationPlugin to avoid circular dependencies
// that cause TDZ errors in Vite SSR
routeTreeFileFooter = routeTreeFileFooter ?? []
return routeTreeFileFooter
}

Expand Down Expand Up @@ -209,8 +148,20 @@ export function tanStackStartRouter(
return [
clientTreePlugin,
tanstackRouterGenerator(() => {
const routerConfig = getConfig().startConfig.router
const plugins = [clientTreeGeneratorPlugin, routesManifestPlugin()]
const { startConfig, resolvedStartConfig } = getConfig()
const routerConfig = startConfig.router
const plugins = [
clientTreeGeneratorPlugin,
routesManifestPlugin(),
// Generate module declaration in a separate .d.ts file to avoid
// circular dependencies that cause TDZ errors in Vite SSR
moduleDeclarationPlugin(() => ({
generatedRouteTreePath: getGeneratedRouteTreePath(),
routerFilePath: resolvedStartConfig.routerFilePath,
startFilePath: resolvedStartConfig.startFilePath,
corePluginOpts,
})),
]
if (startPluginOpts?.prerender?.enabled === true) {
plugins.push(prerenderRoutesPlugin())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ describe('createServerFn compiles correctly', async () => {
const myFunc = () => {
return 'hello from the server';
};
const myServerFn_createServerFn_handler = createServerRpc("eyJmaWxlIjoiL0BpZC90ZXN0LnRzP3Rzcy1zZXJ2ZXJmbi1zcGxpdCIsImV4cG9ydCI6Im15U2VydmVyRm5fY3JlYXRlU2VydmVyRm5faGFuZGxlciJ9", (opts, signal) => myServerFn.__executeServer(opts, signal));
const myServerFn = createServerFn().handler(myServerFn_createServerFn_handler, myFunc);
let myServerFn_createServerFn_handler;
const myServerFn = createServerFn().handler(myServerFn_createServerFn_handler = createServerRpc("eyJmaWxlIjoiL0BpZC90ZXN0LnRzP3Rzcy1zZXJ2ZXJmbi1zcGxpdCIsImV4cG9ydCI6Im15U2VydmVyRm5fY3JlYXRlU2VydmVyRm5faGFuZGxlciJ9", (opts, signal) => myServerFn.__executeServer(opts, signal)), myFunc);
export { myServerFn_createServerFn_handler };"
`)
})
Expand Down Expand Up @@ -194,14 +194,10 @@ describe('createServerFn compiles correctly', async () => {
expect(compiledResultServerProvider!.code).toMatchInlineSnapshot(`
"import { createServerRpc } from '@tanstack/react-start/server-rpc';
import { createServerFn } from '@tanstack/react-start';
const exportedVar = 'exported';
const exportedFn_createServerFn_handler = createServerRpc("eyJmaWxlIjoiL0BpZC90ZXN0LnRzP3Rzcy1zZXJ2ZXJmbi1zcGxpdCIsImV4cG9ydCI6ImV4cG9ydGVkRm5fY3JlYXRlU2VydmVyRm5faGFuZGxlciJ9", (opts, signal) => exportedFn.__executeServer(opts, signal));
const exportedFn = createServerFn().handler(exportedFn_createServerFn_handler, async () => {
return exportedVar;
});
let exportedFn_createServerFn_handler;
const nonExportedVar = 'non-exported';
const nonExportedFn_createServerFn_handler = createServerRpc("eyJmaWxlIjoiL0BpZC90ZXN0LnRzP3Rzcy1zZXJ2ZXJmbi1zcGxpdCIsImV4cG9ydCI6Im5vbkV4cG9ydGVkRm5fY3JlYXRlU2VydmVyRm5faGFuZGxlciJ9", (opts, signal) => nonExportedFn.__executeServer(opts, signal));
const nonExportedFn = createServerFn().handler(nonExportedFn_createServerFn_handler, async () => {
let nonExportedFn_createServerFn_handler;
const nonExportedFn = createServerFn().handler(nonExportedFn_createServerFn_handler = createServerRpc("eyJmaWxlIjoiL0BpZC90ZXN0LnRzP3Rzcy1zZXJ2ZXJmbi1zcGxpdCIsImV4cG9ydCI6Im5vbkV4cG9ydGVkRm5fY3JlYXRlU2VydmVyRm5faGFuZGxlciJ9", (opts, signal) => nonExportedFn.__executeServer(opts, signal)), async () => {
return nonExportedVar;
});
export { exportedFn_createServerFn_handler, nonExportedFn_createServerFn_handler };"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,58 +1,9 @@
import { createServerRpc } from '@tanstack/react-start/server-rpc';
import { createServerFn } from '@tanstack/react-start';
import { z } from 'zod';
const withUseServer_createServerFn_handler = createServerRpc("eyJmaWxlIjoiL0BpZC90ZXN0LnRzP3Rzcy1zZXJ2ZXJmbi1zcGxpdCIsImV4cG9ydCI6IndpdGhVc2VTZXJ2ZXJfY3JlYXRlU2VydmVyRm5faGFuZGxlciJ9", (opts, signal) => withUseServer.__executeServer(opts, signal));
const withUseServer = createServerFn({
method: 'GET'
}).handler(withUseServer_createServerFn_handler, async function () {
console.info('Fetching posts...');
await new Promise(r => setTimeout(r, 500));
return axios.get<Array<PostType>>('https://jsonplaceholder.typicode.com/posts').then(r => r.data.slice(0, 10));
});
const withArrowFunction_createServerFn_handler = createServerRpc("eyJmaWxlIjoiL0BpZC90ZXN0LnRzP3Rzcy1zZXJ2ZXJmbi1zcGxpdCIsImV4cG9ydCI6IndpdGhBcnJvd0Z1bmN0aW9uX2NyZWF0ZVNlcnZlckZuX2hhbmRsZXIifQ", (opts, signal) => withArrowFunction.__executeServer(opts, signal));
const withArrowFunction = createServerFn({
method: 'GET'
}).handler(withArrowFunction_createServerFn_handler, async () => null);
const withArrowFunctionAndFunction_createServerFn_handler = createServerRpc("eyJmaWxlIjoiL0BpZC90ZXN0LnRzP3Rzcy1zZXJ2ZXJmbi1zcGxpdCIsImV4cG9ydCI6IndpdGhBcnJvd0Z1bmN0aW9uQW5kRnVuY3Rpb25fY3JlYXRlU2VydmVyRm5faGFuZGxlciJ9", (opts, signal) => withArrowFunctionAndFunction.__executeServer(opts, signal));
const withArrowFunctionAndFunction = createServerFn({
method: 'GET'
}).handler(withArrowFunctionAndFunction_createServerFn_handler, async () => test());
const withoutUseServer_createServerFn_handler = createServerRpc("eyJmaWxlIjoiL0BpZC90ZXN0LnRzP3Rzcy1zZXJ2ZXJmbi1zcGxpdCIsImV4cG9ydCI6IndpdGhvdXRVc2VTZXJ2ZXJfY3JlYXRlU2VydmVyRm5faGFuZGxlciJ9", (opts, signal) => withoutUseServer.__executeServer(opts, signal));
const withoutUseServer = createServerFn({
method: 'GET'
}).handler(withoutUseServer_createServerFn_handler, async () => {
console.info('Fetching posts...');
await new Promise(r => setTimeout(r, 500));
return axios.get<Array<PostType>>('https://jsonplaceholder.typicode.com/posts').then(r => r.data.slice(0, 10));
});
const withVariable_createServerFn_handler = createServerRpc("eyJmaWxlIjoiL0BpZC90ZXN0LnRzP3Rzcy1zZXJ2ZXJmbi1zcGxpdCIsImV4cG9ydCI6IndpdGhWYXJpYWJsZV9jcmVhdGVTZXJ2ZXJGbl9oYW5kbGVyIn0", (opts, signal) => withVariable.__executeServer(opts, signal));
const withVariable = createServerFn({
method: 'GET'
}).handler(withVariable_createServerFn_handler, abstractedFunction);
async function abstractedFunction() {
console.info('Fetching posts...');
await new Promise(r => setTimeout(r, 500));
return axios.get<Array<PostType>>('https://jsonplaceholder.typicode.com/posts').then(r => r.data.slice(0, 10));
}
function zodValidator<TSchema extends z.ZodSchema, TResult>(schema: TSchema, fn: (input: z.output<TSchema>) => TResult) {
return async (input: unknown) => {
return fn(schema.parse(input));
};
}
const withZodValidator_createServerFn_handler = createServerRpc("eyJmaWxlIjoiL0BpZC90ZXN0LnRzP3Rzcy1zZXJ2ZXJmbi1zcGxpdCIsImV4cG9ydCI6IndpdGhab2RWYWxpZGF0b3JfY3JlYXRlU2VydmVyRm5faGFuZGxlciJ9", (opts, signal) => withZodValidator.__executeServer(opts, signal));
const withZodValidator = createServerFn({
method: 'GET'
}).handler(withZodValidator_createServerFn_handler, zodValidator(z.number(), input => {
return {
'you gave': input
};
}));
const withValidatorFn_createServerFn_handler = createServerRpc("eyJmaWxlIjoiL0BpZC90ZXN0LnRzP3Rzcy1zZXJ2ZXJmbi1zcGxpdCIsImV4cG9ydCI6IndpdGhWYWxpZGF0b3JGbl9jcmVhdGVTZXJ2ZXJGbl9oYW5kbGVyIn0", (opts, signal) => withValidatorFn.__executeServer(opts, signal));
const withValidatorFn = createServerFn({
method: 'GET'
}).inputValidator(z.number()).handler(withValidatorFn_createServerFn_handler, async ({
input
}) => {
return null;
});
let withUseServer_createServerFn_handler;
let withArrowFunction_createServerFn_handler;
let withArrowFunctionAndFunction_createServerFn_handler;
let withoutUseServer_createServerFn_handler;
let withVariable_createServerFn_handler;
let withZodValidator_createServerFn_handler;
let withValidatorFn_createServerFn_handler;
export { withUseServer_createServerFn_handler, withArrowFunction_createServerFn_handler, withArrowFunctionAndFunction_createServerFn_handler, withoutUseServer_createServerFn_handler, withVariable_createServerFn_handler, withZodValidator_createServerFn_handler, withValidatorFn_createServerFn_handler };
Loading