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
21 changes: 21 additions & 0 deletions e2e/react-start/server-functions/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as SubmitPostFormdataRouteImport } from './routes/submit-post-formdata'
import { Route as StatusRouteImport } from './routes/status'
import { Route as ServerOnlyFnRouteImport } from './routes/server-only-fn'
import { Route as ServerFnInClientOnlyFnRouteImport } from './routes/server-fn-in-client-only-fn'
import { Route as SerializeFormDataRouteImport } from './routes/serialize-form-data'
import { Route as ReturnNullRouteImport } from './routes/return-null'
import { Route as RawResponseRouteImport } from './routes/raw-response'
Expand Down Expand Up @@ -55,6 +56,11 @@ const ServerOnlyFnRoute = ServerOnlyFnRouteImport.update({
path: '/server-only-fn',
getParentRoute: () => rootRouteImport,
} as any)
const ServerFnInClientOnlyFnRoute = ServerFnInClientOnlyFnRouteImport.update({
id: '/server-fn-in-client-only-fn',
path: '/server-fn-in-client-only-fn',
getParentRoute: () => rootRouteImport,
} as any)
const SerializeFormDataRoute = SerializeFormDataRouteImport.update({
id: '/serialize-form-data',
path: '/serialize-form-data',
Expand Down Expand Up @@ -206,6 +212,7 @@ export interface FileRoutesByFullPath {
'/raw-response': typeof RawResponseRoute
'/return-null': typeof ReturnNullRoute
'/serialize-form-data': typeof SerializeFormDataRoute
'/server-fn-in-client-only-fn': typeof ServerFnInClientOnlyFnRoute
'/server-only-fn': typeof ServerOnlyFnRoute
'/status': typeof StatusRoute
'/submit-post-formdata': typeof SubmitPostFormdataRoute
Expand Down Expand Up @@ -238,6 +245,7 @@ export interface FileRoutesByTo {
'/raw-response': typeof RawResponseRoute
'/return-null': typeof ReturnNullRoute
'/serialize-form-data': typeof SerializeFormDataRoute
'/server-fn-in-client-only-fn': typeof ServerFnInClientOnlyFnRoute
'/server-only-fn': typeof ServerOnlyFnRoute
'/status': typeof StatusRoute
'/submit-post-formdata': typeof SubmitPostFormdataRoute
Expand Down Expand Up @@ -271,6 +279,7 @@ export interface FileRoutesById {
'/raw-response': typeof RawResponseRoute
'/return-null': typeof ReturnNullRoute
'/serialize-form-data': typeof SerializeFormDataRoute
'/server-fn-in-client-only-fn': typeof ServerFnInClientOnlyFnRoute
'/server-only-fn': typeof ServerOnlyFnRoute
'/status': typeof StatusRoute
'/submit-post-formdata': typeof SubmitPostFormdataRoute
Expand Down Expand Up @@ -305,6 +314,7 @@ export interface FileRouteTypes {
| '/raw-response'
| '/return-null'
| '/serialize-form-data'
| '/server-fn-in-client-only-fn'
| '/server-only-fn'
| '/status'
| '/submit-post-formdata'
Expand Down Expand Up @@ -337,6 +347,7 @@ export interface FileRouteTypes {
| '/raw-response'
| '/return-null'
| '/serialize-form-data'
| '/server-fn-in-client-only-fn'
| '/server-only-fn'
| '/status'
| '/submit-post-formdata'
Expand Down Expand Up @@ -369,6 +380,7 @@ export interface FileRouteTypes {
| '/raw-response'
| '/return-null'
| '/serialize-form-data'
| '/server-fn-in-client-only-fn'
| '/server-only-fn'
| '/status'
| '/submit-post-formdata'
Expand Down Expand Up @@ -402,6 +414,7 @@ export interface RootRouteChildren {
RawResponseRoute: typeof RawResponseRoute
ReturnNullRoute: typeof ReturnNullRoute
SerializeFormDataRoute: typeof SerializeFormDataRoute
ServerFnInClientOnlyFnRoute: typeof ServerFnInClientOnlyFnRoute
ServerOnlyFnRoute: typeof ServerOnlyFnRoute
StatusRoute: typeof StatusRoute
SubmitPostFormdataRoute: typeof SubmitPostFormdataRoute
Expand Down Expand Up @@ -446,6 +459,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ServerOnlyFnRouteImport
parentRoute: typeof rootRouteImport
}
'/server-fn-in-client-only-fn': {
id: '/server-fn-in-client-only-fn'
path: '/server-fn-in-client-only-fn'
fullPath: '/server-fn-in-client-only-fn'
preLoaderRoute: typeof ServerFnInClientOnlyFnRouteImport
parentRoute: typeof rootRouteImport
}
'/serialize-form-data': {
id: '/serialize-form-data'
path: '/serialize-form-data'
Expand Down Expand Up @@ -650,6 +670,7 @@ const rootRouteChildren: RootRouteChildren = {
RawResponseRoute: RawResponseRoute,
ReturnNullRoute: ReturnNullRoute,
SerializeFormDataRoute: SerializeFormDataRoute,
ServerFnInClientOnlyFnRoute: ServerFnInClientOnlyFnRoute,
ServerOnlyFnRoute: ServerOnlyFnRoute,
StatusRoute: StatusRoute,
SubmitPostFormdataRoute: SubmitPostFormdataRoute,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { createFileRoute } from '@tanstack/react-router'
import { createClientOnlyFn, createServerFn } from '@tanstack/react-start'
import { useState } from 'react'

// Server function that should be callable from client-only function
const serverFn = createServerFn().handler(() => {
return 'server function executed successfully'
})

// Client-only function that calls the server function
// This scenario currently fails due to compilation order issues:
// 1. createClientOnlyFn is processed first, removing the serverFn reference on server
// 2. Dead code elimination removes the serverFn entirely
// 3. The server function is never registered, causing runtime errors
const clientOnlyFnThatCallsServerFn = createClientOnlyFn(async () => {
const result = await serverFn()
return 'client-only fn received: ' + result
})

export const Route = createFileRoute('/server-fn-in-client-only-fn')({
component: RouteComponent,
})

function RouteComponent() {
const [result, setResult] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)

async function handleClick() {
try {
const res = await clientOnlyFnThatCallsServerFn()
setResult(res)
setError(null)
} catch (e) {
setResult(null)
setError(e instanceof Error ? e.message : String(e))
}
}

return (
<div>
<h1>Server Function in Client-Only Function Test</h1>
<p>
This test verifies that a server function can be called from inside a
createClientOnlyFn.
</p>
<button
onClick={handleClick}
data-testid="test-server-fn-in-client-only-fn-btn"
>
Call client-only function that calls server function
</button>
<pre data-testid="expected-result">
client-only fn received: server function executed successfully
</pre>
{result && (
<pre data-testid="server-fn-in-client-only-fn-result">{result}</pre>
)}
{error && (
<pre data-testid="server-fn-in-client-only-fn-error">{error}</pre>
)}
</div>
)
}
37 changes: 0 additions & 37 deletions e2e/react-start/server-functions/tests/server-functions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,40 +543,3 @@ test('redirect in server function called in query during SSR', async ({
await expect(page.getByTestId('redirect-target-ssr')).toBeVisible()
expect(page.url()).toContain('/redirect-test-ssr/target')
})

test('server function called only from server (not client) works correctly', async ({
page,
}) => {
await page.goto('/server-only-fn')

await page.waitForLoadState('networkidle')

const expected =
(await page.getByTestId('expected-server-only-fn-result').textContent()) ||
''
expect(expected).not.toBe('')

await page.getByTestId('test-server-only-fn-btn').click()
await page.waitForLoadState('networkidle')

await expect(page.getByTestId('server-only-fn-result')).toContainText(
expected,
)
})

test.use({
whitelistErrors: [
/Failed to load resource: the server responded with a status of 500/,
],
})
test('server function called only from server (not client) cannot be called from the client', async ({
page,
}) => {
await page.goto('/server-only-fn')
await page.waitForLoadState('networkidle')

await page.getByTestId('call-server-fn-from-client-btn').click()
await expect(
page.getByTestId('call-server-fn-from-client-result'),
).toContainText('error')
})
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,11 @@ export function handleCreateMiddleware(
env: 'client' | 'server'
},
) {
if (opts.env === 'server') {
throw new Error('handleCreateMiddleware should not be called on the server')
}
const rootCallExpression = getRootCallExpression(path)

// if (debug)
// console.info(
// 'Handling createMiddleware call expression:',
// rootCallExpression.toString(),
// )

const callExpressionPaths = {
middleware: null as babel.NodePath<t.CallExpression> | null,
inputValidator: null as babel.NodePath<t.CallExpression> | null,
Expand Down Expand Up @@ -51,28 +48,20 @@ export function handleCreateMiddleware(
)
}

// If we're on the client, remove the validator call expression
if (opts.env === 'client') {
if (
t.isMemberExpression(callExpressionPaths.inputValidator.node.callee)
) {
callExpressionPaths.inputValidator.replaceWith(
callExpressionPaths.inputValidator.node.callee.object,
)
}
// remove the validator call expression
if (t.isMemberExpression(callExpressionPaths.inputValidator.node.callee)) {
callExpressionPaths.inputValidator.replaceWith(
callExpressionPaths.inputValidator.node.callee.object,
)
}
}

const serverFnPath = callExpressionPaths.server?.get(
'arguments.0',
) as babel.NodePath<any>

if (
callExpressionPaths.server &&
serverFnPath.node &&
opts.env === 'client'
) {
// If we're on the client, remove the server call expression
if (callExpressionPaths.server && serverFnPath.node) {
// remove the server call expression
if (t.isMemberExpression(callExpressionPaths.server.node.callee)) {
callExpressionPaths.server.replaceWith(
callExpressionPaths.server.node.callee.object,
Expand Down
10 changes: 7 additions & 3 deletions packages/start-plugin-core/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,14 +385,17 @@ export function TanStackStartVitePluginCore(
},
},
tanStackStartRouter(startPluginOpts, getConfig, corePluginOpts),
// N.B. TanStackStartCompilerPlugin must be before the TanStackServerFnPlugin
startCompilerPlugin({ framework: corePluginOpts.framework, environments }),
// N.B. Server function plugins must run BEFORE startCompilerPlugin because:
// 1. createServerFnPlugin transforms createServerFn().handler() to inject 'use server' directive
// 2. TanStackServerFnPlugin extracts 'use server' functions and registers them in the manifest
// 3. startCompilerPlugin handles createClientOnlyFn/createServerOnlyFn and runs DCE
// If startCompilerPlugin runs first, DCE may remove server function code before it can be registered
// (e.g., when a server function is only referenced inside a createClientOnlyFn callback)
createServerFnPlugin({
framework: corePluginOpts.framework,
directive,
environments,
}),

TanStackServerFnPlugin({
// This is the ID that will be available to look up and import
// our server function manifest and resolve its module
Expand Down Expand Up @@ -428,6 +431,7 @@ export function TanStackStartVitePluginCore(
envName: serverFnProviderEnv,
},
}),
startCompilerPlugin({ framework: corePluginOpts.framework, environments }),
loadEnvPlugin(),
startManifestPlugin({
getClientBundle: () => getBundle(VITE_ENVIRONMENT_NAMES.client),
Expand Down
11 changes: 0 additions & 11 deletions packages/start-plugin-core/src/start-compiler-plugin/compilers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
findReferencedIdentifiers,
} from 'babel-dead-code-elimination'
import { generateFromAst, parseAst } from '@tanstack/router-utils'
import { handleCreateMiddleware } from '../create-server-fn-plugin/handleCreateMiddleware'
import { transformFuncs } from './constants'
import { handleCreateIsomorphicFnCallExpression } from './isomorphicFn'
import {
Expand Down Expand Up @@ -41,16 +40,6 @@ export function compileStartOutputFactory(
},
}

// createMiddleware only performs modifications in the client environment
// so we can avoid executing this on the server
if (opts.env === 'client') {
identifiers.createMiddleware = {
name: 'createMiddleware',
handleCallExpression: handleCreateMiddleware,
paths: [],
}
}

const ast = parseAst(opts)

const doDce = opts.dce ?? true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,4 @@ export const transformFuncs = [
'createServerOnlyFn',
'createClientOnlyFn',
'createIsomorphicFn',
'createMiddleware',
] as const
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,13 @@ describe('createMiddleware compiles correctly', async () => {
)
const code = file.toString()

test.each(['client', 'server'] as const)(
`should compile for ${filename} %s`,
async (env) => {
const result = await compile({ env, code, id: filename })
// Note: Middleware compilation only happens on the client
test(`should compile for ${filename} client`, async () => {
const result = await compile({ env: 'client', code, id: filename })

await expect(result!.code).toMatchFileSnapshot(
`./snapshots/${env}/${filename}`,
)
},
)
await expect(result!.code).toMatchFileSnapshot(
`./snapshots/client/${filename}`,
)
})
})
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createMiddleware } from '@tanstack/react-start';
import { foo } from '@some/lib';
export const fnMw = createMiddleware({
type: 'function'
}).client(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createMiddleware } from '@tanstack/react-start';
export const withUseServer = createMiddleware({
id: 'test'
});
export const withoutUseServer = createMiddleware({
id: 'test'
});
export const withVariable = createMiddleware({
id: 'test'
});
export const withZodValidator = createMiddleware({
id: 'test'
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createMiddleware as middlewareFn } from '@tanstack/react-start';
export const withUseServer = middlewareFn({
id: 'test'
});
export const withoutUseServer = middlewareFn({
id: 'test'
});
export const withVariable = middlewareFn({
id: 'test'
});
export const withZodValidator = middlewareFn({
id: 'test'
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as TanStackStart from '@tanstack/react-start';
export const withUseServer = TanStackStart.createMiddleware({
id: 'test'
});
export const withoutUseServer = TanStackStart.createMiddleware({
id: 'test'
});
export const withVariable = TanStackStart.createMiddleware({
id: 'test'
});
export const withZodValidator = TanStackStart.createMiddleware({
id: 'test'
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createMiddleware } from '@tanstack/react-start';
import { z } from 'zod';
export const withUseServer = createMiddleware({
id: 'test'
});
Loading
Loading