Skip to content

Commit a727225

Browse files
fix: correct compiler plugin order, only handle createMiddleware in a … (#6163)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 8a20521 commit a727225

31 files changed

Lines changed: 147 additions & 445 deletions

e2e/react-start/server-functions/src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
1212
import { Route as SubmitPostFormdataRouteImport } from './routes/submit-post-formdata'
1313
import { Route as StatusRouteImport } from './routes/status'
1414
import { Route as ServerOnlyFnRouteImport } from './routes/server-only-fn'
15+
import { Route as ServerFnInClientOnlyFnRouteImport } from './routes/server-fn-in-client-only-fn'
1516
import { Route as SerializeFormDataRouteImport } from './routes/serialize-form-data'
1617
import { Route as ReturnNullRouteImport } from './routes/return-null'
1718
import { Route as RawResponseRouteImport } from './routes/raw-response'
@@ -55,6 +56,11 @@ const ServerOnlyFnRoute = ServerOnlyFnRouteImport.update({
5556
path: '/server-only-fn',
5657
getParentRoute: () => rootRouteImport,
5758
} as any)
59+
const ServerFnInClientOnlyFnRoute = ServerFnInClientOnlyFnRouteImport.update({
60+
id: '/server-fn-in-client-only-fn',
61+
path: '/server-fn-in-client-only-fn',
62+
getParentRoute: () => rootRouteImport,
63+
} as any)
5864
const SerializeFormDataRoute = SerializeFormDataRouteImport.update({
5965
id: '/serialize-form-data',
6066
path: '/serialize-form-data',
@@ -206,6 +212,7 @@ export interface FileRoutesByFullPath {
206212
'/raw-response': typeof RawResponseRoute
207213
'/return-null': typeof ReturnNullRoute
208214
'/serialize-form-data': typeof SerializeFormDataRoute
215+
'/server-fn-in-client-only-fn': typeof ServerFnInClientOnlyFnRoute
209216
'/server-only-fn': typeof ServerOnlyFnRoute
210217
'/status': typeof StatusRoute
211218
'/submit-post-formdata': typeof SubmitPostFormdataRoute
@@ -238,6 +245,7 @@ export interface FileRoutesByTo {
238245
'/raw-response': typeof RawResponseRoute
239246
'/return-null': typeof ReturnNullRoute
240247
'/serialize-form-data': typeof SerializeFormDataRoute
248+
'/server-fn-in-client-only-fn': typeof ServerFnInClientOnlyFnRoute
241249
'/server-only-fn': typeof ServerOnlyFnRoute
242250
'/status': typeof StatusRoute
243251
'/submit-post-formdata': typeof SubmitPostFormdataRoute
@@ -271,6 +279,7 @@ export interface FileRoutesById {
271279
'/raw-response': typeof RawResponseRoute
272280
'/return-null': typeof ReturnNullRoute
273281
'/serialize-form-data': typeof SerializeFormDataRoute
282+
'/server-fn-in-client-only-fn': typeof ServerFnInClientOnlyFnRoute
274283
'/server-only-fn': typeof ServerOnlyFnRoute
275284
'/status': typeof StatusRoute
276285
'/submit-post-formdata': typeof SubmitPostFormdataRoute
@@ -305,6 +314,7 @@ export interface FileRouteTypes {
305314
| '/raw-response'
306315
| '/return-null'
307316
| '/serialize-form-data'
317+
| '/server-fn-in-client-only-fn'
308318
| '/server-only-fn'
309319
| '/status'
310320
| '/submit-post-formdata'
@@ -337,6 +347,7 @@ export interface FileRouteTypes {
337347
| '/raw-response'
338348
| '/return-null'
339349
| '/serialize-form-data'
350+
| '/server-fn-in-client-only-fn'
340351
| '/server-only-fn'
341352
| '/status'
342353
| '/submit-post-formdata'
@@ -369,6 +380,7 @@ export interface FileRouteTypes {
369380
| '/raw-response'
370381
| '/return-null'
371382
| '/serialize-form-data'
383+
| '/server-fn-in-client-only-fn'
372384
| '/server-only-fn'
373385
| '/status'
374386
| '/submit-post-formdata'
@@ -402,6 +414,7 @@ export interface RootRouteChildren {
402414
RawResponseRoute: typeof RawResponseRoute
403415
ReturnNullRoute: typeof ReturnNullRoute
404416
SerializeFormDataRoute: typeof SerializeFormDataRoute
417+
ServerFnInClientOnlyFnRoute: typeof ServerFnInClientOnlyFnRoute
405418
ServerOnlyFnRoute: typeof ServerOnlyFnRoute
406419
StatusRoute: typeof StatusRoute
407420
SubmitPostFormdataRoute: typeof SubmitPostFormdataRoute
@@ -446,6 +459,13 @@ declare module '@tanstack/react-router' {
446459
preLoaderRoute: typeof ServerOnlyFnRouteImport
447460
parentRoute: typeof rootRouteImport
448461
}
462+
'/server-fn-in-client-only-fn': {
463+
id: '/server-fn-in-client-only-fn'
464+
path: '/server-fn-in-client-only-fn'
465+
fullPath: '/server-fn-in-client-only-fn'
466+
preLoaderRoute: typeof ServerFnInClientOnlyFnRouteImport
467+
parentRoute: typeof rootRouteImport
468+
}
449469
'/serialize-form-data': {
450470
id: '/serialize-form-data'
451471
path: '/serialize-form-data'
@@ -650,6 +670,7 @@ const rootRouteChildren: RootRouteChildren = {
650670
RawResponseRoute: RawResponseRoute,
651671
ReturnNullRoute: ReturnNullRoute,
652672
SerializeFormDataRoute: SerializeFormDataRoute,
673+
ServerFnInClientOnlyFnRoute: ServerFnInClientOnlyFnRoute,
653674
ServerOnlyFnRoute: ServerOnlyFnRoute,
654675
StatusRoute: StatusRoute,
655676
SubmitPostFormdataRoute: SubmitPostFormdataRoute,
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { createFileRoute } from '@tanstack/react-router'
2+
import { createClientOnlyFn, createServerFn } from '@tanstack/react-start'
3+
import { useState } from 'react'
4+
5+
// Server function that should be callable from client-only function
6+
const serverFn = createServerFn().handler(() => {
7+
return 'server function executed successfully'
8+
})
9+
10+
// Client-only function that calls the server function
11+
// This scenario currently fails due to compilation order issues:
12+
// 1. createClientOnlyFn is processed first, removing the serverFn reference on server
13+
// 2. Dead code elimination removes the serverFn entirely
14+
// 3. The server function is never registered, causing runtime errors
15+
const clientOnlyFnThatCallsServerFn = createClientOnlyFn(async () => {
16+
const result = await serverFn()
17+
return 'client-only fn received: ' + result
18+
})
19+
20+
export const Route = createFileRoute('/server-fn-in-client-only-fn')({
21+
component: RouteComponent,
22+
})
23+
24+
function RouteComponent() {
25+
const [result, setResult] = useState<string | null>(null)
26+
const [error, setError] = useState<string | null>(null)
27+
28+
async function handleClick() {
29+
try {
30+
const res = await clientOnlyFnThatCallsServerFn()
31+
setResult(res)
32+
setError(null)
33+
} catch (e) {
34+
setResult(null)
35+
setError(e instanceof Error ? e.message : String(e))
36+
}
37+
}
38+
39+
return (
40+
<div>
41+
<h1>Server Function in Client-Only Function Test</h1>
42+
<p>
43+
This test verifies that a server function can be called from inside a
44+
createClientOnlyFn.
45+
</p>
46+
<button
47+
onClick={handleClick}
48+
data-testid="test-server-fn-in-client-only-fn-btn"
49+
>
50+
Call client-only function that calls server function
51+
</button>
52+
<pre data-testid="expected-result">
53+
client-only fn received: server function executed successfully
54+
</pre>
55+
{result && (
56+
<pre data-testid="server-fn-in-client-only-fn-result">{result}</pre>
57+
)}
58+
{error && (
59+
<pre data-testid="server-fn-in-client-only-fn-error">{error}</pre>
60+
)}
61+
</div>
62+
)
63+
}

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

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -543,40 +543,3 @@ test('redirect in server function called in query during SSR', async ({
543543
await expect(page.getByTestId('redirect-target-ssr')).toBeVisible()
544544
expect(page.url()).toContain('/redirect-test-ssr/target')
545545
})
546-
547-
test('server function called only from server (not client) works correctly', async ({
548-
page,
549-
}) => {
550-
await page.goto('/server-only-fn')
551-
552-
await page.waitForLoadState('networkidle')
553-
554-
const expected =
555-
(await page.getByTestId('expected-server-only-fn-result').textContent()) ||
556-
''
557-
expect(expected).not.toBe('')
558-
559-
await page.getByTestId('test-server-only-fn-btn').click()
560-
await page.waitForLoadState('networkidle')
561-
562-
await expect(page.getByTestId('server-only-fn-result')).toContainText(
563-
expected,
564-
)
565-
})
566-
567-
test.use({
568-
whitelistErrors: [
569-
/Failed to load resource: the server responded with a status of 500/,
570-
],
571-
})
572-
test('server function called only from server (not client) cannot be called from the client', async ({
573-
page,
574-
}) => {
575-
await page.goto('/server-only-fn')
576-
await page.waitForLoadState('networkidle')
577-
578-
await page.getByTestId('call-server-fn-from-client-btn').click()
579-
await expect(
580-
page.getByTestId('call-server-fn-from-client-result'),
581-
).toContainText('error')
582-
})

packages/start-plugin-core/src/create-server-fn-plugin/handleCreateMiddleware.ts

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,11 @@ export function handleCreateMiddleware(
88
env: 'client' | 'server'
99
},
1010
) {
11+
if (opts.env === 'server') {
12+
throw new Error('handleCreateMiddleware should not be called on the server')
13+
}
1114
const rootCallExpression = getRootCallExpression(path)
1215

13-
// if (debug)
14-
// console.info(
15-
// 'Handling createMiddleware call expression:',
16-
// rootCallExpression.toString(),
17-
// )
18-
1916
const callExpressionPaths = {
2017
middleware: null as babel.NodePath<t.CallExpression> | null,
2118
inputValidator: null as babel.NodePath<t.CallExpression> | null,
@@ -51,28 +48,20 @@ export function handleCreateMiddleware(
5148
)
5249
}
5350

54-
// If we're on the client, remove the validator call expression
55-
if (opts.env === 'client') {
56-
if (
57-
t.isMemberExpression(callExpressionPaths.inputValidator.node.callee)
58-
) {
59-
callExpressionPaths.inputValidator.replaceWith(
60-
callExpressionPaths.inputValidator.node.callee.object,
61-
)
62-
}
51+
// remove the validator call expression
52+
if (t.isMemberExpression(callExpressionPaths.inputValidator.node.callee)) {
53+
callExpressionPaths.inputValidator.replaceWith(
54+
callExpressionPaths.inputValidator.node.callee.object,
55+
)
6356
}
6457
}
6558

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

70-
if (
71-
callExpressionPaths.server &&
72-
serverFnPath.node &&
73-
opts.env === 'client'
74-
) {
75-
// If we're on the client, remove the server call expression
63+
if (callExpressionPaths.server && serverFnPath.node) {
64+
// remove the server call expression
7665
if (t.isMemberExpression(callExpressionPaths.server.node.callee)) {
7766
callExpressionPaths.server.replaceWith(
7867
callExpressionPaths.server.node.callee.object,

packages/start-plugin-core/src/plugin.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -385,14 +385,17 @@ export function TanStackStartVitePluginCore(
385385
},
386386
},
387387
tanStackStartRouter(startPluginOpts, getConfig, corePluginOpts),
388-
// N.B. TanStackStartCompilerPlugin must be before the TanStackServerFnPlugin
389-
startCompilerPlugin({ framework: corePluginOpts.framework, environments }),
388+
// N.B. Server function plugins must run BEFORE startCompilerPlugin because:
389+
// 1. createServerFnPlugin transforms createServerFn().handler() to inject 'use server' directive
390+
// 2. TanStackServerFnPlugin extracts 'use server' functions and registers them in the manifest
391+
// 3. startCompilerPlugin handles createClientOnlyFn/createServerOnlyFn and runs DCE
392+
// If startCompilerPlugin runs first, DCE may remove server function code before it can be registered
393+
// (e.g., when a server function is only referenced inside a createClientOnlyFn callback)
390394
createServerFnPlugin({
391395
framework: corePluginOpts.framework,
392396
directive,
393397
environments,
394398
}),
395-
396399
TanStackServerFnPlugin({
397400
// This is the ID that will be available to look up and import
398401
// our server function manifest and resolve its module
@@ -428,6 +431,7 @@ export function TanStackStartVitePluginCore(
428431
envName: serverFnProviderEnv,
429432
},
430433
}),
434+
startCompilerPlugin({ framework: corePluginOpts.framework, environments }),
431435
loadEnvPlugin(),
432436
startManifestPlugin({
433437
getClientBundle: () => getBundle(VITE_ENVIRONMENT_NAMES.client),

packages/start-plugin-core/src/start-compiler-plugin/compilers.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
findReferencedIdentifiers,
77
} from 'babel-dead-code-elimination'
88
import { generateFromAst, parseAst } from '@tanstack/router-utils'
9-
import { handleCreateMiddleware } from '../create-server-fn-plugin/handleCreateMiddleware'
109
import { transformFuncs } from './constants'
1110
import { handleCreateIsomorphicFnCallExpression } from './isomorphicFn'
1211
import {
@@ -41,16 +40,6 @@ export function compileStartOutputFactory(
4140
},
4241
}
4342

44-
// createMiddleware only performs modifications in the client environment
45-
// so we can avoid executing this on the server
46-
if (opts.env === 'client') {
47-
identifiers.createMiddleware = {
48-
name: 'createMiddleware',
49-
handleCallExpression: handleCreateMiddleware,
50-
paths: [],
51-
}
52-
}
53-
5443
const ast = parseAst(opts)
5544

5645
const doDce = opts.dce ?? true

packages/start-plugin-core/src/start-compiler-plugin/constants.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,4 @@ export const transformFuncs = [
22
'createServerOnlyFn',
33
'createClientOnlyFn',
44
'createIsomorphicFn',
5-
'createMiddleware',
65
] as const

packages/start-plugin-core/tests/createMiddleware-create-server-fn-plugin/createMiddleware.test.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,13 @@ describe('createMiddleware compiles correctly', async () => {
5050
)
5151
const code = file.toString()
5252

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

58-
await expect(result!.code).toMatchFileSnapshot(
59-
`./snapshots/${env}/${filename}`,
60-
)
61-
},
62-
)
57+
await expect(result!.code).toMatchFileSnapshot(
58+
`./snapshots/client/${filename}`,
59+
)
60+
})
6361
})
6462
})

packages/start-plugin-core/tests/createMiddleware-start-compiler-plugin/snapshots/client/create-function-middleware.ts renamed to packages/start-plugin-core/tests/createMiddleware-create-server-fn-plugin/snapshots/client/create-function-middleware.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { createMiddleware } from '@tanstack/react-start';
2-
import { foo } from '@some/lib';
32
export const fnMw = createMiddleware({
43
type: 'function'
54
}).client(() => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { createMiddleware } from '@tanstack/react-start';
2+
export const withUseServer = createMiddleware({
3+
id: 'test'
4+
});
5+
export const withoutUseServer = createMiddleware({
6+
id: 'test'
7+
});
8+
export const withVariable = createMiddleware({
9+
id: 'test'
10+
});
11+
export const withZodValidator = createMiddleware({
12+
id: 'test'
13+
});

0 commit comments

Comments
 (0)