From 1abc34874a7349750f2f4d8152c37141c4ea0c79 Mon Sep 17 00:00:00 2001 From: Zelys Date: Thu, 7 May 2026 19:40:22 -0500 Subject: [PATCH 01/10] fix(start-client-core): allow middleware to return custom error structures from catch blocks Middleware that wanted to return structured error responses from catch blocks would have those errors thrown immediately to the client. The middleware protocol was using `.error` property for two incompatible purposes: framework errors (Error instances) and application error data (plain objects). This fix uses JavaScript's type system to distinguish them: only Error instances are thrown, allowing middleware to return custom error structures while preserving proper error propagation for real framework errors. Fixes #7238 BREAKING CHANGE: Middleware that throw non-Error values will now have those values captured in result.error instead of being thrown to the client. Only affects non-standard code patterns; best practice is to throw Error instances. --- packages/start-client-core/src/createServerFn.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index 37d4ead122..352dfbe87b 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -162,8 +162,10 @@ export const createServerFn: CreateServerFn = (options, __opts) => { throw redirect } - if (result.error) throw result.error - return result.result + if (result.error instanceof Error) throw result.error + // Non-Error values in result.error are application-level error payloads; + // return them as the resolved value when no explicit result is present. + return result.result ?? result.error }, { // This copies over the URL, function ID @@ -302,10 +304,12 @@ export async function executeMiddleware( const result = await callNextMiddleware(nextCtx) - if (result.error) { + if (result.error instanceof Error) { throw result.error } + // Non-Error values in result.error are application-level error payloads; + // preserve them for downstream handlers. return result } From 909b938102af4c5c78305c62fba0ad9df6f3d2ff Mon Sep 17 00:00:00 2001 From: Zelys Date: Thu, 7 May 2026 22:23:21 -0500 Subject: [PATCH 02/10] fix: handle null returns correctly in server functions The ?? operator treats null as a value, not nullish, so null ?? undefined returns null. However, we need to distinguish between: 1. result.result explicitly set to null (should return null) 2. result.result undefined (should fallback to result.error for custom error payloads) Change to: result.result !== undefined ? result.result : result.error This preserves null while still supporting middleware error payloads. Fixes #7364 --- packages/start-client-core/src/createServerFn.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index 352dfbe87b..ba9f792092 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -165,7 +165,7 @@ export const createServerFn: CreateServerFn = (options, __opts) => { if (result.error instanceof Error) throw result.error // Non-Error values in result.error are application-level error payloads; // return them as the resolved value when no explicit result is present. - return result.result ?? result.error + return result.result !== undefined ? result.result : result.error }, { // This copies over the URL, function ID From fe3a3ae0ea637765e97cdd2eee18834e80a5d578 Mon Sep 17 00:00:00 2001 From: Zelys Date: Fri, 8 May 2026 13:01:22 -0500 Subject: [PATCH 03/10] fix: re-throw redirect and notFound signals in middleware error guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When unwinding middleware, redirect() and notFound() framework signals must be re-thrown to reach the router—they are not Error instances. The previous instanceof Error guard would silently return them as application data, breaking SSR and client-navigation flow detection. Extended both error-handling locations (client fetcher and middleware executor) to also check isRedirect() and isNotFound(), ensuring framework signals propagate correctly while custom error payloads continue to be returned as resolved values. --- packages/start-client-core/src/createServerFn.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index ba9f792092..7a5a8e9815 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -1,6 +1,6 @@ import { mergeHeaders } from '@tanstack/router-core/ssr/client' -import { isRedirect, parseRedirect } from '@tanstack/router-core' +import { isNotFound, isRedirect, parseRedirect } from '@tanstack/router-core' import { TSS_SERVER_FUNCTION_FACTORY } from './constants' import { getStartOptions } from './getStartOptions' import { getStartContextServerOnly } from './getStartContextServerOnly' @@ -162,7 +162,12 @@ export const createServerFn: CreateServerFn = (options, __opts) => { throw redirect } - if (result.error instanceof Error) throw result.error + if ( + result.error instanceof Error || + isRedirect(result.error) || + isNotFound(result.error) + ) + throw result.error // Non-Error values in result.error are application-level error payloads; // return them as the resolved value when no explicit result is present. return result.result !== undefined ? result.result : result.error @@ -304,7 +309,11 @@ export async function executeMiddleware( const result = await callNextMiddleware(nextCtx) - if (result.error instanceof Error) { + if ( + result.error instanceof Error || + isRedirect(result.error) || + isNotFound(result.error) + ) { throw result.error } From c4a563d2da9584a7fab60d9bfc7288d8be4c7624 Mon Sep 17 00:00:00 2001 From: Zelys Date: Fri, 8 May 2026 14:15:22 -0500 Subject: [PATCH 04/10] fix: reorder imports for ESLint compliance (external before relative) --- .../start-client-core/src/createServerFn.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index 7a5a8e9815..37c2e6cbdf 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -1,15 +1,6 @@ import { mergeHeaders } from '@tanstack/router-core/ssr/client' import { isNotFound, isRedirect, parseRedirect } from '@tanstack/router-core' -import { TSS_SERVER_FUNCTION_FACTORY } from './constants' -import { getStartOptions } from './getStartOptions' -import { getStartContextServerOnly } from './getStartContextServerOnly' -import { createNullProtoObject, safeObjectMerge } from './safeObjectMerge' -import type { - ClientFnMeta, - ServerFnMeta, - TSS_SERVER_FUNCTION, -} from './constants' import type { AnyValidator, Constrain, @@ -21,6 +12,16 @@ import type { ValidateSerializableInput, Validator, } from '@tanstack/router-core' + +import { TSS_SERVER_FUNCTION_FACTORY } from './constants' +import { getStartOptions } from './getStartOptions' +import { getStartContextServerOnly } from './getStartContextServerOnly' +import { createNullProtoObject, safeObjectMerge } from './safeObjectMerge' +import type { + ClientFnMeta, + ServerFnMeta, + TSS_SERVER_FUNCTION, +} from './constants' import type { AnyFunctionMiddleware, AnyRequestMiddleware, @@ -30,6 +31,7 @@ import type { IntersectAllValidatorOutputs, } from './createMiddleware' + type TODO = any export type ServerFnStrict = boolean | { input?: boolean; output?: boolean } From ffdd5805d69d0ff6ce871de739fe6d362b7e7c88 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 19:16:57 +0000 Subject: [PATCH 05/10] ci: apply automated fixes --- packages/start-client-core/src/createServerFn.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index 37c2e6cbdf..2d900ffb73 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -31,7 +31,6 @@ import type { IntersectAllValidatorOutputs, } from './createMiddleware' - type TODO = any export type ServerFnStrict = boolean | { input?: boolean; output?: boolean } From 9a08e6aaf5597bd5e2719a6af80ef9537a419022 Mon Sep 17 00:00:00 2001 From: Zelys Date: Sat, 9 May 2026 18:22:02 -0500 Subject: [PATCH 06/10] =?UTF-8?q?fix(start-client-core):=20restore=20impor?= =?UTF-8?q?t=20order=20=E2=80=94=20external=20type=20import=20must=20follo?= =?UTF-8?q?w=20local=20type=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../start-client-core/src/createServerFn.ts | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index 2d900ffb73..c5aafd73f2 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -1,18 +1,6 @@ import { mergeHeaders } from '@tanstack/router-core/ssr/client' import { isNotFound, isRedirect, parseRedirect } from '@tanstack/router-core' -import type { - AnyValidator, - Constrain, - Expand, - Register, - RegisteredSerializableInput, - ResolveValidatorInput, - ValidateSerializable, - ValidateSerializableInput, - Validator, -} from '@tanstack/router-core' - import { TSS_SERVER_FUNCTION_FACTORY } from './constants' import { getStartOptions } from './getStartOptions' import { getStartContextServerOnly } from './getStartContextServerOnly' @@ -30,6 +18,17 @@ import type { IntersectAllValidatorInputs, IntersectAllValidatorOutputs, } from './createMiddleware' +import type { + AnyValidator, + Constrain, + Expand, + Register, + RegisteredSerializableInput, + ResolveValidatorInput, + ValidateSerializable, + ValidateSerializableInput, + Validator, +} from '@tanstack/router-core' type TODO = any From 797ac09bb4addd2fa3ec90fcf20e479d25116ba4 Mon Sep 17 00:00:00 2001 From: Zelys Date: Sat, 9 May 2026 18:47:15 -0500 Subject: [PATCH 07/10] fix(start-client-core): move # virtual module imports after sibling and type imports --- packages/start-client-core/src/client/hydrateStart.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/start-client-core/src/client/hydrateStart.ts b/packages/start-client-core/src/client/hydrateStart.ts index 206b70505c..20ca5b1b0d 100644 --- a/packages/start-client-core/src/client/hydrateStart.ts +++ b/packages/start-client-core/src/client/hydrateStart.ts @@ -1,14 +1,14 @@ import { hydrate } from '@tanstack/router-core/ssr/client' +import { ServerFunctionSerializationAdapter } from './ServerFunctionSerializationAdapter' +import type { AnyRouter, AnySerializationAdapter } from '@tanstack/router-core' +import type { AnyStartInstanceOptions } from '../createStart' import { startInstance } from '#tanstack-start-entry' import { hasPluginAdapters, pluginSerializationAdapters, } from '#tanstack-start-plugin-adapters' import { getRouter } from '#tanstack-router-entry' -import { ServerFunctionSerializationAdapter } from './ServerFunctionSerializationAdapter' -import type { AnyRouter, AnySerializationAdapter } from '@tanstack/router-core' -import type { AnyStartInstanceOptions } from '../createStart' export async function hydrateStart(): Promise { const router = await getRouter() From 39a19bcda8fff58c0d14791628fcca657a7cbc87 Mon Sep 17 00:00:00 2001 From: Zelys Date: Sun, 10 May 2026 11:13:22 -0500 Subject: [PATCH 08/10] fix(start-client-core): restore original #tanstack-* import order in hydrateStart.ts Virtual module imports (#tanstack-*) resolve as internal (rank 2), not unknown. They must appear before sibling imports, not after. Reverts mistaken reorder. --- packages/start-client-core/src/client/hydrateStart.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/start-client-core/src/client/hydrateStart.ts b/packages/start-client-core/src/client/hydrateStart.ts index 20ca5b1b0d..206b70505c 100644 --- a/packages/start-client-core/src/client/hydrateStart.ts +++ b/packages/start-client-core/src/client/hydrateStart.ts @@ -1,14 +1,14 @@ import { hydrate } from '@tanstack/router-core/ssr/client' -import { ServerFunctionSerializationAdapter } from './ServerFunctionSerializationAdapter' -import type { AnyRouter, AnySerializationAdapter } from '@tanstack/router-core' -import type { AnyStartInstanceOptions } from '../createStart' import { startInstance } from '#tanstack-start-entry' import { hasPluginAdapters, pluginSerializationAdapters, } from '#tanstack-start-plugin-adapters' import { getRouter } from '#tanstack-router-entry' +import { ServerFunctionSerializationAdapter } from './ServerFunctionSerializationAdapter' +import type { AnyRouter, AnySerializationAdapter } from '@tanstack/router-core' +import type { AnyStartInstanceOptions } from '../createStart' export async function hydrateStart(): Promise { const router = await getRouter() From 7a914f89287fe7513edd7a5970d874f3e9a9d1a3 Mon Sep 17 00:00:00 2001 From: Zelys Date: Thu, 21 May 2026 17:41:01 -0500 Subject: [PATCH 09/10] test(start-client-core): add e2e coverage for non-Error middleware error payloads Middleware that catches a server fn error and throws a plain object (not an Error instance) should surface that object to the caller rather than returning undefined. The fix in createServerFn.ts already handles this via `result.result !== undefined ? result.result : result.error`, but there was no e2e test exercising the path. Adds /middleware/non-error-payload route and a corresponding spec that asserts the structured payload reaches the client intact. Co-Authored-By: Claude Sonnet 4.6 --- .../server-functions/src/routeTree.gen.ts | 22 ++++++++ .../routes/middleware/non-error-payload.tsx | 55 +++++++++++++++++++ .../tests/server-functions.spec.ts | 21 +++++++ 3 files changed, 98 insertions(+) create mode 100644 e2e/react-start/server-functions/src/routes/middleware/non-error-payload.tsx diff --git a/e2e/react-start/server-functions/src/routeTree.gen.ts b/e2e/react-start/server-functions/src/routeTree.gen.ts index 249a9c42ce..8077133698 100644 --- a/e2e/react-start/server-functions/src/routeTree.gen.ts +++ b/e2e/react-start/server-functions/src/routeTree.gen.ts @@ -47,6 +47,7 @@ import { Route as MiddlewareMiddlewareFactoryRouteImport } from './routes/middle import { Route as MiddlewareFunctionMetadataRouteImport } from './routes/middleware/function-metadata' import { Route as MiddlewareClientMiddlewareRouterRouteImport } from './routes/middleware/client-middleware-router' import { Route as MiddlewareCatchHandlerErrorRouteImport } from './routes/middleware/catch-handler-error' +import { Route as MiddlewareNonErrorPayloadRouteImport } from './routes/middleware/non-error-payload' import { Route as MethodNotAllowedMethodRouteImport } from './routes/method-not-allowed/$method' import { Route as CookiesSetRouteImport } from './routes/cookies/set' import { Route as AbortSignalMethodRouteImport } from './routes/abort-signal/$method' @@ -251,6 +252,12 @@ const MiddlewareCatchHandlerErrorRoute = path: '/middleware/catch-handler-error', getParentRoute: () => rootRouteImport, } as any) +const MiddlewareNonErrorPayloadRoute = + MiddlewareNonErrorPayloadRouteImport.update({ + id: '/middleware/non-error-payload', + path: '/middleware/non-error-payload', + getParentRoute: () => rootRouteImport, + } as any) const MethodNotAllowedMethodRoute = MethodNotAllowedMethodRouteImport.update({ id: '/method-not-allowed/$method', path: '/method-not-allowed/$method', @@ -307,6 +314,7 @@ export interface FileRoutesByFullPath { '/cookies/set': typeof CookiesSetRoute '/method-not-allowed/$method': typeof MethodNotAllowedMethodRoute '/middleware/catch-handler-error': typeof MiddlewareCatchHandlerErrorRoute + '/middleware/non-error-payload': typeof MiddlewareNonErrorPayloadRoute '/middleware/client-middleware-router': typeof MiddlewareClientMiddlewareRouterRoute '/middleware/function-metadata': typeof MiddlewareFunctionMetadataRoute '/middleware/middleware-factory': typeof MiddlewareMiddlewareFactoryRoute @@ -353,6 +361,7 @@ export interface FileRoutesByTo { '/cookies/set': typeof CookiesSetRoute '/method-not-allowed/$method': typeof MethodNotAllowedMethodRoute '/middleware/catch-handler-error': typeof MiddlewareCatchHandlerErrorRoute + '/middleware/non-error-payload': typeof MiddlewareNonErrorPayloadRoute '/middleware/client-middleware-router': typeof MiddlewareClientMiddlewareRouterRoute '/middleware/function-metadata': typeof MiddlewareFunctionMetadataRoute '/middleware/middleware-factory': typeof MiddlewareMiddlewareFactoryRoute @@ -400,6 +409,7 @@ export interface FileRoutesById { '/cookies/set': typeof CookiesSetRoute '/method-not-allowed/$method': typeof MethodNotAllowedMethodRoute '/middleware/catch-handler-error': typeof MiddlewareCatchHandlerErrorRoute + '/middleware/non-error-payload': typeof MiddlewareNonErrorPayloadRoute '/middleware/client-middleware-router': typeof MiddlewareClientMiddlewareRouterRoute '/middleware/function-metadata': typeof MiddlewareFunctionMetadataRoute '/middleware/middleware-factory': typeof MiddlewareMiddlewareFactoryRoute @@ -448,6 +458,7 @@ export interface FileRouteTypes { | '/cookies/set' | '/method-not-allowed/$method' | '/middleware/catch-handler-error' + | '/middleware/non-error-payload' | '/middleware/client-middleware-router' | '/middleware/function-metadata' | '/middleware/middleware-factory' @@ -494,6 +505,7 @@ export interface FileRouteTypes { | '/cookies/set' | '/method-not-allowed/$method' | '/middleware/catch-handler-error' + | '/middleware/non-error-payload' | '/middleware/client-middleware-router' | '/middleware/function-metadata' | '/middleware/middleware-factory' @@ -540,6 +552,7 @@ export interface FileRouteTypes { | '/cookies/set' | '/method-not-allowed/$method' | '/middleware/catch-handler-error' + | '/middleware/non-error-payload' | '/middleware/client-middleware-router' | '/middleware/function-metadata' | '/middleware/middleware-factory' @@ -587,6 +600,7 @@ export interface RootRouteChildren { CookiesSetRoute: typeof CookiesSetRoute MethodNotAllowedMethodRoute: typeof MethodNotAllowedMethodRoute MiddlewareCatchHandlerErrorRoute: typeof MiddlewareCatchHandlerErrorRoute + MiddlewareNonErrorPayloadRoute: typeof MiddlewareNonErrorPayloadRoute MiddlewareClientMiddlewareRouterRoute: typeof MiddlewareClientMiddlewareRouterRoute MiddlewareFunctionMetadataRoute: typeof MiddlewareFunctionMetadataRoute MiddlewareMiddlewareFactoryRoute: typeof MiddlewareMiddlewareFactoryRoute @@ -880,6 +894,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MiddlewareCatchHandlerErrorRouteImport parentRoute: typeof rootRouteImport } + '/middleware/non-error-payload': { + id: '/middleware/non-error-payload' + path: '/middleware/non-error-payload' + fullPath: '/middleware/non-error-payload' + preLoaderRoute: typeof MiddlewareNonErrorPayloadRouteImport + parentRoute: typeof rootRouteImport + } '/method-not-allowed/$method': { id: '/method-not-allowed/$method' path: '/method-not-allowed/$method' @@ -947,6 +968,7 @@ const rootRouteChildren: RootRouteChildren = { CookiesSetRoute: CookiesSetRoute, MethodNotAllowedMethodRoute: MethodNotAllowedMethodRoute, MiddlewareCatchHandlerErrorRoute: MiddlewareCatchHandlerErrorRoute, + MiddlewareNonErrorPayloadRoute: MiddlewareNonErrorPayloadRoute, MiddlewareClientMiddlewareRouterRoute: MiddlewareClientMiddlewareRouterRoute, MiddlewareFunctionMetadataRoute: MiddlewareFunctionMetadataRoute, MiddlewareMiddlewareFactoryRoute: MiddlewareMiddlewareFactoryRoute, diff --git a/e2e/react-start/server-functions/src/routes/middleware/non-error-payload.tsx b/e2e/react-start/server-functions/src/routes/middleware/non-error-payload.tsx new file mode 100644 index 0000000000..31251d092d --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/non-error-payload.tsx @@ -0,0 +1,55 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createMiddleware, createServerFn } from '@tanstack/react-start' +import { useState } from 'react' + +const structuredErrorMiddleware = createMiddleware({ type: 'function' }).server( + async ({ next }) => { + try { + return await next() + } catch { + // Throw a plain object — not an Error instance — as a structured payload. + // The client should receive this object as the resolved value, not undefined. + throw { code: 'HANDLED', message: 'Middleware returned structured payload' } + } + }, +) + +const $serverFnThatThrows = createServerFn({ method: 'GET' }) + .middleware([structuredErrorMiddleware]) + .handler(() => { + throw new Error('Trigger middleware catch') + }) + +export const Route = createFileRoute('/middleware/non-error-payload')({ + component: RouteComponent, +}) + +function RouteComponent() { + const [result, setResult] = useState(undefined) + const [loading, setLoading] = useState(false) + + const handleClick = async () => { + setLoading(true) + const value = await $serverFnThatThrows() + setResult(value) + setLoading(false) + } + + return ( +
+

+ Non-Error Middleware Payload Test +

+ + {result !== undefined && ( +
{JSON.stringify(result)}
+ )} +
+ ) +} 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 4173362e4c..d1ccd52c50 100644 --- a/e2e/react-start/server-functions/tests/server-functions.spec.ts +++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts @@ -1050,6 +1050,27 @@ test('middleware can catch errors thrown by server function handlers', async ({ ) }) +test('middleware can surface non-Error structured payloads to the caller', async ({ + page, +}) => { + await page.goto('/middleware/non-error-payload') + + await page.waitForLoadState('networkidle') + + await expect(page.getByTestId('non-error-payload-title')).toBeVisible() + + await page.getByTestId('trigger-non-error-btn').click() + + await expect(page.getByTestId('payload-result')).toBeVisible() + + const text = await page.getByTestId('payload-result').textContent() + const payload = JSON.parse(text!) + expect(payload).toEqual({ + code: 'HANDLED', + message: 'Middleware returned structured payload', + }) +}) + test('server function with custom fetch implementation passed directly', async ({ page, }) => { From 148d61c014e0ddba008427ef04245dd13fc724c6 Mon Sep 17 00:00:00 2001 From: Zelys Date: Fri, 22 May 2026 00:37:21 -0500 Subject: [PATCH 10/10] ci: re-trigger