diff --git a/e2e/react-start/server-functions/package.json b/e2e/react-start/server-functions/package.json index e3453f32bf8..40dcdeb2739 100644 --- a/e2e/react-start/server-functions/package.json +++ b/e2e/react-start/server-functions/package.json @@ -11,7 +11,9 @@ "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" }, "dependencies": { + "@tanstack/react-query": "^5.66.0", "@tanstack/react-router": "workspace:^", + "@tanstack/react-router-ssr-query": "workspace:^", "@tanstack/react-router-devtools": "workspace:^", "@tanstack/react-start": "workspace:^", "js-cookie": "^3.0.5", diff --git a/e2e/react-start/server-functions/src/routeTree.gen.ts b/e2e/react-start/server-functions/src/routeTree.gen.ts index 080480bb344..b5692de0599 100644 --- a/e2e/react-start/server-functions/src/routeTree.gen.ts +++ b/e2e/react-start/server-functions/src/routeTree.gen.ts @@ -22,6 +22,7 @@ import { Route as DeadCodePreserveRouteImport } from './routes/dead-code-preserv import { Route as ConsistentRouteImport } from './routes/consistent' import { Route as AbortSignalRouteImport } from './routes/abort-signal' import { Route as IndexRouteImport } from './routes/index' +import { Route as PrimitivesIndexRouteImport } from './routes/primitives/index' import { Route as MiddlewareIndexRouteImport } from './routes/middleware/index' import { Route as FormdataRedirectIndexRouteImport } from './routes/formdata-redirect/index' import { Route as FactoryIndexRouteImport } from './routes/factory/index' @@ -97,6 +98,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const PrimitivesIndexRoute = PrimitivesIndexRouteImport.update({ + id: '/primitives/', + path: '/primitives/', + getParentRoute: () => rootRouteImport, +} as any) const MiddlewareIndexRoute = MiddlewareIndexRouteImport.update({ id: '/middleware/', path: '/middleware/', @@ -168,6 +174,7 @@ export interface FileRoutesByFullPath { '/factory': typeof FactoryIndexRoute '/formdata-redirect': typeof FormdataRedirectIndexRoute '/middleware': typeof MiddlewareIndexRoute + '/primitives': typeof PrimitivesIndexRoute '/formdata-redirect/target/$name': typeof FormdataRedirectTargetNameRoute } export interface FileRoutesByTo { @@ -192,6 +199,7 @@ export interface FileRoutesByTo { '/factory': typeof FactoryIndexRoute '/formdata-redirect': typeof FormdataRedirectIndexRoute '/middleware': typeof MiddlewareIndexRoute + '/primitives': typeof PrimitivesIndexRoute '/formdata-redirect/target/$name': typeof FormdataRedirectTargetNameRoute } export interface FileRoutesById { @@ -217,6 +225,7 @@ export interface FileRoutesById { '/factory/': typeof FactoryIndexRoute '/formdata-redirect/': typeof FormdataRedirectIndexRoute '/middleware/': typeof MiddlewareIndexRoute + '/primitives/': typeof PrimitivesIndexRoute '/formdata-redirect/target/$name': typeof FormdataRedirectTargetNameRoute } export interface FileRouteTypes { @@ -243,6 +252,7 @@ export interface FileRouteTypes { | '/factory' | '/formdata-redirect' | '/middleware' + | '/primitives' | '/formdata-redirect/target/$name' fileRoutesByTo: FileRoutesByTo to: @@ -267,6 +277,7 @@ export interface FileRouteTypes { | '/factory' | '/formdata-redirect' | '/middleware' + | '/primitives' | '/formdata-redirect/target/$name' id: | '__root__' @@ -291,6 +302,7 @@ export interface FileRouteTypes { | '/factory/' | '/formdata-redirect/' | '/middleware/' + | '/primitives/' | '/formdata-redirect/target/$name' fileRoutesById: FileRoutesById } @@ -316,6 +328,7 @@ export interface RootRouteChildren { FactoryIndexRoute: typeof FactoryIndexRoute FormdataRedirectIndexRoute: typeof FormdataRedirectIndexRoute MiddlewareIndexRoute: typeof MiddlewareIndexRoute + PrimitivesIndexRoute: typeof PrimitivesIndexRoute FormdataRedirectTargetNameRoute: typeof FormdataRedirectTargetNameRoute } @@ -412,6 +425,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/primitives/': { + id: '/primitives/' + path: '/primitives' + fullPath: '/primitives' + preLoaderRoute: typeof PrimitivesIndexRouteImport + parentRoute: typeof rootRouteImport + } '/middleware/': { id: '/middleware/' path: '/middleware' @@ -500,6 +520,7 @@ const rootRouteChildren: RootRouteChildren = { FactoryIndexRoute: FactoryIndexRoute, FormdataRedirectIndexRoute: FormdataRedirectIndexRoute, MiddlewareIndexRoute: MiddlewareIndexRoute, + PrimitivesIndexRoute: PrimitivesIndexRoute, FormdataRedirectTargetNameRoute: FormdataRedirectTargetNameRoute, } export const routeTree = rootRouteImport diff --git a/e2e/react-start/server-functions/src/router.tsx b/e2e/react-start/server-functions/src/router.tsx index e2d1147335f..5a9a35733d5 100644 --- a/e2e/react-start/server-functions/src/router.tsx +++ b/e2e/react-start/server-functions/src/router.tsx @@ -2,8 +2,11 @@ import { createRouter } from '@tanstack/react-router' import { routeTree } from './routeTree.gen' import { DefaultCatchBoundary } from './components/DefaultCatchBoundary' import { NotFound } from './components/NotFound' +import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query' +import { QueryClient } from '@tanstack/react-query' export function getRouter() { + const queryClient = new QueryClient() const router = createRouter({ routeTree, defaultPreload: 'intent', @@ -16,6 +19,7 @@ export function getRouter() { }, }, }) + setupRouterSsrQueryIntegration({ router, queryClient }) return router } diff --git a/e2e/react-start/server-functions/src/routes/primitives/index.tsx b/e2e/react-start/server-functions/src/routes/primitives/index.tsx new file mode 100644 index 00000000000..e0a9f7c6b9f --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/primitives/index.tsx @@ -0,0 +1,129 @@ +import { useQuery } from '@tanstack/react-query' +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { useCallback } from 'react' +import { z } from 'zod' +export const Route = createFileRoute('/primitives/')({ + component: RouteComponent, +}) + +function stringify(data: any) { + return JSON.stringify(data === undefined ? '$undefined' : data) +} + +const $stringPost = createServerFn({ method: 'POST' }) + .inputValidator(z.string()) + .handler((ctx) => ctx.data) + +const $stringGet = createServerFn({ method: 'GET' }) + .inputValidator(z.string()) + .handler((ctx) => ctx.data) + +const $undefinedPost = createServerFn({ method: 'POST' }) + .inputValidator(z.undefined()) + .handler((ctx) => ctx.data) + +const $undefinedGet = createServerFn({ method: 'GET' }) + .inputValidator(z.undefined()) + .handler((ctx) => ctx.data) + +const $nullPost = createServerFn({ method: 'POST' }) + .inputValidator(z.null()) + .handler((ctx) => ctx.data) + +const $nullGet = createServerFn({ method: 'GET' }) + .inputValidator(z.null()) + .handler((ctx) => ctx.data) + +interface PrimitiveComponentProps { + serverFn: { + get: (opts: { data: T }) => Promise + post: (opts: { data: T }) => Promise + } + data: { + value: T + type: string + } +} + +interface TestProps extends PrimitiveComponentProps { + method: 'get' | 'post' +} +function Test(props: TestProps) { + const queryFn = useCallback(async () => { + const result = await props.serverFn[props.method]({ + data: props.data.value, + }) + if (result === undefined) { + return '$undefined' + } + return result + }, [props]) + const query = useQuery({ queryKey: [props.data.type, props.method], queryFn }) + const testId = `${props.method}-${props.data.type}` + return ( +
+

serverFn method={props.method}

+

expected

+
+ {stringify(props.data.value)} +
+

result

+ {query.isSuccess ? ( +
{stringify(query.data)}
+ ) : null} +
+ ) +} +function PrimitiveComponent(props: PrimitiveComponentProps) { + return ( +
+

data type: {props.data.type}

+ +
+ +
+
+
+ ) +} + +function makeTestCase(props: PrimitiveComponentProps) { + return props +} +const testCases = [ + makeTestCase({ + data: { + value: null, + type: 'null', + }, + serverFn: { + get: $nullGet, + post: $nullPost, + }, + }), + makeTestCase({ + data: { + value: undefined, + type: 'undefined', + }, + serverFn: { + get: $undefinedGet, + post: $undefinedPost, + }, + }), + makeTestCase({ + data: { + value: 'foo-bar', + type: 'string', + }, + serverFn: { + get: $stringGet, + post: $stringPost, + }, + }), +] as Array> + +function RouteComponent() { + return testCases.map((t) => ) +} diff --git a/e2e/react-start/server-functions/tests/server-functions.spec.ts b/e2e/react-start/server-functions/tests/server-functions.spec.ts index 08f48524cb8..2edc524065d 100644 --- a/e2e/react-start/server-functions/tests/server-functions.spec.ts +++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts @@ -443,3 +443,26 @@ test('factory', async ({ page }) => { ) } }) + +test('primitives', async ({ page }) => { + await page.goto('/primitives') + + const testCases = await page + .locator('[data-testid^="expected-"]') + .elementHandles() + for (const testCase of testCases) { + const testId = await testCase.getAttribute('data-testid') + + if (!testId) { + throw new Error('testcase is missing data-testid') + } + + const suffix = testId.replace('expected-', '') + + const expected = + (await page.getByTestId(`expected-${suffix}`).textContent()) || '' + expect(expected).not.toBe('') + + await expect(page.getByTestId(`result-${suffix}`)).toContainText(expected) + } +}) diff --git a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts index 25b38c10357..5511bdc5173 100644 --- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts +++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts @@ -38,27 +38,25 @@ export async function serverFnFetcher( // Arrange the headers const headers = new Headers({ 'x-tsr-redirect': 'manual', - ...(type === 'payload' - ? { - 'content-type': 'application/json', - accept: 'application/x-ndjson, application/json', - } - : {}), ...(first.headers instanceof Headers ? Object.fromEntries(first.headers.entries()) : first.headers), }) + if (type === 'payload') { + headers.set('accept', 'application/x-ndjson, application/json') + } + // If the method is GET, we need to move the payload to the query string if (first.method === 'GET') { if (type === 'formData') { throw new Error('FormData is not supported with GET requests') } - const encodedPayload = encode({ - payload: await serializePayload(first), - }) - - if (encodedPayload) { + const serializedPayload = await serializePayload(first) + if (serializedPayload !== undefined) { + const encodedPayload = encode({ + payload: await serializePayload(first), + }) if (url.includes('?')) { url += `&${encodedPayload}` } else { @@ -73,12 +71,21 @@ export async function serverFnFetcher( url += `?createServerFn` } + let body = undefined + if (first.method === 'POST') { + const fetchBody = await getFetchBody(first) + if (fetchBody?.contentType) { + headers.set('content-type', fetchBody.contentType) + } + body = fetchBody?.body + } + return await getResponse(async () => handler(url, { method: first.method, headers, signal: first.signal, - ...(await getFetcherRequestOptions(first)), + body, }), ) } @@ -100,18 +107,24 @@ export async function serverFnFetcher( async function serializePayload( opts: FunctionMiddlewareClientFnOptions, -) { +): Promise { + let payloadAvailable = false const payloadToSerialize: any = {} - if (opts.data) { + if (opts.data !== undefined) { + payloadAvailable = true payloadToSerialize['data'] = opts.data } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (opts.context && Object.keys(opts.context).length > 0) { + payloadAvailable = true payloadToSerialize['context'] = opts.context } - return serialize(payloadToSerialize) + if (payloadAvailable) { + return serialize(payloadToSerialize) + } + return undefined } async function serialize(data: any) { @@ -120,23 +133,25 @@ async function serialize(data: any) { ) } -async function getFetcherRequestOptions( +async function getFetchBody( opts: FunctionMiddlewareClientFnOptions, -) { - if (opts.method === 'POST') { - if (opts.data instanceof FormData) { - opts.data.set(TSS_FORMDATA_CONTEXT, await serialize(opts.context)) - return { - body: opts.data, - } +): Promise<{ body: FormData | string; contentType?: string } | undefined> { + if (opts.data instanceof FormData) { + let serializedContext = undefined + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (opts.context && Object.keys(opts.context).length > 0) { + serializedContext = await serialize(opts.context) } - - return { - body: await serializePayload(opts), + if (serializedContext !== undefined) { + opts.data.set(TSS_FORMDATA_CONTEXT, serializedContext) } + return { body: opts.data } } - - return {} + const serializedBody = await serializePayload(opts) + if (serializedBody) { + return { body: serializedBody, contentType: 'application/json' } + } + return undefined } /** @@ -155,7 +170,7 @@ async function getResponse(fn: () => Promise) { if (error instanceof Response) { return error } - + console.log(error) throw error } })() diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts index fa0aa995b88..4ccb20a1a70 100644 --- a/packages/start-server-core/src/server-functions-handler.ts +++ b/packages/start-server-core/src/server-functions-handler.ts @@ -106,7 +106,7 @@ export const handleServerAction = async ({ // By default the payload is the search params let payload: any = search.payload // If there's a payload, we should try to parse it - payload = payload ? parsePayload(JSON.parse(payload)) : payload + payload = payload ? parsePayload(JSON.parse(payload)) : {} payload.context = { ...context, ...payload.context } // Send it through! return await action(payload, signal) @@ -116,16 +116,15 @@ export const handleServerAction = async ({ throw new Error('expected POST method') } - if (!contentType || !contentType.includes('application/json')) { - throw new Error('expected application/json content type') + let jsonPayload + if (contentType === 'application/json') { + jsonPayload = await request.json() } - const jsonPayload = await request.json() - // If this POST request was created by createServerFn, // its payload will be the only argument if (isCreateServerFn) { - const payload = parsePayload(jsonPayload) + const payload = jsonPayload ? parsePayload(jsonPayload) : {} payload.context = { ...payload.context, ...context } return await action(payload, signal) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36f5b866099..b7c7c4f4cfb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1622,12 +1622,18 @@ importers: e2e/react-start/server-functions: dependencies: + '@tanstack/react-query': + specifier: 5.66.0 + version: 5.66.0(react@19.0.0) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router '@tanstack/react-router-devtools': specifier: workspace:^ version: link:../../../packages/react-router-devtools + '@tanstack/react-router-ssr-query': + specifier: workspace:* + version: link:../../../packages/react-router-ssr-query '@tanstack/react-start': specifier: workspace:* version: link:../../../packages/react-start