diff --git a/e2e/react-start/server-functions/src/routeTree.gen.ts b/e2e/react-start/server-functions/src/routeTree.gen.ts index c8fa692dcb7..84470fcabca 100644 --- a/e2e/react-start/server-functions/src/routeTree.gen.ts +++ b/e2e/react-start/server-functions/src/routeTree.gen.ts @@ -39,11 +39,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' @@ -203,6 +208,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', @@ -214,6 +231,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', @@ -232,6 +255,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', @@ -287,11 +322,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 @@ -330,11 +370,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 @@ -374,11 +419,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 @@ -419,11 +469,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' @@ -462,11 +517,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' @@ -505,11 +565,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' @@ -549,11 +614,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 @@ -785,6 +855,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' @@ -799,6 +883,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' @@ -820,6 +911,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' @@ -885,11 +990,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..e92400fb178 --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/client-conditional.tsx @@ -0,0 +1,181 @@ +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 }) => { + 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..4c00058adf2 --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/client-early-return.tsx @@ -0,0 +1,117 @@ +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 () => { + 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..7ba29bb1afb --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/nested-early-return.tsx @@ -0,0 +1,229 @@ +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 }) => { + 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(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(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..6c5b2eb6fa7 --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/server-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 .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 }) => { + 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..5fe47e28dea --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/server-early-return.tsx @@ -0,0 +1,196 @@ +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 () => { + 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(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 62d6a0df94f..972d7fc403a 100644 --- a/e2e/react-start/server-functions/tests/server-functions.spec.ts +++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts @@ -1043,3 +1043,269 @@ test('middleware can catch errors thrown by server function handlers', async ({ 'This error should be caught by middleware', ) }) + +// ============================================================================= +// 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') + }) +}) 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/createMiddleware.ts b/packages/start-client-core/src/createMiddleware.ts index b3f9ee0d60f..2c31dfccd73 100644 --- a/packages/start-client-core/src/createMiddleware.ts +++ b/packages/start-client-core/src/createMiddleware.ts @@ -90,7 +90,9 @@ export interface FunctionMiddlewareAfterMiddleware undefined, undefined, undefined, - undefined + undefined, + never, + never >, FunctionMiddlewareServer< TRegister, @@ -110,6 +112,8 @@ export interface FunctionMiddlewareWithTypes< TServerSendContext, TClientContext, TClientSendContext, + TServerEarlyReturn = never, + TClientEarlyReturn = never, > { '~types': FunctionMiddlewareTypes< TRegister, @@ -118,7 +122,9 @@ export interface FunctionMiddlewareWithTypes< TServerContext, TServerSendContext, TClientContext, - TClientSendContext + TClientSendContext, + TServerEarlyReturn, + TClientEarlyReturn > options: FunctionMiddlewareOptions< TRegister, @@ -137,6 +143,8 @@ export interface FunctionMiddlewareTypes< in out TServerSendContext, in out TClientContext, in out TClientSendContext, + out TServerEarlyReturn = never, + out TClientEarlyReturn = never, > { type: 'function' middlewares: TMiddlewares @@ -172,6 +180,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 } /** @@ -217,6 +244,8 @@ export type AnyFunctionMiddleware = FunctionMiddlewareWithTypes< any, any, any, + any, + any, any > @@ -266,6 +295,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, @@ -498,6 +546,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, @@ -526,13 +584,14 @@ export type FunctionMiddlewareServerFnResult< TSendContext, > = | Promise< - FunctionServerResultWithContext< - TRegister, - TMiddlewares, - TServerSendContext, - TServerContext, - TSendContext - > + | FunctionServerResultWithContext< + TRegister, + TMiddlewares, + TServerSendContext, + TServerContext, + TSendContext + > + | ValidateSerializableInput > | FunctionServerResultWithContext< TRegister, @@ -541,6 +600,7 @@ export type FunctionMiddlewareServerFnResult< TServerContext, TSendContext > + | ValidateSerializableInput export interface FunctionMiddlewareAfterServer< TRegister, @@ -550,6 +610,7 @@ export interface FunctionMiddlewareAfterServer< TServerSendContext, TClientContext, TClientSendContext, + TServerEarlyReturn = never, > extends FunctionMiddlewareWithTypes< TRegister, TMiddlewares, @@ -557,7 +618,9 @@ export interface FunctionMiddlewareAfterServer< TServerContext, TServerSendContext, TClientContext, - TClientSendContext + TClientSendContext, + TServerEarlyReturn, + never > {} export interface FunctionMiddlewareClient< @@ -566,10 +629,15 @@ export interface FunctionMiddlewareClient< TInputValidator, > { client: ( - client: FunctionMiddlewareClientFn< + client: ( + options: FunctionMiddlewareClientFnOptions< + TRegister, + TMiddlewares, + TInputValidator + >, + ) => FunctionMiddlewareClientFnResult< TRegister, TMiddlewares, - TInputValidator, TSendServerContext, TNewClientContext >, @@ -595,6 +663,7 @@ export type FunctionMiddlewareClientFn< TInputValidator >, ) => FunctionMiddlewareClientFnResult< + TRegister, TMiddlewares, TSendContext, TClientContext @@ -615,18 +684,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, @@ -654,7 +726,9 @@ export interface FunctionMiddlewareAfterClient< undefined, TServerSendContext, TClientContext, - undefined + undefined, + never, + never >, FunctionMiddlewareServer< TRegister, @@ -683,7 +757,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 b2944e8b533..bcd3fda223f 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' @@ -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, @@ -272,6 +281,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 } @@ -303,7 +316,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) @@ -356,7 +384,7 @@ export interface OptionalFetcher< > extends FetcherBase { ( options?: OptionalFetcherDataOptions, - ): Promise> + ): Promise | AllMiddlewareEarlyReturns> } export interface RequiredFetcher< @@ -366,7 +394,7 @@ export interface RequiredFetcher< > extends FetcherBase { ( opts: RequiredFetcherDataOptions, - ): Promise> + ): Promise | AllMiddlewareEarlyReturns> } export type FetcherBaseOptions = { @@ -774,6 +802,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 4b792e38421..7080ab51f5b 100644 --- a/packages/start-client-core/src/tests/createServerFn.test-d.ts +++ b/packages/start-client-core/src/tests/createServerFn.test-d.ts @@ -865,3 +865,88 @@ test('createServerFn respects TsrSerializable', () => { Promise<{ nested: { custom: MyCustomTypeSerializable } }> >() }) + +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('server function with middleware that may return early', () => { + // This middleware returns early with { earlyReturn: true } instead of calling 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() + .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' }> + >() + }) + + 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]) + .handler(() => { + return { fromServer: true as const } + }) + + // Client early returns are now allowed + expectTypeOf(fn()).toEqualTypeOf>() + }) + + 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]) + .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 } + }) + + // 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 323797d3f36..595122919d9 100644 --- a/packages/start-client-core/src/tests/createServerMiddleware.test-d.ts +++ b/packages/start-client-core/src/tests/createServerMiddleware.test-d.ts @@ -799,3 +799,128 @@ 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', () => { + 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) + .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', () => { + 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) + .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) + .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]) + .server(async ({ data, next, context }) => { + 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, + ) + .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]) + .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]) + .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) + .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') +})