Skip to content

Commit 01a568f

Browse files
authored
fix(start): server functions to handle direct submission of FormData (#3253)
1 parent 2466ec1 commit 01a568f

File tree

6 files changed

+89
-3
lines changed

6 files changed

+89
-3
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { createServerFn } from '@tanstack/start'
2+
3+
const testValues = {
4+
name: 'Sean',
5+
}
6+
7+
export const greetUser = createServerFn({ method: 'POST' })
8+
.validator((data: FormData) => {
9+
if (!(data instanceof FormData)) {
10+
throw new Error('Invalid! FormData is required')
11+
}
12+
const name = data.get('name')
13+
14+
if (!name) {
15+
throw new Error('Name is required')
16+
}
17+
18+
return {
19+
name: name.toString(),
20+
}
21+
})
22+
.handler(({ data: { name } }) => {
23+
return new Response(`Hello, ${name}!`)
24+
})
25+
26+
export function SubmitPostFormDataFn() {
27+
return (
28+
<div className="p-2 border m-2 grid gap-2">
29+
<h3>Submit POST FormData Fn Call</h3>
30+
<div className="overflow-y-auto">
31+
It should return navigate and return{' '}
32+
<code>
33+
<pre data-testid="expected-submit-post-formdata-server-fn-result">
34+
Hello, {testValues.name}!
35+
</pre>
36+
</code>
37+
</div>
38+
<form
39+
className="flex flex-col gap-2"
40+
data-testid="submit-post-formdata-form"
41+
method="POST"
42+
action={greetUser.url}
43+
>
44+
<input type="text" name="name" defaultValue={testValues.name} />
45+
<button
46+
type="submit"
47+
data-testid="test-submit-post-formdata-fn-calls-btn"
48+
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"
49+
>
50+
Submit (native)
51+
</button>
52+
</form>
53+
</div>
54+
)
55+
}

e2e/start/basic/app/routes/server-fns.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { MultipartServerFnCall } from './-server-fns/multipart-formdata-fn-call'
66
import { AllowServerFnReturnNull } from './-server-fns/allow-fn-return-null'
77
import { SerializeFormDataFnCall } from './-server-fns/serialize-formdata-fn-call'
88
import { ResponseHeaders, getTestHeaders } from './-server-fns/response-headers'
9+
import { SubmitPostFormDataFn } from './-server-fns/submit-post-formdata-fn'
910

1011
export const Route = createFileRoute('/server-fns')({
1112
component: RouteComponent,
@@ -25,6 +26,7 @@ function RouteComponent() {
2526
<AllowServerFnReturnNull />
2627
<SerializeFormDataFnCall />
2728
<ResponseHeaders initialTestHeaders={testHeaders} />
29+
<SubmitPostFormDataFn />
2830
</>
2931
)
3032
}

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,3 +214,23 @@ test('server function can correctly send and receive headers', async ({
214214
"accept-encoding": "gzip, deflate, br, zstd"
215215
}`)
216216
})
217+
218+
test('Direct POST submitting FormData to a Server function returns the correct message', async ({
219+
page,
220+
}) => {
221+
await page.goto('/server-fns')
222+
223+
await page.waitForLoadState('networkidle')
224+
225+
const expected =
226+
(await page
227+
.getByTestId('expected-submit-post-formdata-server-fn-result')
228+
.textContent()) || ''
229+
expect(expected).not.toBe('')
230+
231+
await page.getByTestId('test-submit-post-formdata-fn-calls-btn').click()
232+
await page.waitForLoadState('networkidle')
233+
234+
const result = await page.innerText('body')
235+
expect(result).toBe(expected)
236+
})

packages/router-core/src/serializer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export type SerializerParseBy<T, TSerializable> = T extends TSerializable
1717
? ReadableStream
1818
: { [K in keyof T]: SerializerParseBy<T[K], TSerializable> }
1919

20-
export type Serializable = Date | undefined | Error | FormData
20+
export type Serializable = Date | undefined | Error | FormData | Response
2121

2222
export type SerializerStringify<T> = SerializerStringifyBy<T, Serializable>
2323

packages/start-client/src/serializer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ const createSerializer = <TKey extends string, TInput, TSerialized>(
8888
})
8989

9090
// Keep these ordered by predicted frequency
91-
// Make sure to keep DefaultSerializeable in sync with these serializers
91+
// Make sure to keep DefaultSerializable in sync with these serializers
9292
// Also, make sure that they are unit tested in serializer.test.tsx
9393
const serializers = [
9494
createSerializer(

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,21 @@ async function handleServerRequest(request: Request, _event?: H3Event) {
135135
)
136136
}
137137

138+
// Known FormData 'Content-Type' header values
139+
const formDataContentTypes = [
140+
'multipart/form-data',
141+
'application/x-www-form-urlencoded',
142+
]
143+
138144
const response = await (async () => {
139145
try {
140146
const arg = await (async () => {
141147
// FormData
142148
if (
143-
request.headers.get('Content-Type')?.includes('multipart/form-data')
149+
request.headers.get('Content-Type') &&
150+
formDataContentTypes.some((type) =>
151+
request.headers.get('Content-Type')?.includes(type),
152+
)
144153
) {
145154
// We don't support GET requests with FormData payloads... that seems impossible
146155
invariant(

0 commit comments

Comments
 (0)