Skip to content

Commit 4ec65ae

Browse files
fix: restructure server function environment handling (#6160)
1 parent 22d1b4a commit 4ec65ae

File tree

21 files changed

+565
-69
lines changed

21 files changed

+565
-69
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { createServerFn } from '@tanstack/react-start'
2+
3+
// This function is ONLY called from the server, never directly from client code
4+
export const fnOnlyCalledByServer = createServerFn().handler(() => {
5+
return { message: 'hello from server-only function', secret: 42 }
6+
})

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
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'
14+
import { Route as ServerOnlyFnRouteImport } from './routes/server-only-fn'
1415
import { Route as SerializeFormDataRouteImport } from './routes/serialize-form-data'
1516
import { Route as ReturnNullRouteImport } from './routes/return-null'
1617
import { Route as RawResponseRouteImport } from './routes/raw-response'
@@ -49,6 +50,11 @@ const StatusRoute = StatusRouteImport.update({
4950
path: '/status',
5051
getParentRoute: () => rootRouteImport,
5152
} as any)
53+
const ServerOnlyFnRoute = ServerOnlyFnRouteImport.update({
54+
id: '/server-only-fn',
55+
path: '/server-only-fn',
56+
getParentRoute: () => rootRouteImport,
57+
} as any)
5258
const SerializeFormDataRoute = SerializeFormDataRouteImport.update({
5359
id: '/serialize-form-data',
5460
path: '/serialize-form-data',
@@ -200,6 +206,7 @@ export interface FileRoutesByFullPath {
200206
'/raw-response': typeof RawResponseRoute
201207
'/return-null': typeof ReturnNullRoute
202208
'/serialize-form-data': typeof SerializeFormDataRoute
209+
'/server-only-fn': typeof ServerOnlyFnRoute
203210
'/status': typeof StatusRoute
204211
'/submit-post-formdata': typeof SubmitPostFormdataRoute
205212
'/abort-signal/$method': typeof AbortSignalMethodRoute
@@ -231,6 +238,7 @@ export interface FileRoutesByTo {
231238
'/raw-response': typeof RawResponseRoute
232239
'/return-null': typeof ReturnNullRoute
233240
'/serialize-form-data': typeof SerializeFormDataRoute
241+
'/server-only-fn': typeof ServerOnlyFnRoute
234242
'/status': typeof StatusRoute
235243
'/submit-post-formdata': typeof SubmitPostFormdataRoute
236244
'/abort-signal/$method': typeof AbortSignalMethodRoute
@@ -263,6 +271,7 @@ export interface FileRoutesById {
263271
'/raw-response': typeof RawResponseRoute
264272
'/return-null': typeof ReturnNullRoute
265273
'/serialize-form-data': typeof SerializeFormDataRoute
274+
'/server-only-fn': typeof ServerOnlyFnRoute
266275
'/status': typeof StatusRoute
267276
'/submit-post-formdata': typeof SubmitPostFormdataRoute
268277
'/abort-signal/$method': typeof AbortSignalMethodRoute
@@ -296,6 +305,7 @@ export interface FileRouteTypes {
296305
| '/raw-response'
297306
| '/return-null'
298307
| '/serialize-form-data'
308+
| '/server-only-fn'
299309
| '/status'
300310
| '/submit-post-formdata'
301311
| '/abort-signal/$method'
@@ -327,6 +337,7 @@ export interface FileRouteTypes {
327337
| '/raw-response'
328338
| '/return-null'
329339
| '/serialize-form-data'
340+
| '/server-only-fn'
330341
| '/status'
331342
| '/submit-post-formdata'
332343
| '/abort-signal/$method'
@@ -358,6 +369,7 @@ export interface FileRouteTypes {
358369
| '/raw-response'
359370
| '/return-null'
360371
| '/serialize-form-data'
372+
| '/server-only-fn'
361373
| '/status'
362374
| '/submit-post-formdata'
363375
| '/abort-signal/$method'
@@ -390,6 +402,7 @@ export interface RootRouteChildren {
390402
RawResponseRoute: typeof RawResponseRoute
391403
ReturnNullRoute: typeof ReturnNullRoute
392404
SerializeFormDataRoute: typeof SerializeFormDataRoute
405+
ServerOnlyFnRoute: typeof ServerOnlyFnRoute
393406
StatusRoute: typeof StatusRoute
394407
SubmitPostFormdataRoute: typeof SubmitPostFormdataRoute
395408
AbortSignalMethodRoute: typeof AbortSignalMethodRoute
@@ -426,6 +439,13 @@ declare module '@tanstack/react-router' {
426439
preLoaderRoute: typeof StatusRouteImport
427440
parentRoute: typeof rootRouteImport
428441
}
442+
'/server-only-fn': {
443+
id: '/server-only-fn'
444+
path: '/server-only-fn'
445+
fullPath: '/server-only-fn'
446+
preLoaderRoute: typeof ServerOnlyFnRouteImport
447+
parentRoute: typeof rootRouteImport
448+
}
429449
'/serialize-form-data': {
430450
id: '/serialize-form-data'
431451
path: '/serialize-form-data'
@@ -630,6 +650,7 @@ const rootRouteChildren: RootRouteChildren = {
630650
RawResponseRoute: RawResponseRoute,
631651
ReturnNullRoute: ReturnNullRoute,
632652
SerializeFormDataRoute: SerializeFormDataRoute,
653+
ServerOnlyFnRoute: ServerOnlyFnRoute,
633654
StatusRoute: StatusRoute,
634655
SubmitPostFormdataRoute: SubmitPostFormdataRoute,
635656
AbortSignalMethodRoute: AbortSignalMethodRoute,

e2e/react-start/server-functions/src/routes/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ function Home() {
8888
<li>
8989
<Link to="/factory">Server Functions Factory E2E tests</Link>
9090
</li>
91+
<li>
92+
<Link to="/server-only-fn">
93+
Server Function only called by Server Environment is kept in the
94+
server build
95+
</Link>
96+
</li>
9197
</ul>
9298
</div>
9399
)
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { createFileRoute } from '@tanstack/react-router'
2+
import * as React from 'react'
3+
import { createServerFn } from '@tanstack/react-start'
4+
import { fnOnlyCalledByServer } from '~/functions/fnOnlyCalledByServer'
5+
6+
/**
7+
* This tests that server functions called only from the server (not from the client)
8+
* are still included in the build and work correctly at runtime.
9+
*
10+
* The `fnOnlyCalledByServer` is only called from `proxyFnThatCallsServerOnlyFn` on the server,
11+
* and is never referenced directly from client code.
12+
*/
13+
14+
// This function IS called from the client, and it calls serverOnlyFn on the server
15+
const proxyFnThatCallsServerOnlyFn = createServerFn().handler(async () => {
16+
// Call the server-only function from within another server function
17+
const result = await fnOnlyCalledByServer()
18+
return {
19+
fromServerOnlyFn: result,
20+
wrapper: 'client-callable wrapper',
21+
}
22+
})
23+
24+
const getFnOnlyCalledByServer = createServerFn().handler(async () => {
25+
return fnOnlyCalledByServer
26+
})
27+
28+
export const Route = createFileRoute('/server-only-fn')({
29+
component: ServerOnlyFnTest,
30+
})
31+
32+
function ServerOnlyFnTest() {
33+
const [result, setResult] = React.useState<{
34+
fromServerOnlyFn: { message: string; secret: number }
35+
wrapper: string
36+
} | null>(null)
37+
38+
const [callFromServerResult, setCallFromServerResult] = React.useState<
39+
string | null
40+
>(null)
41+
42+
return (
43+
<div className="p-2 m-2 grid gap-2">
44+
<h3>Server-Only Function Test</h3>
45+
<p>
46+
This tests that server functions which are only called from other server
47+
functions (and never directly from the client) still work correctly.
48+
</p>
49+
<div>
50+
Expected result:{' '}
51+
<code>
52+
<pre data-testid="expected-server-only-fn-result">
53+
{JSON.stringify({
54+
fromServerOnlyFn: {
55+
message: 'hello from server-only function',
56+
secret: 42,
57+
},
58+
wrapper: 'client-callable wrapper',
59+
})}
60+
</pre>
61+
</code>
62+
</div>
63+
<div>
64+
Actual result:{' '}
65+
<code>
66+
<pre data-testid="server-only-fn-result">
67+
{result ? JSON.stringify(result) : 'null'}
68+
</pre>
69+
</code>
70+
</div>
71+
<button
72+
data-testid="test-server-only-fn-btn"
73+
type="button"
74+
className="rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
75+
onClick={async () => {
76+
const res = await proxyFnThatCallsServerOnlyFn()
77+
setResult(res)
78+
}}
79+
>
80+
Test Server-Only Function
81+
</button>
82+
83+
<button
84+
data-testid="call-server-fn-from-client-btn"
85+
className="rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
86+
onClick={async () => {
87+
try {
88+
const fn = await getFnOnlyCalledByServer()
89+
await fn()
90+
setCallFromServerResult('success')
91+
} catch (e) {
92+
setCallFromServerResult('error')
93+
}
94+
}}
95+
>
96+
Call Server Fn From Client
97+
</button>
98+
{callFromServerResult && (
99+
<div data-testid="call-server-fn-from-client-result">
100+
{callFromServerResult}
101+
</div>
102+
)}
103+
</div>
104+
)
105+
}

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,3 +543,40 @@ 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+
})

0 commit comments

Comments
 (0)