Skip to content

Commit 337b740

Browse files
authored
feat: enable async serverFn validation (#5583)
1 parent c23dec4 commit 337b740

7 files changed

Lines changed: 317 additions & 9 deletions

File tree

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { Route as FormdataContextRouteImport } from './routes/formdata-context'
2323
import { Route as EnvOnlyRouteImport } from './routes/env-only'
2424
import { Route as DeadCodePreserveRouteImport } from './routes/dead-code-preserve'
2525
import { Route as ConsistentRouteImport } from './routes/consistent'
26+
import { Route as AsyncValidationRouteImport } from './routes/async-validation'
2627
import { Route as IndexRouteImport } from './routes/index'
2728
import { Route as RedirectTestIndexRouteImport } from './routes/redirect-test/index'
2829
import { Route as RedirectTestSsrIndexRouteImport } from './routes/redirect-test-ssr/index'
@@ -116,6 +117,11 @@ const ConsistentRoute = ConsistentRouteImport.update({
116117
path: '/consistent',
117118
getParentRoute: () => rootRouteImport,
118119
} as any)
120+
const AsyncValidationRoute = AsyncValidationRouteImport.update({
121+
id: '/async-validation',
122+
path: '/async-validation',
123+
getParentRoute: () => rootRouteImport,
124+
} as any)
119125
const IndexRoute = IndexRouteImport.update({
120126
id: '/',
121127
path: '/',
@@ -237,6 +243,7 @@ const FormdataRedirectTargetNameRoute =
237243

238244
export interface FileRoutesByFullPath {
239245
'/': typeof IndexRoute
246+
'/async-validation': typeof AsyncValidationRoute
240247
'/consistent': typeof ConsistentRoute
241248
'/dead-code-preserve': typeof DeadCodePreserveRoute
242249
'/env-only': typeof EnvOnlyRoute
@@ -275,6 +282,7 @@ export interface FileRoutesByFullPath {
275282
}
276283
export interface FileRoutesByTo {
277284
'/': typeof IndexRoute
285+
'/async-validation': typeof AsyncValidationRoute
278286
'/consistent': typeof ConsistentRoute
279287
'/dead-code-preserve': typeof DeadCodePreserveRoute
280288
'/env-only': typeof EnvOnlyRoute
@@ -314,6 +322,7 @@ export interface FileRoutesByTo {
314322
export interface FileRoutesById {
315323
__root__: typeof rootRouteImport
316324
'/': typeof IndexRoute
325+
'/async-validation': typeof AsyncValidationRoute
317326
'/consistent': typeof ConsistentRoute
318327
'/dead-code-preserve': typeof DeadCodePreserveRoute
319328
'/env-only': typeof EnvOnlyRoute
@@ -354,6 +363,7 @@ export interface FileRouteTypes {
354363
fileRoutesByFullPath: FileRoutesByFullPath
355364
fullPaths:
356365
| '/'
366+
| '/async-validation'
357367
| '/consistent'
358368
| '/dead-code-preserve'
359369
| '/env-only'
@@ -392,6 +402,7 @@ export interface FileRouteTypes {
392402
fileRoutesByTo: FileRoutesByTo
393403
to:
394404
| '/'
405+
| '/async-validation'
395406
| '/consistent'
396407
| '/dead-code-preserve'
397408
| '/env-only'
@@ -430,6 +441,7 @@ export interface FileRouteTypes {
430441
id:
431442
| '__root__'
432443
| '/'
444+
| '/async-validation'
433445
| '/consistent'
434446
| '/dead-code-preserve'
435447
| '/env-only'
@@ -469,6 +481,7 @@ export interface FileRouteTypes {
469481
}
470482
export interface RootRouteChildren {
471483
IndexRoute: typeof IndexRoute
484+
AsyncValidationRoute: typeof AsyncValidationRoute
472485
ConsistentRoute: typeof ConsistentRoute
473486
DeadCodePreserveRoute: typeof DeadCodePreserveRoute
474487
EnvOnlyRoute: typeof EnvOnlyRoute
@@ -606,6 +619,13 @@ declare module '@tanstack/react-router' {
606619
preLoaderRoute: typeof ConsistentRouteImport
607620
parentRoute: typeof rootRouteImport
608621
}
622+
'/async-validation': {
623+
id: '/async-validation'
624+
path: '/async-validation'
625+
fullPath: '/async-validation'
626+
preLoaderRoute: typeof AsyncValidationRouteImport
627+
parentRoute: typeof rootRouteImport
628+
}
609629
'/': {
610630
id: '/'
611631
path: '/'
@@ -765,6 +785,7 @@ declare module '@tanstack/react-router' {
765785

766786
const rootRouteChildren: RootRouteChildren = {
767787
IndexRoute: IndexRoute,
788+
AsyncValidationRoute: AsyncValidationRoute,
768789
ConsistentRoute: ConsistentRoute,
769790
DeadCodePreserveRoute: DeadCodePreserveRoute,
770791
EnvOnlyRoute: EnvOnlyRoute,
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { createFileRoute } from '@tanstack/react-router'
2+
import { createServerFn } from '@tanstack/react-start'
3+
import React from 'react'
4+
import { z } from 'zod'
5+
6+
export const Route = createFileRoute('/async-validation')({
7+
component: RouteComponent,
8+
})
9+
10+
const asyncValidationSchema = z
11+
.string()
12+
.refine((data) => Promise.resolve(data !== 'invalid'))
13+
14+
const asyncValidationServerFn = createServerFn()
15+
.inputValidator(asyncValidationSchema)
16+
.handler(({ data }) => data)
17+
18+
function RouteComponent() {
19+
const [errorMessage, setErrorMessage] = React.useState<string | undefined>(
20+
undefined,
21+
)
22+
const [result, setResult] = React.useState<string | undefined>(undefined)
23+
24+
const callServerFn = async (value: string) => {
25+
setErrorMessage(undefined)
26+
setResult(undefined)
27+
28+
try {
29+
const serverFnResult = await asyncValidationServerFn({ data: value })
30+
setResult(serverFnResult)
31+
} catch (error) {
32+
setErrorMessage(error instanceof Error ? error.message : 'unknown')
33+
}
34+
}
35+
36+
return (
37+
<div>
38+
<button
39+
data-testid="run-with-valid-btn"
40+
onClick={() => {
41+
callServerFn('valid')
42+
}}
43+
>
44+
call server function with valid value
45+
</button>
46+
<br />
47+
<button
48+
data-testid="run-with-invalid-btn"
49+
onClick={() => {
50+
callServerFn('invalid')
51+
}}
52+
>
53+
call server function with invalid value
54+
</button>
55+
<div className="p-2">
56+
result: <p data-testid="result">{result ?? '$undefined'}</p>
57+
</div>
58+
<div className="p-2">
59+
message:{' '}
60+
<p data-testid="errorMessage">{errorMessage ?? '$undefined'}</p>
61+
</div>
62+
</div>
63+
)
64+
}

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,17 @@ function Home() {
6363
</li>
6464
<li>
6565
<Link to="/dead-code-preserve">
66-
dead code elimation only affects code after transformation
66+
dead code elimination only affects code after transformation
6767
</Link>
6868
</li>
6969
<li>
7070
<Link to="/abort-signal">aborting a server function call</Link>
7171
</li>
72+
<li>
73+
<Link to="/async-validation">
74+
server function with async validation
75+
</Link>
76+
</li>
7277
<li>
7378
<Link to="/raw-response">server function returns raw response</Link>
7479
</li>

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,54 @@ test.describe('server function sets cookies', () => {
367367
})
368368
})
369369

370+
test.describe('server functions with async validation', () => {
371+
test.use({
372+
whitelistErrors: [
373+
/Failed to load resource: the server responded with a status of 500/,
374+
],
375+
})
376+
377+
test('with valid input', async ({ page }) => {
378+
await page.goto('/async-validation')
379+
380+
await page.waitForLoadState('networkidle')
381+
382+
await page.getByTestId('run-with-valid-btn').click()
383+
await page.waitForLoadState('networkidle')
384+
await page.waitForSelector('[data-testid="result"]:has-text("valid")')
385+
await page.waitForSelector(
386+
'[data-testid="errorMessage"]:has-text("$undefined")',
387+
)
388+
389+
const result = (await page.getByTestId('result').textContent()) || ''
390+
expect(result).toBe('valid')
391+
392+
const errorMessage =
393+
(await page.getByTestId('errorMessage').textContent()) || ''
394+
expect(errorMessage).toBe('$undefined')
395+
})
396+
397+
test('with invalid input', async ({ page }) => {
398+
await page.goto('/async-validation')
399+
400+
await page.waitForLoadState('networkidle')
401+
402+
await page.getByTestId('run-with-invalid-btn').click()
403+
await page.waitForLoadState('networkidle')
404+
await page.waitForSelector('[data-testid="result"]:has-text("$undefined")')
405+
await page.waitForSelector(
406+
'[data-testid="errorMessage"]:has-text("invalid")',
407+
)
408+
409+
const result = (await page.getByTestId('result').textContent()) || ''
410+
expect(result).toBe('$undefined')
411+
412+
const errorMessage =
413+
(await page.getByTestId('errorMessage').textContent()) || ''
414+
expect(errorMessage).toContain('Invalid input')
415+
})
416+
})
417+
370418
test('raw response', async ({ page }) => {
371419
await page.goto('/raw-response')
372420

packages/start-client-core/src/createMiddleware.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ export type IntersectAllValidatorOutputs<TMiddlewares, TInputValidator> =
227227
? IntersectAllMiddleware<TMiddlewares, 'allOutput'>
228228
: IntersectAssign<
229229
IntersectAllMiddleware<TMiddlewares, 'allOutput'>,
230-
ResolveValidatorOutput<TInputValidator>
230+
Awaited<ResolveValidatorOutput<TInputValidator>>
231231
>
232232

233233
/**

packages/start-client-core/src/createServerFn.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -703,17 +703,14 @@ export type MiddlewareFn = (
703703
},
704704
) => Promise<ServerFnMiddlewareResult>
705705

706-
export function execValidator(
706+
export async function execValidator(
707707
validator: AnyValidator,
708708
input: unknown,
709-
): unknown {
709+
): Promise<unknown> {
710710
if (validator == null) return {}
711711

712712
if ('~standard' in validator) {
713-
const result = validator['~standard'].validate(input)
714-
715-
if (result instanceof Promise)
716-
throw new Error('Async validation not supported')
713+
const result = await validator['~standard'].validate(input)
717714

718715
if (result.issues)
719716
throw new Error(JSON.stringify(result.issues, undefined, 2))

0 commit comments

Comments
 (0)