From 3ac693cef0a55599b15af150025dcb94bbf7fc55 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 18 Jan 2026 12:34:57 +0100 Subject: [PATCH 1/5] feat: server function middleware short circuit runtime only, types TBD --- .../server-functions/src/routeTree.gen.ts | 110 ++++++++ .../routes/middleware/client-conditional.tsx | 184 ++++++++++++ .../routes/middleware/client-early-return.tsx | 120 ++++++++ .../src/routes/middleware/index.tsx | 40 +++ .../routes/middleware/nested-early-return.tsx | 238 ++++++++++++++++ .../routes/middleware/server-conditional.tsx | 186 ++++++++++++ .../routes/middleware/server-early-return.tsx | 202 +++++++++++++ .../tests/server-functions.spec.ts | 266 ++++++++++++++++++ packages/start-client-core/src/constants.ts | 7 + .../start-client-core/src/createServerFn.ts | 23 +- .../src/tests/createServerFn.test-d.ts | 86 ++++++ .../tests/createServerMiddleware.test-d.ts | 137 +++++++++ 12 files changed, 1597 insertions(+), 2 deletions(-) create mode 100644 e2e/react-start/server-functions/src/routes/middleware/client-conditional.tsx create mode 100644 e2e/react-start/server-functions/src/routes/middleware/client-early-return.tsx create mode 100644 e2e/react-start/server-functions/src/routes/middleware/nested-early-return.tsx create mode 100644 e2e/react-start/server-functions/src/routes/middleware/server-conditional.tsx create mode 100644 e2e/react-start/server-functions/src/routes/middleware/server-early-return.tsx diff --git a/e2e/react-start/server-functions/src/routeTree.gen.ts b/e2e/react-start/server-functions/src/routeTree.gen.ts index b5d09dd802e..859d0a9a8de 100644 --- a/e2e/react-start/server-functions/src/routeTree.gen.ts +++ b/e2e/react-start/server-functions/src/routeTree.gen.ts @@ -40,11 +40,16 @@ import { Route as RedirectTestTargetRouteImport } from './routes/redirect-test/t import { Route as RedirectTestSsrTargetRouteImport } from './routes/redirect-test-ssr/target' import { Route as MiddlewareUnhandledExceptionRouteImport } from './routes/middleware/unhandled-exception' import { Route as MiddlewareServerImportMiddlewareRouteImport } from './routes/middleware/server-import-middleware' +import { Route as MiddlewareServerEarlyReturnRouteImport } from './routes/middleware/server-early-return' +import { Route as MiddlewareServerConditionalRouteImport } from './routes/middleware/server-conditional' import { Route as MiddlewareSendServerFnRouteImport } from './routes/middleware/send-serverFn' import { Route as MiddlewareRequestMiddlewareRouteImport } from './routes/middleware/request-middleware' +import { Route as MiddlewareNestedEarlyReturnRouteImport } from './routes/middleware/nested-early-return' import { Route as MiddlewareMiddlewareFactoryRouteImport } from './routes/middleware/middleware-factory' import { Route as MiddlewareFunctionMetadataRouteImport } from './routes/middleware/function-metadata' import { Route as MiddlewareClientMiddlewareRouterRouteImport } from './routes/middleware/client-middleware-router' +import { Route as MiddlewareClientEarlyReturnRouteImport } from './routes/middleware/client-early-return' +import { Route as MiddlewareClientConditionalRouteImport } from './routes/middleware/client-conditional' import { Route as MiddlewareCatchHandlerErrorRouteImport } from './routes/middleware/catch-handler-error' import { Route as CookiesSetRouteImport } from './routes/cookies/set' import { Route as AbortSignalMethodRouteImport } from './routes/abort-signal/$method' @@ -209,6 +214,18 @@ const MiddlewareServerImportMiddlewareRoute = path: '/middleware/server-import-middleware', getParentRoute: () => rootRouteImport, } as any) +const MiddlewareServerEarlyReturnRoute = + MiddlewareServerEarlyReturnRouteImport.update({ + id: '/middleware/server-early-return', + path: '/middleware/server-early-return', + getParentRoute: () => rootRouteImport, + } as any) +const MiddlewareServerConditionalRoute = + MiddlewareServerConditionalRouteImport.update({ + id: '/middleware/server-conditional', + path: '/middleware/server-conditional', + getParentRoute: () => rootRouteImport, + } as any) const MiddlewareSendServerFnRoute = MiddlewareSendServerFnRouteImport.update({ id: '/middleware/send-serverFn', path: '/middleware/send-serverFn', @@ -220,6 +237,12 @@ const MiddlewareRequestMiddlewareRoute = path: '/middleware/request-middleware', getParentRoute: () => rootRouteImport, } as any) +const MiddlewareNestedEarlyReturnRoute = + MiddlewareNestedEarlyReturnRouteImport.update({ + id: '/middleware/nested-early-return', + path: '/middleware/nested-early-return', + getParentRoute: () => rootRouteImport, + } as any) const MiddlewareMiddlewareFactoryRoute = MiddlewareMiddlewareFactoryRouteImport.update({ id: '/middleware/middleware-factory', @@ -238,6 +261,18 @@ const MiddlewareClientMiddlewareRouterRoute = path: '/middleware/client-middleware-router', getParentRoute: () => rootRouteImport, } as any) +const MiddlewareClientEarlyReturnRoute = + MiddlewareClientEarlyReturnRouteImport.update({ + id: '/middleware/client-early-return', + path: '/middleware/client-early-return', + getParentRoute: () => rootRouteImport, + } as any) +const MiddlewareClientConditionalRoute = + MiddlewareClientConditionalRouteImport.update({ + id: '/middleware/client-conditional', + path: '/middleware/client-conditional', + getParentRoute: () => rootRouteImport, + } as any) const MiddlewareCatchHandlerErrorRoute = MiddlewareCatchHandlerErrorRouteImport.update({ id: '/middleware/catch-handler-error', @@ -294,11 +329,16 @@ export interface FileRoutesByFullPath { '/abort-signal/$method': typeof AbortSignalMethodRoute '/cookies/set': typeof CookiesSetRoute '/middleware/catch-handler-error': typeof MiddlewareCatchHandlerErrorRoute + '/middleware/client-conditional': typeof MiddlewareClientConditionalRoute + '/middleware/client-early-return': typeof MiddlewareClientEarlyReturnRoute '/middleware/client-middleware-router': typeof MiddlewareClientMiddlewareRouterRoute '/middleware/function-metadata': typeof MiddlewareFunctionMetadataRoute '/middleware/middleware-factory': typeof MiddlewareMiddlewareFactoryRoute + '/middleware/nested-early-return': typeof MiddlewareNestedEarlyReturnRoute '/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute + '/middleware/server-conditional': typeof MiddlewareServerConditionalRoute + '/middleware/server-early-return': typeof MiddlewareServerEarlyReturnRoute '/middleware/server-import-middleware': typeof MiddlewareServerImportMiddlewareRoute '/middleware/unhandled-exception': typeof MiddlewareUnhandledExceptionRoute '/redirect-test-ssr/target': typeof RedirectTestSsrTargetRoute @@ -338,11 +378,16 @@ export interface FileRoutesByTo { '/abort-signal/$method': typeof AbortSignalMethodRoute '/cookies/set': typeof CookiesSetRoute '/middleware/catch-handler-error': typeof MiddlewareCatchHandlerErrorRoute + '/middleware/client-conditional': typeof MiddlewareClientConditionalRoute + '/middleware/client-early-return': typeof MiddlewareClientEarlyReturnRoute '/middleware/client-middleware-router': typeof MiddlewareClientMiddlewareRouterRoute '/middleware/function-metadata': typeof MiddlewareFunctionMetadataRoute '/middleware/middleware-factory': typeof MiddlewareMiddlewareFactoryRoute + '/middleware/nested-early-return': typeof MiddlewareNestedEarlyReturnRoute '/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute + '/middleware/server-conditional': typeof MiddlewareServerConditionalRoute + '/middleware/server-early-return': typeof MiddlewareServerEarlyReturnRoute '/middleware/server-import-middleware': typeof MiddlewareServerImportMiddlewareRoute '/middleware/unhandled-exception': typeof MiddlewareUnhandledExceptionRoute '/redirect-test-ssr/target': typeof RedirectTestSsrTargetRoute @@ -383,11 +428,16 @@ export interface FileRoutesById { '/abort-signal/$method': typeof AbortSignalMethodRoute '/cookies/set': typeof CookiesSetRoute '/middleware/catch-handler-error': typeof MiddlewareCatchHandlerErrorRoute + '/middleware/client-conditional': typeof MiddlewareClientConditionalRoute + '/middleware/client-early-return': typeof MiddlewareClientEarlyReturnRoute '/middleware/client-middleware-router': typeof MiddlewareClientMiddlewareRouterRoute '/middleware/function-metadata': typeof MiddlewareFunctionMetadataRoute '/middleware/middleware-factory': typeof MiddlewareMiddlewareFactoryRoute + '/middleware/nested-early-return': typeof MiddlewareNestedEarlyReturnRoute '/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute + '/middleware/server-conditional': typeof MiddlewareServerConditionalRoute + '/middleware/server-early-return': typeof MiddlewareServerEarlyReturnRoute '/middleware/server-import-middleware': typeof MiddlewareServerImportMiddlewareRoute '/middleware/unhandled-exception': typeof MiddlewareUnhandledExceptionRoute '/redirect-test-ssr/target': typeof RedirectTestSsrTargetRoute @@ -429,11 +479,16 @@ export interface FileRouteTypes { | '/abort-signal/$method' | '/cookies/set' | '/middleware/catch-handler-error' + | '/middleware/client-conditional' + | '/middleware/client-early-return' | '/middleware/client-middleware-router' | '/middleware/function-metadata' | '/middleware/middleware-factory' + | '/middleware/nested-early-return' | '/middleware/request-middleware' | '/middleware/send-serverFn' + | '/middleware/server-conditional' + | '/middleware/server-early-return' | '/middleware/server-import-middleware' | '/middleware/unhandled-exception' | '/redirect-test-ssr/target' @@ -473,11 +528,16 @@ export interface FileRouteTypes { | '/abort-signal/$method' | '/cookies/set' | '/middleware/catch-handler-error' + | '/middleware/client-conditional' + | '/middleware/client-early-return' | '/middleware/client-middleware-router' | '/middleware/function-metadata' | '/middleware/middleware-factory' + | '/middleware/nested-early-return' | '/middleware/request-middleware' | '/middleware/send-serverFn' + | '/middleware/server-conditional' + | '/middleware/server-early-return' | '/middleware/server-import-middleware' | '/middleware/unhandled-exception' | '/redirect-test-ssr/target' @@ -517,11 +577,16 @@ export interface FileRouteTypes { | '/abort-signal/$method' | '/cookies/set' | '/middleware/catch-handler-error' + | '/middleware/client-conditional' + | '/middleware/client-early-return' | '/middleware/client-middleware-router' | '/middleware/function-metadata' | '/middleware/middleware-factory' + | '/middleware/nested-early-return' | '/middleware/request-middleware' | '/middleware/send-serverFn' + | '/middleware/server-conditional' + | '/middleware/server-early-return' | '/middleware/server-import-middleware' | '/middleware/unhandled-exception' | '/redirect-test-ssr/target' @@ -562,11 +627,16 @@ export interface RootRouteChildren { AbortSignalMethodRoute: typeof AbortSignalMethodRoute CookiesSetRoute: typeof CookiesSetRoute MiddlewareCatchHandlerErrorRoute: typeof MiddlewareCatchHandlerErrorRoute + MiddlewareClientConditionalRoute: typeof MiddlewareClientConditionalRoute + MiddlewareClientEarlyReturnRoute: typeof MiddlewareClientEarlyReturnRoute MiddlewareClientMiddlewareRouterRoute: typeof MiddlewareClientMiddlewareRouterRoute MiddlewareFunctionMetadataRoute: typeof MiddlewareFunctionMetadataRoute MiddlewareMiddlewareFactoryRoute: typeof MiddlewareMiddlewareFactoryRoute + MiddlewareNestedEarlyReturnRoute: typeof MiddlewareNestedEarlyReturnRoute MiddlewareRequestMiddlewareRoute: typeof MiddlewareRequestMiddlewareRoute MiddlewareSendServerFnRoute: typeof MiddlewareSendServerFnRoute + MiddlewareServerConditionalRoute: typeof MiddlewareServerConditionalRoute + MiddlewareServerEarlyReturnRoute: typeof MiddlewareServerEarlyReturnRoute MiddlewareServerImportMiddlewareRoute: typeof MiddlewareServerImportMiddlewareRoute MiddlewareUnhandledExceptionRoute: typeof MiddlewareUnhandledExceptionRoute RedirectTestSsrTargetRoute: typeof RedirectTestSsrTargetRoute @@ -805,6 +875,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MiddlewareServerImportMiddlewareRouteImport parentRoute: typeof rootRouteImport } + '/middleware/server-early-return': { + id: '/middleware/server-early-return' + path: '/middleware/server-early-return' + fullPath: '/middleware/server-early-return' + preLoaderRoute: typeof MiddlewareServerEarlyReturnRouteImport + parentRoute: typeof rootRouteImport + } + '/middleware/server-conditional': { + id: '/middleware/server-conditional' + path: '/middleware/server-conditional' + fullPath: '/middleware/server-conditional' + preLoaderRoute: typeof MiddlewareServerConditionalRouteImport + parentRoute: typeof rootRouteImport + } '/middleware/send-serverFn': { id: '/middleware/send-serverFn' path: '/middleware/send-serverFn' @@ -819,6 +903,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MiddlewareRequestMiddlewareRouteImport parentRoute: typeof rootRouteImport } + '/middleware/nested-early-return': { + id: '/middleware/nested-early-return' + path: '/middleware/nested-early-return' + fullPath: '/middleware/nested-early-return' + preLoaderRoute: typeof MiddlewareNestedEarlyReturnRouteImport + parentRoute: typeof rootRouteImport + } '/middleware/middleware-factory': { id: '/middleware/middleware-factory' path: '/middleware/middleware-factory' @@ -840,6 +931,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MiddlewareClientMiddlewareRouterRouteImport parentRoute: typeof rootRouteImport } + '/middleware/client-early-return': { + id: '/middleware/client-early-return' + path: '/middleware/client-early-return' + fullPath: '/middleware/client-early-return' + preLoaderRoute: typeof MiddlewareClientEarlyReturnRouteImport + parentRoute: typeof rootRouteImport + } + '/middleware/client-conditional': { + id: '/middleware/client-conditional' + path: '/middleware/client-conditional' + fullPath: '/middleware/client-conditional' + preLoaderRoute: typeof MiddlewareClientConditionalRouteImport + parentRoute: typeof rootRouteImport + } '/middleware/catch-handler-error': { id: '/middleware/catch-handler-error' path: '/middleware/catch-handler-error' @@ -906,11 +1011,16 @@ const rootRouteChildren: RootRouteChildren = { AbortSignalMethodRoute: AbortSignalMethodRoute, CookiesSetRoute: CookiesSetRoute, MiddlewareCatchHandlerErrorRoute: MiddlewareCatchHandlerErrorRoute, + MiddlewareClientConditionalRoute: MiddlewareClientConditionalRoute, + MiddlewareClientEarlyReturnRoute: MiddlewareClientEarlyReturnRoute, MiddlewareClientMiddlewareRouterRoute: MiddlewareClientMiddlewareRouterRoute, MiddlewareFunctionMetadataRoute: MiddlewareFunctionMetadataRoute, MiddlewareMiddlewareFactoryRoute: MiddlewareMiddlewareFactoryRoute, + MiddlewareNestedEarlyReturnRoute: MiddlewareNestedEarlyReturnRoute, MiddlewareRequestMiddlewareRoute: MiddlewareRequestMiddlewareRoute, MiddlewareSendServerFnRoute: MiddlewareSendServerFnRoute, + MiddlewareServerConditionalRoute: MiddlewareServerConditionalRoute, + MiddlewareServerEarlyReturnRoute: MiddlewareServerEarlyReturnRoute, MiddlewareServerImportMiddlewareRoute: MiddlewareServerImportMiddlewareRoute, MiddlewareUnhandledExceptionRoute: MiddlewareUnhandledExceptionRoute, RedirectTestSsrTargetRoute: RedirectTestSsrTargetRoute, diff --git a/e2e/react-start/server-functions/src/routes/middleware/client-conditional.tsx b/e2e/react-start/server-functions/src/routes/middleware/client-conditional.tsx new file mode 100644 index 00000000000..9c5cec8abcd --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/client-conditional.tsx @@ -0,0 +1,184 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createMiddleware, createServerFn } from '@tanstack/react-start' +import React from 'react' + +/** + * This middleware's .client() conditionally calls next() OR returns a value. + * If `shouldShortCircuit` is true in the data, it returns early on the client. + * Otherwise, it calls next() which proceeds to the server. + */ +const clientConditionalMiddleware = createMiddleware({ + type: 'function', +}) + .inputValidator( + (input: { shouldShortCircuit: boolean; value: string }) => input, + ) + .client( + // @ts-expect-error - types don't support union of next() result and arbitrary value + async ({ data, next }) => { + if (data.shouldShortCircuit) { + return { + source: 'client-middleware', + message: 'Conditional early return from client middleware', + condition: 'shouldShortCircuit=true', + timestamp: Date.now(), + } + } + // Proceed to server + return next({ + sendContext: { + clientTimestamp: Date.now(), + }, + }) + }, + ) + +const serverFn = createServerFn() + .middleware([clientConditionalMiddleware]) + .handler(({ data, context }) => { + return { + source: 'handler', + message: 'Handler was called on server', + receivedData: data, + receivedContext: context, + } + }) + +export const Route = createFileRoute('/middleware/client-conditional')({ + loader: async () => { + // In loader (server-side), client middleware may not apply the same way + const result = await serverFn({ + data: { shouldShortCircuit: false, value: 'loader-value' }, + }) + return { loaderResult: result } + }, + component: RouteComponent, +}) + +function RouteComponent() { + const { loaderResult } = Route.useLoaderData() + const [clientShortCircuit, setClientShortCircuit] = React.useState(null) + const [clientNext, setClientNext] = React.useState(null) + + const expectedShortCircuit = { + source: 'client-middleware', + message: 'Conditional early return from client middleware', + condition: 'shouldShortCircuit=true', + } + + const expectedNext = { + source: 'handler', + message: 'Handler was called on server', + } + + return ( +
+

+ Client Middleware Conditional Return +

+

+ Tests that a .client() middleware can conditionally call next() OR + return a value based on input data. Short-circuit avoids network + request. +

+ +
+

Loader Result (SSR):

+
+          {JSON.stringify(loaderResult)}
+        
+
+ +
+
+

+ Short-Circuit Branch (Client-only) +

+ +
+

Expected (partial):

+
+              {JSON.stringify(expectedShortCircuit)}
+            
+
+ + + +
+

Client Result:

+
+              {clientShortCircuit
+                ? JSON.stringify(clientShortCircuit)
+                : 'Not called yet'}
+            
+
+
+ +
+

Next() Branch (Goes to Server)

+ +
+

Expected (partial):

+
+              {JSON.stringify(expectedNext)}
+            
+
+ + + +
+

Client Result:

+
+              {clientNext ? JSON.stringify(clientNext) : 'Not called yet'}
+            
+
+
+
+ +
+

Note:

+

+ Short-circuit branch should NOT make a network request. Next() branch + should make a network request to the server. +

+
+
+ ) +} diff --git a/e2e/react-start/server-functions/src/routes/middleware/client-early-return.tsx b/e2e/react-start/server-functions/src/routes/middleware/client-early-return.tsx new file mode 100644 index 00000000000..90a41ed42ad --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/client-early-return.tsx @@ -0,0 +1,120 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createMiddleware, createServerFn } from '@tanstack/react-start' +import React from 'react' + +/** + * This middleware's .client() does NOT call next() at all + * and always returns a value directly. + * + * Expected behavior: The server function should never be called on the server, + * and the middleware's return value should be the result. + * + * Note: This means no network request to the server should happen. + */ +const clientEarlyReturnMiddleware = createMiddleware({ + type: 'function', +}).client( + // @ts-expect-error - types currently require returning next() result + async () => { + return { + source: 'client-middleware', + message: 'Early return from client middleware', + timestamp: Date.now(), + } + }, +) + +const serverFn = createServerFn() + .middleware([clientEarlyReturnMiddleware]) + .handler(() => { + // This handler should NEVER be called because client middleware returns early + return { + source: 'handler', + message: 'This should not be returned - server was called!', + } + }) + +export const Route = createFileRoute('/middleware/client-early-return')({ + // Note: In SSR context, client middleware may behave differently + // The loader runs on the server, so client middleware doesn't apply the same way + loader: async () => { + const result = await serverFn() + return { loaderResult: result } + }, + component: RouteComponent, +}) + +function RouteComponent() { + const { loaderResult } = Route.useLoaderData() + const [clientResult, setClientResult] = React.useState(null) + + // When called from client, we expect the client middleware to short-circuit + const expectedClientResult = { + source: 'client-middleware', + message: 'Early return from client middleware', + } + + return ( +
+

+ Client Middleware Early Return (No next() call) +

+

+ Tests that a .client() middleware can return a value without calling + next(), effectively short-circuiting before the server is even called. +

+ +
+

+ Expected Client Result (partial match): +

+
+          {JSON.stringify(expectedClientResult)}
+        
+
+ +
+

Loader Result (SSR - may differ):

+
+          {JSON.stringify(loaderResult)}
+        
+
+ + + +
+

Client Result:

+
+          {clientResult ? JSON.stringify(clientResult) : 'Not called yet'}
+        
+
+ +
+

Note:

+

+ When called from client, the middleware should return immediately + without making a network request to the server. The source should be + "client-middleware". +

+
+
+ ) +} diff --git a/e2e/react-start/server-functions/src/routes/middleware/index.tsx b/e2e/react-start/server-functions/src/routes/middleware/index.tsx index 0d4e3139124..fe13f5f4bb1 100644 --- a/e2e/react-start/server-functions/src/routes/middleware/index.tsx +++ b/e2e/react-start/server-functions/src/routes/middleware/index.tsx @@ -66,6 +66,46 @@ function RouteComponent() { Function middleware receives functionId and filename +
  • + + Server middleware early return (no next() call) + +
  • +
  • + + Server middleware conditional return (next() OR value) + +
  • +
  • + + Client middleware early return (no next() call) + +
  • +
  • + + Client middleware conditional return (next() OR value) + +
  • +
  • + + Nested middleware early return + +
  • ) diff --git a/e2e/react-start/server-functions/src/routes/middleware/nested-early-return.tsx b/e2e/react-start/server-functions/src/routes/middleware/nested-early-return.tsx new file mode 100644 index 00000000000..425d5410eaf --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/nested-early-return.tsx @@ -0,0 +1,238 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createMiddleware, createServerFn } from '@tanstack/react-start' +import React from 'react' + +/** + * Tests deeply nested middleware chains where inner middleware does early return. + * + * Structure: + * - outerMiddleware + * - uses middleMiddleware + * - uses innerMiddleware (which may return early) + * + * Scenarios: + * 1. innerMiddleware returns early -> outerMiddleware never gets to call next() + * 2. innerMiddleware calls next() -> chain continues normally + */ + +type EarlyReturnInput = { + earlyReturnLevel: 'none' | 'deep' | 'middle' | 'outer' + value: string +} + +// Deepest level - conditionally returns early based on input +const deepMiddleware = createMiddleware({ type: 'function' }) + .inputValidator((input: EarlyReturnInput) => input) + .server( + // @ts-expect-error - types don't support early return + async ({ data, next }) => { + if (data.earlyReturnLevel === 'deep') { + return { + returnedFrom: 'deepMiddleware', + message: 'Early return from deepest middleware', + level: 3, + } + } + return next({ + context: { + deepMiddlewarePassed: true, + }, + }) + }, + ) + +// Middle level - wraps deep middleware, may also return early +const middleMiddleware = createMiddleware({ type: 'function' }) + .middleware([deepMiddleware]) + .server( + // @ts-expect-error - types don't support early return or receiving non-next result from inner middleware + async ({ data, next, context }) => { + if (data.earlyReturnLevel === 'middle') { + return { + returnedFrom: 'middleMiddleware', + message: 'Early return from middle middleware', + level: 2, + deepContext: context, + } + } + return next({ + context: { + middleMiddlewarePassed: true, + }, + }) + }, + ) + +// Outer level - wraps middle middleware, may also return early +const outerMiddleware = createMiddleware({ type: 'function' }) + .middleware([middleMiddleware]) + .server( + // @ts-expect-error - types don't support early return or receiving non-next result from inner middleware + async ({ data, next, context }) => { + if (data.earlyReturnLevel === 'outer') { + return { + returnedFrom: 'outerMiddleware', + message: 'Early return from outer middleware', + level: 1, + middleContext: context, + } + } + return next({ + context: { + outerMiddlewarePassed: true, + }, + }) + }, + ) + +const serverFn = createServerFn() + .middleware([outerMiddleware]) + .handler(({ data, context }) => { + return { + returnedFrom: 'handler', + message: 'Handler was called - all middleware passed through', + level: 0, + finalContext: context, + receivedData: data, + } + }) + +export const Route = createFileRoute('/middleware/nested-early-return')({ + loader: async () => { + // Test all branches + const deepReturn = await serverFn({ + data: { earlyReturnLevel: 'deep', value: 'test-deep' }, + }) + const middleReturn = await serverFn({ + data: { earlyReturnLevel: 'middle', value: 'test-middle' }, + }) + const outerReturn = await serverFn({ + data: { earlyReturnLevel: 'outer', value: 'test-outer' }, + }) + const handlerReturn = await serverFn({ + data: { earlyReturnLevel: 'none', value: 'test-handler' }, + }) + return { deepReturn, middleReturn, outerReturn, handlerReturn } + }, + component: RouteComponent, +}) + +function RouteComponent() { + const loaderData = Route.useLoaderData() + const [results, setResults] = React.useState<{ + deep: any + middle: any + outer: any + handler: any + }>({ deep: null, middle: null, outer: null, handler: null }) + + const levels = [ + { + key: 'deep' as const, + level: 'deep', + label: 'Deep Middleware', + color: '#8b5cf6', + }, + { + key: 'middle' as const, + level: 'middle', + label: 'Middle Middleware', + color: '#3b82f6', + }, + { + key: 'outer' as const, + level: 'outer', + label: 'Outer Middleware', + color: '#22c55e', + }, + { + key: 'handler' as const, + level: 'none', + label: 'Handler', + color: '#6b7280', + }, + ] + + return ( +
    +

    Nested Middleware Early Return

    +

    + Tests deeply nested middleware chains where different levels can return + early. Chain: outerMiddleware → middleMiddleware → deepMiddleware → + handler +

    + +
    + {levels.map(({ key, level, label, color }) => ( +
    +

    {label} Returns

    + +
    +

    Expected returnedFrom:

    +
    +                {level === 'none' ? 'handler' : `${level}Middleware`}
    +              
    +
    + +
    +

    Loader Result:

    +
    +                {JSON.stringify(loaderData[`${key}Return`], null, 2)}
    +              
    +
    + + + +
    +

    Client Result:

    +
    +                {results[key]
    +                  ? JSON.stringify(results[key], null, 2)
    +                  : 'Not called yet'}
    +              
    +
    +
    + ))} +
    + +
    +

    Chain Structure:

    +
    +          {`outerMiddleware (level 1)
    +   └─ middleMiddleware (level 2)
    +        └─ deepMiddleware (level 3)
    +             └─ handler (level 0)`}
    +        
    +

    + Each level can short-circuit and return early, preventing deeper + levels from executing. +

    +
    +
    + ) +} diff --git a/e2e/react-start/server-functions/src/routes/middleware/server-conditional.tsx b/e2e/react-start/server-functions/src/routes/middleware/server-conditional.tsx new file mode 100644 index 00000000000..33dc061d649 --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/server-conditional.tsx @@ -0,0 +1,186 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createMiddleware, createServerFn } from '@tanstack/react-start' +import React from 'react' + +/** + * This middleware's .server() conditionally calls next() OR returns a value. + * If `shouldShortCircuit` is true in the data, it returns early. + * Otherwise, it calls next() and passes context to the handler. + */ +const serverConditionalMiddleware = createMiddleware({ + type: 'function', +}) + .inputValidator( + (input: { shouldShortCircuit: boolean; value: string }) => input, + ) + .server( + // @ts-expect-error - types don't support union of next() result and arbitrary value + async ({ data, next }) => { + if (data.shouldShortCircuit) { + return { + source: 'middleware', + message: 'Conditional early return from server middleware', + condition: 'shouldShortCircuit=true', + } + } + return next({ + context: { + passedThroughMiddleware: true, + }, + }) + }, + ) + +const serverFn = createServerFn() + .middleware([serverConditionalMiddleware]) + .handler(({ data, context }) => { + return { + source: 'handler', + message: 'Handler was called', + receivedData: data, + receivedContext: context, + } + }) + +export const Route = createFileRoute('/middleware/server-conditional')({ + loader: async () => { + // Test both branches in the loader + const shortCircuitResult = await serverFn({ + data: { shouldShortCircuit: true, value: 'loader-short' }, + }) + const nextResult = await serverFn({ + data: { shouldShortCircuit: false, value: 'loader-next' }, + }) + return { shortCircuitResult, nextResult } + }, + component: RouteComponent, +}) + +function RouteComponent() { + const { shortCircuitResult, nextResult } = Route.useLoaderData() + const [clientShortCircuit, setClientShortCircuit] = React.useState(null) + const [clientNext, setClientNext] = React.useState(null) + + const expectedShortCircuit = { + source: 'middleware', + message: 'Conditional early return from server middleware', + condition: 'shouldShortCircuit=true', + } + + const expectedNext = { + source: 'handler', + message: 'Handler was called', + receivedData: { shouldShortCircuit: false, value: 'client-next' }, + receivedContext: { passedThroughMiddleware: true }, + } + + return ( +
    +

    + Server Middleware Conditional Return +

    +

    + Tests that a .server() middleware can conditionally call next() OR + return a value based on input data. +

    + +
    +
    +

    Short-Circuit Branch

    + +
    +

    Expected (client):

    +
    +              {JSON.stringify(expectedShortCircuit)}
    +            
    +
    + +
    +

    Loader Result:

    +
    +              {JSON.stringify(shortCircuitResult)}
    +            
    +
    + + + +
    +

    Client Result:

    +
    +              {clientShortCircuit
    +                ? JSON.stringify(clientShortCircuit)
    +                : 'Not called yet'}
    +            
    +
    +
    + +
    +

    Next() Branch

    + +
    +

    Expected (client):

    +
    +              {JSON.stringify(expectedNext)}
    +            
    +
    + +
    +

    Loader Result:

    +
    +              {JSON.stringify(nextResult)}
    +            
    +
    + + + +
    +

    Client Result:

    +
    +              {clientNext ? JSON.stringify(clientNext) : 'Not called yet'}
    +            
    +
    +
    +
    +
    + ) +} diff --git a/e2e/react-start/server-functions/src/routes/middleware/server-early-return.tsx b/e2e/react-start/server-functions/src/routes/middleware/server-early-return.tsx new file mode 100644 index 00000000000..dd99f0b2717 --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/server-early-return.tsx @@ -0,0 +1,202 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createMiddleware, createServerFn } from '@tanstack/react-start' +import React from 'react' + +/** + * This middleware's .server() does NOT call next() at all + * and always returns a value directly. + * + * Expected behavior: The handler should never be called, + * and the middleware's return value should be the result. + */ +const serverEarlyReturnMiddleware = createMiddleware({ + type: 'function', +}).server( + // @ts-expect-error - types currently require returning next() result + async () => { + return { + source: 'middleware', + message: 'Early return from server middleware', + } + }, +) + +const serverFn = createServerFn() + .middleware([serverEarlyReturnMiddleware]) + .handler(() => { + // This handler should NEVER be called because middleware returns early + return { + source: 'handler', + message: 'This should not be returned', + } + }) + +/** + * This middleware returns an object that contains a "method" property. + * This tests that our early return detection uses a Symbol, not duck-typing. + * If we were checking for 'method' in result, this would cause a false positive. + */ +const methodPropertyMiddleware = createMiddleware({ + type: 'function', +}).server( + // @ts-expect-error - types currently require returning next() result + async () => { + return { + source: 'middleware', + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + message: 'Early return with method property', + } + }, +) + +const serverFnWithMethodProperty = createServerFn() + .middleware([methodPropertyMiddleware]) + .handler(() => { + // This handler should NEVER be called because middleware returns early + return { + source: 'handler', + message: 'This should not be returned', + } + }) + +export const Route = createFileRoute('/middleware/server-early-return')({ + loader: async () => { + const result = await serverFn() + const resultWithMethod = await serverFnWithMethodProperty() + return { loaderResult: result, loaderResultWithMethod: resultWithMethod } + }, + component: RouteComponent, +}) + +function RouteComponent() { + const { loaderResult, loaderResultWithMethod } = Route.useLoaderData() + const [clientResult, setClientResult] = React.useState(null) + const [clientResultWithMethod, setClientResultWithMethod] = + React.useState(null) + + const expectedResult = { + source: 'middleware', + message: 'Early return from server middleware', + } + + const expectedResultWithMethod = { + source: 'middleware', + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + message: 'Early return with method property', + } + + return ( +
    +

    + Server Middleware Early Return (No next() call) +

    +

    + Tests that a .server() middleware can return a value without calling + next(), effectively short-circuiting the middleware chain. +

    + +
    +

    Expected Result:

    +
    +          {JSON.stringify(expectedResult)}
    +        
    +
    + +
    +

    Loader Result (SSR):

    +
    +          {JSON.stringify(loaderResult)}
    +        
    +
    + + + +
    +

    Client Result:

    +
    +          {clientResult ? JSON.stringify(clientResult) : 'Not called yet'}
    +        
    +
    + +
    + +

    + Early Return with 'method' Property +

    +

    + This tests that early return detection uses a Symbol, not duck-typing. +

    + +
    +

    Expected Result (with method):

    +
    +          {JSON.stringify(expectedResultWithMethod)}
    +        
    +
    + +
    +

    Loader Result with method (SSR):

    +
    +          {JSON.stringify(loaderResultWithMethod)}
    +        
    +
    + + + +
    +

    Client Result (with method):

    +
    +          {clientResultWithMethod
    +            ? JSON.stringify(clientResultWithMethod)
    +            : 'Not called yet'}
    +        
    +
    +
    + ) +} 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 a03e02448f1..9d02dae25c8 100644 --- a/e2e/react-start/server-functions/tests/server-functions.spec.ts +++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts @@ -1044,6 +1044,272 @@ test('middleware can catch errors thrown by server function handlers', async ({ ) }) +// ============================================================================= +// Middleware Early Return Tests +// These tests verify that middleware can return values without calling next() +// ============================================================================= + +test.describe('server middleware early return (no next() call)', () => { + test('middleware returns value instead of calling next()', async ({ + page, + }) => { + await page.goto('/middleware/server-early-return') + await page.waitForLoadState('networkidle') + + // Check that loader result came from middleware, not handler + const loaderResult = await page.getByTestId('loader-result').textContent() + expect(loaderResult).toContain('middleware') + expect(loaderResult).toContain('Early return from server middleware') + expect(loaderResult).not.toContain('handler') + + // Test client-side invocation + await page.getByTestId('invoke-btn').click() + // Wait for the result to appear (not just networkidle) + await expect(page.getByTestId('client-result')).not.toContainText( + 'Not called yet', + { timeout: 10000 }, + ) + + const clientResult = await page.getByTestId('client-result').textContent() + expect(clientResult).toContain('middleware') + expect(clientResult).toContain('Early return from server middleware') + expect(clientResult).not.toContain('handler') + }) + + test('middleware returns object with "method" property (tests Symbol-based detection)', async ({ + page, + }) => { + await page.goto('/middleware/server-early-return') + await page.waitForLoadState('networkidle') + + // Check that loader result with method property came from middleware, not handler + // This tests that we use a Symbol to detect next() results, not duck-typing + const loaderResult = await page + .getByTestId('loader-result-method') + .textContent() + expect(loaderResult).toContain('middleware') + expect(loaderResult).toContain('Early return with method property') + expect(loaderResult).toContain('"method":"GET"') + expect(loaderResult).not.toContain('This should not be returned') + + // Test client-side invocation + await page.getByTestId('invoke-btn-method').click() + await expect(page.getByTestId('client-result-method')).not.toContainText( + 'Not called yet', + { timeout: 10000 }, + ) + + const clientResult = await page + .getByTestId('client-result-method') + .textContent() + expect(clientResult).toContain('middleware') + expect(clientResult).toContain('Early return with method property') + expect(clientResult).toContain('"method":"GET"') + expect(clientResult).not.toContain('This should not be returned') + }) +}) + +test.describe('server middleware conditional return (next() OR value)', () => { + test('middleware returns early when condition is true', async ({ page }) => { + await page.goto('/middleware/server-conditional') + await page.waitForLoadState('networkidle') + + // Check loader short-circuit result + const loaderShortCircuit = await page + .getByTestId('loader-short-circuit') + .textContent() + expect(loaderShortCircuit).toContain('middleware') + expect(loaderShortCircuit).toContain('Conditional early return') + + // Test client-side short-circuit + await page.getByTestId('invoke-short-circuit-btn').click() + // Wait for the result to appear (not just networkidle) + await expect(page.getByTestId('client-short-circuit')).not.toContainText( + 'Not called yet', + { timeout: 10000 }, + ) + + const clientShortCircuit = await page + .getByTestId('client-short-circuit') + .textContent() + expect(clientShortCircuit).toContain('middleware') + expect(clientShortCircuit).toContain('Conditional early return') + }) + + test('middleware calls next() when condition is false', async ({ page }) => { + await page.goto('/middleware/server-conditional') + await page.waitForLoadState('networkidle') + + // Check loader next result + const loaderNext = await page.getByTestId('loader-next').textContent() + expect(loaderNext).toContain('handler') + expect(loaderNext).toContain('Handler was called') + + // Test client-side next() call + await page.getByTestId('invoke-next-btn').click() + // Wait for the result to appear (not just networkidle) + await expect(page.getByTestId('client-next')).not.toContainText( + 'Not called yet', + { timeout: 10000 }, + ) + + const clientNext = await page.getByTestId('client-next').textContent() + expect(clientNext).toContain('handler') + expect(clientNext).toContain('Handler was called') + }) +}) + +test.describe('client middleware early return (no next() call)', () => { + test('client middleware returns value instead of calling next()', async ({ + page, + }) => { + await page.goto('/middleware/client-early-return') + await page.waitForLoadState('networkidle') + + // Test client-side invocation - should return from client middleware + await page.getByTestId('invoke-btn').click() + // Wait for the result to appear (not just networkidle) + await expect(page.getByTestId('client-result')).not.toContainText( + 'Not called yet', + { timeout: 10000 }, + ) + + const clientResult = await page.getByTestId('client-result').textContent() + expect(clientResult).toContain('client-middleware') + expect(clientResult).toContain('Early return from client middleware') + expect(clientResult).not.toContain('handler') + }) +}) + +test.describe('client middleware conditional return (next() OR value)', () => { + test('client middleware returns early when condition is true', async ({ + page, + }) => { + await page.goto('/middleware/client-conditional') + await page.waitForLoadState('networkidle') + + // Test client-side short-circuit + await page.getByTestId('invoke-short-circuit-btn').click() + // Wait for the result to appear (not just networkidle) + await expect(page.getByTestId('client-short-circuit')).not.toContainText( + 'Not called yet', + { timeout: 10000 }, + ) + + const clientShortCircuit = await page + .getByTestId('client-short-circuit') + .textContent() + expect(clientShortCircuit).toContain('client-middleware') + expect(clientShortCircuit).toContain('Conditional early return') + }) + + test('client middleware calls next() when condition is false', async ({ + page, + }) => { + await page.goto('/middleware/client-conditional') + await page.waitForLoadState('networkidle') + + // Test client-side next() call + await page.getByTestId('invoke-next-btn').click() + // Wait for the result to appear (not just networkidle) + await expect(page.getByTestId('client-next')).not.toContainText( + 'Not called yet', + { timeout: 10000 }, + ) + + const clientNext = await page.getByTestId('client-next').textContent() + expect(clientNext).toContain('handler') + expect(clientNext).toContain('Handler was called on server') + }) +}) + +test.describe('nested middleware early return', () => { + test('deep middleware returns early', async ({ page }) => { + await page.goto('/middleware/nested-early-return') + await page.waitForLoadState('networkidle') + + // Check loader result for deep early return + const loaderDeep = await page.getByTestId('loader-deep').textContent() + expect(loaderDeep).toContain('deepMiddleware') + expect(loaderDeep).toContain('Early return from deepest middleware') + + // Test client-side invocation + await page.getByTestId('invoke-deep-btn').click() + // Wait for the result to appear (not just networkidle) + await expect(page.getByTestId('client-deep')).not.toContainText( + 'Not called yet', + { timeout: 10000 }, + ) + + const clientDeep = await page.getByTestId('client-deep').textContent() + expect(clientDeep).toContain('deepMiddleware') + }) + + test('middle middleware returns early', async ({ page }) => { + await page.goto('/middleware/nested-early-return') + await page.waitForLoadState('networkidle') + + // Check loader result for middle early return + const loaderMiddle = await page.getByTestId('loader-middle').textContent() + expect(loaderMiddle).toContain('middleMiddleware') + expect(loaderMiddle).toContain('Early return from middle middleware') + + // Test client-side invocation + await page.getByTestId('invoke-middle-btn').click() + // Wait for the result to appear (not just networkidle) + await expect(page.getByTestId('client-middle')).not.toContainText( + 'Not called yet', + { timeout: 10000 }, + ) + + const clientMiddle = await page.getByTestId('client-middle').textContent() + expect(clientMiddle).toContain('middleMiddleware') + }) + + test('outer middleware returns early', async ({ page }) => { + await page.goto('/middleware/nested-early-return') + await page.waitForLoadState('networkidle') + + // Check loader result for outer early return + const loaderOuter = await page.getByTestId('loader-outer').textContent() + expect(loaderOuter).toContain('outerMiddleware') + expect(loaderOuter).toContain('Early return from outer middleware') + + // Test client-side invocation + await page.getByTestId('invoke-outer-btn').click() + // Wait for the result to appear (not just networkidle) + await expect(page.getByTestId('client-outer')).not.toContainText( + 'Not called yet', + { timeout: 10000 }, + ) + + const clientOuter = await page.getByTestId('client-outer').textContent() + expect(clientOuter).toContain('outerMiddleware') + }) + + test('all middleware passes through to handler', async ({ page }) => { + await page.goto('/middleware/nested-early-return') + await page.waitForLoadState('networkidle') + + // Check loader result for handler + const loaderHandler = await page.getByTestId('loader-handler').textContent() + expect(loaderHandler).toContain('handler') + expect(loaderHandler).toContain('Handler was called') + + // Test client-side invocation + await page.getByTestId('invoke-handler-btn').click() + // Wait for the result to appear (not just networkidle) + await expect(page.getByTestId('client-handler')).not.toContainText( + 'Not called yet', + { timeout: 10000 }, + ) + + const clientHandler = await page.getByTestId('client-handler').textContent() + expect(clientHandler).toContain('handler') + expect(clientHandler).toContain('Handler was called') + }) +}) + test('server function with custom fetch implementation passed directly', async ({ page, }) => { diff --git a/packages/start-client-core/src/constants.ts b/packages/start-client-core/src/constants.ts index 3df983dfe78..eccd965066f 100644 --- a/packages/start-client-core/src/constants.ts +++ b/packages/start-client-core/src/constants.ts @@ -3,6 +3,13 @@ export const TSS_SERVER_FUNCTION = Symbol.for('TSS_SERVER_FUNCTION') export const TSS_SERVER_FUNCTION_FACTORY = Symbol.for( 'TSS_SERVER_FUNCTION_FACTORY', ) +/** + * Symbol used to mark middleware results that came from calling next(). + * This allows us to distinguish between early returns (user values) and + * proper middleware chain results without relying on duck-typing which + * could cause false positives if user returns an object with similar shape. + */ +export const TSS_MIDDLEWARE_RESULT = Symbol.for('TSS_MIDDLEWARE_RESULT') export const X_TSS_SERIALIZED = 'x-tss-serialized' export const X_TSS_RAW_RESPONSE = 'x-tss-raw' diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index 39542d1e2a1..4ea87b5c082 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -1,7 +1,7 @@ import { mergeHeaders } from '@tanstack/router-core/ssr/client' import { isRedirect, parseRedirect } from '@tanstack/router-core' -import { TSS_SERVER_FUNCTION_FACTORY } from './constants' +import { TSS_MIDDLEWARE_RESULT, TSS_SERVER_FUNCTION_FACTORY } from './constants' import { getStartOptions } from './getStartOptions' import { getStartContextServerOnly } from './getStartContextServerOnly' import { createNullProtoObject, safeObjectMerge } from './safeObjectMerge' @@ -275,6 +275,10 @@ export async function executeMiddleware( throw result.error } + // Mark this result as coming from next() so we can distinguish + // it from early returns by the middleware + ;(result as any)[TSS_MIDDLEWARE_RESULT] = true + return result } @@ -306,7 +310,22 @@ export async function executeMiddleware( ) } - return result + // Check if the result came from calling next() by looking for our marker symbol. + // This is more robust than duck-typing (e.g., checking for 'method' property) + // because user code could return an object that happens to have similar properties. + if ( + typeof result === 'object' && + result !== null && + TSS_MIDDLEWARE_RESULT in result + ) { + return result + } + + // Early return from middleware - wrap the value as the result + return { + ...ctx, + result, + } } return callNextMiddleware(ctx) diff --git a/packages/start-client-core/src/tests/createServerFn.test-d.ts b/packages/start-client-core/src/tests/createServerFn.test-d.ts index f0cb181a43d..64cc766df16 100644 --- a/packages/start-client-core/src/tests/createServerFn.test-d.ts +++ b/packages/start-client-core/src/tests/createServerFn.test-d.ts @@ -879,3 +879,89 @@ test('createServerFn respects TsrSerializable', () => { Promise<{ nested: { custom: MyCustomTypeSerializable } }> >() }) + +describe('middleware early return types', () => { + // NOTE: These tests document that the types currently do NOT support middleware + // early returns. The @ts-expect-error directives mark where type errors occur. + // Once proper type support is added, these @ts-expect-error directives should be + // removed and the tests updated to verify proper union types. + + test('server function with middleware that may return early - types currently do not support this', () => { + // This middleware returns early with { earlyReturn: true } instead of calling next() + const earlyReturnMiddleware = createMiddleware({ type: 'function' }) + // @ts-expect-error - Types currently require next() to be called, early returns are not typed + .server(({ next }) => { + const shouldShortCircuit = Math.random() > 0.5 + if (shouldShortCircuit) { + // Early return - does NOT call next() + return { earlyReturn: true as const, value: 'short-circuited' } + } + return next({ context: { middlewareRan: true } as const }) + }) + + // The server function handler returns a different type + const fn = createServerFn() + .middleware([earlyReturnMiddleware]) + .handler(() => { + return { handlerResult: 'from-handler' as const } + }) + + // Currently, the type only includes the handler return type. + // Ideally, it should be a union: { earlyReturn: true, value: string } | { handlerResult: 'from-handler' } + expectTypeOf(fn()).toEqualTypeOf< + Promise<{ handlerResult: 'from-handler' }> + >() + }) + + test('client middleware early return types - not currently supported', () => { + const clientEarlyReturnMiddleware = createMiddleware({ type: 'function' }) + // @ts-expect-error - Types currently require next() to be called, early returns are not typed + .client(({ next }) => { + const cached = { fromCache: true as const } + if (cached) { + return cached + } + return next() + }) + + const fn = createServerFn() + .middleware([clientEarlyReturnMiddleware]) + .handler(() => { + return { fromServer: true as const } + }) + + // Currently only includes handler return type + expectTypeOf(fn()).toEqualTypeOf>() + }) + + test('nested middleware early returns - not currently typed', () => { + const outerMiddleware = createMiddleware({ type: 'function' }) + // @ts-expect-error - Types currently require next() to be called + .server(({ next }) => { + if (Math.random() > 0.9) { + return { level: 'outer' as const } + } + return next({ context: { outer: true } as const }) + }) + + const innerMiddleware = createMiddleware({ type: 'function' }) + .middleware([outerMiddleware]) + // @ts-expect-error - Types currently require next() to be called + .server(({ next }) => { + if (Math.random() > 0.9) { + return { level: 'inner' as const } + } + return next({ context: { inner: true } as const }) + }) + + const fn = createServerFn() + .middleware([innerMiddleware]) + .handler(() => { + return { level: 'handler' as const } + }) + + // Currently only includes handler return type + // Ideally: { level: 'outer' } | { level: 'inner' } | { level: 'handler' } + expectTypeOf(fn()).toEqualTypeOf>() + }) +}) diff --git a/packages/start-client-core/src/tests/createServerMiddleware.test-d.ts b/packages/start-client-core/src/tests/createServerMiddleware.test-d.ts index b5f84b70227..5afe0454bb3 100644 --- a/packages/start-client-core/src/tests/createServerMiddleware.test-d.ts +++ b/packages/start-client-core/src/tests/createServerMiddleware.test-d.ts @@ -808,3 +808,140 @@ test('createMiddleware with type request can return sync Response', () => { }) }) }) + +// ============================================================================= +// Middleware Early Return Tests (Expected Type Failures) +// These tests document the expected behavior when middleware returns early +// without calling next(). Currently types don't support this, so we use +// ts-expect-error directives to mark expected failures. +// ============================================================================= + +test('createMiddleware server can return early without calling next', () => { + // @ts-expect-error - types currently require returning next() result + createMiddleware({ type: 'function' }).server(async () => { + // Returning a value directly without calling next() + return { earlyReturn: true, message: 'Short-circuited' } + }) +}) + +test('createMiddleware server can conditionally call next or return value', () => { + createMiddleware({ type: 'function' }) + .inputValidator((input: { shouldShortCircuit: boolean }) => input) + // @ts-expect-error - types don't support returning arbitrary value instead of next() result + .server(async ({ data, next }) => { + if (data.shouldShortCircuit) { + return { earlyReturn: true, message: 'Short-circuited' } + } + return next({ context: { passedThrough: true } }) + }) +}) + +test('createMiddleware client can return early without calling next', () => { + // @ts-expect-error - types currently require returning next() result + createMiddleware({ type: 'function' }).client(async () => { + // Returning a value directly without calling next() + return { earlyReturn: true, message: 'Client short-circuited' } + }) +}) + +test('createMiddleware client can conditionally call next or return value', () => { + createMiddleware({ type: 'function' }) + .inputValidator((input: { shouldShortCircuit: boolean }) => input) + // @ts-expect-error - types don't support returning arbitrary value + .client(async ({ data, next }) => { + if (data.shouldShortCircuit) { + return { earlyReturn: true, message: 'Client short-circuited' } + } + return next({ sendContext: { fromClient: true } }) + }) +}) + +test('nested middleware where inner middleware returns early', () => { + const innerMiddleware = createMiddleware({ type: 'function' }) + .inputValidator((input: { level: string }) => input) + // @ts-expect-error - types don't support returning arbitrary value + .server(async ({ data, next }) => { + if (data.level === 'inner') { + return { returnedFrom: 'inner', level: 2 } + } + return next({ context: { innerPassed: true } }) + }) + + const outerMiddleware = createMiddleware({ type: 'function' }) + .middleware([innerMiddleware]) + // @ts-expect-error - types don't support returning arbitrary value + .server(async ({ data, next, context }) => { + // Context should potentially include early return value from inner middleware + // but currently types don't support this + if (data.level === 'outer') { + return { returnedFrom: 'outer', level: 1, innerContext: context } + } + return next({ context: { outerPassed: true } }) + }) + + // Just verify middleware is created successfully + expectTypeOf(outerMiddleware).toHaveProperty('options') +}) + +test('deeply nested middleware chain with early return at each level', () => { + const deepMiddleware = createMiddleware({ type: 'function' }) + .inputValidator( + (input: { earlyReturnLevel: 'none' | 'deep' | 'middle' | 'outer' }) => + input, + ) + // @ts-expect-error - types don't support returning arbitrary value + .server(async ({ data, next }) => { + if (data.earlyReturnLevel === 'deep') { + return { returnedFrom: 'deep', level: 3 } + } + return next({ context: { deepPassed: true } }) + }) + + const middleMiddleware = createMiddleware({ type: 'function' }) + .middleware([deepMiddleware]) + // @ts-expect-error - types don't support returning arbitrary value + .server(async ({ data, next, context }) => { + if (data.earlyReturnLevel === 'middle') { + return { returnedFrom: 'middle', level: 2, deepContext: context } + } + return next({ context: { middlePassed: true } }) + }) + + const outerMiddleware = createMiddleware({ type: 'function' }) + .middleware([middleMiddleware]) + // @ts-expect-error - types don't support returning arbitrary value + .server(async ({ data, next, context }) => { + if (data.earlyReturnLevel === 'outer') { + return { returnedFrom: 'outer', level: 1, middleContext: context } + } + return next({ context: { outerPassed: true } }) + }) + + // Verify the chain is created + expectTypeOf(outerMiddleware).toHaveProperty('options') +}) + +test('client middleware early return prevents server call', () => { + const clientEarlyReturnMiddleware = createMiddleware({ type: 'function' }) + .inputValidator((input: { skipServer: boolean }) => input) + // @ts-expect-error - types don't support returning arbitrary value + .client(async ({ data, next }) => { + if (data.skipServer) { + // This should prevent any network request to the server + return { source: 'client', message: 'Skipped server entirely' } + } + return next({ sendContext: { clientCalled: true } }) + }) + + // Chain with server middleware that should never be reached + const withServerMiddleware = createMiddleware({ type: 'function' }) + .middleware([clientEarlyReturnMiddleware]) + .server(async ({ next, context }) => { + // If client returned early, this should never execute + return next({ + context: { serverReached: true, clientContext: context }, + }) + }) + + expectTypeOf(withServerMiddleware).toHaveProperty('options') +}) From 0d196f7b97cb3e3c094000dbf9e0032f2d74ceb6 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 18 Jan 2026 14:10:34 +0100 Subject: [PATCH 2/5] wip types --- .../routes/middleware/client-conditional.tsx | 33 +++-- .../routes/middleware/client-early-return.tsx | 17 ++- .../routes/middleware/nested-early-return.tsx | 91 +++++++------- .../routes/middleware/server-conditional.tsx | 29 ++--- .../routes/middleware/server-early-return.tsx | 34 +++--- .../start-client-core/src/createMiddleware.ts | 114 +++++++++++++++--- .../start-client-core/src/createServerFn.ts | 14 ++- .../src/tests/createServerFn.test-d.ts | 59 +++++---- .../tests/createServerMiddleware.test-d.ts | 12 -- 9 files changed, 226 insertions(+), 177 deletions(-) diff --git a/e2e/react-start/server-functions/src/routes/middleware/client-conditional.tsx b/e2e/react-start/server-functions/src/routes/middleware/client-conditional.tsx index 9c5cec8abcd..e92400fb178 100644 --- a/e2e/react-start/server-functions/src/routes/middleware/client-conditional.tsx +++ b/e2e/react-start/server-functions/src/routes/middleware/client-conditional.tsx @@ -13,25 +13,22 @@ const clientConditionalMiddleware = createMiddleware({ .inputValidator( (input: { shouldShortCircuit: boolean; value: string }) => input, ) - .client( - // @ts-expect-error - types don't support union of next() result and arbitrary value - async ({ data, next }) => { - if (data.shouldShortCircuit) { - return { - source: 'client-middleware', - message: 'Conditional early return from client middleware', - condition: 'shouldShortCircuit=true', - timestamp: Date.now(), - } + .client(async ({ data, next }) => { + if (data.shouldShortCircuit) { + return { + source: 'client-middleware', + message: 'Conditional early return from client middleware', + condition: 'shouldShortCircuit=true', + timestamp: Date.now(), } - // Proceed to server - return next({ - sendContext: { - clientTimestamp: Date.now(), - }, - }) - }, - ) + } + // Proceed to server + return next({ + sendContext: { + clientTimestamp: Date.now(), + }, + }) + }) const serverFn = createServerFn() .middleware([clientConditionalMiddleware]) diff --git a/e2e/react-start/server-functions/src/routes/middleware/client-early-return.tsx b/e2e/react-start/server-functions/src/routes/middleware/client-early-return.tsx index 90a41ed42ad..4c00058adf2 100644 --- a/e2e/react-start/server-functions/src/routes/middleware/client-early-return.tsx +++ b/e2e/react-start/server-functions/src/routes/middleware/client-early-return.tsx @@ -13,16 +13,13 @@ import React from 'react' */ const clientEarlyReturnMiddleware = createMiddleware({ type: 'function', -}).client( - // @ts-expect-error - types currently require returning next() result - async () => { - return { - source: 'client-middleware', - message: 'Early return from client middleware', - timestamp: Date.now(), - } - }, -) +}).client(async () => { + return { + source: 'client-middleware', + message: 'Early return from client middleware', + timestamp: Date.now(), + } +}) const serverFn = createServerFn() .middleware([clientEarlyReturnMiddleware]) diff --git a/e2e/react-start/server-functions/src/routes/middleware/nested-early-return.tsx b/e2e/react-start/server-functions/src/routes/middleware/nested-early-return.tsx index 425d5410eaf..7ba29bb1afb 100644 --- a/e2e/react-start/server-functions/src/routes/middleware/nested-early-return.tsx +++ b/e2e/react-start/server-functions/src/routes/middleware/nested-early-return.tsx @@ -23,67 +23,58 @@ type EarlyReturnInput = { // Deepest level - conditionally returns early based on input const deepMiddleware = createMiddleware({ type: 'function' }) .inputValidator((input: EarlyReturnInput) => input) - .server( - // @ts-expect-error - types don't support early return - async ({ data, next }) => { - if (data.earlyReturnLevel === 'deep') { - return { - returnedFrom: 'deepMiddleware', - message: 'Early return from deepest middleware', - level: 3, - } + .server(async ({ data, next }) => { + if (data.earlyReturnLevel === 'deep') { + return { + returnedFrom: 'deepMiddleware', + message: 'Early return from deepest middleware', + level: 3, } - return next({ - context: { - deepMiddlewarePassed: true, - }, - }) - }, - ) + } + return next({ + context: { + deepMiddlewarePassed: true, + }, + }) + }) // Middle level - wraps deep middleware, may also return early const middleMiddleware = createMiddleware({ type: 'function' }) .middleware([deepMiddleware]) - .server( - // @ts-expect-error - types don't support early return or receiving non-next result from inner middleware - async ({ data, next, context }) => { - if (data.earlyReturnLevel === 'middle') { - return { - returnedFrom: 'middleMiddleware', - message: 'Early return from middle middleware', - level: 2, - deepContext: context, - } + .server(async ({ data, next, context }) => { + if (data.earlyReturnLevel === 'middle') { + return { + returnedFrom: 'middleMiddleware', + message: 'Early return from middle middleware', + level: 2, + deepContext: context, } - return next({ - context: { - middleMiddlewarePassed: true, - }, - }) - }, - ) + } + return next({ + context: { + middleMiddlewarePassed: true, + }, + }) + }) // Outer level - wraps middle middleware, may also return early const outerMiddleware = createMiddleware({ type: 'function' }) .middleware([middleMiddleware]) - .server( - // @ts-expect-error - types don't support early return or receiving non-next result from inner middleware - async ({ data, next, context }) => { - if (data.earlyReturnLevel === 'outer') { - return { - returnedFrom: 'outerMiddleware', - message: 'Early return from outer middleware', - level: 1, - middleContext: context, - } + .server(async ({ data, next, context }) => { + if (data.earlyReturnLevel === 'outer') { + return { + returnedFrom: 'outerMiddleware', + message: 'Early return from outer middleware', + level: 1, + middleContext: context, } - return next({ - context: { - outerMiddlewarePassed: true, - }, - }) - }, - ) + } + return next({ + context: { + outerMiddlewarePassed: true, + }, + }) + }) const serverFn = createServerFn() .middleware([outerMiddleware]) diff --git a/e2e/react-start/server-functions/src/routes/middleware/server-conditional.tsx b/e2e/react-start/server-functions/src/routes/middleware/server-conditional.tsx index 33dc061d649..6c5b2eb6fa7 100644 --- a/e2e/react-start/server-functions/src/routes/middleware/server-conditional.tsx +++ b/e2e/react-start/server-functions/src/routes/middleware/server-conditional.tsx @@ -13,23 +13,20 @@ const serverConditionalMiddleware = createMiddleware({ .inputValidator( (input: { shouldShortCircuit: boolean; value: string }) => input, ) - .server( - // @ts-expect-error - types don't support union of next() result and arbitrary value - async ({ data, next }) => { - if (data.shouldShortCircuit) { - return { - source: 'middleware', - message: 'Conditional early return from server middleware', - condition: 'shouldShortCircuit=true', - } + .server(async ({ data, next }) => { + if (data.shouldShortCircuit) { + return { + source: 'middleware', + message: 'Conditional early return from server middleware', + condition: 'shouldShortCircuit=true', } - return next({ - context: { - passedThroughMiddleware: true, - }, - }) - }, - ) + } + return next({ + context: { + passedThroughMiddleware: true, + }, + }) + }) const serverFn = createServerFn() .middleware([serverConditionalMiddleware]) diff --git a/e2e/react-start/server-functions/src/routes/middleware/server-early-return.tsx b/e2e/react-start/server-functions/src/routes/middleware/server-early-return.tsx index dd99f0b2717..5fe47e28dea 100644 --- a/e2e/react-start/server-functions/src/routes/middleware/server-early-return.tsx +++ b/e2e/react-start/server-functions/src/routes/middleware/server-early-return.tsx @@ -11,15 +11,12 @@ import React from 'react' */ const serverEarlyReturnMiddleware = createMiddleware({ type: 'function', -}).server( - // @ts-expect-error - types currently require returning next() result - async () => { - return { - source: 'middleware', - message: 'Early return from server middleware', - } - }, -) +}).server(async () => { + return { + source: 'middleware', + message: 'Early return from server middleware', + } +}) const serverFn = createServerFn() .middleware([serverEarlyReturnMiddleware]) @@ -38,17 +35,14 @@ const serverFn = createServerFn() */ const methodPropertyMiddleware = createMiddleware({ type: 'function', -}).server( - // @ts-expect-error - types currently require returning next() result - async () => { - return { - source: 'middleware', - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - message: 'Early return with method property', - } - }, -) +}).server(async () => { + return { + source: 'middleware', + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + message: 'Early return with method property', + } +}) const serverFnWithMethodProperty = createServerFn() .middleware([methodPropertyMiddleware]) diff --git a/packages/start-client-core/src/createMiddleware.ts b/packages/start-client-core/src/createMiddleware.ts index 9e0a4357918..3bbad377a60 100644 --- a/packages/start-client-core/src/createMiddleware.ts +++ b/packages/start-client-core/src/createMiddleware.ts @@ -95,7 +95,9 @@ export interface FunctionMiddlewareAfterMiddleware undefined, undefined, undefined, - undefined + undefined, + never, + never >, FunctionMiddlewareServer< TRegister, @@ -115,6 +117,8 @@ export interface FunctionMiddlewareWithTypes< TServerSendContext, TClientContext, TClientSendContext, + TServerEarlyReturn = never, + TClientEarlyReturn = never, > { '~types': FunctionMiddlewareTypes< TRegister, @@ -123,7 +127,9 @@ export interface FunctionMiddlewareWithTypes< TServerContext, TServerSendContext, TClientContext, - TClientSendContext + TClientSendContext, + TServerEarlyReturn, + TClientEarlyReturn > options: FunctionMiddlewareOptions< TRegister, @@ -142,6 +148,8 @@ export interface FunctionMiddlewareTypes< in out TServerSendContext, in out TClientContext, in out TClientSendContext, + out TServerEarlyReturn = never, + out TClientEarlyReturn = never, > { type: 'function' middlewares: TMiddlewares @@ -177,6 +185,25 @@ export interface FunctionMiddlewareTypes< TClientSendContext > inputValidator: TInputValidator + // Early return types + serverEarlyReturn: TServerEarlyReturn + clientEarlyReturn: TClientEarlyReturn + allServerEarlyReturns: UnionAllMiddleware< + TMiddlewares, + 'serverEarlyReturn' + > extends infer U + ? [U] extends [never] + ? TServerEarlyReturn + : U | TServerEarlyReturn + : TServerEarlyReturn + allClientEarlyReturns: UnionAllMiddleware< + TMiddlewares, + 'clientEarlyReturn' + > extends infer U + ? [U] extends [never] + ? TClientEarlyReturn + : U | TClientEarlyReturn + : TClientEarlyReturn } /** @@ -222,6 +249,8 @@ export type AnyFunctionMiddleware = FunctionMiddlewareWithTypes< any, any, any, + any, + any, any > @@ -271,6 +300,25 @@ export type AssignAllMiddleware< : TAcc : TAcc +/** + * Recursively union a type field from all middleware in a chain. + * Unlike AssignAllMiddleware which merges objects, this creates a union type. + * Used for accumulating early return types from middleware. + */ +export type UnionAllMiddleware< + TMiddlewares, + TType extends keyof AnyFunctionMiddleware['~types'], + TAcc = never, +> = TMiddlewares extends readonly [infer TMiddleware, ...infer TRest] + ? TMiddleware extends AnyFunctionMiddleware + ? UnionAllMiddleware< + TRest, + TType, + TAcc | TMiddleware['~types'][TType & keyof TMiddleware['~types']] + > + : UnionAllMiddleware + : TAcc + export type AssignAllClientContextAfterNext< TMiddlewares, TClientContext = undefined, @@ -504,6 +552,16 @@ export type FunctionServerResultWithContext< sendContext: Expand> } +/** + * Extract only the early return types from a middleware function's return type, + * excluding the next() result type (which has the branded property). + */ +export type ExtractEarlyReturn = T extends { + 'use functions must return the result of next()': true +} + ? never + : T + export interface FunctionMiddlewareServerFnOptions< in out TRegister, in out TMiddlewares, @@ -532,13 +590,14 @@ export type FunctionMiddlewareServerFnResult< TSendContext, > = | Promise< - FunctionServerResultWithContext< - TRegister, - TMiddlewares, - TServerSendContext, - TServerContext, - TSendContext - > + | FunctionServerResultWithContext< + TRegister, + TMiddlewares, + TServerSendContext, + TServerContext, + TSendContext + > + | ValidateSerializableInput > | FunctionServerResultWithContext< TRegister, @@ -547,6 +606,7 @@ export type FunctionMiddlewareServerFnResult< TServerContext, TSendContext > + | ValidateSerializableInput export interface FunctionMiddlewareAfterServer< TRegister, @@ -556,6 +616,7 @@ export interface FunctionMiddlewareAfterServer< TServerSendContext, TClientContext, TClientSendContext, + TServerEarlyReturn = never, > extends FunctionMiddlewareWithTypes< TRegister, TMiddlewares, @@ -563,7 +624,9 @@ export interface FunctionMiddlewareAfterServer< TServerContext, TServerSendContext, TClientContext, - TClientSendContext + TClientSendContext, + TServerEarlyReturn, + never > {} export interface FunctionMiddlewareClient< @@ -572,10 +635,15 @@ export interface FunctionMiddlewareClient< TInputValidator, > { client: ( - client: FunctionMiddlewareClientFn< + client: ( + options: FunctionMiddlewareClientFnOptions< + TRegister, + TMiddlewares, + TInputValidator + >, + ) => FunctionMiddlewareClientFnResult< TRegister, TMiddlewares, - TInputValidator, TSendServerContext, TNewClientContext >, @@ -601,6 +669,7 @@ export type FunctionMiddlewareClientFn< TInputValidator >, ) => FunctionMiddlewareClientFnResult< + TRegister, TMiddlewares, TSendContext, TClientContext @@ -623,18 +692,21 @@ export interface FunctionMiddlewareClientFnOptions< } export type FunctionMiddlewareClientFnResult< + TRegister, TMiddlewares, TSendContext, TClientContext, > = | Promise< - FunctionClientResultWithContext< - TMiddlewares, - TSendContext, - TClientContext - > + | FunctionClientResultWithContext< + TMiddlewares, + TSendContext, + TClientContext + > + | ValidateSerializableInput > | FunctionClientResultWithContext + | ValidateSerializableInput export type FunctionClientResultWithContext< in out TMiddlewares, @@ -663,7 +735,9 @@ export interface FunctionMiddlewareAfterClient< undefined, TServerSendContext, TClientContext, - undefined + undefined, + never, + never >, FunctionMiddlewareServer< TRegister, @@ -692,7 +766,9 @@ export interface FunctionMiddlewareAfterValidator< undefined, undefined, undefined, - undefined + undefined, + never, + never >, FunctionMiddlewareServer< TRegister, diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index 4ea87b5c082..f3b3a7a844a 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -29,10 +29,19 @@ import type { FunctionMiddlewareServerFnResult, IntersectAllValidatorInputs, IntersectAllValidatorOutputs, + UnionAllMiddleware, } from './createMiddleware' type TODO = any +/** + * Extracts all possible early return types from a middleware chain. + * This includes both server and client early returns from all middleware. + */ +export type AllMiddlewareEarlyReturns = + | UnionAllMiddleware + | UnionAllMiddleware + export type CreateServerFn = < TMethod extends Method, TResponse = unknown, @@ -380,7 +389,7 @@ export interface OptionalFetcher< > extends FetcherBase { ( options?: OptionalFetcherDataOptions, - ): Promise> + ): Promise | AllMiddlewareEarlyReturns> } export interface RequiredFetcher< @@ -390,7 +399,7 @@ export interface RequiredFetcher< > extends FetcherBase { ( opts: RequiredFetcherDataOptions, - ): Promise> + ): Promise | AllMiddlewareEarlyReturns> } export type CustomFetch = typeof globalThis.fetch @@ -805,6 +814,7 @@ function serverFnBaseToMiddleware( const res = await options.extractedFn?.(payload) return next(res) as unknown as FunctionMiddlewareClientFnResult< + any, any, any, any diff --git a/packages/start-client-core/src/tests/createServerFn.test-d.ts b/packages/start-client-core/src/tests/createServerFn.test-d.ts index 64cc766df16..c1aa9f2cdbe 100644 --- a/packages/start-client-core/src/tests/createServerFn.test-d.ts +++ b/packages/start-client-core/src/tests/createServerFn.test-d.ts @@ -881,23 +881,22 @@ test('createServerFn respects TsrSerializable', () => { }) describe('middleware early return types', () => { - // NOTE: These tests document that the types currently do NOT support middleware - // early returns. The @ts-expect-error directives mark where type errors occur. - // Once proper type support is added, these @ts-expect-error directives should be - // removed and the tests updated to verify proper union types. + // Middleware can now return values directly instead of calling next(). + // Early returns are allowed at the type level, but the specific return type + // is not yet tracked through the middleware chain. - test('server function with middleware that may return early - types currently do not support this', () => { + test('server function with middleware that may return early', () => { // This middleware returns early with { earlyReturn: true } instead of calling next() - const earlyReturnMiddleware = createMiddleware({ type: 'function' }) - // @ts-expect-error - Types currently require next() to be called, early returns are not typed - .server(({ next }) => { + const earlyReturnMiddleware = createMiddleware({ type: 'function' }).server( + ({ next }) => { const shouldShortCircuit = Math.random() > 0.5 if (shouldShortCircuit) { // Early return - does NOT call next() return { earlyReturn: true as const, value: 'short-circuited' } } return next({ context: { middlewareRan: true } as const }) - }) + }, + ) // The server function handler returns a different type const fn = createServerFn() @@ -906,23 +905,24 @@ describe('middleware early return types', () => { return { handlerResult: 'from-handler' as const } }) - // Currently, the type only includes the handler return type. - // Ideally, it should be a union: { earlyReturn: true, value: string } | { handlerResult: 'from-handler' } + // Early returns are now allowed but the specific type is not tracked. + // The return type currently includes handler return and unknown (for any early return). + // TODO: Track early return types through middleware chain for full union type expectTypeOf(fn()).toEqualTypeOf< Promise<{ handlerResult: 'from-handler' }> >() }) - test('client middleware early return types - not currently supported', () => { - const clientEarlyReturnMiddleware = createMiddleware({ type: 'function' }) - // @ts-expect-error - Types currently require next() to be called, early returns are not typed - .client(({ next }) => { - const cached = { fromCache: true as const } - if (cached) { - return cached - } - return next() - }) + test('client middleware early return types', () => { + const clientEarlyReturnMiddleware = createMiddleware({ + type: 'function', + }).client(({ next }) => { + const cached = { fromCache: true as const } + if (cached) { + return cached + } + return next() + }) const fn = createServerFn() .middleware([clientEarlyReturnMiddleware]) @@ -930,23 +930,22 @@ describe('middleware early return types', () => { return { fromServer: true as const } }) - // Currently only includes handler return type + // Client early returns are now allowed expectTypeOf(fn()).toEqualTypeOf>() }) - test('nested middleware early returns - not currently typed', () => { - const outerMiddleware = createMiddleware({ type: 'function' }) - // @ts-expect-error - Types currently require next() to be called - .server(({ next }) => { + test('nested middleware early returns', () => { + const outerMiddleware = createMiddleware({ type: 'function' }).server( + ({ next }) => { if (Math.random() > 0.9) { return { level: 'outer' as const } } return next({ context: { outer: true } as const }) - }) + }, + ) const innerMiddleware = createMiddleware({ type: 'function' }) .middleware([outerMiddleware]) - // @ts-expect-error - Types currently require next() to be called .server(({ next }) => { if (Math.random() > 0.9) { return { level: 'inner' as const } @@ -960,8 +959,8 @@ describe('middleware early return types', () => { return { level: 'handler' as const } }) - // Currently only includes handler return type - // Ideally: { level: 'outer' } | { level: 'inner' } | { level: 'handler' } + // Nested early returns are now allowed + // TODO: Track specific early return types: { level: 'outer' } | { level: 'inner' } | { level: 'handler' } expectTypeOf(fn()).toEqualTypeOf>() }) }) diff --git a/packages/start-client-core/src/tests/createServerMiddleware.test-d.ts b/packages/start-client-core/src/tests/createServerMiddleware.test-d.ts index 5afe0454bb3..c75292ce72b 100644 --- a/packages/start-client-core/src/tests/createServerMiddleware.test-d.ts +++ b/packages/start-client-core/src/tests/createServerMiddleware.test-d.ts @@ -817,7 +817,6 @@ test('createMiddleware with type request can return sync Response', () => { // ============================================================================= test('createMiddleware server can return early without calling next', () => { - // @ts-expect-error - types currently require returning next() result createMiddleware({ type: 'function' }).server(async () => { // Returning a value directly without calling next() return { earlyReturn: true, message: 'Short-circuited' } @@ -827,7 +826,6 @@ test('createMiddleware server can return early without calling next', () => { test('createMiddleware server can conditionally call next or return value', () => { createMiddleware({ type: 'function' }) .inputValidator((input: { shouldShortCircuit: boolean }) => input) - // @ts-expect-error - types don't support returning arbitrary value instead of next() result .server(async ({ data, next }) => { if (data.shouldShortCircuit) { return { earlyReturn: true, message: 'Short-circuited' } @@ -837,7 +835,6 @@ test('createMiddleware server can conditionally call next or return value', () = }) test('createMiddleware client can return early without calling next', () => { - // @ts-expect-error - types currently require returning next() result createMiddleware({ type: 'function' }).client(async () => { // Returning a value directly without calling next() return { earlyReturn: true, message: 'Client short-circuited' } @@ -847,7 +844,6 @@ test('createMiddleware client can return early without calling next', () => { test('createMiddleware client can conditionally call next or return value', () => { createMiddleware({ type: 'function' }) .inputValidator((input: { shouldShortCircuit: boolean }) => input) - // @ts-expect-error - types don't support returning arbitrary value .client(async ({ data, next }) => { if (data.shouldShortCircuit) { return { earlyReturn: true, message: 'Client short-circuited' } @@ -859,7 +855,6 @@ test('createMiddleware client can conditionally call next or return value', () = test('nested middleware where inner middleware returns early', () => { const innerMiddleware = createMiddleware({ type: 'function' }) .inputValidator((input: { level: string }) => input) - // @ts-expect-error - types don't support returning arbitrary value .server(async ({ data, next }) => { if (data.level === 'inner') { return { returnedFrom: 'inner', level: 2 } @@ -869,10 +864,7 @@ test('nested middleware where inner middleware returns early', () => { const outerMiddleware = createMiddleware({ type: 'function' }) .middleware([innerMiddleware]) - // @ts-expect-error - types don't support returning arbitrary value .server(async ({ data, next, context }) => { - // Context should potentially include early return value from inner middleware - // but currently types don't support this if (data.level === 'outer') { return { returnedFrom: 'outer', level: 1, innerContext: context } } @@ -889,7 +881,6 @@ test('deeply nested middleware chain with early return at each level', () => { (input: { earlyReturnLevel: 'none' | 'deep' | 'middle' | 'outer' }) => input, ) - // @ts-expect-error - types don't support returning arbitrary value .server(async ({ data, next }) => { if (data.earlyReturnLevel === 'deep') { return { returnedFrom: 'deep', level: 3 } @@ -899,7 +890,6 @@ test('deeply nested middleware chain with early return at each level', () => { const middleMiddleware = createMiddleware({ type: 'function' }) .middleware([deepMiddleware]) - // @ts-expect-error - types don't support returning arbitrary value .server(async ({ data, next, context }) => { if (data.earlyReturnLevel === 'middle') { return { returnedFrom: 'middle', level: 2, deepContext: context } @@ -909,7 +899,6 @@ test('deeply nested middleware chain with early return at each level', () => { const outerMiddleware = createMiddleware({ type: 'function' }) .middleware([middleMiddleware]) - // @ts-expect-error - types don't support returning arbitrary value .server(async ({ data, next, context }) => { if (data.earlyReturnLevel === 'outer') { return { returnedFrom: 'outer', level: 1, middleContext: context } @@ -924,7 +913,6 @@ test('deeply nested middleware chain with early return at each level', () => { test('client middleware early return prevents server call', () => { const clientEarlyReturnMiddleware = createMiddleware({ type: 'function' }) .inputValidator((input: { skipServer: boolean }) => input) - // @ts-expect-error - types don't support returning arbitrary value .client(async ({ data, next }) => { if (data.skipServer) { // This should prevent any network request to the server From 44d03e10e95cc8488158db8ee8c3cfb2f0d38282 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 18 Jan 2026 19:01:08 +0100 Subject: [PATCH 3/5] result() --- .../routes/middleware/client-conditional.tsx | 16 +- .../routes/middleware/client-early-return.tsx | 14 +- .../routes/middleware/nested-early-return.tsx | 46 ++-- .../routes/middleware/server-conditional.tsx | 14 +- .../routes/middleware/server-early-return.tsx | 28 +- packages/start-client-core/src/constants.ts | 9 + .../start-client-core/src/createMiddleware.ts | 248 ++++++++++++++---- .../start-client-core/src/createServerFn.ts | 117 ++++++++- .../src/tests/createServerFn.test-d.ts | 112 ++++++-- .../tests/createServerMiddleware.test-d.ts | 171 ++++++++---- 10 files changed, 597 insertions(+), 178 deletions(-) diff --git a/e2e/react-start/server-functions/src/routes/middleware/client-conditional.tsx b/e2e/react-start/server-functions/src/routes/middleware/client-conditional.tsx index e92400fb178..8f5765ee885 100644 --- a/e2e/react-start/server-functions/src/routes/middleware/client-conditional.tsx +++ b/e2e/react-start/server-functions/src/routes/middleware/client-conditional.tsx @@ -13,14 +13,16 @@ const clientConditionalMiddleware = createMiddleware({ .inputValidator( (input: { shouldShortCircuit: boolean; value: string }) => input, ) - .client(async ({ data, next }) => { + .client(async ({ data, next, result }) => { if (data.shouldShortCircuit) { - return { - source: 'client-middleware', - message: 'Conditional early return from client middleware', - condition: 'shouldShortCircuit=true', - timestamp: Date.now(), - } + return result({ + data: { + source: 'client-middleware', + message: 'Conditional early return from client middleware', + condition: 'shouldShortCircuit=true', + timestamp: Date.now(), + }, + }) } // Proceed to server return next({ diff --git a/e2e/react-start/server-functions/src/routes/middleware/client-early-return.tsx b/e2e/react-start/server-functions/src/routes/middleware/client-early-return.tsx index 4c00058adf2..b48e3fc3ad0 100644 --- a/e2e/react-start/server-functions/src/routes/middleware/client-early-return.tsx +++ b/e2e/react-start/server-functions/src/routes/middleware/client-early-return.tsx @@ -13,12 +13,14 @@ import React from 'react' */ const clientEarlyReturnMiddleware = createMiddleware({ type: 'function', -}).client(async () => { - return { - source: 'client-middleware', - message: 'Early return from client middleware', - timestamp: Date.now(), - } +}).client(async ({ result }) => { + return result({ + data: { + source: 'client-middleware', + message: 'Early return from client middleware', + timestamp: Date.now(), + }, + }) }) const serverFn = createServerFn() diff --git a/e2e/react-start/server-functions/src/routes/middleware/nested-early-return.tsx b/e2e/react-start/server-functions/src/routes/middleware/nested-early-return.tsx index 7ba29bb1afb..77c9f2f03e9 100644 --- a/e2e/react-start/server-functions/src/routes/middleware/nested-early-return.tsx +++ b/e2e/react-start/server-functions/src/routes/middleware/nested-early-return.tsx @@ -23,13 +23,15 @@ type EarlyReturnInput = { // Deepest level - conditionally returns early based on input const deepMiddleware = createMiddleware({ type: 'function' }) .inputValidator((input: EarlyReturnInput) => input) - .server(async ({ data, next }) => { + .server(async ({ data, next, result }) => { if (data.earlyReturnLevel === 'deep') { - return { - returnedFrom: 'deepMiddleware', - message: 'Early return from deepest middleware', - level: 3, - } + return result({ + data: { + returnedFrom: 'deepMiddleware', + message: 'Early return from deepest middleware', + level: 3, + }, + }) } return next({ context: { @@ -41,14 +43,16 @@ const deepMiddleware = createMiddleware({ type: 'function' }) // Middle level - wraps deep middleware, may also return early const middleMiddleware = createMiddleware({ type: 'function' }) .middleware([deepMiddleware]) - .server(async ({ data, next, context }) => { + .server(async ({ data, next, context, result }) => { if (data.earlyReturnLevel === 'middle') { - return { - returnedFrom: 'middleMiddleware', - message: 'Early return from middle middleware', - level: 2, - deepContext: context, - } + return result({ + data: { + returnedFrom: 'middleMiddleware', + message: 'Early return from middle middleware', + level: 2, + deepContext: context, + }, + }) } return next({ context: { @@ -60,14 +64,16 @@ const middleMiddleware = createMiddleware({ type: 'function' }) // Outer level - wraps middle middleware, may also return early const outerMiddleware = createMiddleware({ type: 'function' }) .middleware([middleMiddleware]) - .server(async ({ data, next, context }) => { + .server(async ({ data, next, context, result }) => { if (data.earlyReturnLevel === 'outer') { - return { - returnedFrom: 'outerMiddleware', - message: 'Early return from outer middleware', - level: 1, - middleContext: context, - } + return result({ + data: { + returnedFrom: 'outerMiddleware', + message: 'Early return from outer middleware', + level: 1, + middleContext: context, + }, + }) } return next({ context: { diff --git a/e2e/react-start/server-functions/src/routes/middleware/server-conditional.tsx b/e2e/react-start/server-functions/src/routes/middleware/server-conditional.tsx index 6c5b2eb6fa7..cfed6cdbded 100644 --- a/e2e/react-start/server-functions/src/routes/middleware/server-conditional.tsx +++ b/e2e/react-start/server-functions/src/routes/middleware/server-conditional.tsx @@ -13,13 +13,15 @@ const serverConditionalMiddleware = createMiddleware({ .inputValidator( (input: { shouldShortCircuit: boolean; value: string }) => input, ) - .server(async ({ data, next }) => { + .server(async ({ data, next, result }) => { if (data.shouldShortCircuit) { - return { - source: 'middleware', - message: 'Conditional early return from server middleware', - condition: 'shouldShortCircuit=true', - } + return result({ + data: { + source: 'middleware', + message: 'Conditional early return from server middleware', + condition: 'shouldShortCircuit=true', + }, + }) } return next({ context: { diff --git a/e2e/react-start/server-functions/src/routes/middleware/server-early-return.tsx b/e2e/react-start/server-functions/src/routes/middleware/server-early-return.tsx index 5fe47e28dea..f573a9d802c 100644 --- a/e2e/react-start/server-functions/src/routes/middleware/server-early-return.tsx +++ b/e2e/react-start/server-functions/src/routes/middleware/server-early-return.tsx @@ -11,11 +11,13 @@ import React from 'react' */ const serverEarlyReturnMiddleware = createMiddleware({ type: 'function', -}).server(async () => { - return { - source: 'middleware', - message: 'Early return from server middleware', - } +}).server(async ({ result }) => { + return result({ + data: { + source: 'middleware', + message: 'Early return from server middleware', + }, + }) }) const serverFn = createServerFn() @@ -35,13 +37,15 @@ const serverFn = createServerFn() */ const methodPropertyMiddleware = createMiddleware({ type: 'function', -}).server(async () => { - return { - source: 'middleware', - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - message: 'Early return with method property', - } +}).server(async ({ result }) => { + return result({ + data: { + source: 'middleware', + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + message: 'Early return with method property', + }, + }) }) const serverFnWithMethodProperty = createServerFn() diff --git a/packages/start-client-core/src/constants.ts b/packages/start-client-core/src/constants.ts index eccd965066f..fb0ee94417e 100644 --- a/packages/start-client-core/src/constants.ts +++ b/packages/start-client-core/src/constants.ts @@ -11,6 +11,15 @@ export const TSS_SERVER_FUNCTION_FACTORY = Symbol.for( */ export const TSS_MIDDLEWARE_RESULT = Symbol.for('TSS_MIDDLEWARE_RESULT') +/** + * Symbol used to mark middleware results that came from calling result(). + * This allows middleware to explicitly short-circuit the chain with a typed + * early return value that gets tracked through the type system. + */ +export const TSS_MIDDLEWARE_EARLY_RESULT = Symbol.for( + 'TSS_MIDDLEWARE_EARLY_RESULT', +) + export const X_TSS_SERIALIZED = 'x-tss-serialized' export const X_TSS_RAW_RESPONSE = 'x-tss-raw' export const X_TSS_CONTEXT = 'x-tss-context' diff --git a/packages/start-client-core/src/createMiddleware.ts b/packages/start-client-core/src/createMiddleware.ts index 3bbad377a60..8d80c529995 100644 --- a/packages/start-client-core/src/createMiddleware.ts +++ b/packages/start-client-core/src/createMiddleware.ts @@ -18,6 +18,73 @@ import type { ValidateSerializableInput, } from '@tanstack/router-core' +/** + * Branded type for result() returns. This allows the type system to: + * 1. Distinguish result() returns from next() returns + * 2. Track the exact data type of early returns through the middleware chain + * 3. Include headers in early return responses + */ +export type FunctionMiddlewareResultReturn = { + 'use functions must return the result of result()': true + _data: TData +} + +/** + * The result function type that middleware receives. + * Call result() to short-circuit the middleware chain with an early return value. + * The data must be serializable. + */ +export type FunctionMiddlewareResultFn = ( + options: FunctionMiddlewareResultOptions, +) => FunctionMiddlewareResultReturn + +/** + * Options for the result() function in middleware. + */ +export interface FunctionMiddlewareResultOptions { + /** The data to return. Must be serializable. */ + data: ValidateSerializableInput + /** Optional headers to include in the response */ + headers?: HeadersInit +} + +/** + * Extract the data type from a FunctionMiddlewareResultReturn. + */ +export type ExtractResultData = + T extends FunctionMiddlewareResultReturn ? TData : never + +/** + * Brand for the value returned by calling next(). + */ +export type MiddlewareNextReturn = { + 'use functions must return the result of next()': true +} + +/** + * Extract early return types from a middleware function's return type. + * This extracts TData from FunctionMiddlewareResultReturn in the return union, + * excluding the next() result type. + */ +export type ExtractEarlyReturnFromResult = TReturn extends + | Promise + | infer TDirectReturn + ? ExtractResultData< + Exclude + > + : never + +/** + * Extract whether a middleware return type contains next() result. + */ +export type HasNextReturn = TReturn extends + | Promise + | infer TDirectReturn + ? MiddlewareNextReturn extends TPromiseReturn | TDirectReturn + ? true + : false + : false + export type CreateMiddlewareFn = ( options?: { type?: TType @@ -469,25 +536,74 @@ export interface FunctionMiddlewareServer< TInputValidator, TServerSendContext, TClientContext, + TClientEarlyReturn = never, > { - server: ( - server: FunctionMiddlewareServerFn< + server: { + < + TNewServerContext = undefined, + TSendContext = undefined, + TReturn extends FunctionMiddlewareServerFnResult< + TRegister, + TMiddlewares, + TServerSendContext, + TNewServerContext, + TSendContext, + any + > = FunctionMiddlewareServerFnResult< + TRegister, + TMiddlewares, + TServerSendContext, + TNewServerContext, + TSendContext, + any + >, + >( + server: ( + options: FunctionMiddlewareServerFnOptions< + TRegister, + TMiddlewares, + TInputValidator, + TServerSendContext + >, + ) => TReturn, + ): FunctionMiddlewareAfterServer< TRegister, TMiddlewares, TInputValidator, + TNewServerContext, TServerSendContext, + TClientContext, + TSendContext, + ExtractEarlyReturnFromResult, + TClientEarlyReturn + > + + < + TNewServerContext = undefined, + TSendContext = undefined, + TServerEarlyReturn = never, + >( + server: FunctionMiddlewareServerFn< + TRegister, + TMiddlewares, + TInputValidator, + TServerSendContext, + TNewServerContext, + TSendContext, + TServerEarlyReturn + >, + ): FunctionMiddlewareAfterServer< + TRegister, + TMiddlewares, + TInputValidator, TNewServerContext, - TSendContext - >, - ) => FunctionMiddlewareAfterServer< - TRegister, - TMiddlewares, - TInputValidator, - TNewServerContext, - TServerSendContext, - TClientContext, - TSendContext - > + TServerSendContext, + TClientContext, + TSendContext, + TServerEarlyReturn, + TClientEarlyReturn + > + } } export type FunctionMiddlewareServerFn< @@ -497,6 +613,7 @@ export type FunctionMiddlewareServerFn< TServerSendContext, TNewServerContext, TSendContext, + TServerEarlyReturn = never, > = ( options: FunctionMiddlewareServerFnOptions< TRegister, @@ -509,7 +626,8 @@ export type FunctionMiddlewareServerFn< TMiddlewares, TServerSendContext, TNewServerContext, - TSendContext + TSendContext, + TServerEarlyReturn > export type FunctionMiddlewareServerNextFn< @@ -552,16 +670,6 @@ export type FunctionServerResultWithContext< sendContext: Expand> } -/** - * Extract only the early return types from a middleware function's return type, - * excluding the next() result type (which has the branded property). - */ -export type ExtractEarlyReturn = T extends { - 'use functions must return the result of next()': true -} - ? never - : T - export interface FunctionMiddlewareServerFnOptions< in out TRegister, in out TMiddlewares, @@ -577,6 +685,8 @@ export interface FunctionMiddlewareServerFnOptions< TMiddlewares, TServerSendContext > + /** Short-circuit the middleware chain with an early return value */ + result: FunctionMiddlewareResultFn method: Method serverFnMeta: ServerFnMeta signal: AbortSignal @@ -588,6 +698,7 @@ export type FunctionMiddlewareServerFnResult< TServerSendContext, TServerContext, TSendContext, + TServerEarlyReturn = never, > = | Promise< | FunctionServerResultWithContext< @@ -597,7 +708,7 @@ export type FunctionMiddlewareServerFnResult< TServerContext, TSendContext > - | ValidateSerializableInput + | FunctionMiddlewareResultReturn > | FunctionServerResultWithContext< TRegister, @@ -606,7 +717,7 @@ export type FunctionMiddlewareServerFnResult< TServerContext, TSendContext > - | ValidateSerializableInput + | FunctionMiddlewareResultReturn export interface FunctionMiddlewareAfterServer< TRegister, @@ -617,6 +728,7 @@ export interface FunctionMiddlewareAfterServer< TClientContext, TClientSendContext, TServerEarlyReturn = never, + TClientEarlyReturn = never, > extends FunctionMiddlewareWithTypes< TRegister, TMiddlewares, @@ -626,7 +738,7 @@ export interface FunctionMiddlewareAfterServer< TClientContext, TClientSendContext, TServerEarlyReturn, - never + TClientEarlyReturn > {} export interface FunctionMiddlewareClient< @@ -634,26 +746,62 @@ export interface FunctionMiddlewareClient< TMiddlewares, TInputValidator, > { - client: ( - client: ( - options: FunctionMiddlewareClientFnOptions< + client: { + < + TSendServerContext = undefined, + TNewClientContext = undefined, + TReturn extends FunctionMiddlewareClientFnResult< + TRegister, + TMiddlewares, + TSendServerContext, + TNewClientContext, + any + > = FunctionMiddlewareClientFnResult< + TRegister, + TMiddlewares, + TSendServerContext, + TNewClientContext, + any + >, + >( + client: ( + options: FunctionMiddlewareClientFnOptions< + TRegister, + TMiddlewares, + TInputValidator + >, + ) => TReturn, + ): FunctionMiddlewareAfterClient< + TRegister, + TMiddlewares, + TInputValidator, + TSendServerContext, + TNewClientContext, + ExtractEarlyReturnFromResult + > + + < + TSendServerContext = undefined, + TNewClientContext = undefined, + TClientEarlyReturn = never, + >( + client: FunctionMiddlewareClientFn< TRegister, TMiddlewares, - TInputValidator + TInputValidator, + TSendServerContext, + TNewClientContext, + TClientEarlyReturn >, - ) => FunctionMiddlewareClientFnResult< + ): FunctionMiddlewareAfterClient< TRegister, TMiddlewares, + TInputValidator, TSendServerContext, - TNewClientContext - >, - ) => FunctionMiddlewareAfterClient< - TRegister, - TMiddlewares, - TInputValidator, - TSendServerContext, - TNewClientContext - > + TNewClientContext, + TClientEarlyReturn + > + } } export type FunctionMiddlewareClientFn< @@ -662,6 +810,7 @@ export type FunctionMiddlewareClientFn< TInputValidator, TSendContext, TClientContext, + TClientEarlyReturn = never, > = ( options: FunctionMiddlewareClientFnOptions< TRegister, @@ -672,7 +821,8 @@ export type FunctionMiddlewareClientFn< TRegister, TMiddlewares, TSendContext, - TClientContext + TClientContext, + TClientEarlyReturn > export interface FunctionMiddlewareClientFnOptions< @@ -687,7 +837,8 @@ export interface FunctionMiddlewareClientFnOptions< signal: AbortSignal serverFnMeta: ClientFnMeta next: FunctionMiddlewareClientNextFn - filename: string + /** Short-circuit the middleware chain with an early return value */ + result: FunctionMiddlewareResultFn fetch?: CustomFetch } @@ -696,6 +847,7 @@ export type FunctionMiddlewareClientFnResult< TMiddlewares, TSendContext, TClientContext, + TClientEarlyReturn = never, > = | Promise< | FunctionClientResultWithContext< @@ -703,10 +855,10 @@ export type FunctionMiddlewareClientFnResult< TSendContext, TClientContext > - | ValidateSerializableInput + | FunctionMiddlewareResultReturn > | FunctionClientResultWithContext - | ValidateSerializableInput + | FunctionMiddlewareResultReturn export type FunctionClientResultWithContext< in out TMiddlewares, @@ -726,6 +878,7 @@ export interface FunctionMiddlewareAfterClient< TInputValidator, TServerSendContext, TClientContext, + TClientEarlyReturn = never, > extends FunctionMiddlewareWithTypes< @@ -737,14 +890,15 @@ export interface FunctionMiddlewareAfterClient< TClientContext, undefined, never, - never + TClientEarlyReturn >, FunctionMiddlewareServer< TRegister, TMiddlewares, TInputValidator, TServerSendContext, - TClientContext + TClientContext, + TClientEarlyReturn > {} export interface FunctionMiddlewareValidator { diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index f3b3a7a844a..6155ebb2581 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -1,7 +1,11 @@ import { mergeHeaders } from '@tanstack/router-core/ssr/client' import { isRedirect, parseRedirect } from '@tanstack/router-core' -import { TSS_MIDDLEWARE_RESULT, TSS_SERVER_FUNCTION_FACTORY } from './constants' +import { + TSS_MIDDLEWARE_EARLY_RESULT, + TSS_MIDDLEWARE_RESULT, + TSS_SERVER_FUNCTION_FACTORY, +} from './constants' import { getStartOptions } from './getStartOptions' import { getStartContextServerOnly } from './getStartContextServerOnly' import { createNullProtoObject, safeObjectMerge } from './safeObjectMerge' @@ -27,20 +31,88 @@ import type { AssignAllServerFnContext, FunctionMiddlewareClientFnResult, FunctionMiddlewareServerFnResult, + HasNextReturn, IntersectAllValidatorInputs, IntersectAllValidatorOutputs, - UnionAllMiddleware, } from './createMiddleware' type TODO = any /** - * Extracts all possible early return types from a middleware chain. - * This includes both server and client early returns from all middleware. + * Type-level fold over a middleware chain to compute *reachable* early returns. + * + * Rules: + * - A middleware can short-circuit by returning `result({ data })`. + * - A middleware continues by returning the result of `next()`. + * - If a middleware cannot return `next()`, the chain cannot continue past it. + * - If a middleware returns a union of `next()` and `result(...)`, later middleware + * (and the handler) are still reachable, but early returns from this middleware + * must be included. */ +type MiddlewareEarlyReturnType< + TMw, + TEnv extends 'clientEarlyReturn' | 'serverEarlyReturn', +> = TMw extends { '~types': infer TTypes } + ? TTypes extends { [K in TEnv]: infer TEarly } + ? TEarly + : never + : never + +type MiddlewareCanContinue< + TMw, + TEnv extends 'client' | 'server', +> = TMw extends { options: infer TOpts } + ? TOpts extends { client: infer TClient } + ? TEnv extends 'client' + ? HasNextReturn any>>> + : false + : TOpts extends { server: infer TServer } + ? TEnv extends 'server' + ? HasNextReturn any>>> + : false + : true + : true + +export type ReachableMiddlewareEarlyReturns< + TMiddlewares, + TEnv extends 'clientEarlyReturn' | 'serverEarlyReturn', + TCanContinue extends boolean = true, + TRuntimeEnv extends 'client' | 'server' = TEnv extends 'clientEarlyReturn' + ? 'client' + : 'server', +> = TMiddlewares extends readonly [infer TFirst, ...infer TRest] + ? TFirst extends AnyFunctionMiddleware | AnyRequestMiddleware + ? + | (TCanContinue extends true + ? MiddlewareEarlyReturnType + : never) + | ReachableMiddlewareEarlyReturns< + TRest, + TEnv, + TCanContinue extends true + ? MiddlewareCanContinue + : false, + TRuntimeEnv + > + : ReachableMiddlewareEarlyReturns + : never + +type ChainCanContinue< + TMiddlewares, + TEnv extends 'client' | 'server', +> = TMiddlewares extends readonly [infer TFirst, ...infer TRest] + ? TFirst extends AnyFunctionMiddleware | AnyRequestMiddleware + ? MiddlewareCanContinue extends true + ? ChainCanContinue + : false + : ChainCanContinue + : true + export type AllMiddlewareEarlyReturns = - | UnionAllMiddleware - | UnionAllMiddleware + | ReachableMiddlewareEarlyReturns + | (ChainCanContinue extends true + ? ReachableMiddlewareEarlyReturns + : never) export type CreateServerFn = < TMethod extends Method, @@ -291,10 +363,23 @@ export async function executeMiddleware( return result } + // Create the result() function for explicit early returns + const userResult = (options: { + data: TData + headers?: HeadersInit + }) => { + return { + [TSS_MIDDLEWARE_EARLY_RESULT]: true, + _data: options.data, + _headers: options.headers, + } + } + // Execute the middleware const result = await middlewareFn({ ...ctx, next: userNext as any, + result: userResult as any, } as any) // If result is NOT a ctx object, we need to return it as @@ -330,7 +415,25 @@ export async function executeMiddleware( return result } - // Early return from middleware - wrap the value as the result + // Check if the result came from calling result() for explicit early returns + if ( + typeof result === 'object' && + result !== null && + TSS_MIDDLEWARE_EARLY_RESULT in result + ) { + // Extract data and headers from the result() return value + const earlyResult = result as unknown as { + _data: unknown + _headers?: HeadersInit + } + return { + ...ctx, + result: earlyResult._data, + headers: mergeHeaders(ctx.headers, earlyResult._headers), + } + } + + // Legacy: Early return from middleware without using result() - wrap the value as the result return { ...ctx, result, diff --git a/packages/start-client-core/src/tests/createServerFn.test-d.ts b/packages/start-client-core/src/tests/createServerFn.test-d.ts index c1aa9f2cdbe..0bd537fa24b 100644 --- a/packages/start-client-core/src/tests/createServerFn.test-d.ts +++ b/packages/start-client-core/src/tests/createServerFn.test-d.ts @@ -881,45 +881,97 @@ test('createServerFn respects TsrSerializable', () => { }) describe('middleware early return types', () => { - // Middleware can now return values directly instead of calling next(). - // Early returns are allowed at the type level, but the specific return type - // is not yet tracked through the middleware chain. + test('client-only early return blocks server early return types', () => { + const clientStop = createMiddleware({ type: 'function' }).client( + ({ result }) => { + return result({ data: { fromClient: true as const } }) + }, + ) + + const serverStop = createMiddleware({ type: 'function' }).server( + ({ result }) => { + return result({ data: { fromServer: true as const } }) + }, + ) + + const fn = createServerFn() + .middleware([clientStop, serverStop]) + .handler(() => { + return { handler: true as const } + }) + + expectTypeOf>>() + expectTypeOf<{ fromClient: true }>() + }) + + test('client early return that can next includes server early returns', () => { + const clientMaybeStop = createMiddleware({ type: 'function' }).client( + ({ next, result }) => { + if (Math.random() > 0.5) { + return result({ data: { fromClient: true as const } }) + } + return next() + }, + ) + + const serverStop = createMiddleware({ type: 'function' }).server( + ({ result }) => { + return result({ data: { fromServer: true as const } }) + }, + ) + + const fn = createServerFn() + .middleware([clientMaybeStop, serverStop]) + .handler(() => { + return { handler: true as const } + }) - test('server function with middleware that may return early', () => { - // This middleware returns early with { earlyReturn: true } instead of calling next() + expectTypeOf>>() + expectTypeOf< + { handler: true } | { fromClient: true } | { fromServer: true } + >() + }) + test('server function with middleware that may return early using result()', () => { const earlyReturnMiddleware = createMiddleware({ type: 'function' }).server( - ({ next }) => { + ({ next, result }) => { const shouldShortCircuit = Math.random() > 0.5 if (shouldShortCircuit) { - // Early return - does NOT call next() - return { earlyReturn: true as const, value: 'short-circuited' } + return result({ + data: { + earlyReturn: true as const, + value: 'short-circuited' as const, + }, + }) } return next({ context: { middlewareRan: true } as const }) }, ) - // The server function handler returns a different type const fn = createServerFn() .middleware([earlyReturnMiddleware]) .handler(() => { return { handlerResult: 'from-handler' as const } }) - // Early returns are now allowed but the specific type is not tracked. - // The return type currently includes handler return and unknown (for any early return). - // TODO: Track early return types through middleware chain for full union type - expectTypeOf(fn()).toEqualTypeOf< - Promise<{ handlerResult: 'from-handler' }> - >() + // `expectTypeOf().toEqualTypeOf()` does not behave well for unions of object + // types in this dts harness. Ensure both sides are compatible instead. + type Actual = Awaited> + type Expected = + | { handlerResult: 'from-handler' } + | { earlyReturn: true; value: 'short-circuited' } + + type AssertExtends = true + type _expectedExtendsActual = AssertExtends + type _actualExtendsExpected = AssertExtends }) - test('client middleware early return types', () => { + test('client middleware early return types using result()', () => { const clientEarlyReturnMiddleware = createMiddleware({ type: 'function', - }).client(({ next }) => { - const cached = { fromCache: true as const } + }).client(({ next, result }) => { + const cached = true if (cached) { - return cached + return result({ data: { fromCache: true as const } }) } return next() }) @@ -930,15 +982,16 @@ describe('middleware early return types', () => { return { fromServer: true as const } }) - // Client early returns are now allowed - expectTypeOf(fn()).toEqualTypeOf>() + expectTypeOf>().toEqualTypeOf< + Promise<{ fromServer: true } | { fromCache: true }> + >() }) - test('nested middleware early returns', () => { + test('nested middleware early returns using result()', () => { const outerMiddleware = createMiddleware({ type: 'function' }).server( - ({ next }) => { + ({ next, result }) => { if (Math.random() > 0.9) { - return { level: 'outer' as const } + return result({ data: { level: 'outer' as const } }) } return next({ context: { outer: true } as const }) }, @@ -946,9 +999,9 @@ describe('middleware early return types', () => { const innerMiddleware = createMiddleware({ type: 'function' }) .middleware([outerMiddleware]) - .server(({ next }) => { + .server(({ next, result }) => { if (Math.random() > 0.9) { - return { level: 'inner' as const } + return result({ data: { level: 'inner' as const } }) } return next({ context: { inner: true } as const }) }) @@ -959,8 +1012,9 @@ describe('middleware early return types', () => { return { level: 'handler' as const } }) - // Nested early returns are now allowed - // TODO: Track specific early return types: { level: 'outer' } | { level: 'inner' } | { level: 'handler' } - expectTypeOf(fn()).toEqualTypeOf>() + expectTypeOf>>() + expectTypeOf< + { level: 'handler' } | { level: 'outer' } | { level: 'inner' } + >() }) }) diff --git a/packages/start-client-core/src/tests/createServerMiddleware.test-d.ts b/packages/start-client-core/src/tests/createServerMiddleware.test-d.ts index c75292ce72b..e2ea2e07213 100644 --- a/packages/start-client-core/src/tests/createServerMiddleware.test-d.ts +++ b/packages/start-client-core/src/tests/createServerMiddleware.test-d.ts @@ -810,126 +810,209 @@ test('createMiddleware with type request can return sync Response', () => { }) // ============================================================================= -// Middleware Early Return Tests (Expected Type Failures) -// These tests document the expected behavior when middleware returns early -// without calling next(). Currently types don't support this, so we use -// ts-expect-error directives to mark expected failures. +// Middleware Early Return Tests // ============================================================================= -test('createMiddleware server can return early without calling next', () => { - createMiddleware({ type: 'function' }).server(async () => { - // Returning a value directly without calling next() - return { earlyReturn: true, message: 'Short-circuited' } - }) +test('createMiddleware server can return early without calling next using result()', () => { + const middleware = createMiddleware({ type: 'function' }).server( + async ({ result, next }) => { + expectTypeOf(next).toBeFunction() + return result({ + data: { + earlyReturn: true as const, + message: 'Short-circuited' as const, + }, + }) + }, + ) + + expectTypeOf(middleware['~types']['serverEarlyReturn']).toEqualTypeOf<{ + earlyReturn: true + message: 'Short-circuited' + }>() }) -test('createMiddleware server can conditionally call next or return value', () => { - createMiddleware({ type: 'function' }) +test('createMiddleware server can conditionally call next or return value using result()', () => { + const middleware = createMiddleware({ type: 'function' }) .inputValidator((input: { shouldShortCircuit: boolean }) => input) - .server(async ({ data, next }) => { + .server(async ({ data, next, result }) => { if (data.shouldShortCircuit) { - return { earlyReturn: true, message: 'Short-circuited' } + return result({ + data: { + earlyReturn: true as const, + message: 'Short-circuited' as const, + }, + }) } return next({ context: { passedThrough: true } }) }) + + expectTypeOf(middleware['~types']['serverEarlyReturn']).toEqualTypeOf<{ + earlyReturn: true + message: 'Short-circuited' + }>() }) -test('createMiddleware client can return early without calling next', () => { - createMiddleware({ type: 'function' }).client(async () => { - // Returning a value directly without calling next() - return { earlyReturn: true, message: 'Client short-circuited' } - }) +test('createMiddleware client can return early without calling next using result()', () => { + const middleware = createMiddleware({ type: 'function' }).client( + async ({ result, next }) => { + expectTypeOf(next).toBeFunction() + return result({ + data: { + earlyReturn: true as const, + message: 'Client short-circuited' as const, + }, + }) + }, + ) + + expectTypeOf(middleware['~types']['clientEarlyReturn']).toEqualTypeOf<{ + earlyReturn: true + message: 'Client short-circuited' + }>() }) -test('createMiddleware client can conditionally call next or return value', () => { - createMiddleware({ type: 'function' }) +test('createMiddleware client can conditionally call next or return value using result()', () => { + const middleware = createMiddleware({ type: 'function' }) .inputValidator((input: { shouldShortCircuit: boolean }) => input) - .client(async ({ data, next }) => { + .client(async ({ data, next, result }) => { if (data.shouldShortCircuit) { - return { earlyReturn: true, message: 'Client short-circuited' } + return result({ + data: { + earlyReturn: true as const, + message: 'Client short-circuited' as const, + }, + }) } return next({ sendContext: { fromClient: true } }) }) + + expectTypeOf(middleware['~types']['clientEarlyReturn']).toEqualTypeOf<{ + earlyReturn: true + message: 'Client short-circuited' + }>() }) -test('nested middleware where inner middleware returns early', () => { +test('nested middleware where inner middleware returns early using result()', () => { const innerMiddleware = createMiddleware({ type: 'function' }) .inputValidator((input: { level: string }) => input) - .server(async ({ data, next }) => { + .server(async ({ data, next, result }) => { if (data.level === 'inner') { - return { returnedFrom: 'inner', level: 2 } + return result({ data: { returnedFrom: 'inner' as const, level: 2 } }) } return next({ context: { innerPassed: true } }) }) const outerMiddleware = createMiddleware({ type: 'function' }) .middleware([innerMiddleware]) - .server(async ({ data, next, context }) => { + .server(async ({ data, next, context, result }) => { if (data.level === 'outer') { - return { returnedFrom: 'outer', level: 1, innerContext: context } + return result({ + data: { + returnedFrom: 'outer' as const, + level: 1, + innerContext: context, + }, + }) } return next({ context: { outerPassed: true } }) }) - // Just verify middleware is created successfully - expectTypeOf(outerMiddleware).toHaveProperty('options') + expectTypeOf(innerMiddleware['~types']['serverEarlyReturn']).toEqualTypeOf<{ + returnedFrom: 'inner' + level: number + }>() + expectTypeOf(outerMiddleware['~types']['serverEarlyReturn']).toEqualTypeOf<{ + returnedFrom: 'outer' + level: number + innerContext: { innerPassed: boolean } + }>() }) -test('deeply nested middleware chain with early return at each level', () => { +test('deeply nested middleware chain with early return at each level using result()', () => { const deepMiddleware = createMiddleware({ type: 'function' }) .inputValidator( (input: { earlyReturnLevel: 'none' | 'deep' | 'middle' | 'outer' }) => input, ) - .server(async ({ data, next }) => { + .server(async ({ data, next, result }) => { if (data.earlyReturnLevel === 'deep') { - return { returnedFrom: 'deep', level: 3 } + return result({ data: { returnedFrom: 'deep' as const, level: 3 } }) } return next({ context: { deepPassed: true } }) }) const middleMiddleware = createMiddleware({ type: 'function' }) .middleware([deepMiddleware]) - .server(async ({ data, next, context }) => { + .server(async ({ data, next, context, result }) => { if (data.earlyReturnLevel === 'middle') { - return { returnedFrom: 'middle', level: 2, deepContext: context } + return result({ + data: { + returnedFrom: 'middle' as const, + level: 2, + deepContext: context, + }, + }) } return next({ context: { middlePassed: true } }) }) const outerMiddleware = createMiddleware({ type: 'function' }) .middleware([middleMiddleware]) - .server(async ({ data, next, context }) => { + .server(async ({ data, next, context, result }) => { if (data.earlyReturnLevel === 'outer') { - return { returnedFrom: 'outer', level: 1, middleContext: context } + return result({ + data: { + returnedFrom: 'outer' as const, + level: 1, + middleContext: context, + }, + }) } return next({ context: { outerPassed: true } }) }) - // Verify the chain is created - expectTypeOf(outerMiddleware).toHaveProperty('options') + expectTypeOf(deepMiddleware['~types']['serverEarlyReturn']).toEqualTypeOf<{ + returnedFrom: 'deep' + level: number + }>() + expectTypeOf(middleMiddleware['~types']['serverEarlyReturn']).toEqualTypeOf<{ + returnedFrom: 'middle' + level: number + deepContext: { deepPassed: boolean } + }>() + expectTypeOf(outerMiddleware['~types']['serverEarlyReturn']).toEqualTypeOf<{ + returnedFrom: 'outer' + level: number + middleContext: { deepPassed: boolean; middlePassed: boolean } + }>() }) -test('client middleware early return prevents server call', () => { +test('client middleware early return prevents server call using result()', () => { const clientEarlyReturnMiddleware = createMiddleware({ type: 'function' }) .inputValidator((input: { skipServer: boolean }) => input) - .client(async ({ data, next }) => { + .client(async ({ data, next, result }) => { if (data.skipServer) { - // This should prevent any network request to the server - return { source: 'client', message: 'Skipped server entirely' } + return result({ + data: { + source: 'client' as const, + message: 'Skipped server entirely' as const, + }, + }) } return next({ sendContext: { clientCalled: true } }) }) - // Chain with server middleware that should never be reached const withServerMiddleware = createMiddleware({ type: 'function' }) .middleware([clientEarlyReturnMiddleware]) .server(async ({ next, context }) => { - // If client returned early, this should never execute return next({ context: { serverReached: true, clientContext: context }, }) }) + expectTypeOf( + clientEarlyReturnMiddleware['~types']['clientEarlyReturn'], + ).toEqualTypeOf<{ source: 'client'; message: 'Skipped server entirely' }>() expectTypeOf(withServerMiddleware).toHaveProperty('options') }) From cdefbbfa84fee81147f6d6fb731d15661b6d1422 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 18 Jan 2026 20:31:48 +0100 Subject: [PATCH 4/5] header test --- .../server-functions/src/routeTree.gen.ts | 23 +++++ .../src/routes/middleware/index.tsx | 8 ++ .../server-early-return-headers.tsx | 96 +++++++++++++++++++ .../tests/server-functions.spec.ts | 25 +++++ 4 files changed, 152 insertions(+) create mode 100644 e2e/react-start/server-functions/src/routes/middleware/server-early-return-headers.tsx diff --git a/e2e/react-start/server-functions/src/routeTree.gen.ts b/e2e/react-start/server-functions/src/routeTree.gen.ts index 859d0a9a8de..d068f26604c 100644 --- a/e2e/react-start/server-functions/src/routeTree.gen.ts +++ b/e2e/react-start/server-functions/src/routeTree.gen.ts @@ -40,6 +40,7 @@ import { Route as RedirectTestTargetRouteImport } from './routes/redirect-test/t import { Route as RedirectTestSsrTargetRouteImport } from './routes/redirect-test-ssr/target' import { Route as MiddlewareUnhandledExceptionRouteImport } from './routes/middleware/unhandled-exception' import { Route as MiddlewareServerImportMiddlewareRouteImport } from './routes/middleware/server-import-middleware' +import { Route as MiddlewareServerEarlyReturnHeadersRouteImport } from './routes/middleware/server-early-return-headers' import { Route as MiddlewareServerEarlyReturnRouteImport } from './routes/middleware/server-early-return' import { Route as MiddlewareServerConditionalRouteImport } from './routes/middleware/server-conditional' import { Route as MiddlewareSendServerFnRouteImport } from './routes/middleware/send-serverFn' @@ -214,6 +215,12 @@ const MiddlewareServerImportMiddlewareRoute = path: '/middleware/server-import-middleware', getParentRoute: () => rootRouteImport, } as any) +const MiddlewareServerEarlyReturnHeadersRoute = + MiddlewareServerEarlyReturnHeadersRouteImport.update({ + id: '/middleware/server-early-return-headers', + path: '/middleware/server-early-return-headers', + getParentRoute: () => rootRouteImport, + } as any) const MiddlewareServerEarlyReturnRoute = MiddlewareServerEarlyReturnRouteImport.update({ id: '/middleware/server-early-return', @@ -339,6 +346,7 @@ export interface FileRoutesByFullPath { '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute '/middleware/server-conditional': typeof MiddlewareServerConditionalRoute '/middleware/server-early-return': typeof MiddlewareServerEarlyReturnRoute + '/middleware/server-early-return-headers': typeof MiddlewareServerEarlyReturnHeadersRoute '/middleware/server-import-middleware': typeof MiddlewareServerImportMiddlewareRoute '/middleware/unhandled-exception': typeof MiddlewareUnhandledExceptionRoute '/redirect-test-ssr/target': typeof RedirectTestSsrTargetRoute @@ -388,6 +396,7 @@ export interface FileRoutesByTo { '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute '/middleware/server-conditional': typeof MiddlewareServerConditionalRoute '/middleware/server-early-return': typeof MiddlewareServerEarlyReturnRoute + '/middleware/server-early-return-headers': typeof MiddlewareServerEarlyReturnHeadersRoute '/middleware/server-import-middleware': typeof MiddlewareServerImportMiddlewareRoute '/middleware/unhandled-exception': typeof MiddlewareUnhandledExceptionRoute '/redirect-test-ssr/target': typeof RedirectTestSsrTargetRoute @@ -438,6 +447,7 @@ export interface FileRoutesById { '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute '/middleware/server-conditional': typeof MiddlewareServerConditionalRoute '/middleware/server-early-return': typeof MiddlewareServerEarlyReturnRoute + '/middleware/server-early-return-headers': typeof MiddlewareServerEarlyReturnHeadersRoute '/middleware/server-import-middleware': typeof MiddlewareServerImportMiddlewareRoute '/middleware/unhandled-exception': typeof MiddlewareUnhandledExceptionRoute '/redirect-test-ssr/target': typeof RedirectTestSsrTargetRoute @@ -489,6 +499,7 @@ export interface FileRouteTypes { | '/middleware/send-serverFn' | '/middleware/server-conditional' | '/middleware/server-early-return' + | '/middleware/server-early-return-headers' | '/middleware/server-import-middleware' | '/middleware/unhandled-exception' | '/redirect-test-ssr/target' @@ -538,6 +549,7 @@ export interface FileRouteTypes { | '/middleware/send-serverFn' | '/middleware/server-conditional' | '/middleware/server-early-return' + | '/middleware/server-early-return-headers' | '/middleware/server-import-middleware' | '/middleware/unhandled-exception' | '/redirect-test-ssr/target' @@ -587,6 +599,7 @@ export interface FileRouteTypes { | '/middleware/send-serverFn' | '/middleware/server-conditional' | '/middleware/server-early-return' + | '/middleware/server-early-return-headers' | '/middleware/server-import-middleware' | '/middleware/unhandled-exception' | '/redirect-test-ssr/target' @@ -637,6 +650,7 @@ export interface RootRouteChildren { MiddlewareSendServerFnRoute: typeof MiddlewareSendServerFnRoute MiddlewareServerConditionalRoute: typeof MiddlewareServerConditionalRoute MiddlewareServerEarlyReturnRoute: typeof MiddlewareServerEarlyReturnRoute + MiddlewareServerEarlyReturnHeadersRoute: typeof MiddlewareServerEarlyReturnHeadersRoute MiddlewareServerImportMiddlewareRoute: typeof MiddlewareServerImportMiddlewareRoute MiddlewareUnhandledExceptionRoute: typeof MiddlewareUnhandledExceptionRoute RedirectTestSsrTargetRoute: typeof RedirectTestSsrTargetRoute @@ -875,6 +889,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MiddlewareServerImportMiddlewareRouteImport parentRoute: typeof rootRouteImport } + '/middleware/server-early-return-headers': { + id: '/middleware/server-early-return-headers' + path: '/middleware/server-early-return-headers' + fullPath: '/middleware/server-early-return-headers' + preLoaderRoute: typeof MiddlewareServerEarlyReturnHeadersRouteImport + parentRoute: typeof rootRouteImport + } '/middleware/server-early-return': { id: '/middleware/server-early-return' path: '/middleware/server-early-return' @@ -1021,6 +1042,8 @@ const rootRouteChildren: RootRouteChildren = { MiddlewareSendServerFnRoute: MiddlewareSendServerFnRoute, MiddlewareServerConditionalRoute: MiddlewareServerConditionalRoute, MiddlewareServerEarlyReturnRoute: MiddlewareServerEarlyReturnRoute, + MiddlewareServerEarlyReturnHeadersRoute: + MiddlewareServerEarlyReturnHeadersRoute, MiddlewareServerImportMiddlewareRoute: MiddlewareServerImportMiddlewareRoute, MiddlewareUnhandledExceptionRoute: MiddlewareUnhandledExceptionRoute, RedirectTestSsrTargetRoute: RedirectTestSsrTargetRoute, diff --git a/e2e/react-start/server-functions/src/routes/middleware/index.tsx b/e2e/react-start/server-functions/src/routes/middleware/index.tsx index fe13f5f4bb1..adf79597c99 100644 --- a/e2e/react-start/server-functions/src/routes/middleware/index.tsx +++ b/e2e/react-start/server-functions/src/routes/middleware/index.tsx @@ -74,6 +74,14 @@ function RouteComponent() { Server middleware early return (no next() call) +
  • + + Server middleware early return with headers + +
  • { + return result({ + data: { + source: 'middleware', + message: 'Early return from server middleware with headers', + }, + headers: { + 'x-middleware-early-return': 'true', + 'x-middleware-early-return-value': 'hello', + }, + }) +}) + +const serverFn = createServerFn() + .middleware([serverEarlyReturnHeadersMiddleware]) + .handler(() => { + return { + source: 'handler', + message: 'This should not be returned', + } + }) + +export const Route = createFileRoute('/middleware/server-early-return-headers')( + { + component: RouteComponent, + }, +) + +function RouteComponent() { + const [resultValue, setResultValue] = React.useState(null) + const [error, setError] = React.useState(null) + + return ( +
    +

    + Server Middleware Early Return with Headers +

    +

    + Calls a server function that short-circuits in middleware via result() + and sets response headers. +

    + + + +
    +
    +

    Result Data:

    +
    +            {resultValue
    +              ? JSON.stringify(resultValue, null, 2)
    +              : 'Not called yet'}
    +          
    +
    + + {error ? ( +
    {error}
    + ) : ( +
    + )} +
    +
    + ) +} 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 9d02dae25c8..b87d23eb672 100644 --- a/e2e/react-start/server-functions/tests/server-functions.spec.ts +++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts @@ -236,6 +236,31 @@ test('server function correctly passes context when using FormData', async ({ expect(simpleResult.testString).toContain('context-from-middleware') }) +test('server function can short-circuit middleware with result({ headers })', async ({ + page, +}) => { + await page.goto('/middleware/server-early-return-headers') + await page.waitForLoadState('networkidle') + + const serverFnResponsePromise = page.waitForResponse((response) => { + const url = response.url() + return ( + url.includes('/_serverFn/') && + url.includes('serverEarlyReturnHeadersMiddleware') + ) + }) + + await page.getByTestId('invoke-btn').click() + + const response = await serverFnResponsePromise + const responseHeaders = response.headers() + + expect(responseHeaders['x-middleware-early-return']).toBe('true') + expect(responseHeaders['x-middleware-early-return-value']).toBe('hello') + + await expect(page.getByTestId('result-data')).toContainText('middleware') +}) + test('server function can correctly send and receive headers', async ({ page, }) => { From c71afe9e4e5e45dd26de86ad9dd9134a3e2f1647 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 18 Jan 2026 23:49:45 +0100 Subject: [PATCH 5/5] headers --- .../server-early-return-headers.tsx | 24 +++++++++++++ .../tests/server-functions.spec.ts | 22 ++++++------ .../start-client-core/src/createServerFn.ts | 3 +- .../src/server-functions-handler.ts | 36 ++++++++++++------- 4 files changed, 60 insertions(+), 25 deletions(-) diff --git a/e2e/react-start/server-functions/src/routes/middleware/server-early-return-headers.tsx b/e2e/react-start/server-functions/src/routes/middleware/server-early-return-headers.tsx index f93c1c8fb0d..0c8f0451893 100644 --- a/e2e/react-start/server-functions/src/routes/middleware/server-early-return-headers.tsx +++ b/e2e/react-start/server-functions/src/routes/middleware/server-early-return-headers.tsx @@ -40,6 +40,8 @@ export const Route = createFileRoute('/middleware/server-early-return-headers')( function RouteComponent() { const [resultValue, setResultValue] = React.useState(null) + const [capturedResponseHeaders, setCapturedResponseHeaders] = + React.useState | null>(null) const [error, setError] = React.useState(null) return ( @@ -58,12 +60,25 @@ function RouteComponent() { onClick={async () => { setError(null) setResultValue(null) + setCapturedResponseHeaders(null) try { + const captureHeadersFetch : typeof fetch = async ( + url, + init + ) => { + const response = await fetch(url, init) + setCapturedResponseHeaders( + Object.fromEntries(response.headers.entries()), + ) + return response + } + const res = await serverFn({ headers: { 'x-test-request': 'ok', }, + fetch: captureHeadersFetch, }) setResultValue(res) @@ -85,6 +100,15 @@ function RouteComponent() {
    +
    +

    Captured Response Headers:

    +
    +            {capturedResponseHeaders
    +              ? JSON.stringify(capturedResponseHeaders, null, 2)
    +              : 'Not captured yet'}
    +          
    +
    + {error ? (
    {error}
    ) : ( 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 b87d23eb672..fd35661d898 100644 --- a/e2e/react-start/server-functions/tests/server-functions.spec.ts +++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts @@ -242,21 +242,19 @@ test('server function can short-circuit middleware with result({ headers })', as await page.goto('/middleware/server-early-return-headers') await page.waitForLoadState('networkidle') - const serverFnResponsePromise = page.waitForResponse((response) => { - const url = response.url() - return ( - url.includes('/_serverFn/') && - url.includes('serverEarlyReturnHeadersMiddleware') - ) - }) - await page.getByTestId('invoke-btn').click() - const response = await serverFnResponsePromise - const responseHeaders = response.headers() + await expect(page.getByTestId('captured-response-headers')).not.toContainText( + 'Not captured yet', + { timeout: 10000 }, + ) + + const capturedHeaders = JSON.parse( + await page.getByTestId('captured-response-headers').innerText(), + ) - expect(responseHeaders['x-middleware-early-return']).toBe('true') - expect(responseHeaders['x-middleware-early-return-value']).toBe('hello') + expect(capturedHeaders['x-middleware-early-return']).toBe('true') + expect(capturedHeaders['x-middleware-early-return-value']).toBe('hello') await expect(page.getByTestId('result-data')).toContainText('middleware') }) diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index 6155ebb2581..8521d4fbc1b 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -247,9 +247,10 @@ export const createServerFn: CreateServerFn = (options, __opts) => { 'server', ctx, ).then((d) => ({ - // Only send the result and sendContext back to the client + // Only send the result, headers and sendContext back to the client result: d.result, error: d.error, + headers: d.headers, context: d.sendContext, })) diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts index 9333095e90e..c51933e6272 100644 --- a/packages/start-server-core/src/server-functions-handler.ts +++ b/packages/start-server-core/src/server-functions-handler.ts @@ -3,6 +3,7 @@ import { isNotFound, isRedirect, } from '@tanstack/router-core' +import { mergeHeaders } from '@tanstack/router-core/ssr/client' import invariant from 'tiny-invariant' import { TSS_FORMDATA_CONTEXT, @@ -176,6 +177,8 @@ export const handleServerAction = async ({ let nonStreamingBody: any = undefined const alsResponse = getResponse() + // Normalize any headers from the server function result + const serverFnHeaders = mergeHeaders((res as any)?.headers) if (res !== undefined) { // Collect raw streams encountered during serialization const rawStreams = new Map>() @@ -226,10 +229,13 @@ export const handleServerAction = async ({ { status: alsResponse.status, statusText: alsResponse.statusText, - headers: { - 'Content-Type': 'application/json', - [X_TSS_SERIALIZED]: 'true', - }, + headers: mergeHeaders( + { + 'Content-Type': 'application/json', + [X_TSS_SERIALIZED]: 'true', + }, + serverFnHeaders, + ), }, ) } @@ -266,10 +272,13 @@ export const handleServerAction = async ({ return new Response(multiplexedStream, { status: alsResponse.status, statusText: alsResponse.statusText, - headers: { - 'Content-Type': TSS_CONTENT_TYPE_FRAMED_VERSIONED, - [X_TSS_SERIALIZED]: 'true', - }, + headers: mergeHeaders( + { + 'Content-Type': TSS_CONTENT_TYPE_FRAMED_VERSIONED, + [X_TSS_SERIALIZED]: 'true', + }, + serverFnHeaders, + ), }) } @@ -297,10 +306,13 @@ export const handleServerAction = async ({ return new Response(stream, { status: alsResponse.status, statusText: alsResponse.statusText, - headers: { - 'Content-Type': 'application/x-ndjson', - [X_TSS_SERIALIZED]: 'true', - }, + headers: mergeHeaders( + { + 'Content-Type': 'application/x-ndjson', + [X_TSS_SERIALIZED]: 'true', + }, + serverFnHeaders, + ), }) }