Skip to content

Commit de69637

Browse files
authored
fix(start): resolve server functions using a POST JSON request (#2766)
* test(start): check for consistent createServerFn responses between client and server * chore(start): react-dom nesting rules * fix(start): resolve payloads for POST requests * fix(start): enforce default method as `'GET'`
1 parent 5e27e25 commit de69637

File tree

6 files changed

+190
-2
lines changed

6 files changed

+190
-2
lines changed

e2e/start/basic/app/routeTree.gen.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import { Route as rootRoute } from './routes/__root'
1414
import { Route as UsersImport } from './routes/users'
1515
import { Route as StatusImport } from './routes/status'
16+
import { Route as ServerFnsImport } from './routes/server-fns'
1617
import { Route as SearchParamsImport } from './routes/search-params'
1718
import { Route as RedirectImport } from './routes/redirect'
1819
import { Route as PostsImport } from './routes/posts'
@@ -42,6 +43,12 @@ const StatusRoute = StatusImport.update({
4243
getParentRoute: () => rootRoute,
4344
} as any)
4445

46+
const ServerFnsRoute = ServerFnsImport.update({
47+
id: '/server-fns',
48+
path: '/server-fns',
49+
getParentRoute: () => rootRoute,
50+
} as any)
51+
4552
const SearchParamsRoute = SearchParamsImport.update({
4653
id: '/search-params',
4754
path: '/search-params',
@@ -170,6 +177,13 @@ declare module '@tanstack/react-router' {
170177
preLoaderRoute: typeof SearchParamsImport
171178
parentRoute: typeof rootRoute
172179
}
180+
'/server-fns': {
181+
id: '/server-fns'
182+
path: '/server-fns'
183+
fullPath: '/server-fns'
184+
preLoaderRoute: typeof ServerFnsImport
185+
parentRoute: typeof rootRoute
186+
}
173187
'/status': {
174188
id: '/status'
175189
path: '/status'
@@ -301,6 +315,7 @@ export interface FileRoutesByFullPath {
301315
'/posts': typeof PostsRouteWithChildren
302316
'/redirect': typeof RedirectRoute
303317
'/search-params': typeof SearchParamsRoute
318+
'/server-fns': typeof ServerFnsRoute
304319
'/status': typeof StatusRoute
305320
'/users': typeof UsersRouteWithChildren
306321
'/posts/$postId': typeof PostsPostIdRoute
@@ -318,6 +333,7 @@ export interface FileRoutesByTo {
318333
'/deferred': typeof DeferredRoute
319334
'/redirect': typeof RedirectRoute
320335
'/search-params': typeof SearchParamsRoute
336+
'/server-fns': typeof ServerFnsRoute
321337
'/status': typeof StatusRoute
322338
'/posts/$postId': typeof PostsPostIdRoute
323339
'/users/$userId': typeof UsersUserIdRoute
@@ -336,6 +352,7 @@ export interface FileRoutesById {
336352
'/posts': typeof PostsRouteWithChildren
337353
'/redirect': typeof RedirectRoute
338354
'/search-params': typeof SearchParamsRoute
355+
'/server-fns': typeof ServerFnsRoute
339356
'/status': typeof StatusRoute
340357
'/users': typeof UsersRouteWithChildren
341358
'/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren
@@ -357,6 +374,7 @@ export interface FileRouteTypes {
357374
| '/posts'
358375
| '/redirect'
359376
| '/search-params'
377+
| '/server-fns'
360378
| '/status'
361379
| '/users'
362380
| '/posts/$postId'
@@ -373,6 +391,7 @@ export interface FileRouteTypes {
373391
| '/deferred'
374392
| '/redirect'
375393
| '/search-params'
394+
| '/server-fns'
376395
| '/status'
377396
| '/posts/$postId'
378397
| '/users/$userId'
@@ -389,6 +408,7 @@ export interface FileRouteTypes {
389408
| '/posts'
390409
| '/redirect'
391410
| '/search-params'
411+
| '/server-fns'
392412
| '/status'
393413
| '/users'
394414
| '/_layout/_layout-2'
@@ -409,6 +429,7 @@ export interface RootRouteChildren {
409429
PostsRoute: typeof PostsRouteWithChildren
410430
RedirectRoute: typeof RedirectRoute
411431
SearchParamsRoute: typeof SearchParamsRoute
432+
ServerFnsRoute: typeof ServerFnsRoute
412433
StatusRoute: typeof StatusRoute
413434
UsersRoute: typeof UsersRouteWithChildren
414435
PostsPostIdDeepRoute: typeof PostsPostIdDeepRoute
@@ -421,6 +442,7 @@ const rootRouteChildren: RootRouteChildren = {
421442
PostsRoute: PostsRouteWithChildren,
422443
RedirectRoute: RedirectRoute,
423444
SearchParamsRoute: SearchParamsRoute,
445+
ServerFnsRoute: ServerFnsRoute,
424446
StatusRoute: StatusRoute,
425447
UsersRoute: UsersRouteWithChildren,
426448
PostsPostIdDeepRoute: PostsPostIdDeepRoute,
@@ -442,6 +464,7 @@ export const routeTree = rootRoute
442464
"/posts",
443465
"/redirect",
444466
"/search-params",
467+
"/server-fns",
445468
"/status",
446469
"/users",
447470
"/posts_/$postId/deep"
@@ -472,6 +495,9 @@ export const routeTree = rootRoute
472495
"/search-params": {
473496
"filePath": "search-params.tsx"
474497
},
498+
"/server-fns": {
499+
"filePath": "server-fns.tsx"
500+
},
475501
"/status": {
476502
"filePath": "status.tsx"
477503
},
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import * as React from 'react'
2+
import { createFileRoute } from '@tanstack/react-router'
3+
import { createServerFn } from '@tanstack/start'
4+
5+
export const Route = createFileRoute('/server-fns')({
6+
component: RouteComponent,
7+
})
8+
9+
function RouteComponent() {
10+
return (
11+
<>
12+
<ConsistentServerFnCalls />
13+
</>
14+
)
15+
}
16+
17+
// START CONSISTENT_SERVER_FN_CALLS
18+
const cons_getFn1 = createServerFn()
19+
.validator((d: { username: string }) => d)
20+
.handler(async ({ data }) => {
21+
return { payload: data }
22+
})
23+
24+
const cons_serverGetFn1 = createServerFn()
25+
.validator((d: { username: string }) => d)
26+
.handler(({ data }) => {
27+
return cons_getFn1({ data })
28+
})
29+
30+
const cons_postFn1 = createServerFn({ method: 'POST' })
31+
.validator((d: { username: string }) => d)
32+
.handler(async ({ data }) => {
33+
return { payload: data }
34+
})
35+
36+
const cons_serverPostFn1 = createServerFn({ method: 'POST' })
37+
.validator((d: { username: string }) => d)
38+
.handler(({ data }) => {
39+
return cons_postFn1({ data })
40+
})
41+
42+
/**
43+
* This component checks whether the returned payloads from server function
44+
* are the same, regardless of whether the server function is called directly
45+
* from the client or from within the server function.
46+
* @link https://github.com/TanStack/router/issues/1866
47+
* @link https://github.com/TanStack/router/issues/2481
48+
*/
49+
function ConsistentServerFnCalls() {
50+
const [getServerResult, setGetServerResult] = React.useState({})
51+
const [getDirectResult, setGetDirectResult] = React.useState({})
52+
53+
const [postServerResult, setPostServerResult] = React.useState({})
54+
const [postDirectResult, setPostDirectResult] = React.useState({})
55+
56+
return (
57+
<div className="p-2 border m-2 grid gap-2">
58+
<h3>Consistent Server Fn GET Calls</h3>
59+
<p>
60+
This component checks whether the returned payloads from server function
61+
are the same, regardless of whether the server function is called
62+
directly from the client or from within the server function.
63+
</p>
64+
<div>
65+
It should return{' '}
66+
<code>
67+
<pre data-testid="expected-consistent-server-fns-result">
68+
{JSON.stringify({ payload: { username: 'TEST' } })}
69+
</pre>
70+
</code>
71+
</div>
72+
<p>
73+
{`GET: cons_getFn1 called from server cons_serverGetFn1 returns`}
74+
<br />
75+
<span data-testid="cons_serverGetFn1-response">
76+
{JSON.stringify(getServerResult)}
77+
</span>
78+
</p>
79+
<p>
80+
{`GET: cons_getFn1 called directly returns`}
81+
<br />
82+
<span data-testid="cons_getFn1-response">
83+
{JSON.stringify(getDirectResult)}
84+
</span>
85+
</p>
86+
<p>
87+
{`POST: cons_postFn1 called from cons_serverPostFn1 returns`}
88+
<br />
89+
<span data-testid="cons_serverPostFn1-response">
90+
{JSON.stringify(postServerResult)}
91+
</span>
92+
</p>
93+
<p>
94+
{`POST: cons_postFn1 called directly returns`}
95+
<br />
96+
<span data-testid="cons_postFn1-response">
97+
{JSON.stringify(postDirectResult)}
98+
</span>
99+
</p>
100+
<button
101+
data-testid="test-consistent-server-fn-calls-btn"
102+
type="button"
103+
className="rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
104+
onClick={() => {
105+
// GET calls
106+
cons_serverGetFn1({ data: { username: 'TEST' } }).then(
107+
setGetServerResult,
108+
)
109+
cons_getFn1({ data: { username: 'TEST' } }).then(setGetDirectResult)
110+
111+
// POST calls
112+
cons_serverPostFn1({ data: { username: 'TEST' } }).then(
113+
setPostServerResult,
114+
)
115+
cons_postFn1({ data: { username: 'TEST' } }).then(setPostDirectResult)
116+
}}
117+
>
118+
Test Consistent server function responses
119+
</button>
120+
</div>
121+
)
122+
}
123+
124+
// END CONSISTENT_SERVER_FN_CALLS

e2e/start/basic/tests/app.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,33 @@ test('invoking a server function with custom response status code', async ({
101101
})
102102
await requestPromise
103103
})
104+
105+
test('Consistent server function returns both on client and server for GET and POST calls', async ({
106+
page,
107+
}) => {
108+
await page.goto('/server-fns')
109+
110+
await page.waitForLoadState('networkidle')
111+
const expected =
112+
(await page
113+
.getByTestId('expected-consistent-server-fns-result')
114+
.textContent()) || ''
115+
expect(expected).not.toBe('')
116+
117+
await page.getByTestId('test-consistent-server-fn-calls-btn').click()
118+
await page.waitForLoadState('networkidle')
119+
120+
// GET calls
121+
await expect(page.getByTestId('cons_serverGetFn1-response')).toContainText(
122+
expected,
123+
)
124+
await expect(page.getByTestId('cons_getFn1-response')).toContainText(expected)
125+
126+
// POST calls
127+
await expect(page.getByTestId('cons_serverPostFn1-response')).toContainText(
128+
expected,
129+
)
130+
await expect(page.getByTestId('cons_postFn1-response')).toContainText(
131+
expected,
132+
)
133+
})

packages/start/src/client-runtime/fetcher.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,10 @@ export async function fetcher(
5555
body:
5656
type === 'formData'
5757
? first.data
58-
: (defaultTransformer.stringify(first.data ?? null) as any),
58+
: (defaultTransformer.stringify({
59+
data: first.data ?? null,
60+
context: first.context,
61+
}) as any),
5962
}
6063
: {}),
6164
})

packages/start/src/client/createServerFn.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,10 @@ export function createServerFn<
153153
TValidator
154154
>
155155

156+
if (typeof resolvedOptions.method === 'undefined') {
157+
resolvedOptions.method = 'GET' as TMethod
158+
}
159+
156160
return {
157161
options: resolvedOptions as any,
158162
middleware: (middleware) => {

packages/start/src/server-handler/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ export async function handleServerRequest(request: Request, _event?: H3Event) {
7979
}
8080

8181
// For non-form, non-get
82-
return await request.json()
82+
const jsonPayloadAsString = await request.text()
83+
return defaultTransformer.parse(jsonPayloadAsString)
8384
})()
8485

8586
const result = await action(arg)

0 commit comments

Comments
 (0)