diff --git a/e2e/react-start/server-functions/src/routeTree.gen.ts b/e2e/react-start/server-functions/src/routeTree.gen.ts index b5d09dd802..d068f26604 100644 --- a/e2e/react-start/server-functions/src/routeTree.gen.ts +++ b/e2e/react-start/server-functions/src/routeTree.gen.ts @@ -40,11 +40,17 @@ 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' 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 +215,24 @@ 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', + 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 +244,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 +268,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 +336,17 @@ 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-early-return-headers': typeof MiddlewareServerEarlyReturnHeadersRoute '/middleware/server-import-middleware': typeof MiddlewareServerImportMiddlewareRoute '/middleware/unhandled-exception': typeof MiddlewareUnhandledExceptionRoute '/redirect-test-ssr/target': typeof RedirectTestSsrTargetRoute @@ -338,11 +386,17 @@ 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-early-return-headers': typeof MiddlewareServerEarlyReturnHeadersRoute '/middleware/server-import-middleware': typeof MiddlewareServerImportMiddlewareRoute '/middleware/unhandled-exception': typeof MiddlewareUnhandledExceptionRoute '/redirect-test-ssr/target': typeof RedirectTestSsrTargetRoute @@ -383,11 +437,17 @@ 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-early-return-headers': typeof MiddlewareServerEarlyReturnHeadersRoute '/middleware/server-import-middleware': typeof MiddlewareServerImportMiddlewareRoute '/middleware/unhandled-exception': typeof MiddlewareUnhandledExceptionRoute '/redirect-test-ssr/target': typeof RedirectTestSsrTargetRoute @@ -429,11 +489,17 @@ 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-early-return-headers' | '/middleware/server-import-middleware' | '/middleware/unhandled-exception' | '/redirect-test-ssr/target' @@ -473,11 +539,17 @@ 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-early-return-headers' | '/middleware/server-import-middleware' | '/middleware/unhandled-exception' | '/redirect-test-ssr/target' @@ -517,11 +589,17 @@ 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-early-return-headers' | '/middleware/server-import-middleware' | '/middleware/unhandled-exception' | '/redirect-test-ssr/target' @@ -562,11 +640,17 @@ 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 + MiddlewareServerEarlyReturnHeadersRoute: typeof MiddlewareServerEarlyReturnHeadersRoute MiddlewareServerImportMiddlewareRoute: typeof MiddlewareServerImportMiddlewareRoute MiddlewareUnhandledExceptionRoute: typeof MiddlewareUnhandledExceptionRoute RedirectTestSsrTargetRoute: typeof RedirectTestSsrTargetRoute @@ -805,6 +889,27 @@ 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' + 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 +924,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 +952,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 +1032,18 @@ 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, + MiddlewareServerEarlyReturnHeadersRoute: + MiddlewareServerEarlyReturnHeadersRoute, 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 0000000000..8f5765ee88 --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/client-conditional.tsx @@ -0,0 +1,183 @@ +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(async ({ data, next, result }) => { + if (data.shouldShortCircuit) { + return result({ + data: { + 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 0000000000..b48e3fc3ad --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/client-early-return.tsx @@ -0,0 +1,119 @@ +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(async ({ result }) => { + return result({ + data: { + 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 0d4e313912..adf79597c9 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,54 @@ function RouteComponent() { Function middleware receives functionId and filename +
  • + + Server middleware early return (no next() call) + +
  • +
  • + + Server middleware early return with headers + +
  • +
  • + + 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 0000000000..77c9f2f03e --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/nested-early-return.tsx @@ -0,0 +1,235 @@ +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(async ({ data, next, result }) => { + if (data.earlyReturnLevel === 'deep') { + return result({ + data: { + 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(async ({ data, next, context, result }) => { + if (data.earlyReturnLevel === 'middle') { + return result({ + data: { + 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(async ({ data, next, context, result }) => { + if (data.earlyReturnLevel === 'outer') { + return result({ + data: { + 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 0000000000..cfed6cdbde --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/server-conditional.tsx @@ -0,0 +1,185 @@ +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(async ({ data, next, result }) => { + if (data.shouldShortCircuit) { + return result({ + data: { + 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-headers.tsx b/e2e/react-start/server-functions/src/routes/middleware/server-early-return-headers.tsx new file mode 100644 index 0000000000..0c8f045189 --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/server-early-return-headers.tsx @@ -0,0 +1,120 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createMiddleware, createServerFn } from '@tanstack/react-start' +import React from 'react' + +/** + * Verifies that middleware can short-circuit with result({ headers }). + * + * The e2e assertion for the headers is done in Playwright by + * inspecting the `_serverFn` response headers. + */ +const serverEarlyReturnHeadersMiddleware = createMiddleware({ + type: 'function', +}).server(async ({ result }) => { + 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 [capturedResponseHeaders, setCapturedResponseHeaders] = + React.useState | null>(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'}
    +          
    +
    + +
    +

    Captured Response Headers:

    +
    +            {capturedResponseHeaders
    +              ? JSON.stringify(capturedResponseHeaders, null, 2)
    +              : 'Not captured yet'}
    +          
    +
    + + {error ? ( +
    {error}
    + ) : ( +
    + )} +
    +
    + ) +} 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 0000000000..f573a9d802 --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/server-early-return.tsx @@ -0,0 +1,200 @@ +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(async ({ result }) => { + return result({ + data: { + 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(async ({ result }) => { + return result({ + data: { + 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 a03e02448f..fd35661d89 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,29 @@ 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') + + await page.getByTestId('invoke-btn').click() + + 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(capturedHeaders['x-middleware-early-return']).toBe('true') + expect(capturedHeaders['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, }) => { @@ -1044,6 +1067,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 3df983dfe7..fb0ee94417 100644 --- a/packages/start-client-core/src/constants.ts +++ b/packages/start-client-core/src/constants.ts @@ -3,6 +3,22 @@ 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') + +/** + * 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' diff --git a/packages/start-client-core/src/createMiddleware.ts b/packages/start-client-core/src/createMiddleware.ts index 9e0a435791..8d80c52999 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 @@ -95,7 +162,9 @@ export interface FunctionMiddlewareAfterMiddleware undefined, undefined, undefined, - undefined + undefined, + never, + never >, FunctionMiddlewareServer< TRegister, @@ -115,6 +184,8 @@ export interface FunctionMiddlewareWithTypes< TServerSendContext, TClientContext, TClientSendContext, + TServerEarlyReturn = never, + TClientEarlyReturn = never, > { '~types': FunctionMiddlewareTypes< TRegister, @@ -123,7 +194,9 @@ export interface FunctionMiddlewareWithTypes< TServerContext, TServerSendContext, TClientContext, - TClientSendContext + TClientSendContext, + TServerEarlyReturn, + TClientEarlyReturn > options: FunctionMiddlewareOptions< TRegister, @@ -142,6 +215,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 +252,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 +316,8 @@ export type AnyFunctionMiddleware = FunctionMiddlewareWithTypes< any, any, any, + any, + any, any > @@ -271,6 +367,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, @@ -421,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< @@ -449,6 +613,7 @@ export type FunctionMiddlewareServerFn< TServerSendContext, TNewServerContext, TSendContext, + TServerEarlyReturn = never, > = ( options: FunctionMiddlewareServerFnOptions< TRegister, @@ -461,7 +626,8 @@ export type FunctionMiddlewareServerFn< TMiddlewares, TServerSendContext, TNewServerContext, - TSendContext + TSendContext, + TServerEarlyReturn > export type FunctionMiddlewareServerNextFn< @@ -519,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 @@ -530,15 +698,17 @@ export type FunctionMiddlewareServerFnResult< TServerSendContext, TServerContext, TSendContext, + TServerEarlyReturn = never, > = | Promise< - FunctionServerResultWithContext< - TRegister, - TMiddlewares, - TServerSendContext, - TServerContext, - TSendContext - > + | FunctionServerResultWithContext< + TRegister, + TMiddlewares, + TServerSendContext, + TServerContext, + TSendContext + > + | FunctionMiddlewareResultReturn > | FunctionServerResultWithContext< TRegister, @@ -547,6 +717,7 @@ export type FunctionMiddlewareServerFnResult< TServerContext, TSendContext > + | FunctionMiddlewareResultReturn export interface FunctionMiddlewareAfterServer< TRegister, @@ -556,6 +727,8 @@ export interface FunctionMiddlewareAfterServer< TServerSendContext, TClientContext, TClientSendContext, + TServerEarlyReturn = never, + TClientEarlyReturn = never, > extends FunctionMiddlewareWithTypes< TRegister, TMiddlewares, @@ -563,7 +736,9 @@ export interface FunctionMiddlewareAfterServer< TServerContext, TServerSendContext, TClientContext, - TClientSendContext + TClientSendContext, + TServerEarlyReturn, + TClientEarlyReturn > {} export interface FunctionMiddlewareClient< @@ -571,21 +746,62 @@ export interface FunctionMiddlewareClient< TMiddlewares, TInputValidator, > { - client: ( - client: FunctionMiddlewareClientFn< + 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 - >, - ) => FunctionMiddlewareAfterClient< - TRegister, - TMiddlewares, - TInputValidator, - TSendServerContext, - TNewClientContext - > + TNewClientContext, + ExtractEarlyReturnFromResult + > + + < + TSendServerContext = undefined, + TNewClientContext = undefined, + TClientEarlyReturn = never, + >( + client: FunctionMiddlewareClientFn< + TRegister, + TMiddlewares, + TInputValidator, + TSendServerContext, + TNewClientContext, + TClientEarlyReturn + >, + ): FunctionMiddlewareAfterClient< + TRegister, + TMiddlewares, + TInputValidator, + TSendServerContext, + TNewClientContext, + TClientEarlyReturn + > + } } export type FunctionMiddlewareClientFn< @@ -594,6 +810,7 @@ export type FunctionMiddlewareClientFn< TInputValidator, TSendContext, TClientContext, + TClientEarlyReturn = never, > = ( options: FunctionMiddlewareClientFnOptions< TRegister, @@ -601,9 +818,11 @@ export type FunctionMiddlewareClientFn< TInputValidator >, ) => FunctionMiddlewareClientFnResult< + TRegister, TMiddlewares, TSendContext, - TClientContext + TClientContext, + TClientEarlyReturn > export interface FunctionMiddlewareClientFnOptions< @@ -618,23 +837,28 @@ 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 } export type FunctionMiddlewareClientFnResult< + TRegister, TMiddlewares, TSendContext, TClientContext, + TClientEarlyReturn = never, > = | Promise< - FunctionClientResultWithContext< - TMiddlewares, - TSendContext, - TClientContext - > + | FunctionClientResultWithContext< + TMiddlewares, + TSendContext, + TClientContext + > + | FunctionMiddlewareResultReturn > | FunctionClientResultWithContext + | FunctionMiddlewareResultReturn export type FunctionClientResultWithContext< in out TMiddlewares, @@ -654,6 +878,7 @@ export interface FunctionMiddlewareAfterClient< TInputValidator, TServerSendContext, TClientContext, + TClientEarlyReturn = never, > extends FunctionMiddlewareWithTypes< @@ -663,14 +888,17 @@ export interface FunctionMiddlewareAfterClient< undefined, TServerSendContext, TClientContext, - undefined + undefined, + never, + TClientEarlyReturn >, FunctionMiddlewareServer< TRegister, TMiddlewares, TInputValidator, TServerSendContext, - TClientContext + TClientContext, + TClientEarlyReturn > {} export interface FunctionMiddlewareValidator { @@ -692,7 +920,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 39542d1e2a..8521d4fbc1 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_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,12 +31,89 @@ import type { AssignAllServerFnContext, FunctionMiddlewareClientFnResult, FunctionMiddlewareServerFnResult, + HasNextReturn, IntersectAllValidatorInputs, IntersectAllValidatorOutputs, } from './createMiddleware' type TODO = any +/** + * 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 = + | ReachableMiddlewareEarlyReturns + | (ChainCanContinue extends true + ? ReachableMiddlewareEarlyReturns + : never) + export type CreateServerFn = < TMethod extends Method, TResponse = unknown, @@ -166,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, })) @@ -275,13 +357,30 @@ 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 } + // 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 @@ -306,7 +405,40 @@ 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 + } + + // 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, + } } return callNextMiddleware(ctx) @@ -361,7 +493,7 @@ export interface OptionalFetcher< > extends FetcherBase { ( options?: OptionalFetcherDataOptions, - ): Promise> + ): Promise | AllMiddlewareEarlyReturns> } export interface RequiredFetcher< @@ -371,7 +503,7 @@ export interface RequiredFetcher< > extends FetcherBase { ( opts: RequiredFetcherDataOptions, - ): Promise> + ): Promise | AllMiddlewareEarlyReturns> } export type CustomFetch = typeof globalThis.fetch @@ -786,6 +918,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 f0cb181a43..0bd537fa24 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,142 @@ test('createServerFn respects TsrSerializable', () => { Promise<{ nested: { custom: MyCustomTypeSerializable } }> >() }) + +describe('middleware early return types', () => { + 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 } + }) + + 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, result }) => { + const shouldShortCircuit = Math.random() > 0.5 + if (shouldShortCircuit) { + return result({ + data: { + earlyReturn: true as const, + value: 'short-circuited' as const, + }, + }) + } + return next({ context: { middlewareRan: true } as const }) + }, + ) + + const fn = createServerFn() + .middleware([earlyReturnMiddleware]) + .handler(() => { + return { handlerResult: 'from-handler' as const } + }) + + // `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 using result()', () => { + const clientEarlyReturnMiddleware = createMiddleware({ + type: 'function', + }).client(({ next, result }) => { + const cached = true + if (cached) { + return result({ data: { fromCache: true as const } }) + } + return next() + }) + + const fn = createServerFn() + .middleware([clientEarlyReturnMiddleware]) + .handler(() => { + return { fromServer: true as const } + }) + + expectTypeOf>().toEqualTypeOf< + Promise<{ fromServer: true } | { fromCache: true }> + >() + }) + + test('nested middleware early returns using result()', () => { + const outerMiddleware = createMiddleware({ type: 'function' }).server( + ({ next, result }) => { + if (Math.random() > 0.9) { + return result({ data: { level: 'outer' as const } }) + } + return next({ context: { outer: true } as const }) + }, + ) + + const innerMiddleware = createMiddleware({ type: 'function' }) + .middleware([outerMiddleware]) + .server(({ next, result }) => { + if (Math.random() > 0.9) { + return result({ data: { level: 'inner' as const } }) + } + return next({ context: { inner: true } as const }) + }) + + const fn = createServerFn() + .middleware([innerMiddleware]) + .handler(() => { + return { level: 'handler' as const } + }) + + 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 b5f84b7022..e2ea2e0721 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,211 @@ test('createMiddleware with type request can return sync Response', () => { }) }) }) + +// ============================================================================= +// Middleware Early Return Tests +// ============================================================================= + +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 using result()', () => { + const middleware = createMiddleware({ type: 'function' }) + .inputValidator((input: { shouldShortCircuit: boolean }) => input) + .server(async ({ data, next, result }) => { + if (data.shouldShortCircuit) { + 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 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 using result()', () => { + const middleware = createMiddleware({ type: 'function' }) + .inputValidator((input: { shouldShortCircuit: boolean }) => input) + .client(async ({ data, next, result }) => { + if (data.shouldShortCircuit) { + 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 using result()', () => { + const innerMiddleware = createMiddleware({ type: 'function' }) + .inputValidator((input: { level: string }) => input) + .server(async ({ data, next, result }) => { + if (data.level === 'inner') { + 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, result }) => { + if (data.level === 'outer') { + return result({ + data: { + returnedFrom: 'outer' as const, + level: 1, + innerContext: context, + }, + }) + } + return next({ context: { outerPassed: true } }) + }) + + 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 using result()', () => { + const deepMiddleware = createMiddleware({ type: 'function' }) + .inputValidator( + (input: { earlyReturnLevel: 'none' | 'deep' | 'middle' | 'outer' }) => + input, + ) + .server(async ({ data, next, result }) => { + if (data.earlyReturnLevel === 'deep') { + 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, result }) => { + if (data.earlyReturnLevel === 'middle') { + 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, result }) => { + if (data.earlyReturnLevel === 'outer') { + return result({ + data: { + returnedFrom: 'outer' as const, + level: 1, + middleContext: context, + }, + }) + } + return next({ context: { outerPassed: true } }) + }) + + 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 using result()', () => { + const clientEarlyReturnMiddleware = createMiddleware({ type: 'function' }) + .inputValidator((input: { skipServer: boolean }) => input) + .client(async ({ data, next, result }) => { + if (data.skipServer) { + return result({ + data: { + source: 'client' as const, + message: 'Skipped server entirely' as const, + }, + }) + } + return next({ sendContext: { clientCalled: true } }) + }) + + const withServerMiddleware = createMiddleware({ type: 'function' }) + .middleware([clientEarlyReturnMiddleware]) + .server(async ({ next, context }) => { + return next({ + context: { serverReached: true, clientContext: context }, + }) + }) + + expectTypeOf( + clientEarlyReturnMiddleware['~types']['clientEarlyReturn'], + ).toEqualTypeOf<{ source: 'client'; message: 'Skipped server entirely' }>() + expectTypeOf(withServerMiddleware).toHaveProperty('options') +}) diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts index 9333095e90..c51933e627 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, + ), }) }