From b2a382e3ac29feee4e6b9478adf62b3e396125bf Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 17 Oct 2025 14:15:56 -0600 Subject: [PATCH 01/10] fix: refactor middleware --- .../server-functions/src/routeTree.gen.ts | 22 ++++ .../server-functions/src/routes/index.tsx | 6 +- .../routes/middleware/unhandled-exception.tsx | 33 +++++ .../start-client-core/src/createServerFn.ts | 113 ++++++++++-------- packages/start-client-core/src/index.tsx | 1 - 5 files changed, 120 insertions(+), 55 deletions(-) create mode 100644 e2e/react-start/server-functions/src/routes/middleware/unhandled-exception.tsx diff --git a/e2e/react-start/server-functions/src/routeTree.gen.ts b/e2e/react-start/server-functions/src/routeTree.gen.ts index f83dc9146be..92cffa88efd 100644 --- a/e2e/react-start/server-functions/src/routeTree.gen.ts +++ b/e2e/react-start/server-functions/src/routeTree.gen.ts @@ -34,6 +34,7 @@ import { Route as CookiesIndexRouteImport } from './routes/cookies/index' import { Route as AbortSignalIndexRouteImport } from './routes/abort-signal/index' import { Route as RedirectTestTargetRouteImport } from './routes/redirect-test/target' 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 MiddlewareSendServerFnRouteImport } from './routes/middleware/send-serverFn' import { Route as MiddlewareRequestMiddlewareRouteImport } from './routes/middleware/request-middleware' @@ -168,6 +169,12 @@ const RedirectTestSsrTargetRoute = RedirectTestSsrTargetRouteImport.update({ path: '/redirect-test-ssr/target', getParentRoute: () => rootRouteImport, } as any) +const MiddlewareUnhandledExceptionRoute = + MiddlewareUnhandledExceptionRouteImport.update({ + id: '/middleware/unhandled-exception', + path: '/middleware/unhandled-exception', + getParentRoute: () => rootRouteImport, + } as any) const MiddlewareServerImportMiddlewareRoute = MiddlewareServerImportMiddlewareRouteImport.update({ id: '/middleware/server-import-middleware', @@ -237,6 +244,7 @@ export interface FileRoutesByFullPath { '/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute '/middleware/server-import-middleware': typeof MiddlewareServerImportMiddlewareRoute + '/middleware/unhandled-exception': typeof MiddlewareUnhandledExceptionRoute '/redirect-test-ssr/target': typeof RedirectTestSsrTargetRoute '/redirect-test/target': typeof RedirectTestTargetRoute '/abort-signal': typeof AbortSignalIndexRoute @@ -272,6 +280,7 @@ export interface FileRoutesByTo { '/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute '/middleware/server-import-middleware': typeof MiddlewareServerImportMiddlewareRoute + '/middleware/unhandled-exception': typeof MiddlewareUnhandledExceptionRoute '/redirect-test-ssr/target': typeof RedirectTestSsrTargetRoute '/redirect-test/target': typeof RedirectTestTargetRoute '/abort-signal': typeof AbortSignalIndexRoute @@ -308,6 +317,7 @@ export interface FileRoutesById { '/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute '/middleware/server-import-middleware': typeof MiddlewareServerImportMiddlewareRoute + '/middleware/unhandled-exception': typeof MiddlewareUnhandledExceptionRoute '/redirect-test-ssr/target': typeof RedirectTestSsrTargetRoute '/redirect-test/target': typeof RedirectTestTargetRoute '/abort-signal/': typeof AbortSignalIndexRoute @@ -345,6 +355,7 @@ export interface FileRouteTypes { | '/middleware/request-middleware' | '/middleware/send-serverFn' | '/middleware/server-import-middleware' + | '/middleware/unhandled-exception' | '/redirect-test-ssr/target' | '/redirect-test/target' | '/abort-signal' @@ -380,6 +391,7 @@ export interface FileRouteTypes { | '/middleware/request-middleware' | '/middleware/send-serverFn' | '/middleware/server-import-middleware' + | '/middleware/unhandled-exception' | '/redirect-test-ssr/target' | '/redirect-test/target' | '/abort-signal' @@ -415,6 +427,7 @@ export interface FileRouteTypes { | '/middleware/request-middleware' | '/middleware/send-serverFn' | '/middleware/server-import-middleware' + | '/middleware/unhandled-exception' | '/redirect-test-ssr/target' | '/redirect-test/target' | '/abort-signal/' @@ -451,6 +464,7 @@ export interface RootRouteChildren { MiddlewareRequestMiddlewareRoute: typeof MiddlewareRequestMiddlewareRoute MiddlewareSendServerFnRoute: typeof MiddlewareSendServerFnRoute MiddlewareServerImportMiddlewareRoute: typeof MiddlewareServerImportMiddlewareRoute + MiddlewareUnhandledExceptionRoute: typeof MiddlewareUnhandledExceptionRoute RedirectTestSsrTargetRoute: typeof RedirectTestSsrTargetRoute RedirectTestTargetRoute: typeof RedirectTestTargetRoute AbortSignalIndexRoute: typeof AbortSignalIndexRoute @@ -641,6 +655,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RedirectTestSsrTargetRouteImport parentRoute: typeof rootRouteImport } + '/middleware/unhandled-exception': { + id: '/middleware/unhandled-exception' + path: '/middleware/unhandled-exception' + fullPath: '/middleware/unhandled-exception' + preLoaderRoute: typeof MiddlewareUnhandledExceptionRouteImport + parentRoute: typeof rootRouteImport + } '/middleware/server-import-middleware': { id: '/middleware/server-import-middleware' path: '/middleware/server-import-middleware' @@ -723,6 +744,7 @@ const rootRouteChildren: RootRouteChildren = { MiddlewareRequestMiddlewareRoute: MiddlewareRequestMiddlewareRoute, MiddlewareSendServerFnRoute: MiddlewareSendServerFnRoute, MiddlewareServerImportMiddlewareRoute: MiddlewareServerImportMiddlewareRoute, + MiddlewareUnhandledExceptionRoute: MiddlewareUnhandledExceptionRoute, RedirectTestSsrTargetRoute: RedirectTestSsrTargetRoute, RedirectTestTargetRoute: RedirectTestTargetRoute, AbortSignalIndexRoute: AbortSignalIndexRoute, diff --git a/e2e/react-start/server-functions/src/routes/index.tsx b/e2e/react-start/server-functions/src/routes/index.tsx index ca57f1540b9..5929fb3ab7d 100644 --- a/e2e/react-start/server-functions/src/routes/index.tsx +++ b/e2e/react-start/server-functions/src/routes/index.tsx @@ -91,7 +91,11 @@ function Home() {
  • Server Function only called by Server Environment is kept in the - server build + server build +
  • +
  • + + Server Functions Middleware Unhandled Exception E2E tests
  • diff --git a/e2e/react-start/server-functions/src/routes/middleware/unhandled-exception.tsx b/e2e/react-start/server-functions/src/routes/middleware/unhandled-exception.tsx new file mode 100644 index 00000000000..f6d3bb60179 --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/unhandled-exception.tsx @@ -0,0 +1,33 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createMiddleware, createServerFn } from '@tanstack/react-start' + +export const authMiddleware = createMiddleware({ type: 'function' }).server( + async ({ next, context }) => { + throw new Error('Unauthorized') + }, +) + +const personServerFn = createServerFn({ method: 'GET' }) + .middleware([authMiddleware]) + .inputValidator((d: string) => d) + .handler(({ data: name }) => { + return { name, randomNumber: Math.floor(Math.random() * 100) } + }) + +export const Route = createFileRoute('/middleware/unhandled-exception')({ + loader: async () => { + return { + person: await personServerFn({ data: 'John Doe' }), + } + }, + component: RouteComponent, +}) + +function RouteComponent() { + const { person } = Route.useLoaderData() + return ( +
    + {person.name} - {person.randomNumber} +
    + ) +} diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index 3307d4b7cee..2468aa459fe 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -1,4 +1,3 @@ -import { isNotFound, isRedirect } from '@tanstack/router-core' import { mergeHeaders } from '@tanstack/router-core/ssr/client' import { TSS_SERVER_FUNCTION_FACTORY } from './constants' @@ -112,17 +111,17 @@ export const createServerFn: CreateServerFn = (options, __opts) => { return Object.assign( async (opts?: CompiledFetcherFnOptions) => { // Start by executing the client-side middleware chain - return executeMiddleware(resolvedMiddleware, 'client', { + const result = await executeMiddleware(resolvedMiddleware, 'client', { ...extractedFn, ...newOptions, data: opts?.data as any, headers: opts?.headers, signal: opts?.signal, context: {}, - }).then((d) => { - if (d.error) throw d.error - return d.result }) + + if (result.error) throw result.error + return result.result }, { // This copies over the URL, function ID @@ -178,7 +177,7 @@ export async function executeMiddleware( ...middlewares, ]) - const next: NextFn = async (ctx) => { + const callNextMiddleware: NextFn = async (ctx) => { // Get the next middleware const nextMiddleware = flattenedMiddlewares.shift() @@ -210,27 +209,70 @@ export async function executeMiddleware( middlewareFn = nextMiddleware.options.server as MiddlewareFn | undefined } - if (middlewareFn) { - // Execute the middleware - return applyMiddleware(middlewareFn, ctx, async (newCtx) => { - return next(newCtx).catch((error: any) => { - if (isRedirect(error) || isNotFound(error)) { + // Execute the middleware + try { + if (middlewareFn) { + const userNext = async ( + userCtx: ServerFnMiddlewareResult | undefined = {} as any, + ) => { + // Return the next middleware + const nextCtx = { + ...ctx, + ...userCtx, + context: { + ...ctx.context, + ...userCtx.context, + }, + sendContext: { + ...ctx.sendContext, + ...(userCtx.sendContext ?? {}), + }, + headers: mergeHeaders(ctx.headers, userCtx.headers), + result: + userCtx.result !== undefined + ? userCtx.result + : userCtx instanceof Response + ? userCtx + : (ctx as any).result, + error: userCtx.error ?? (ctx as any).error, + } + + try { + return await callNextMiddleware(nextCtx) + } catch (error: any) { return { - ...newCtx, + ...nextCtx, error, } } + } - throw error - }) - }) - } + // Execute the middleware + const result = await middlewareFn({ + ...ctx, + next: userNext as any, + } as any) + + if (!(result as any)) { + throw new Error( + 'User middleware returned undefined. You must call next() or return a result in your middlewares.', + ) + } + + return result + } - return next(ctx) + return callNextMiddleware(ctx) + } catch (error: any) { + return { + ...ctx, + error, + } + } } // Start the middleware chain - return next({ + return callNextMiddleware({ ...opts, headers: opts.headers || {}, sendContext: opts.sendContext || {}, @@ -622,41 +664,6 @@ export type MiddlewareFn = ( }, ) => Promise -export const applyMiddleware = async ( - middlewareFn: MiddlewareFn, - ctx: ServerFnMiddlewareOptions, - nextFn: NextFn, -) => { - return middlewareFn({ - ...ctx, - next: (async ( - userCtx: ServerFnMiddlewareResult | undefined = {} as any, - ) => { - // Return the next middleware - return nextFn({ - ...ctx, - ...userCtx, - context: { - ...ctx.context, - ...userCtx.context, - }, - sendContext: { - ...ctx.sendContext, - ...(userCtx.sendContext ?? {}), - }, - headers: mergeHeaders(ctx.headers, userCtx.headers), - result: - userCtx.result !== undefined - ? userCtx.result - : userCtx instanceof Response - ? userCtx - : (ctx as any).result, - error: userCtx.error ?? (ctx as any).error, - }) - }) as any, - } as any) -} - export function execValidator( validator: AnyValidator, input: unknown, diff --git a/packages/start-client-core/src/index.tsx b/packages/start-client-core/src/index.tsx index eb5f7eb8f0e..afd5f4265fc 100644 --- a/packages/start-client-core/src/index.tsx +++ b/packages/start-client-core/src/index.tsx @@ -72,7 +72,6 @@ export type { RequiredFetcher, } from './createServerFn' export { - applyMiddleware, execValidator, flattenMiddlewares, executeMiddleware, From 42f3b9916d63913aab36e58aa415832d5d8bebb4 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 17 Oct 2025 14:52:25 -0600 Subject: [PATCH 02/10] fix: catch errors from validators --- .../start-client-core/src/createServerFn.ts | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index 2468aa459fe..e1b4b9c2004 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -186,31 +186,33 @@ export async function executeMiddleware( return ctx } - if ( - 'inputValidator' in nextMiddleware.options && - nextMiddleware.options.inputValidator && - env === 'server' - ) { - // Execute the middleware's input function - ctx.data = await execValidator( - nextMiddleware.options.inputValidator, - ctx.data, - ) - } + // Execute the middleware + try { + if ( + 'inputValidator' in nextMiddleware.options && + nextMiddleware.options.inputValidator && + env === 'server' + ) { + // Execute the middleware's input function + ctx.data = await execValidator( + nextMiddleware.options.inputValidator, + ctx.data, + ) + } - let middlewareFn: MiddlewareFn | undefined = undefined - if (env === 'client') { - if ('client' in nextMiddleware.options) { - middlewareFn = nextMiddleware.options.client as MiddlewareFn | undefined + let middlewareFn: MiddlewareFn | undefined = undefined + if (env === 'client') { + if ('client' in nextMiddleware.options) { + middlewareFn = nextMiddleware.options.client as + | MiddlewareFn + | undefined + } + } + // env === 'server' + else if ('server' in nextMiddleware.options) { + middlewareFn = nextMiddleware.options.server as MiddlewareFn | undefined } - } - // env === 'server' - else if ('server' in nextMiddleware.options) { - middlewareFn = nextMiddleware.options.server as MiddlewareFn | undefined - } - // Execute the middleware - try { if (middlewareFn) { const userNext = async ( userCtx: ServerFnMiddlewareResult | undefined = {} as any, From bbf786701863fcf164e76e4f8ba17319200c1f08 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 27 Oct 2025 10:59:43 -0700 Subject: [PATCH 03/10] checkpoint --- .../src/routes/submit-post-formdata.tsx | 2 +- .../src/client-rpc/serverFnFetcher.ts | 39 ++- packages/start-client-core/src/constants.ts | 1 + .../start-client-core/src/createServerFn.ts | 44 +++- packages/start-client-core/src/index.tsx | 1 + .../src/createStartHandler.ts | 5 +- .../src/server-functions-handler.ts | 235 +++++++++++------- 7 files changed, 217 insertions(+), 110 deletions(-) diff --git a/e2e/react-start/server-functions/src/routes/submit-post-formdata.tsx b/e2e/react-start/server-functions/src/routes/submit-post-formdata.tsx index 6bff33f35f3..4f6303c28e3 100644 --- a/e2e/react-start/server-functions/src/routes/submit-post-formdata.tsx +++ b/e2e/react-start/server-functions/src/routes/submit-post-formdata.tsx @@ -33,7 +33,7 @@ function SubmitPostFormDataFn() {

    Submit POST FormData Fn Call

    - It should return navigate and return{' '} + It should navigate to a raw response of {''}
                 Hello, {testValues.name}!
    diff --git a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts
    index 4cf9ac381ce..c02ea30f4f4 100644
    --- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts
    +++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts
    @@ -25,6 +25,15 @@ function hasOwnProperties(obj: object): boolean {
       }
       return false
     }
    +// caller =>
    +//   serverFnFetcher =>
    +//     client =>
    +//       server =>
    +//         fn =>
    +//       seroval =>
    +//     client middleware =>
    +//   serverFnFetcher =>
    +// caller
     
     export async function serverFnFetcher(
       url: string,
    @@ -43,7 +52,8 @@ export async function serverFnFetcher(
     
       // Arrange the headers
       const headers = first.headers ? new Headers(first.headers) : new Headers()
    -  headers.set('x-tsr-redirect', 'manual')
    +  headers.set('x-tsr-serverFn', 'true')
    +  headers.set('x-tsr-createServerFn', 'true')
     
       if (type === 'payload') {
         headers.set('accept', 'application/x-ndjson, application/json')
    @@ -146,7 +156,7 @@ async function getFetchBody(
     async function getResponse(fn: () => Promise) {
       let response: Response
       try {
    -    response = await fn()
    +    response = await fn() // client => server => fn => server => client
       } catch (error) {
         if (error instanceof Response) {
           response = error
    @@ -159,22 +169,16 @@ async function getResponse(fn: () => Promise) {
       if (response.headers.get(X_TSS_RAW_RESPONSE) === 'true') {
         return response
       }
    +
       const contentType = response.headers.get('content-type')
       invariant(contentType, 'expected content-type header to be set')
       const serializedByStart = !!response.headers.get(X_TSS_SERIALIZED)
    -  // If the response is not ok, throw an error
    -  if (!response.ok) {
    -    if (serializedByStart && contentType.includes('application/json')) {
    -      const jsonPayload = await response.json()
    -      const result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins! })
    -      throw result
    -    }
    -
    -    throw new Error(await response.text())
    -  }
     
    +  // If the response is serialized by the start server, we need to process it
    +  // differently than a normal response.
       if (serializedByStart) {
         let result
    +    // If it's a stream from the start serializer, process it as such
         if (contentType.includes('application/x-ndjson')) {
           const refs = new Map()
           result = await processServerFnResponse({
    @@ -187,17 +191,22 @@ async function getResponse(fn: () => Promise) {
             },
           })
         }
    +    // If it's a JSON response, it can be simpler
         if (contentType.includes('application/json')) {
           const jsonPayload = await response.json()
           result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins! })
         }
    +
         invariant(result, 'expected result to be resolved')
         if (result instanceof Error) {
           throw result
         }
    +
         return result
       }
     
    +  // If it wasn't processed by the start serializer, check
    +  // if it's JSON
       if (contentType.includes('application/json')) {
         const jsonPayload = await response.json()
         const redirect = parseRedirect(jsonPayload)
    @@ -210,6 +219,12 @@ async function getResponse(fn: () => Promise) {
         return jsonPayload
       }
     
    +  // Otherwise, if it's not OK, throw the content
    +  if (!response.ok) {
    +    throw new Error(await response.text())
    +  }
    +
    +  // Or return the response itself
       return response
     }
     
    diff --git a/packages/start-client-core/src/constants.ts b/packages/start-client-core/src/constants.ts
    index 1e541af3231..4e0777068d2 100644
    --- a/packages/start-client-core/src/constants.ts
    +++ b/packages/start-client-core/src/constants.ts
    @@ -6,4 +6,5 @@ export const TSS_SERVER_FUNCTION_FACTORY = Symbol.for(
     
     export const X_TSS_SERIALIZED = 'x-tss-serialized'
     export const X_TSS_RAW_RESPONSE = 'x-tss-raw'
    +export const X_TSS_CONTEXT = 'x-tss-context'
     export {}
    diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts
    index e1b4b9c2004..2722460da4f 100644
    --- a/packages/start-client-core/src/createServerFn.ts
    +++ b/packages/start-client-core/src/createServerFn.ts
    @@ -1,9 +1,9 @@
     import { mergeHeaders } from '@tanstack/router-core/ssr/client'
     
    +import { isRedirect, parseRedirect } from '@tanstack/router-core'
     import { TSS_SERVER_FUNCTION_FACTORY } from './constants'
     import { getStartOptions } from './getStartOptions'
     import { getStartContextServerOnly } from './getStartContextServerOnly'
    -import type { TSS_SERVER_FUNCTION } from './constants'
     import type {
       AnyValidator,
       Constrain,
    @@ -15,6 +15,7 @@ import type {
       ValidateSerializableInput,
       Validator,
     } from '@tanstack/router-core'
    +import type { TSS_SERVER_FUNCTION } from './constants'
     import type {
       AnyFunctionMiddleware,
       AnyRequestMiddleware,
    @@ -120,6 +121,11 @@ export const createServerFn: CreateServerFn = (options, __opts) => {
                 context: {},
               })
     
    +          const redirect = parseRedirect(result.error)
    +          if (redirect) {
    +            throw redirect
    +          }
    +
               if (result.error) throw result.error
               return result.result
             },
    @@ -143,14 +149,18 @@ export const createServerFn: CreateServerFn = (options, __opts) => {
                   request: startContext.request,
                 }
     
    -            return executeMiddleware(resolvedMiddleware, 'server', ctx).then(
    -              (d) => ({
    -                // Only send the result and sendContext back to the client
    -                result: d.result,
    -                error: d.error,
    -                context: d.sendContext,
    -              }),
    -            )
    +            const result = await executeMiddleware(
    +              resolvedMiddleware,
    +              'server',
    +              ctx,
    +            ).then((d) => ({
    +              // Only send the result and sendContext back to the client
    +              result: d.result,
    +              error: d.error,
    +              context: d.sendContext,
    +            }))
    +
    +            return result
               },
             },
           ) as any
    @@ -255,6 +265,22 @@ export async function executeMiddleware(
               next: userNext as any,
             } as any)
     
    +        // If result is NOT a ctx object, we need to return it as
    +        // the { result }
    +        if (isRedirect(result)) {
    +          return {
    +            ...ctx,
    +            error: result,
    +          }
    +        }
    +
    +        if (result instanceof Response) {
    +          return {
    +            ...ctx,
    +            result,
    +          }
    +        }
    +
             if (!(result as any)) {
               throw new Error(
                 'User middleware returned undefined. You must call next() or return a result in your middlewares.',
    diff --git a/packages/start-client-core/src/index.tsx b/packages/start-client-core/src/index.tsx
    index afd5f4265fc..49b143e3bc9 100644
    --- a/packages/start-client-core/src/index.tsx
    +++ b/packages/start-client-core/src/index.tsx
    @@ -82,6 +82,7 @@ export {
       TSS_SERVER_FUNCTION,
       X_TSS_SERIALIZED,
       X_TSS_RAW_RESPONSE,
    +  X_TSS_CONTEXT,
     } from './constants'
     
     export type * from './serverRoute'
    diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts
    index b0984b32751..cb66999f085 100644
    --- a/packages/start-server-core/src/createStartHandler.ts
    +++ b/packages/start-server-core/src/createStartHandler.ts
    @@ -245,7 +245,6 @@ export function createStartHandler(
             [...middlewares, requestHandlerMiddleware],
             {
               request,
    -
               context: requestOpts?.context || {},
             },
           )
    @@ -254,7 +253,7 @@ export function createStartHandler(
     
           if (isRedirect(response)) {
             if (isResolvedRedirect(response)) {
    -          if (request.headers.get('x-tsr-redirect') === 'manual') {
    +          if (request.headers.get('x-tsr-createServerFn') === 'true') {
                 return Response.json(
                   {
                     ...response.options,
    @@ -295,7 +294,7 @@ export function createStartHandler(
             const router = await getRouter()
             const redirect = router.resolveRedirect(response)
     
    -        if (request.headers.get('x-tsr-redirect') === 'manual') {
    +        if (request.headers.get('x-tsr-createServerFn') === 'true') {
               return Response.json(
                 {
                   ...response.options,
    diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts
    index 129f80afe78..5942cd0d83b 100644
    --- a/packages/start-server-core/src/server-functions-handler.ts
    +++ b/packages/start-server-core/src/server-functions-handler.ts
    @@ -1,10 +1,11 @@
    -import { isNotFound } from '@tanstack/router-core'
    +import { isNotFound, isPlainObject } from '@tanstack/router-core'
     import invariant from 'tiny-invariant'
     import {
       TSS_FORMDATA_CONTEXT,
       X_TSS_RAW_RESPONSE,
       X_TSS_SERIALIZED,
       getDefaultSerovalPlugins,
    +  json,
     } from '@tanstack/start-client-core'
     import { fromJSON, toCrossJSONAsync, toCrossJSONStream } from 'seroval'
     import { getResponse } from './request-response'
    @@ -54,6 +55,10 @@ export const handleServerAction = async ({
     
       const action = await getServerFnById(serverFnId, { fromClient: true })
     
    +  const isServerFn = request.headers.get('x-tsr-serverFn') === 'true'
    +  const isCreateServerFn =
    +    request.headers.get('x-tsr-createServerFn') === 'true'
    +
       // Initialize serovalPlugins lazily (cached at module level)
       if (!serovalPlugins) {
         serovalPlugins = getDefaultSerovalPlugins()
    @@ -68,7 +73,7 @@ export const handleServerAction = async ({
     
       const response = await (async () => {
         try {
    -      const result = await (async () => {
    +      let res = await (async () => {
             // FormData
             if (
               FORM_DATA_CONTENT_TYPES.some(
    @@ -133,99 +138,159 @@ export const handleServerAction = async ({
             return await action(payload, signal)
           })()
     
    -      // Any time we get a Response back, we should just
    -      // return it immediately.
    -      if (result.result instanceof Response) {
    -        result.result.headers.set(X_TSS_RAW_RESPONSE, 'true')
    -        return result.result
    -      }
    +      const isCtxResult =
    +        isPlainObject(res) &&
    +        'context' in res &&
    +        ('result' in res || 'error' in res)
    +
    +      console.log(
    +        {
    +          isServerFn,
    +          isCreateServerFn,
    +          isCtxResult,
    +        },
    +        res,
    +      )
     
    -      if (isNotFound(result)) {
    -        return isNotFoundResponse(result)
    +      function unwrapResultOrError(result: any) {
    +        if (
    +          isPlainObject(result) &&
    +          ('result' in result || 'error' in result)
    +        ) {
    +          console.log('tanner')
    +          return result.result || result.error
    +        }
    +        return result
           }
     
    -      const response = getResponse()
    -      let nonStreamingBody: any = undefined
    -
    -      if (result !== undefined) {
    -        // first run without the stream in case `result` does not need streaming
    -        let done = false as boolean
    -        const callbacks: {
    -          onParse: (value: any) => void
    -          onDone: () => void
    -          onError: (error: any) => void
    -        } = {
    -          onParse: (value) => {
    -            nonStreamingBody = value
    -          },
    -          onDone: () => {
    -            done = true
    -          },
    -          onError: (error) => {
    -            throw error
    -          },
    +      // This was not called by the serverFnFetcher, so it's likely a no-JS POST request)
    +      if (isCtxResult) {
    +        const unwrapped = unwrapResultOrError(res)
    +        if (unwrapped instanceof Response) {
    +          res = unwrapped
    +        } else {
    +          res = json(unwrapped)
             }
    -        toCrossJSONStream(result, {
    -          refs: new Map(),
    -          plugins: serovalPlugins,
    -          onParse(value) {
    -            callbacks.onParse(value)
    -          },
    -          onDone() {
    -            callbacks.onDone()
    -          },
    -          onError: (error) => {
    -            callbacks.onError(error)
    -          },
    -        })
    -        if (done) {
    -          return new Response(
    -            nonStreamingBody ? JSON.stringify(nonStreamingBody) : undefined,
    -            {
    -              status: response.status,
    -              statusText: response.statusText,
    -              headers: {
    -                'Content-Type': 'application/json',
    -                [X_TSS_SERIALIZED]: 'true',
    +      }
    +
    +      if (isNotFound(res)) {
    +        res = isNotFoundResponse(res)
    +      }
    +
    +      if (!isServerFn) {
    +        return res
    +      }
    +
    +      if (res instanceof Response) {
    +        res.headers.set(X_TSS_RAW_RESPONSE, 'true')
    +        return res
    +      }
    +
    +      // TODO: RSCs Where are we getting this package?
    +      // if (isValidElement(result)) {
    +      //   const { renderToPipeableStream } = await import(
    +      //     // @ts-expect-error
    +      //     'react-server-dom/server'
    +      //   )
    +
    +      //   const pipeableStream = renderToPipeableStream(result)
    +
    +      //   setHeaders(event, {
    +      //     'Content-Type': 'text/x-component',
    +      //   } as any)
    +
    +      //   sendStream(event, response)
    +      //   event._handled = true
    +
    +      //   return new Response(null, { status: 200 })
    +      // }
    +
    +      return serializeResult(res)
    +
    +      function serializeResult(res: unknown): Response {
    +        let nonStreamingBody: any = undefined
    +
    +        const alsResponse = getResponse()
    +        if (res !== undefined) {
    +          // first run without the stream in case `result` does not need streaming
    +          let done = false as boolean
    +          const callbacks: {
    +            onParse: (value: any) => void
    +            onDone: () => void
    +            onError: (error: any) => void
    +          } = {
    +            onParse: (value) => {
    +              nonStreamingBody = value
    +            },
    +            onDone: () => {
    +              done = true
    +            },
    +            onError: (error) => {
    +              throw error
    +            },
    +          }
    +          toCrossJSONStream(res, {
    +            refs: new Map(),
    +            plugins: serovalPlugins,
    +            onParse(value) {
    +              callbacks.onParse(value)
    +            },
    +            onDone() {
    +              callbacks.onDone()
    +            },
    +            onError: (error) => {
    +              callbacks.onError(error)
    +            },
    +          })
    +          if (done) {
    +            return new Response(
    +              nonStreamingBody ? JSON.stringify(nonStreamingBody) : undefined,
    +              {
    +                status: alsResponse.status,
    +                statusText: alsResponse.statusText,
    +                headers: {
    +                  'Content-Type': 'application/json',
    +                  [X_TSS_SERIALIZED]: 'true',
    +                },
                   },
    +            )
    +          }
    +
    +          // not done yet, we need to stream
    +          const encoder = new TextEncoder()
    +          const stream = new ReadableStream({
    +            start(controller) {
    +              callbacks.onParse = (value) =>
    +                controller.enqueue(encoder.encode(JSON.stringify(value) + '\n'))
    +              callbacks.onDone = () => {
    +                try {
    +                  controller.close()
    +                } catch (error) {
    +                  controller.error(error)
    +                }
    +              }
    +              callbacks.onError = (error) => controller.error(error)
    +              // stream the initial body
    +              if (nonStreamingBody !== undefined) {
    +                callbacks.onParse(nonStreamingBody)
    +              }
                 },
    -          )
    +          })
    +          return new Response(stream, {
    +            status: alsResponse.status,
    +            statusText: alsResponse.statusText,
    +            headers: {
    +              'Content-Type': 'application/x-ndjson',
    +              [X_TSS_SERIALIZED]: 'true',
    +            },
    +          })
             }
     
    -        // not done yet, we need to stream
    -        const encoder = new TextEncoder()
    -        const stream = new ReadableStream({
    -          start(controller) {
    -            callbacks.onParse = (value) =>
    -              controller.enqueue(encoder.encode(JSON.stringify(value) + '\n'))
    -            callbacks.onDone = () => {
    -              try {
    -                controller.close()
    -              } catch (error) {
    -                controller.error(error)
    -              }
    -            }
    -            callbacks.onError = (error) => controller.error(error)
    -            // stream the initial body
    -            if (nonStreamingBody !== undefined) {
    -              callbacks.onParse(nonStreamingBody)
    -            }
    -          },
    -        })
    -        return new Response(stream, {
    -          status: response.status,
    -          statusText: response.statusText,
    -          headers: {
    -            'Content-Type': 'application/x-ndjson',
    -            [X_TSS_SERIALIZED]: 'true',
    -          },
    +        return new Response(undefined, {
    +          status: alsResponse.status,
    +          statusText: alsResponse.statusText,
             })
           }
    -
    -      return new Response(undefined, {
    -        status: response.status,
    -        statusText: response.statusText,
    -      })
         } catch (error: any) {
           if (error instanceof Response) {
             return error
    
    From c1ce5865733fe062c70936bdd99004fd6d9b810f Mon Sep 17 00:00:00 2001
    From: Manuel Schiller 
    Date: Fri, 26 Dec 2025 01:52:46 +0100
    Subject: [PATCH 04/10] fix
    
    ---
     .../src/server-functions-handler.ts              | 16 +++++++++-------
     1 file changed, 9 insertions(+), 7 deletions(-)
    
    diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts
    index 5942cd0d83b..dd22fa40868 100644
    --- a/packages/start-server-core/src/server-functions-handler.ts
    +++ b/packages/start-server-core/src/server-functions-handler.ts
    @@ -1,4 +1,4 @@
    -import { isNotFound, isPlainObject } from '@tanstack/router-core'
    +import { isNotFound, isPlainObject, isRedirect } from '@tanstack/router-core'
     import invariant from 'tiny-invariant'
     import {
       TSS_FORMDATA_CONTEXT,
    @@ -163,9 +163,8 @@ export const handleServerAction = async ({
             return result
           }
     
    -      // This was not called by the serverFnFetcher, so it's likely a no-JS POST request)
    -      if (isCtxResult) {
    -        const unwrapped = unwrapResultOrError(res)
    +      const unwrapped = unwrapResultOrError(res)
    +      if (!isCtxResult) {
             if (unwrapped instanceof Response) {
               res = unwrapped
             } else {
    @@ -181,9 +180,12 @@ export const handleServerAction = async ({
             return res
           }
     
    -      if (res instanceof Response) {
    -        res.headers.set(X_TSS_RAW_RESPONSE, 'true')
    -        return res
    +      if (unwrapped instanceof Response) {
    +        if (isRedirect(unwrapped)) {
    +          return unwrapped
    +        }
    +        unwrapped.headers.set(X_TSS_RAW_RESPONSE, 'true')
    +        return unwrapped
           }
     
           // TODO: RSCs Where are we getting this package?
    
    From f349038b15d5a99952b39b8a601305056db33edf Mon Sep 17 00:00:00 2001
    From: Manuel Schiller 
    Date: Fri, 26 Dec 2025 02:59:51 +0100
    Subject: [PATCH 05/10] wip
    
    ---
     .../src/client-rpc/serverFnFetcher.ts         |  1 -
     .../src/createStartHandler.ts                 |  4 +-
     .../src/server-functions-handler.ts           | 60 +------------------
     3 files changed, 5 insertions(+), 60 deletions(-)
    
    diff --git a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts
    index c02ea30f4f4..6e54793d863 100644
    --- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts
    +++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts
    @@ -53,7 +53,6 @@ export async function serverFnFetcher(
       // Arrange the headers
       const headers = first.headers ? new Headers(first.headers) : new Headers()
       headers.set('x-tsr-serverFn', 'true')
    -  headers.set('x-tsr-createServerFn', 'true')
     
       if (type === 'payload') {
         headers.set('accept', 'application/x-ndjson, application/json')
    diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts
    index cb66999f085..7c6bbc868b8 100644
    --- a/packages/start-server-core/src/createStartHandler.ts
    +++ b/packages/start-server-core/src/createStartHandler.ts
    @@ -253,7 +253,7 @@ export function createStartHandler(
     
           if (isRedirect(response)) {
             if (isResolvedRedirect(response)) {
    -          if (request.headers.get('x-tsr-createServerFn') === 'true') {
    +          if (request.headers.get('x-tsr-serverFn') === 'true') {
                 return Response.json(
                   {
                     ...response.options,
    @@ -294,7 +294,7 @@ export function createStartHandler(
             const router = await getRouter()
             const redirect = router.resolveRedirect(response)
     
    -        if (request.headers.get('x-tsr-createServerFn') === 'true') {
    +        if (request.headers.get('x-tsr-serverFn') === 'true') {
               return Response.json(
                 {
                   ...response.options,
    diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts
    index dd22fa40868..4571a0fe18d 100644
    --- a/packages/start-server-core/src/server-functions-handler.ts
    +++ b/packages/start-server-core/src/server-functions-handler.ts
    @@ -1,11 +1,10 @@
    -import { isNotFound, isPlainObject, isRedirect } from '@tanstack/router-core'
    +import { isNotFound, isRedirect } from '@tanstack/router-core'
     import invariant from 'tiny-invariant'
     import {
       TSS_FORMDATA_CONTEXT,
       X_TSS_RAW_RESPONSE,
       X_TSS_SERIALIZED,
       getDefaultSerovalPlugins,
    -  json,
     } from '@tanstack/start-client-core'
     import { fromJSON, toCrossJSONAsync, toCrossJSONStream } from 'seroval'
     import { getResponse } from './request-response'
    @@ -56,8 +55,6 @@ export const handleServerAction = async ({
       const action = await getServerFnById(serverFnId, { fromClient: true })
     
       const isServerFn = request.headers.get('x-tsr-serverFn') === 'true'
    -  const isCreateServerFn =
    -    request.headers.get('x-tsr-createServerFn') === 'true'
     
       // Initialize serovalPlugins lazily (cached at module level)
       if (!serovalPlugins) {
    @@ -138,46 +135,14 @@ export const handleServerAction = async ({
             return await action(payload, signal)
           })()
     
    -      const isCtxResult =
    -        isPlainObject(res) &&
    -        'context' in res &&
    -        ('result' in res || 'error' in res)
    -
    -      console.log(
    -        {
    -          isServerFn,
    -          isCreateServerFn,
    -          isCtxResult,
    -        },
    -        res,
    -      )
    -
    -      function unwrapResultOrError(result: any) {
    -        if (
    -          isPlainObject(result) &&
    -          ('result' in result || 'error' in result)
    -        ) {
    -          console.log('tanner')
    -          return result.result || result.error
    -        }
    -        return result
    -      }
    -
    -      const unwrapped = unwrapResultOrError(res)
    -      if (!isCtxResult) {
    -        if (unwrapped instanceof Response) {
    -          res = unwrapped
    -        } else {
    -          res = json(unwrapped)
    -        }
    -      }
    +      const unwrapped = res.result || res.error
     
           if (isNotFound(res)) {
             res = isNotFoundResponse(res)
           }
     
           if (!isServerFn) {
    -        return res
    +        return unwrapped
           }
     
           if (unwrapped instanceof Response) {
    @@ -188,25 +153,6 @@ export const handleServerAction = async ({
             return unwrapped
           }
     
    -      // TODO: RSCs Where are we getting this package?
    -      // if (isValidElement(result)) {
    -      //   const { renderToPipeableStream } = await import(
    -      //     // @ts-expect-error
    -      //     'react-server-dom/server'
    -      //   )
    -
    -      //   const pipeableStream = renderToPipeableStream(result)
    -
    -      //   setHeaders(event, {
    -      //     'Content-Type': 'text/x-component',
    -      //   } as any)
    -
    -      //   sendStream(event, response)
    -      //   event._handled = true
    -
    -      //   return new Response(null, { status: 200 })
    -      // }
    -
           return serializeResult(res)
     
           function serializeResult(res: unknown): Response {
    
    From 7a4350a6c4d433b0db95e12eed1560f5404ce1ce Mon Sep 17 00:00:00 2001
    From: Manuel Schiller 
    Date: Fri, 26 Dec 2025 03:00:15 +0100
    Subject: [PATCH 06/10] no regex for server fn id parsing
    
    ---
     .../src/createStartHandler.ts                 | 15 +++++++++++++-
     .../src/server-functions-handler.ts           | 20 +++----------------
     2 files changed, 17 insertions(+), 18 deletions(-)
    
    diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts
    index 7c6bbc868b8..fb07cf3986d 100644
    --- a/packages/start-server-core/src/createStartHandler.ts
    +++ b/packages/start-server-core/src/createStartHandler.ts
    @@ -148,10 +148,23 @@ export function createStartHandler(
                 async () => {
                   try {
                     // First, let's attempt to handle server functions
    -                if (href.startsWith(process.env.TSS_SERVER_FN_BASE)) {
    +                // Extract the serverFnId once here and pass it through.
    +                if (url.pathname.startsWith(process.env.TSS_SERVER_FN_BASE)) {
    +                  const base = process.env.TSS_SERVER_FN_BASE
    +                  const serverFnId = url.pathname
    +                    .slice(base.length)
    +                    .split('/')[0]
    +
    +                  if (!serverFnId) {
    +                    throw new Error(
    +                      'Invalid server action param for serverFnId',
    +                    )
    +                  }
    +
                       return await handleServerAction({
                         request,
                         context: requestOpts?.context,
    +                    serverFnId,
                       })
                     }
     
    diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts
    index 4571a0fe18d..8fc82006cf3 100644
    --- a/packages/start-server-core/src/server-functions-handler.ts
    +++ b/packages/start-server-core/src/server-functions-handler.ts
    @@ -11,8 +11,6 @@ import { getResponse } from './request-response'
     import { getServerFnById } from './getServerFnById'
     import type { Plugin as SerovalPlugin } from 'seroval'
     
    -let regex: RegExp | undefined = undefined
    -
     // Cache serovalPlugins at module level to avoid repeated calls
     let serovalPlugins: Array> | undefined = undefined
     
    @@ -25,32 +23,20 @@ const FORM_DATA_CONTENT_TYPES = [
     export const handleServerAction = async ({
       request,
       context,
    +  serverFnId,
     }: {
       request: Request
       context: any
    +  serverFnId: string
     }) => {
       const controller = new AbortController()
       const signal = controller.signal
       const abort = () => controller.abort()
       request.signal.addEventListener('abort', abort)
     
    -  if (regex === undefined) {
    -    regex = new RegExp(`${process.env.TSS_SERVER_FN_BASE}([^/?#]+)`)
    -  }
    -
       const method = request.method
       const methodLower = method.toLowerCase()
    -  const url = new URL(request.url, 'http://localhost:3000')
    -  // extract the serverFnId from the url as host/_serverFn/:serverFnId
    -  // Define a regex to match the path and extract the :thing part
    -
    -  // Execute the regex
    -  const match = url.pathname.match(regex)
    -  const serverFnId = match ? match[1] : null
    -
    -  if (typeof serverFnId !== 'string') {
    -    throw new Error('Invalid server action param for serverFnId: ' + serverFnId)
    -  }
    +  const url = new URL(request.url)
     
       const action = await getServerFnById(serverFnId, { fromClient: true })
     
    
    From af16683ddcd93bcd5e5c22b10ab66a4424c71406 Mon Sep 17 00:00:00 2001
    From: Manuel Schiller 
    Date: Fri, 26 Dec 2025 03:12:51 +0100
    Subject: [PATCH 07/10] add e2e tests
    
    ---
     .../server-functions/src/routeTree.gen.ts     | 46 +++++++++++++
     .../src/routes/middleware/index.tsx           |  8 +++
     .../redirect-with-middleware/index.tsx        | 65 +++++++++++++++++++
     .../redirect-with-middleware/target.tsx       | 20 ++++++
     .../routes/middleware/unhandled-exception.tsx | 36 +++++-----
     .../tests/server-functions.spec.ts            | 45 +++++++++++++
     6 files changed, 205 insertions(+), 15 deletions(-)
     create mode 100644 e2e/react-start/server-functions/src/routes/middleware/redirect-with-middleware/index.tsx
     create mode 100644 e2e/react-start/server-functions/src/routes/middleware/redirect-with-middleware/target.tsx
    
    diff --git a/e2e/react-start/server-functions/src/routeTree.gen.ts b/e2e/react-start/server-functions/src/routeTree.gen.ts
    index 92cffa88efd..89acfa559cd 100644
    --- a/e2e/react-start/server-functions/src/routeTree.gen.ts
    +++ b/e2e/react-start/server-functions/src/routeTree.gen.ts
    @@ -42,6 +42,8 @@ import { Route as MiddlewareMiddlewareFactoryRouteImport } from './routes/middle
     import { Route as MiddlewareClientMiddlewareRouterRouteImport } from './routes/middleware/client-middleware-router'
     import { Route as CookiesSetRouteImport } from './routes/cookies/set'
     import { Route as AbortSignalMethodRouteImport } from './routes/abort-signal/$method'
    +import { Route as MiddlewareRedirectWithMiddlewareIndexRouteImport } from './routes/middleware/redirect-with-middleware/index'
    +import { Route as MiddlewareRedirectWithMiddlewareTargetRouteImport } from './routes/middleware/redirect-with-middleware/target'
     import { Route as FormdataRedirectTargetNameRouteImport } from './routes/formdata-redirect/target.$name'
     
     const SubmitPostFormdataRoute = SubmitPostFormdataRouteImport.update({
    @@ -214,6 +216,18 @@ const AbortSignalMethodRoute = AbortSignalMethodRouteImport.update({
       path: '/abort-signal/$method',
       getParentRoute: () => rootRouteImport,
     } as any)
    +const MiddlewareRedirectWithMiddlewareIndexRoute =
    +  MiddlewareRedirectWithMiddlewareIndexRouteImport.update({
    +    id: '/middleware/redirect-with-middleware/',
    +    path: '/middleware/redirect-with-middleware/',
    +    getParentRoute: () => rootRouteImport,
    +  } as any)
    +const MiddlewareRedirectWithMiddlewareTargetRoute =
    +  MiddlewareRedirectWithMiddlewareTargetRouteImport.update({
    +    id: '/middleware/redirect-with-middleware/target',
    +    path: '/middleware/redirect-with-middleware/target',
    +    getParentRoute: () => rootRouteImport,
    +  } as any)
     const FormdataRedirectTargetNameRoute =
       FormdataRedirectTargetNameRouteImport.update({
         id: '/formdata-redirect/target/$name',
    @@ -256,6 +270,8 @@ export interface FileRoutesByFullPath {
       '/redirect-test-ssr': typeof RedirectTestSsrIndexRoute
       '/redirect-test': typeof RedirectTestIndexRoute
       '/formdata-redirect/target/$name': typeof FormdataRedirectTargetNameRoute
    +  '/middleware/redirect-with-middleware/target': typeof MiddlewareRedirectWithMiddlewareTargetRoute
    +  '/middleware/redirect-with-middleware': typeof MiddlewareRedirectWithMiddlewareIndexRoute
     }
     export interface FileRoutesByTo {
       '/': typeof IndexRoute
    @@ -292,6 +308,8 @@ export interface FileRoutesByTo {
       '/redirect-test-ssr': typeof RedirectTestSsrIndexRoute
       '/redirect-test': typeof RedirectTestIndexRoute
       '/formdata-redirect/target/$name': typeof FormdataRedirectTargetNameRoute
    +  '/middleware/redirect-with-middleware/target': typeof MiddlewareRedirectWithMiddlewareTargetRoute
    +  '/middleware/redirect-with-middleware': typeof MiddlewareRedirectWithMiddlewareIndexRoute
     }
     export interface FileRoutesById {
       __root__: typeof rootRouteImport
    @@ -329,6 +347,8 @@ export interface FileRoutesById {
       '/redirect-test-ssr/': typeof RedirectTestSsrIndexRoute
       '/redirect-test/': typeof RedirectTestIndexRoute
       '/formdata-redirect/target/$name': typeof FormdataRedirectTargetNameRoute
    +  '/middleware/redirect-with-middleware/target': typeof MiddlewareRedirectWithMiddlewareTargetRoute
    +  '/middleware/redirect-with-middleware/': typeof MiddlewareRedirectWithMiddlewareIndexRoute
     }
     export interface FileRouteTypes {
       fileRoutesByFullPath: FileRoutesByFullPath
    @@ -367,6 +387,8 @@ export interface FileRouteTypes {
         | '/redirect-test-ssr'
         | '/redirect-test'
         | '/formdata-redirect/target/$name'
    +    | '/middleware/redirect-with-middleware/target'
    +    | '/middleware/redirect-with-middleware'
       fileRoutesByTo: FileRoutesByTo
       to:
         | '/'
    @@ -403,6 +425,8 @@ export interface FileRouteTypes {
         | '/redirect-test-ssr'
         | '/redirect-test'
         | '/formdata-redirect/target/$name'
    +    | '/middleware/redirect-with-middleware/target'
    +    | '/middleware/redirect-with-middleware'
       id:
         | '__root__'
         | '/'
    @@ -439,6 +463,8 @@ export interface FileRouteTypes {
         | '/redirect-test-ssr/'
         | '/redirect-test/'
         | '/formdata-redirect/target/$name'
    +    | '/middleware/redirect-with-middleware/target'
    +    | '/middleware/redirect-with-middleware/'
       fileRoutesById: FileRoutesById
     }
     export interface RootRouteChildren {
    @@ -476,6 +502,8 @@ export interface RootRouteChildren {
       RedirectTestSsrIndexRoute: typeof RedirectTestSsrIndexRoute
       RedirectTestIndexRoute: typeof RedirectTestIndexRoute
       FormdataRedirectTargetNameRoute: typeof FormdataRedirectTargetNameRoute
    +  MiddlewareRedirectWithMiddlewareTargetRoute: typeof MiddlewareRedirectWithMiddlewareTargetRoute
    +  MiddlewareRedirectWithMiddlewareIndexRoute: typeof MiddlewareRedirectWithMiddlewareIndexRoute
     }
     
     declare module '@tanstack/react-router' {
    @@ -711,6 +739,20 @@ declare module '@tanstack/react-router' {
           preLoaderRoute: typeof AbortSignalMethodRouteImport
           parentRoute: typeof rootRouteImport
         }
    +    '/middleware/redirect-with-middleware/': {
    +      id: '/middleware/redirect-with-middleware/'
    +      path: '/middleware/redirect-with-middleware'
    +      fullPath: '/middleware/redirect-with-middleware'
    +      preLoaderRoute: typeof MiddlewareRedirectWithMiddlewareIndexRouteImport
    +      parentRoute: typeof rootRouteImport
    +    }
    +    '/middleware/redirect-with-middleware/target': {
    +      id: '/middleware/redirect-with-middleware/target'
    +      path: '/middleware/redirect-with-middleware/target'
    +      fullPath: '/middleware/redirect-with-middleware/target'
    +      preLoaderRoute: typeof MiddlewareRedirectWithMiddlewareTargetRouteImport
    +      parentRoute: typeof rootRouteImport
    +    }
         '/formdata-redirect/target/$name': {
           id: '/formdata-redirect/target/$name'
           path: '/formdata-redirect/target/$name'
    @@ -756,6 +798,10 @@ const rootRouteChildren: RootRouteChildren = {
       RedirectTestSsrIndexRoute: RedirectTestSsrIndexRoute,
       RedirectTestIndexRoute: RedirectTestIndexRoute,
       FormdataRedirectTargetNameRoute: FormdataRedirectTargetNameRoute,
    +  MiddlewareRedirectWithMiddlewareTargetRoute:
    +    MiddlewareRedirectWithMiddlewareTargetRoute,
    +  MiddlewareRedirectWithMiddlewareIndexRoute:
    +    MiddlewareRedirectWithMiddlewareIndexRoute,
     }
     export const routeTree = rootRouteImport
       ._addFileChildren(rootRouteChildren)
    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 8a12ac30537..1f95f655a6a 100644
    --- a/e2e/react-start/server-functions/src/routes/middleware/index.tsx
    +++ b/e2e/react-start/server-functions/src/routes/middleware/index.tsx
    @@ -50,6 +50,14 @@ function RouteComponent() {
                 build
               
             
    +        
  • + + Redirect via server function with middleware + +
  • ) diff --git a/e2e/react-start/server-functions/src/routes/middleware/redirect-with-middleware/index.tsx b/e2e/react-start/server-functions/src/routes/middleware/redirect-with-middleware/index.tsx new file mode 100644 index 00000000000..676eefe273e --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/redirect-with-middleware/index.tsx @@ -0,0 +1,65 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' +import { + createMiddleware, + createServerFn, + useServerFn, +} from '@tanstack/react-start' +import { useState } from 'react' + +// Function middleware that adds context +// Issue #5372: Middleware causes serialization error when throwing redirects via server function +const testMiddleware = createMiddleware({ type: 'function' }).server( + async ({ next }) => { + return next({ + context: { + fromMiddleware: true, + }, + }) + }, +) + +// Server function with middleware that throws redirect +const $redirectWithMiddleware = createServerFn({ method: 'POST' }) + .middleware([testMiddleware]) + .handler(async () => { + throw redirect({ to: '/middleware/redirect-with-middleware/target' }) + }) + +export const Route = createFileRoute('/middleware/redirect-with-middleware/')({ + component: RouteComponent, +}) + +function RouteComponent() { + const redirectFn = useServerFn($redirectWithMiddleware) + const [error, setError] = useState(null) + + return ( +
    +

    Middleware Redirect Test

    +

    + This tests that redirects thrown via server functions work correctly + when function middleware is attached (Issue #5372) +

    + + {error && ( +
    + Error: {error} +
    + )} +
    + ) +} diff --git a/e2e/react-start/server-functions/src/routes/middleware/redirect-with-middleware/target.tsx b/e2e/react-start/server-functions/src/routes/middleware/redirect-with-middleware/target.tsx new file mode 100644 index 00000000000..d47cd7b70a0 --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/redirect-with-middleware/target.tsx @@ -0,0 +1,20 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute( + '/middleware/redirect-with-middleware/target', +)({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
    +

    + Redirect Target (Middleware) +

    +

    + Successfully redirected! Middleware did not cause serialization error. +

    +
    + ) +} diff --git a/e2e/react-start/server-functions/src/routes/middleware/unhandled-exception.tsx b/e2e/react-start/server-functions/src/routes/middleware/unhandled-exception.tsx index f6d3bb60179..e1fb36c589d 100644 --- a/e2e/react-start/server-functions/src/routes/middleware/unhandled-exception.tsx +++ b/e2e/react-start/server-functions/src/routes/middleware/unhandled-exception.tsx @@ -1,33 +1,39 @@ import { createFileRoute } from '@tanstack/react-router' import { createMiddleware, createServerFn } from '@tanstack/react-start' -export const authMiddleware = createMiddleware({ type: 'function' }).server( - async ({ next, context }) => { - throw new Error('Unauthorized') +// Middleware that throws an unhandled exception +// Issue #5266: Server crashes when unhandled exception in server function or middleware +const throwingMiddleware = createMiddleware({ type: 'function' }).server( + async () => { + throw new Error('Unhandled middleware exception') }, ) -const personServerFn = createServerFn({ method: 'GET' }) - .middleware([authMiddleware]) - .inputValidator((d: string) => d) - .handler(({ data: name }) => { - return { name, randomNumber: Math.floor(Math.random() * 100) } +const serverFnWithThrowingMiddleware = createServerFn({ method: 'GET' }) + .middleware([throwingMiddleware]) + .handler(() => { + return { success: true } }) export const Route = createFileRoute('/middleware/unhandled-exception')({ loader: async () => { return { - person: await personServerFn({ data: 'John Doe' }), + result: await serverFnWithThrowingMiddleware(), } }, + errorComponent: ({ error }) => { + return ( +
    +

    Error Caught

    +

    + {error instanceof Error ? error.message : 'Unknown error'} +

    +
    + ) + }, component: RouteComponent, }) function RouteComponent() { - const { person } = Route.useLoaderData() - return ( -
    - {person.name} - {person.randomNumber} -
    - ) + return
    Should not render
    } 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 a428f5640ea..52f8726f091 100644 --- a/e2e/react-start/server-functions/tests/server-functions.spec.ts +++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts @@ -664,3 +664,48 @@ test('middleware factories with server-only imports are stripped from client bui 'x-factory-two', ) }) + +test('redirect via server function with middleware does not cause serialization error (issue #5372)', async ({ + page, +}) => { + // This test verifies that throwing a redirect from a server function + // that has middleware attached does not cause a SerovalUnsupportedTypeError. + // Issue #5372: Middleware causes serialization error when throwing redirects via server function + await page.goto('/middleware/redirect-with-middleware') + + await page.waitForLoadState('networkidle') + + // Verify we're on the source page + await expect(page.getByTestId('middleware-redirect-source')).toBeVisible() + + // Click the button to trigger the redirect via server function with middleware + await page.getByTestId('trigger-redirect-btn').click() + + // Should redirect to target page without serialization error + await expect(page.getByTestId('middleware-redirect-target')).toBeVisible() + expect(page.url()).toContain('/middleware/redirect-with-middleware/target') + + // Verify no error was shown (would indicate serialization failure) + await expect(page.getByTestId('error-message')).not.toBeVisible() +}) + +test.describe('unhandled exception in middleware (issue #5266)', () => { + // Whitelist the expected 500 error since this test verifies error handling + test.use({ whitelistErrors: [/500/] }) + + test('does not crash server and shows error component', async ({ page }) => { + // This test verifies that when a middleware throws an unhandled exception, + // the server does not crash and the error is properly caught and displayed. + // Issue #5266: Server crashes when unhandled exception in server function or middleware + await page.goto('/middleware/unhandled-exception') + + // The error should be caught and displayed via errorComponent + await expect(page.getByTestId('unhandled-exception-error')).toBeVisible() + + // Verify the error message is shown + await expect(page.getByTestId('error-message')).toBeVisible() + + // The route component should NOT render since error was thrown + await expect(page.getByTestId('route-success')).not.toBeVisible() + }) +}) From 8285a37604b063ce2004fd6128a11fabe2c7abad Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Fri, 26 Dec 2025 03:47:14 +0100 Subject: [PATCH 08/10] fix: dedupe global request middleware w.r.t. server functions --- .../.gitignore | 6 + .../package.json | 34 ++ .../playwright.config.ts | 35 ++ .../src/routeTree.gen.ts | 105 +++++ .../src/router.tsx | 12 + .../src/routes/__root.tsx | 52 +++ .../src/routes/index.tsx | 32 ++ .../src/routes/multiple-server-functions.tsx | 374 ++++++++++++++++++ .../src/routes/simple.tsx | 125 ++++++ .../src/start.ts | 83 ++++ .../src/styles/app.css | 1 + .../tests/global-middleware.spec.ts | 131 ++++++ .../tsconfig.json | 23 ++ .../vite.config.ts | 16 + .../start-client-core/src/createServerFn.ts | 13 +- .../src/createStartHandler.ts | 17 +- .../src/async-local-storage.ts | 3 + pnpm-lock.yaml | 76 +++- 18 files changed, 1121 insertions(+), 17 deletions(-) create mode 100644 e2e/react-start/server-functions-global-middleware/.gitignore create mode 100644 e2e/react-start/server-functions-global-middleware/package.json create mode 100644 e2e/react-start/server-functions-global-middleware/playwright.config.ts create mode 100644 e2e/react-start/server-functions-global-middleware/src/routeTree.gen.ts create mode 100644 e2e/react-start/server-functions-global-middleware/src/router.tsx create mode 100644 e2e/react-start/server-functions-global-middleware/src/routes/__root.tsx create mode 100644 e2e/react-start/server-functions-global-middleware/src/routes/index.tsx create mode 100644 e2e/react-start/server-functions-global-middleware/src/routes/multiple-server-functions.tsx create mode 100644 e2e/react-start/server-functions-global-middleware/src/routes/simple.tsx create mode 100644 e2e/react-start/server-functions-global-middleware/src/start.ts create mode 100644 e2e/react-start/server-functions-global-middleware/src/styles/app.css create mode 100644 e2e/react-start/server-functions-global-middleware/tests/global-middleware.spec.ts create mode 100644 e2e/react-start/server-functions-global-middleware/tsconfig.json create mode 100644 e2e/react-start/server-functions-global-middleware/vite.config.ts diff --git a/e2e/react-start/server-functions-global-middleware/.gitignore b/e2e/react-start/server-functions-global-middleware/.gitignore new file mode 100644 index 00000000000..ea6591949b9 --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.nitro +.output +port*.txt +test-results diff --git a/e2e/react-start/server-functions-global-middleware/package.json b/e2e/react-start/server-functions-global-middleware/package.json new file mode 100644 index 00000000000..8060481fad0 --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/package.json @@ -0,0 +1,34 @@ +{ + "name": "tanstack-react-start-e2e-server-functions-global-middleware", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "pnpx srvx --prod -s ../client dist/server/server.js", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-start": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^7.1.7" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tailwindcss/vite": "^4.1.18", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "srvx": "^0.9.8", + "tailwindcss": "^4.1.18", + "typescript": "^5.7.2", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/e2e/react-start/server-functions-global-middleware/playwright.config.ts b/e2e/react-start/server-functions-global-middleware/playwright.config.ts new file mode 100644 index 00000000000..8330e023eea --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +export const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + workers: 1, + + reporter: [['line']], + + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL, + }, + + webServer: { + command: `VITE_SERVER_PORT=${PORT} pnpm build && PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-start/server-functions-global-middleware/src/routeTree.gen.ts b/e2e/react-start/server-functions-global-middleware/src/routeTree.gen.ts new file mode 100644 index 00000000000..bcc17d676ef --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/src/routeTree.gen.ts @@ -0,0 +1,105 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as SimpleRouteImport } from './routes/simple' +import { Route as MultipleServerFunctionsRouteImport } from './routes/multiple-server-functions' +import { Route as IndexRouteImport } from './routes/index' + +const SimpleRoute = SimpleRouteImport.update({ + id: '/simple', + path: '/simple', + getParentRoute: () => rootRouteImport, +} as any) +const MultipleServerFunctionsRoute = MultipleServerFunctionsRouteImport.update({ + id: '/multiple-server-functions', + path: '/multiple-server-functions', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/multiple-server-functions': typeof MultipleServerFunctionsRoute + '/simple': typeof SimpleRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/multiple-server-functions': typeof MultipleServerFunctionsRoute + '/simple': typeof SimpleRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/multiple-server-functions': typeof MultipleServerFunctionsRoute + '/simple': typeof SimpleRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/multiple-server-functions' | '/simple' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/multiple-server-functions' | '/simple' + id: '__root__' | '/' | '/multiple-server-functions' | '/simple' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + MultipleServerFunctionsRoute: typeof MultipleServerFunctionsRoute + SimpleRoute: typeof SimpleRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/simple': { + id: '/simple' + path: '/simple' + fullPath: '/simple' + preLoaderRoute: typeof SimpleRouteImport + parentRoute: typeof rootRouteImport + } + '/multiple-server-functions': { + id: '/multiple-server-functions' + path: '/multiple-server-functions' + fullPath: '/multiple-server-functions' + preLoaderRoute: typeof MultipleServerFunctionsRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + MultipleServerFunctionsRoute: MultipleServerFunctionsRoute, + SimpleRoute: SimpleRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { startInstance } from './start.ts' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + config: Awaited> + } +} diff --git a/e2e/react-start/server-functions-global-middleware/src/router.tsx b/e2e/react-start/server-functions-global-middleware/src/router.tsx new file mode 100644 index 00000000000..6ee5705ca9b --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/src/router.tsx @@ -0,0 +1,12 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + scrollRestoration: true, + }) + + return router +} diff --git a/e2e/react-start/server-functions-global-middleware/src/routes/__root.tsx b/e2e/react-start/server-functions-global-middleware/src/routes/__root.tsx new file mode 100644 index 00000000000..de9dedae745 --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/src/routes/__root.tsx @@ -0,0 +1,52 @@ +/// +import * as React from 'react' +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' +import appCss from '~/styles/app.css?url' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { + charSet: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + ], + links: [{ rel: 'stylesheet', href: appCss }], + }), + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + +
    + + Home + +
    +
    + + + + + ) +} diff --git a/e2e/react-start/server-functions-global-middleware/src/routes/index.tsx b/e2e/react-start/server-functions-global-middleware/src/routes/index.tsx new file mode 100644 index 00000000000..509affdf046 --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/src/routes/index.tsx @@ -0,0 +1,32 @@ +import { Link, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
    +

    + Global Middleware Deduplication E2E Tests +

    +

    + Tests for issue #5239: global request middleware is executed multiple + times for single request +

    +
      +
    • + + Simple test - single server function with global middleware + +
    • +
    • + + Complex test - multiple server functions in loader with shared + global middleware + +
    • +
    +
    + ) +} diff --git a/e2e/react-start/server-functions-global-middleware/src/routes/multiple-server-functions.tsx b/e2e/react-start/server-functions-global-middleware/src/routes/multiple-server-functions.tsx new file mode 100644 index 00000000000..276fd9a94c8 --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/src/routes/multiple-server-functions.tsx @@ -0,0 +1,374 @@ +import { createFileRoute, Link } from '@tanstack/react-router' +import { createServerFn, createMiddleware } from '@tanstack/react-start' +import { + globalFunctionMiddleware, + loggingMiddleware, + getMiddlewareExecutionCounts, + trackMiddlewareExecution, +} from '~/start' + +// Local middleware that should also be tracked +const localMiddleware = createMiddleware({ type: 'function' }).server( + async ({ next }) => { + trackMiddlewareExecution('localMiddleware') + return next({ + context: { + localMiddlewareExecuted: true, + }, + }) + }, +) + +// Server function 1 - uses global middleware (implicit from start.ts) +// Plus explicitly adds globalFunctionMiddleware again (should be deduped) +const serverFn1 = createServerFn() + .middleware([globalFunctionMiddleware]) + .handler(async ({ context }) => { + const counts = getMiddlewareExecutionCounts() + return { + fn: 'serverFn1', + globalMiddlewareExecuted: (context as any).globalMiddlewareExecuted, + // Return counts for this specific request + counts, + } + }) + +// Server function 2 - uses global middleware plus local middleware +const serverFn2 = createServerFn() + .middleware([globalFunctionMiddleware, localMiddleware]) + .handler(async ({ context }) => { + const counts = getMiddlewareExecutionCounts() + return { + fn: 'serverFn2', + globalMiddlewareExecuted: (context as any).globalMiddlewareExecuted, + localMiddlewareExecuted: (context as any).localMiddlewareExecuted, + counts, + } + }) + +// Server function 3 - uses loggingMiddleware (which is in requestMiddleware) +// plus local middleware +const serverFn3 = createServerFn() + .middleware([loggingMiddleware, localMiddleware]) + .handler(async ({ context }) => { + const counts = getMiddlewareExecutionCounts() + return { + fn: 'serverFn3', + loggingMiddlewareExecuted: (context as any).loggingMiddlewareExecuted, + localMiddlewareExecuted: (context as any).localMiddlewareExecuted, + counts, + } + }) + +// Final server function to get execution counts after all others have run +const getExecutionCountsFn = createServerFn().handler(async () => { + const counts = getMiddlewareExecutionCounts() + return { counts } +}) + +export const Route = createFileRoute('/multiple-server-functions')({ + loader: async () => { + // Call multiple server functions in the same request + // Each one has global middleware, but it should only execute once per function call + // The BUG is that global middleware executes multiple times per function call + const [result1, result2, result3] = await Promise.all([ + serverFn1(), + serverFn2(), + serverFn3(), + ]) + + // Get the final execution counts + const executionData = await getExecutionCountsFn() + + return { + results: [result1, result2, result3], + executionCounts: executionData.counts, + } + }, + component: MultipleServerFunctionsComponent, +}) + +function MultipleServerFunctionsComponent() { + const data = Route.useLoaderData() + + // For SSR: all functions run in same request, counts accumulate + // For client-side: each function is separate request, counts are per-request + // + // What we're testing is deduplication WITHIN each server function call: + // - loggingMiddleware is in requestMiddleware AND serverFn3 - should run once per request + // - globalFunctionMiddleware is in functionMiddleware AND explicitly added - should run once per fn + // + // For SSR mode (accumulating counts): + // - loggingMiddleware: 1 (runs in request middleware, deduped in serverFn3) + // - globalFunctionMiddleware: 4 (once per server function call) + // - globalFunctionMiddleware2: 4 (once per server function call) + // - localMiddleware: 2 (serverFn2 and serverFn3) + // + // For client-side mode: we verify each function's individual counts show deduplication worked + + const loggingCount = data.executionCounts['loggingMiddleware'] || 0 + const globalCount = data.executionCounts['globalFunctionMiddleware'] || 0 + const global2Count = data.executionCounts['globalFunctionMiddleware2'] || 0 + const localCount = data.executionCounts['localMiddleware'] || 0 + + // Detect if we're in SSR mode by checking if counts are > 1 + // (In client-side, last request is getExecutionCountsFn which only has global middlewares) + const isSSRMode = globalCount > 1 + + // Expected counts for SSR mode + const expectedLoggingCountSSR = 1 + const expectedGlobalCountSSR = 4 + const expectedGlobal2CountSSR = 4 + const expectedLocalCountSSR = 2 + + // Expected counts for client-side mode (last request only has global middlewares running once) + const expectedLoggingCountClient = 1 + const expectedGlobalCountClient = 1 + const expectedGlobal2CountClient = 1 + const expectedLocalCountClient = 0 // not in getExecutionCountsFn + + const expectedLoggingCount = isSSRMode + ? expectedLoggingCountSSR + : expectedLoggingCountClient + const expectedGlobalCount = isSSRMode + ? expectedGlobalCountSSR + : expectedGlobalCountClient + const expectedGlobal2Count = isSSRMode + ? expectedGlobal2CountSSR + : expectedGlobal2CountClient + const expectedLocalCount = isSSRMode + ? expectedLocalCountSSR + : expectedLocalCountClient + + // For client-side, also verify that individual function results show correct deduplication + // Each function should have its middlewares run exactly once PER REQUEST + // Note: In SSR mode, all functions share the same counts (accumulated), so we skip these checks + const fn1Counts = (data.results[0] as any).counts || {} + const fn2Counts = (data.results[1] as any).counts || {} + const fn3Counts = (data.results[2] as any).counts || {} + + // Per-function deduplication check - only valid for client-side mode + // In SSR, all functions run in same request, so counts accumulate + // In client-side, each function is a separate request, so counts are per-request + let perFunctionDedupeOk = true + if (!isSSRMode) { + // serverFn1: globalFunctionMiddleware=1, globalFunctionMiddleware2=1, loggingMiddleware=1 + // serverFn2: adds localMiddleware=1 + // serverFn3: loggingMiddleware=1 (deduped with request middleware), localMiddleware=1 + const fn1GlobalOk = fn1Counts['globalFunctionMiddleware'] === 1 + const fn1Global2Ok = fn1Counts['globalFunctionMiddleware2'] === 1 + const fn2LocalOk = fn2Counts['localMiddleware'] === 1 + const fn3LoggingOk = fn3Counts['loggingMiddleware'] === 1 // deduped with request middleware + + perFunctionDedupeOk = + fn1GlobalOk && fn1Global2Ok && fn2LocalOk && fn3LoggingOk + } + + const isLoggingCorrect = loggingCount === expectedLoggingCount + const isGlobalDeduped = globalCount === expectedGlobalCount + const isGlobal2Deduped = global2Count === expectedGlobal2Count + const isLocalCorrect = localCount === expectedLocalCount + + const allPassed = isSSRMode + ? isLoggingCorrect && + isGlobalDeduped && + isGlobal2Deduped && + isLocalCorrect && + perFunctionDedupeOk + : perFunctionDedupeOk // For client-side, just verify per-function deduplication + + return ( +
    +

    + Multiple Server Functions with Global Middleware (Issue #5239) +

    + +
    + + ← Back to Home + +
    + +
    + Mode: {isSSRMode ? 'SSR (direct navigation)' : 'Client-side navigation'} +
    + +
    +

    Overall Test Result:

    +
    + {allPassed + ? 'PASS: All middlewares executed correct number of times' + : 'FAIL: Middleware execution counts are incorrect'} +
    +
    + +
    +

    + Middleware Execution Counts (Final Request): +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    MiddlewareExpectedActualStatus
    loggingMiddleware + {expectedLoggingCount} + + {loggingCount} + + {isLoggingCorrect ? '✓' : '✗'} +
    + globalFunctionMiddleware + + {expectedGlobalCount} + + {globalCount} + + {isGlobalDeduped ? '✓' : '✗'} +
    + globalFunctionMiddleware2 + + {expectedGlobal2Count} + + {global2Count} + + {isGlobal2Deduped ? '✓' : '✗'} +
    localMiddleware + {expectedLocalCount} + + {localCount} + + {isLocalCorrect ? '✓' : '✗'} +
    +
    + +
    +

    Per-Function Deduplication Check:

    +
    + {isSSRMode ? ( +
    + (In SSR mode, all functions share the same request context, so + per-function checks are skipped) +
    + ) : ( + <> +
    + serverFn1 globalFunctionMiddleware=1:{' '} + {fn1Counts['globalFunctionMiddleware'] === 1 + ? '✓' + : '✗ got ' + fn1Counts['globalFunctionMiddleware']} +
    +
    + serverFn1 globalFunctionMiddleware2=1:{' '} + {fn1Counts['globalFunctionMiddleware2'] === 1 + ? '✓' + : '✗ got ' + fn1Counts['globalFunctionMiddleware2']} +
    +
    + serverFn2 localMiddleware=1:{' '} + {fn2Counts['localMiddleware'] === 1 + ? '✓' + : '✗ got ' + fn2Counts['localMiddleware']} +
    +
    + serverFn3 loggingMiddleware=1 (deduped):{' '} + {fn3Counts['loggingMiddleware'] === 1 + ? '✓' + : '✗ got ' + fn3Counts['loggingMiddleware']} +
    + + )} +
    +
    + +
    +

    Server Function Results:

    +
    +          {JSON.stringify(data.results, null, 2)}
    +        
    +
    + +
    +

    Raw Execution Counts (Final Request):

    +
    +          {JSON.stringify(data.executionCounts, null, 2)}
    +        
    +
    +
    + ) +} diff --git a/e2e/react-start/server-functions-global-middleware/src/routes/simple.tsx b/e2e/react-start/server-functions-global-middleware/src/routes/simple.tsx new file mode 100644 index 00000000000..8f75ca16613 --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/src/routes/simple.tsx @@ -0,0 +1,125 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { loggingMiddleware, getMiddlewareExecutionCounts } from '~/start' + +// This test reproduces issue #5239: +// - loggingMiddleware is registered as global REQUEST middleware in start.ts +// - The same loggingMiddleware is also attached to this server function +// - The bug is that it executes multiple times instead of being deduped + +// Simple server function that uses loggingMiddleware +// loggingMiddleware is already in requestMiddleware in start.ts +// If we also attach it here, it should be deduped and run only once per context +const simpleServerFn = createServerFn() + .middleware([loggingMiddleware]) // Same middleware as in requestMiddleware - should be deduped + .handler(async ({ context }) => { + const counts = getMiddlewareExecutionCounts() + + return { + success: true, + loggingMiddlewareExecuted: (context as any).loggingMiddlewareExecuted, + globalMiddlewareExecuted: (context as any).globalMiddlewareExecuted, + globalMiddleware2Executed: (context as any).globalMiddleware2Executed, + middlewareExecutionCounts: counts, + } + }) + +export const Route = createFileRoute('/simple')({ + loader: async () => { + const result = await simpleServerFn() + return result + }, + component: SimpleComponent, +}) + +function SimpleComponent() { + const data = Route.useLoaderData() + + // Check loggingMiddleware count + // loggingMiddleware is in BOTH requestMiddleware AND server function middleware + // With proper deduplication, it should only run once total for this request + const loggingCount = data.middlewareExecutionCounts['loggingMiddleware'] || 0 + const globalCount = + data.middlewareExecutionCounts['globalFunctionMiddleware'] || 0 + const global2Count = + data.middlewareExecutionCounts['globalFunctionMiddleware2'] || 0 + + // Expected: loggingMiddleware runs once (deduped across request and function middleware) + // globalFunctionMiddleware and globalFunctionMiddleware2 each run once + const expectedLoggingCount = 1 // Should be deduped + const expectedGlobalCount = 1 + const expectedGlobal2Count = 1 + + const isDeduped = + loggingCount === expectedLoggingCount && + globalCount === expectedGlobalCount && + global2Count === expectedGlobal2Count + + return ( +
    +

    + Simple Global Middleware Test (Issue #5239) +

    + +
    +

    Test Result:

    +
    + {isDeduped + ? 'PASS: Middleware was deduped correctly' + : 'FAIL: Middleware executed multiple times'} +
    +
    + +
    +

    Middleware Execution Counts:

    +
    +          {JSON.stringify(data.middlewareExecutionCounts, null, 2)}
    +        
    +
    + +
    +

    Context Values:

    +
    + loggingMiddlewareExecuted: + + {String(data.loggingMiddlewareExecuted)} + +
    +
    + globalMiddlewareExecuted: + + {String(data.globalMiddlewareExecuted)} + +
    +
    + globalMiddleware2Executed: + + {String(data.globalMiddleware2Executed)} + +
    +
    + +
    +

    + Logging Middleware Count: {loggingCount} +

    +

    Global Middleware Count: {globalCount}

    +

    + Global Middleware 2 Count: {global2Count} +

    +
    + + {/* Hidden test values for e2e */} +
    + {expectedLoggingCount} + {expectedGlobalCount} + + {expectedGlobal2Count} + +
    +
    + ) +} diff --git a/e2e/react-start/server-functions-global-middleware/src/start.ts b/e2e/react-start/server-functions-global-middleware/src/start.ts new file mode 100644 index 00000000000..474968298d6 --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/src/start.ts @@ -0,0 +1,83 @@ +import { + createStart, + createMiddleware, + createServerOnlyFn, +} from '@tanstack/react-start' +import { getRequest } from '@tanstack/react-start/server' + +// Use a WeakMap keyed by Request object for request-scoped tracking +// This is cleaner than global state and automatically garbage collects +const requestMiddlewareCounts = new WeakMap>() + +// Helper to track middleware execution - attaches to the Request object +// This is server-only since it uses getRequest() +export const trackMiddlewareExecution = createServerOnlyFn( + (middlewareName: string) => { + const request = getRequest() + let counts = requestMiddlewareCounts.get(request) + if (!counts) { + counts = {} + requestMiddlewareCounts.set(request, counts) + } + counts[middlewareName] = (counts[middlewareName] || 0) + 1 + console.log( + `[MIDDLEWARE] ${middlewareName} executed. Count: ${counts[middlewareName]}`, + ) + return counts[middlewareName] + }, +) + +// Helper to get execution counts for the current request +// Wrapped in createServerOnlyFn since it uses getRequest() +export const getMiddlewareExecutionCounts = createServerOnlyFn( + (): Record => { + const request = getRequest() + return requestMiddlewareCounts.get(request) || {} + }, +) + +// This is the middleware from issue #5239 - it's registered as BOTH: +// 1. Global request middleware (in startInstance) +// 2. Server function middleware (attached to individual server functions) +// The bug is that it executes multiple times instead of being deduped +export const loggingMiddleware = createMiddleware().server(async ({ next }) => { + trackMiddlewareExecution('loggingMiddleware') + return next({ + context: { + loggingMiddlewareExecuted: true, + }, + }) +}) + +// Global function middleware that should be deduped across server functions +export const globalFunctionMiddleware = createMiddleware({ + type: 'function', +}).server(async ({ next }) => { + trackMiddlewareExecution('globalFunctionMiddleware') + return next({ + context: { + globalMiddlewareExecuted: true, + }, + }) +}) + +// A second global middleware to test multiple global middlewares +export const globalFunctionMiddleware2 = createMiddleware({ + type: 'function', +}).server(async ({ next }) => { + trackMiddlewareExecution('globalFunctionMiddleware2') + return next({ + context: { + globalMiddleware2Executed: true, + }, + }) +}) + +// Create the start instance with global middleware +export const startInstance = createStart(() => ({ + // Global function middleware that applies to all server functions + functionMiddleware: [globalFunctionMiddleware, globalFunctionMiddleware2], + // Request middleware - includes loggingMiddleware (issue #5239 scenario) + // AND the same loggingMiddleware is also attached to server functions + requestMiddleware: [loggingMiddleware], +})) diff --git a/e2e/react-start/server-functions-global-middleware/src/styles/app.css b/e2e/react-start/server-functions-global-middleware/src/styles/app.css new file mode 100644 index 00000000000..d4b5078586e --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/src/styles/app.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/e2e/react-start/server-functions-global-middleware/tests/global-middleware.spec.ts b/e2e/react-start/server-functions-global-middleware/tests/global-middleware.spec.ts new file mode 100644 index 00000000000..dfc4ae315a6 --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/tests/global-middleware.spec.ts @@ -0,0 +1,131 @@ +import { expect, Page } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +type NavigationMethod = 'direct' | 'client-side' + +async function navigateToRoute( + page: Page, + route: string, + linkTestId: string, + method: NavigationMethod, +) { + if (method === 'direct') { + await page.goto(route) + } else { + // Client-side navigation: go to home first, then click link + await page.goto('/') + await page.waitForLoadState('networkidle') + await page.getByTestId(linkTestId).click() + } + await page.waitForLoadState('networkidle') +} + +async function testSimpleMiddlewareDeduplication( + page: Page, + method: NavigationMethod, +) { + await navigateToRoute(page, '/simple', 'link-simple', method) + + // Check that both global middlewares were executed + await expect(page.getByTestId('global-middleware-executed')).toContainText( + 'true', + ) + await expect(page.getByTestId('global-middleware-2-executed')).toContainText( + 'true', + ) + + // Check that deduplication worked - each global middleware should execute exactly once + const expectedGlobalCount = await page + .getByTestId('expected-global-count') + .textContent() + const actualGlobalCount = await page.getByTestId('global-count').textContent() + + // Extract the count number from the text + const actualCount = actualGlobalCount?.match(/\d+/)?.[0] || '0' + + expect(actualCount).toBe(expectedGlobalCount) + + // Check the deduplication status + await expect(page.getByTestId('deduplication-status')).toContainText('PASS') +} + +async function testMultipleServerFunctionsDeduplication( + page: Page, + method: NavigationMethod, +) { + await navigateToRoute( + page, + '/multiple-server-functions', + 'link-multiple', + method, + ) + + // For SSR (direct): all server functions run in same request, counts accumulate + // For client-side: each server function is a separate HTTP request + // The key thing we're testing is that middleware is deduped WITHIN each server fn call + + // Check overall status - the component handles both cases + await expect(page.getByTestId('overall-status')).toContainText('PASS') + + // For direct navigation, verify the accumulated counts + if (method === 'direct') { + const expectedGlobalCount = await page + .getByTestId('expected-global-count') + .textContent() + const actualGlobalCount = await page + .getByTestId('actual-global-count') + .textContent() + expect(actualGlobalCount).toBe(expectedGlobalCount) + + const expectedGlobal2Count = await page + .getByTestId('expected-global-2-count') + .textContent() + const actualGlobal2Count = await page + .getByTestId('actual-global-2-count') + .textContent() + expect(actualGlobal2Count).toBe(expectedGlobal2Count) + + const expectedLocalCount = await page + .getByTestId('expected-local-count') + .textContent() + const actualLocalCount = await page + .getByTestId('actual-local-count') + .textContent() + expect(actualLocalCount).toBe(expectedLocalCount) + + // Verify the status indicators + await expect(page.getByTestId('global-status')).toContainText('✓') + await expect(page.getByTestId('global-2-status')).toContainText('✓') + await expect(page.getByTestId('local-status')).toContainText('✓') + } +} + +test.describe('Global middleware deduplication (issue #5239)', () => { + test.describe('direct navigation (SSR)', () => { + test('simple test - global middleware attached to server function should be deduped', async ({ + page, + }) => { + await testSimpleMiddlewareDeduplication(page, 'direct') + }) + + test('multiple server functions - global middleware should be deduped per function call', async ({ + page, + }) => { + await testMultipleServerFunctionsDeduplication(page, 'direct') + }) + }) + + test.describe('client-side navigation', () => { + test('simple test - global middleware attached to server function should be deduped', async ({ + page, + }) => { + await testSimpleMiddlewareDeduplication(page, 'client-side') + }) + + test('multiple server functions - global middleware should be deduped per function call', async ({ + page, + }) => { + await testMultipleServerFunctionsDeduplication(page, 'client-side') + }) + }) +}) diff --git a/e2e/react-start/server-functions-global-middleware/tsconfig.json b/e2e/react-start/server-functions-global-middleware/tsconfig.json new file mode 100644 index 00000000000..a07911fe5f4 --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true, + "types": ["vite/client"] + } +} diff --git a/e2e/react-start/server-functions-global-middleware/vite.config.ts b/e2e/react-start/server-functions-global-middleware/vite.config.ts new file mode 100644 index 00000000000..3e946e6645b --- /dev/null +++ b/e2e/react-start/server-functions-global-middleware/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [ + tailwindcss(), + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + viteReact(), + ], +}) diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index 2722460da4f..0da9c0e46c9 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -182,11 +182,22 @@ export async function executeMiddleware( opts: ServerFnMiddlewareOptions, ): Promise { const globalMiddlewares = getStartOptions()?.functionMiddleware || [] - const flattenedMiddlewares = flattenMiddlewares([ + let flattenedMiddlewares = flattenMiddlewares([ ...globalMiddlewares, ...middlewares, ]) + // On server, filter out middlewares that already executed in the request phase + // to prevent duplicate execution (issue #5239) + if (env === 'server') { + const startContext = getStartContextServerOnly({ throwIfNotFound: false }) + if (startContext?.executedRequestMiddlewares) { + flattenedMiddlewares = flattenedMiddlewares.filter( + (m) => !startContext.executedRequestMiddlewares.has(m), + ) + } + } + const callNextMiddleware: NextFn = async (ctx) => { // Get the next middleware const nextMiddleware = flattenedMiddlewares.shift() diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index fb07cf3986d..bf34d58e9eb 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -136,6 +136,15 @@ export function createStartHandler( return router } + const flattenedRequestMiddlewares = startOptions.requestMiddleware + ? flattenMiddlewares(startOptions.requestMiddleware) + : [] + // Track which middlewares were executed in the request phase + // to prevent duplicate execution in server functions (issue #5239) + const executedRequestMiddlewares = new Set( + flattenedRequestMiddlewares, + ) + const requestHandlerMiddleware = handlerToMiddleware( async ({ context }) => { const response = await runWithStartContext( @@ -144,6 +153,7 @@ export function createStartHandler( startOptions: requestStartOptions, contextAfterGlobalMiddlewares: context, request, + executedRequestMiddlewares, }, async () => { try { @@ -250,10 +260,9 @@ export function createStartHandler( }, ) - const flattenedMiddlewares = startOptions.requestMiddleware - ? flattenMiddlewares(startOptions.requestMiddleware) - : [] - const middlewares = flattenedMiddlewares.map((d) => d.options.server) + const middlewares = flattenedRequestMiddlewares.map( + (d) => d.options.server, + ) const ctx = await executeMiddleware( [...middlewares, requestHandlerMiddleware], { diff --git a/packages/start-storage-context/src/async-local-storage.ts b/packages/start-storage-context/src/async-local-storage.ts index d952ca6bdda..526c918266e 100644 --- a/packages/start-storage-context/src/async-local-storage.ts +++ b/packages/start-storage-context/src/async-local-storage.ts @@ -8,6 +8,9 @@ export interface StartStorageContext { startOptions: /* AnyStartInstanceOptions*/ any contextAfterGlobalMiddlewares: any + // Track middlewares that have already executed in the request phase + // to prevent duplicate execution + executedRequestMiddlewares: Set } // Use a global symbol to ensure the same AsyncLocalStorage instance is shared diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b62d3b08ec3..dd23b30df61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1966,6 +1966,58 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.8.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/react-start/server-functions-global-middleware: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-start': + specifier: workspace:* + version: link:../../../packages/react-start + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + vite: + specifier: ^7.1.7 + version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + devDependencies: + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tailwindcss/vite': + specifier: ^4.1.18 + version: 4.1.18(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + '@types/react': + specifier: ^19.2.2 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.2(@types/react@19.2.2) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + srvx: + specifier: ^0.9.8 + version: 0.9.8 + tailwindcss: + specifier: ^4.1.18 + version: 4.1.18 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/react-start/server-routes: dependencies: '@tanstack/react-query': @@ -10269,7 +10321,7 @@ importers: devDependencies: '@netlify/vite-plugin-tanstack-start': specifier: ^1.1.4 - version: 1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + version: 1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) @@ -26405,13 +26457,13 @@ snapshots: uuid: 11.1.0 write-file-atomic: 5.0.1 - '@netlify/dev@4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)': + '@netlify/dev@4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)': dependencies: '@netlify/blobs': 10.1.0 '@netlify/config': 23.2.0 '@netlify/dev-utils': 4.3.0 '@netlify/edge-functions-dev': 1.0.0 - '@netlify/functions-dev': 1.0.0(rollup@4.52.5) + '@netlify/functions-dev': 1.0.0(encoding@0.1.13)(rollup@4.52.5) '@netlify/headers': 2.1.0 '@netlify/images': 1.3.0(@netlify/blobs@10.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0) '@netlify/redirects': 3.1.0 @@ -26479,12 +26531,12 @@ snapshots: dependencies: '@netlify/types': 2.1.0 - '@netlify/functions-dev@1.0.0(rollup@4.52.5)': + '@netlify/functions-dev@1.0.0(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@netlify/blobs': 10.1.0 '@netlify/dev-utils': 4.3.0 '@netlify/functions': 5.0.0 - '@netlify/zip-it-and-ship-it': 14.1.11(rollup@4.52.5) + '@netlify/zip-it-and-ship-it': 14.1.11(encoding@0.1.13)(rollup@4.52.5) cron-parser: 4.9.0 decache: 4.6.2 extract-zip: 2.0.1 @@ -26574,9 +26626,9 @@ snapshots: '@netlify/types@2.1.0': {} - '@netlify/vite-plugin-tanstack-start@1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': + '@netlify/vite-plugin-tanstack-start@1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': dependencies: - '@netlify/vite-plugin': 2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + '@netlify/vite-plugin': 2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) vite: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) optionalDependencies: '@tanstack/solid-start': link:packages/solid-start @@ -26604,9 +26656,9 @@ snapshots: - supports-color - uploadthing - '@netlify/vite-plugin@2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': + '@netlify/vite-plugin@2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': dependencies: - '@netlify/dev': 4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5) + '@netlify/dev': 4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5) '@netlify/dev-utils': 4.3.0 dedent: 1.7.0(babel-plugin-macros@3.1.0) vite: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) @@ -26634,13 +26686,13 @@ snapshots: - supports-color - uploadthing - '@netlify/zip-it-and-ship-it@14.1.11(rollup@4.52.5)': + '@netlify/zip-it-and-ship-it@14.1.11(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@babel/parser': 7.28.5 '@babel/types': 7.28.4 '@netlify/binary-info': 1.0.0 '@netlify/serverless-functions-api': 2.7.1 - '@vercel/nft': 0.29.4(rollup@4.52.5) + '@vercel/nft': 0.29.4(encoding@0.1.13)(rollup@4.52.5) archiver: 7.0.1 common-path-prefix: 3.0.0 copy-file: 11.1.0 @@ -29741,7 +29793,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/nft@0.29.4(rollup@4.52.5)': + '@vercel/nft@0.29.4(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@mapbox/node-pre-gyp': 2.0.0(encoding@0.1.13) '@rollup/pluginutils': 5.1.4(rollup@4.52.5) From b92454c24a17dfb3b89f7c153d2271c1a05e3df5 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Fri, 26 Dec 2025 04:04:41 +0100 Subject: [PATCH 09/10] fix: deduplicate request middlware --- .../.gitignore | 5 + .../package.json | 34 ++++++ .../playwright.config.ts | 35 ++++++ .../src/routeTree.gen.ts | 88 ++++++++++++++ .../src/router.tsx | 12 ++ .../src/routes/__root.tsx | 61 ++++++++++ .../src/routes/index.tsx | 30 +++++ .../routes/server-route-with-middleware.tsx | 100 +++++++++++++++ .../src/start.ts | 67 ++++++++++ .../src/styles/app.css | 1 + .../server-routes-global-middleware.spec.ts | 114 ++++++++++++++++++ .../tsconfig.json | 23 ++++ .../vite.config.ts | 16 +++ .../src/createStartHandler.ts | 9 +- pnpm-lock.yaml | 52 ++++++++ 15 files changed, 646 insertions(+), 1 deletion(-) create mode 100644 e2e/react-start/server-routes-global-middleware/.gitignore create mode 100644 e2e/react-start/server-routes-global-middleware/package.json create mode 100644 e2e/react-start/server-routes-global-middleware/playwright.config.ts create mode 100644 e2e/react-start/server-routes-global-middleware/src/routeTree.gen.ts create mode 100644 e2e/react-start/server-routes-global-middleware/src/router.tsx create mode 100644 e2e/react-start/server-routes-global-middleware/src/routes/__root.tsx create mode 100644 e2e/react-start/server-routes-global-middleware/src/routes/index.tsx create mode 100644 e2e/react-start/server-routes-global-middleware/src/routes/server-route-with-middleware.tsx create mode 100644 e2e/react-start/server-routes-global-middleware/src/start.ts create mode 100644 e2e/react-start/server-routes-global-middleware/src/styles/app.css create mode 100644 e2e/react-start/server-routes-global-middleware/tests/server-routes-global-middleware.spec.ts create mode 100644 e2e/react-start/server-routes-global-middleware/tsconfig.json create mode 100644 e2e/react-start/server-routes-global-middleware/vite.config.ts diff --git a/e2e/react-start/server-routes-global-middleware/.gitignore b/e2e/react-start/server-routes-global-middleware/.gitignore new file mode 100644 index 00000000000..6f11533a728 --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +.tanstack +port-*.txt +test-results diff --git a/e2e/react-start/server-routes-global-middleware/package.json b/e2e/react-start/server-routes-global-middleware/package.json new file mode 100644 index 00000000000..1fa0ee5fc81 --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/package.json @@ -0,0 +1,34 @@ +{ + "name": "tanstack-react-start-e2e-server-routes-global-middleware", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "pnpx srvx --prod -s ../client dist/server/server.js", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-start": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^7.1.7" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tailwindcss/vite": "^4.1.18", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "srvx": "^0.9.8", + "tailwindcss": "^4.1.18", + "typescript": "^5.7.2", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/e2e/react-start/server-routes-global-middleware/playwright.config.ts b/e2e/react-start/server-routes-global-middleware/playwright.config.ts new file mode 100644 index 00000000000..8330e023eea --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +export const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + workers: 1, + + reporter: [['line']], + + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL, + }, + + webServer: { + command: `VITE_SERVER_PORT=${PORT} pnpm build && PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-start/server-routes-global-middleware/src/routeTree.gen.ts b/e2e/react-start/server-routes-global-middleware/src/routeTree.gen.ts new file mode 100644 index 00000000000..6ec0a951282 --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/src/routeTree.gen.ts @@ -0,0 +1,88 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as ServerRouteWithMiddlewareRouteImport } from './routes/server-route-with-middleware' +import { Route as IndexRouteImport } from './routes/index' + +const ServerRouteWithMiddlewareRoute = + ServerRouteWithMiddlewareRouteImport.update({ + id: '/server-route-with-middleware', + path: '/server-route-with-middleware', + getParentRoute: () => rootRouteImport, + } as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/server-route-with-middleware': typeof ServerRouteWithMiddlewareRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/server-route-with-middleware': typeof ServerRouteWithMiddlewareRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/server-route-with-middleware': typeof ServerRouteWithMiddlewareRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/server-route-with-middleware' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/server-route-with-middleware' + id: '__root__' | '/' | '/server-route-with-middleware' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ServerRouteWithMiddlewareRoute: typeof ServerRouteWithMiddlewareRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/server-route-with-middleware': { + id: '/server-route-with-middleware' + path: '/server-route-with-middleware' + fullPath: '/server-route-with-middleware' + preLoaderRoute: typeof ServerRouteWithMiddlewareRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ServerRouteWithMiddlewareRoute: ServerRouteWithMiddlewareRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { startInstance } from './start.ts' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + config: Awaited> + } +} diff --git a/e2e/react-start/server-routes-global-middleware/src/router.tsx b/e2e/react-start/server-routes-global-middleware/src/router.tsx new file mode 100644 index 00000000000..6ee5705ca9b --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/src/router.tsx @@ -0,0 +1,12 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + scrollRestoration: true, + }) + + return router +} diff --git a/e2e/react-start/server-routes-global-middleware/src/routes/__root.tsx b/e2e/react-start/server-routes-global-middleware/src/routes/__root.tsx new file mode 100644 index 00000000000..97ee885af73 --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/src/routes/__root.tsx @@ -0,0 +1,61 @@ +/// +import * as React from 'react' +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' +import appCss from '~/styles/app.css?url' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { + charSet: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + ], + links: [{ rel: 'stylesheet', href: appCss }], + }), + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + +
    + + Home + + + Server Route Test + +
    +
    + + + + + ) +} diff --git a/e2e/react-start/server-routes-global-middleware/src/routes/index.tsx b/e2e/react-start/server-routes-global-middleware/src/routes/index.tsx new file mode 100644 index 00000000000..362ef0947a6 --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/src/routes/index.tsx @@ -0,0 +1,30 @@ +import { Link, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
    +

    + Server Routes Global Middleware Deduplication E2E Tests +

    +

    + Tests for issue #5239: global request middleware is executed multiple + times for single request (server routes variant) +

    +
      +
    • + + Server route with middleware - tests deduplication when same + middleware is in global requestMiddleware and server route + +
    • +
    +
    + ) +} diff --git a/e2e/react-start/server-routes-global-middleware/src/routes/server-route-with-middleware.tsx b/e2e/react-start/server-routes-global-middleware/src/routes/server-route-with-middleware.tsx new file mode 100644 index 00000000000..c14655e3328 --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/src/routes/server-route-with-middleware.tsx @@ -0,0 +1,100 @@ +import { createFileRoute } from '@tanstack/react-router' +import { getMiddlewareExecutionCounts, loggingMiddleware } from '~/start' + +export const Route = createFileRoute('/server-route-with-middleware')({ + // Server route configuration + // loggingMiddleware is ALSO in global requestMiddleware (see start.ts) + // This tests that it only executes once, not twice + server: { + middleware: [loggingMiddleware], + handlers: { + GET: async ({ next, context }) => { + // Get the middleware execution counts at the time of the GET handler + const counts = await getMiddlewareExecutionCounts() + + // Add counts to context - this will be passed to the router as serverContext + return next({ + context: { + middlewareCountsAtHandler: counts, + }, + }) + }, + }, + }, + // beforeLoad runs on server during SSR, can access serverContext + beforeLoad: async ({ context, serverContext }) => { + // On server, serverContext contains data from GET handler + middleware context + // On client (navigation), serverContext won't be present + + // Get the final middleware execution counts + const finalCounts = await getMiddlewareExecutionCounts() + + return { + serverContext, + middlewareCounts: finalCounts, + } + }, + component: ServerRouteWithMiddleware, +}) + +function ServerRouteWithMiddleware() { + const { serverContext, middlewareCounts } = Route.useRouteContext() + + return ( +
    +

    + Server Route with Global Middleware Test +

    + +
    +

    Middleware Execution Counts:

    +
    +          {JSON.stringify(middlewareCounts, null, 2)}
    +        
    +
    + +
    +

    loggingMiddleware Count:

    + + {(middlewareCounts as any)?.loggingMiddleware ?? 'N/A'} + +
    + +
    +

    authMiddleware Count:

    + + {(middlewareCounts as any)?.authMiddleware ?? 'N/A'} + +
    + +
    +

    Server Context:

    +
    +          {JSON.stringify(serverContext, null, 2)}
    +        
    +
    + +
    +

    Deduplication Status:

    + + {(middlewareCounts as any)?.loggingMiddleware === 1 + ? 'SUCCESS: loggingMiddleware executed exactly once' + : `FAILURE: loggingMiddleware executed ${(middlewareCounts as any)?.loggingMiddleware ?? 0} times (expected 1)`} + +
    +
    + ) +} diff --git a/e2e/react-start/server-routes-global-middleware/src/start.ts b/e2e/react-start/server-routes-global-middleware/src/start.ts new file mode 100644 index 00000000000..5c22a7e00ce --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/src/start.ts @@ -0,0 +1,67 @@ +import { + createIsomorphicFn, + createMiddleware, + createStart, +} from '@tanstack/react-start' +import { getRequest } from '@tanstack/react-start/server' + +// Use a WeakMap keyed by Request object for request-scoped tracking +const requestMiddlewareCounts = new WeakMap>() + +// Helper to track middleware execution - only runs on server +export const trackMiddlewareExecution = createIsomorphicFn().server( + (middlewareName: string) => { + const request = getRequest() + let counts = requestMiddlewareCounts.get(request) + if (!counts) { + counts = {} + requestMiddlewareCounts.set(request, counts) + } + counts[middlewareName] = (counts[middlewareName] || 0) + 1 + console.log( + `[MIDDLEWARE] ${middlewareName} executed. Count: ${counts[middlewareName]}`, + ) + return counts[middlewareName] + }, +) + +// Helper to get execution counts for the current request +// Uses createIsomorphicFn so it can be called in beforeLoad without crashing on client +// Returns undefined on client (no .client() impl), returns counts on server +export const getMiddlewareExecutionCounts = createIsomorphicFn().server( + (): Record => { + const request = getRequest() + return requestMiddlewareCounts.get(request) || {} + }, +) + +// This middleware is registered as BOTH: +// 1. Global request middleware (in startInstance) +// 2. Server route middleware (attached to individual routes) +// The bug would be that it executes multiple times instead of being deduped +export const loggingMiddleware = createMiddleware().server(async ({ next }) => { + trackMiddlewareExecution('loggingMiddleware') + return next({ + context: { + loggingMiddlewareExecuted: true, + loggingMiddlewareTimestamp: Date.now(), + }, + }) +}) + +// Another global middleware for testing +export const authMiddleware = createMiddleware().server(async ({ next }) => { + trackMiddlewareExecution('authMiddleware') + return next({ + context: { + authMiddlewareExecuted: true, + userId: 'test-user-123', + }, + }) +}) + +// Create the start instance with global request middleware +export const startInstance = createStart(() => ({ + // Global request middleware - applies to all requests + requestMiddleware: [loggingMiddleware, authMiddleware], +})) diff --git a/e2e/react-start/server-routes-global-middleware/src/styles/app.css b/e2e/react-start/server-routes-global-middleware/src/styles/app.css new file mode 100644 index 00000000000..d4b5078586e --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/src/styles/app.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/e2e/react-start/server-routes-global-middleware/tests/server-routes-global-middleware.spec.ts b/e2e/react-start/server-routes-global-middleware/tests/server-routes-global-middleware.spec.ts new file mode 100644 index 00000000000..a97a2692469 --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/tests/server-routes-global-middleware.spec.ts @@ -0,0 +1,114 @@ +import { expect, test } from '@playwright/test' + +/** + * E2E tests for server routes global middleware deduplication + * Issue #5239: global request middleware is executed multiple times for single request + * + * These tests verify that when the same middleware is registered in BOTH: + * 1. Global requestMiddleware (in start.ts) + * 2. Server route middleware (in the route's server.middleware array) + * + * ...the middleware only executes ONCE per request, not twice. + * + * NOTE: Server route handlers (GET, POST, etc.) only execute on direct navigation (SSR). + * Client-side navigation does NOT trigger the server route handler. + */ + +test.describe('Server Routes Global Middleware Deduplication', () => { + test.describe('Direct Navigation (SSR)', () => { + test('loggingMiddleware should execute exactly once when in both global and route middleware', async ({ + page, + }) => { + // Navigate directly to the server route + // This triggers SSR with the full server route handler execution + await page.goto('/server-route-with-middleware') + + // Wait for the page to fully load + await expect( + page.locator('[data-testid="middleware-counts"]'), + ).toBeVisible() + + // Get the middleware execution count for loggingMiddleware + const loggingCount = await page + .locator('[data-testid="logging-middleware-count"]') + .textContent() + + // loggingMiddleware should execute exactly once + // It's in global requestMiddleware AND in the route's server.middleware + // With proper deduplication, it should only run once + expect(loggingCount).toBe('1') + + // Verify the deduplication status message + const dedupStatus = await page + .locator('[data-testid="dedup-status"]') + .textContent() + expect(dedupStatus).toContain('SUCCESS') + }) + + test('authMiddleware should execute exactly once (global only)', async ({ + page, + }) => { + // Navigate directly to the server route + await page.goto('/server-route-with-middleware') + + // Wait for the page to fully load + await expect( + page.locator('[data-testid="middleware-counts"]'), + ).toBeVisible() + + // Get the middleware execution count for authMiddleware + const authCount = await page + .locator('[data-testid="auth-middleware-count"]') + .textContent() + + // authMiddleware is only in global requestMiddleware, not in route middleware + // It should execute exactly once + expect(authCount).toBe('1') + }) + + test('server context should contain middleware data', async ({ page }) => { + // Navigate directly to the server route + await page.goto('/server-route-with-middleware') + + // Wait for the page to fully load + await expect(page.locator('[data-testid="server-context"]')).toBeVisible() + + // Get the server context + const serverContextText = await page + .locator('[data-testid="server-context"]') + .textContent() + + const serverContext = JSON.parse(serverContextText || '{}') + + // Verify middleware context data is present + expect(serverContext.loggingMiddlewareExecuted).toBe(true) + expect(serverContext.authMiddlewareExecuted).toBe(true) + expect(serverContext.userId).toBe('test-user-123') + }) + + test('middleware counts object should have correct structure', async ({ + page, + }) => { + // Navigate directly to the server route + await page.goto('/server-route-with-middleware') + + // Wait for the page to fully load + await expect( + page.locator('[data-testid="middleware-counts"]'), + ).toBeVisible() + + // Get the full middleware counts object + const countsText = await page + .locator('[data-testid="middleware-counts"]') + .textContent() + + const counts = JSON.parse(countsText || '{}') + + // Both middlewares should have executed exactly once + expect(counts).toEqual({ + loggingMiddleware: 1, + authMiddleware: 1, + }) + }) + }) +}) diff --git a/e2e/react-start/server-routes-global-middleware/tsconfig.json b/e2e/react-start/server-routes-global-middleware/tsconfig.json new file mode 100644 index 00000000000..a07911fe5f4 --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true, + "types": ["vite/client"] + } +} diff --git a/e2e/react-start/server-routes-global-middleware/vite.config.ts b/e2e/react-start/server-routes-global-middleware/vite.config.ts new file mode 100644 index 00000000000..3e946e6645b --- /dev/null +++ b/e2e/react-start/server-routes-global-middleware/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [ + tailwindcss(), + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + viteReact(), + ], +}) diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index bf34d58e9eb..ebc0c4aa053 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -244,6 +244,7 @@ export function createStartHandler( request, executeRouter, context, + executedRequestMiddlewares, }) return response @@ -352,6 +353,7 @@ async function handleServerRoutes({ request, executeRouter, context, + executedRequestMiddlewares, }: { getRouter: () => Awaitable request: Request @@ -361,6 +363,7 @@ async function handleServerRoutes({ serverContext: any }) => Promise context: any + executedRequestMiddlewares: Set }) { const router = await getRouter() let url = new URL(request.url) @@ -376,9 +379,13 @@ async function handleServerRoutes({ // TODO: Error handling? What happens when its `throw redirect()` vs `throw new Error()`? + // Collect middleware from matched routes, filtering out those already executed + // in the request phase (issue #5239 - prevent duplicate execution) const middlewares = flattenMiddlewares( matchedRoutes.flatMap((r) => r.options.server?.middleware).filter(Boolean), - ).map((d) => d.options.server) + ) + .filter((middleware) => !executedRequestMiddlewares.has(middleware)) + .map((d) => d.options.server) const server = foundRoute?.options.server if (server && isExactMatch) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd23b30df61..a4afc2465af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2097,6 +2097,58 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.8.3)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/react-start/server-routes-global-middleware: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-start': + specifier: workspace:* + version: link:../../../packages/react-start + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + vite: + specifier: ^7.1.7 + version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + devDependencies: + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tailwindcss/vite': + specifier: ^4.1.18 + version: 4.1.18(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + '@types/react': + specifier: ^19.2.2 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.2(@types/react@19.2.2) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + srvx: + specifier: ^0.9.8 + version: 0.9.8 + tailwindcss: + specifier: ^4.1.18 + version: 4.1.18 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/react-start/spa-mode: dependencies: '@tanstack/react-router': From d27ac2a4a8bb4e1e39e9ccfa28876c2c857286fa Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Fri, 26 Dec 2025 11:08:41 +0100 Subject: [PATCH 10/10] wip --- .../server-functions/src/routes/index.tsx | 3 +- .../start-client-core/src/createServerFn.ts | 48 +- packages/start-client-core/src/index.tsx | 1 + .../start-client-core/src/safeObjectMerge.ts | 38 + .../src/createStartHandler.ts | 749 +++++++++--------- .../src/server-functions-handler.ts | 24 +- 6 files changed, 467 insertions(+), 396 deletions(-) create mode 100644 packages/start-client-core/src/safeObjectMerge.ts diff --git a/e2e/react-start/server-functions/src/routes/index.tsx b/e2e/react-start/server-functions/src/routes/index.tsx index 5929fb3ab7d..30a90ae6f09 100644 --- a/e2e/react-start/server-functions/src/routes/index.tsx +++ b/e2e/react-start/server-functions/src/routes/index.tsx @@ -91,7 +91,8 @@ function Home() {
  • Server Function only called by Server Environment is kept in the - server build + server build +
  • diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index 0da9c0e46c9..b9294328e67 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -4,6 +4,7 @@ import { isRedirect, parseRedirect } from '@tanstack/router-core' import { TSS_SERVER_FUNCTION_FACTORY } from './constants' import { getStartOptions } from './getStartOptions' import { getStartContextServerOnly } from './getStartContextServerOnly' +import { createNullProtoObject, safeObjectMerge } from './safeObjectMerge' import type { AnyValidator, Constrain, @@ -118,7 +119,7 @@ export const createServerFn: CreateServerFn = (options, __opts) => { data: opts?.data as any, headers: opts?.headers, signal: opts?.signal, - context: {}, + context: createNullProtoObject(), }) const redirect = parseRedirect(result.error) @@ -138,13 +139,14 @@ export const createServerFn: CreateServerFn = (options, __opts) => { const startContext = getStartContextServerOnly() const serverContextAfterGlobalMiddlewares = startContext.contextAfterGlobalMiddlewares + // Use safeObjectMerge for opts.context which comes from client const ctx = { ...extractedFn, ...opts, - context: { - ...serverContextAfterGlobalMiddlewares, - ...opts.context, - }, + context: safeObjectMerge( + serverContextAfterGlobalMiddlewares, + opts.context, + ), signal, request: startContext.request, } @@ -239,17 +241,12 @@ export async function executeMiddleware( userCtx: ServerFnMiddlewareResult | undefined = {} as any, ) => { // Return the next middleware + // Use safeObjectMerge for context objects to prevent prototype pollution const nextCtx = { ...ctx, ...userCtx, - context: { - ...ctx.context, - ...userCtx.context, - }, - sendContext: { - ...ctx.sendContext, - ...(userCtx.sendContext ?? {}), - }, + context: safeObjectMerge(ctx.context, userCtx.context), + sendContext: safeObjectMerge(ctx.sendContext, userCtx.sendContext), headers: mergeHeaders(ctx.headers, userCtx.headers), result: userCtx.result !== undefined @@ -315,7 +312,7 @@ export async function executeMiddleware( ...opts, headers: opts.headers || {}, sendContext: opts.sendContext || {}, - context: opts.context || {}, + context: opts.context || createNullProtoObject(), }) } @@ -652,18 +649,21 @@ export interface ServerFnTypes< allOutput: IntersectAllValidatorOutputs } -export function flattenMiddlewares( - middlewares: Array, -): Array { - const seen = new Set() - const flattened: Array = [] +export function flattenMiddlewares< + T extends AnyFunctionMiddleware | AnyRequestMiddleware, +>(middlewares: Array, maxDepth: number = 100): Array { + const seen = new Set() + const flattened: Array = [] - const recurse = ( - middleware: Array, - ) => { + const recurse = (middleware: Array, depth: number) => { + if (depth > maxDepth) { + throw new Error( + `Middleware nesting depth exceeded maximum of ${maxDepth}. Check for circular references.`, + ) + } middleware.forEach((m) => { if (m.options.middleware) { - recurse(m.options.middleware) + recurse(m.options.middleware as Array, depth + 1) } if (!seen.has(m)) { @@ -673,7 +673,7 @@ export function flattenMiddlewares( }) } - recurse(middlewares) + recurse(middlewares, 0) return flattened } diff --git a/packages/start-client-core/src/index.tsx b/packages/start-client-core/src/index.tsx index 49b143e3bc9..1dbdff8f19c 100644 --- a/packages/start-client-core/src/index.tsx +++ b/packages/start-client-core/src/index.tsx @@ -100,3 +100,4 @@ export type { Register } from '@tanstack/router-core' export { getRouterInstance } from './getRouterInstance' export { getDefaultSerovalPlugins } from './getDefaultSerovalPlugins' export { getGlobalStartContext } from './getGlobalStartContext' +export { safeObjectMerge, createNullProtoObject } from './safeObjectMerge' diff --git a/packages/start-client-core/src/safeObjectMerge.ts b/packages/start-client-core/src/safeObjectMerge.ts new file mode 100644 index 00000000000..e5eb6966b2f --- /dev/null +++ b/packages/start-client-core/src/safeObjectMerge.ts @@ -0,0 +1,38 @@ +function isSafeKey(key: string): boolean { + return key !== '__proto__' && key !== 'constructor' && key !== 'prototype' +} + +/** + * Merge target and source into a new null-proto object, filtering dangerous keys. + */ +export function safeObjectMerge>( + target: T | undefined, + source: Record | null | undefined, +): T { + const result = Object.create(null) as T + if (target) { + for (const key of Object.keys(target)) { + if (isSafeKey(key)) result[key as keyof T] = target[key] as T[keyof T] + } + } + if (source && typeof source === 'object') { + for (const key of Object.keys(source)) { + if (isSafeKey(key)) result[key as keyof T] = source[key] as T[keyof T] + } + } + return result +} + +/** + * Create a null-prototype object, optionally copying from source. + */ +export function createNullProtoObject( + source?: T, +): { [K in keyof T]: T[K] } { + if (!source) return Object.create(null) + const obj = Object.create(null) + for (const key of Object.keys(source)) { + if (isSafeKey(key)) obj[key] = (source as Record)[key] + } + return obj +} diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index ebc0c4aa053..1f2820c7f13 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -1,5 +1,10 @@ import { createMemoryHistory } from '@tanstack/history' -import { flattenMiddlewares, mergeHeaders } from '@tanstack/start-client-core' +import { + createNullProtoObject, + flattenMiddlewares, + mergeHeaders, + safeObjectMerge, +} from '@tanstack/start-client-core' import { executeRewriteInput, isRedirect, @@ -17,6 +22,8 @@ import { handleServerAction } from './server-functions-handler' import { HEADERS } from './constants' import { ServerFunctionSerializationAdapter } from './serializer/ServerFunctionSerializationAdapter' import type { + AnyFunctionMiddleware, + AnyRequestMiddleware, AnyStartInstanceOptions, RouteMethod, RouteMethodHandlerFn, @@ -27,7 +34,6 @@ import type { RequestHandler } from './request-handler' import type { AnyRoute, AnyRouter, - Awaitable, Manifest, Register, } from '@tanstack/router-core' @@ -35,6 +41,10 @@ import type { HandlerCallback } from '@tanstack/router-core/ssr/server' type TODO = any +type AnyMiddlewareServerFn = + | AnyRequestMiddleware['options']['server'] + | AnyFunctionMiddleware['options']['server'] + function getStartResponseHeaders(opts: { router: AnyRouter }) { const headers = mergeHeaders( { @@ -47,46 +57,165 @@ function getStartResponseHeaders(opts: { router: AnyRouter }) { return headers } -export function createStartHandler( - cb: HandlerCallback, -): RequestHandler { - const ROUTER_BASEPATH = process.env.TSS_ROUTER_BASEPATH || '/' - let startRoutesManifest: Manifest | null = null - let startEntry: StartEntry | null = null - let routerEntry: RouterEntry | null = null - const getEntries = async (): Promise<{ - startEntry: StartEntry - routerEntry: RouterEntry - }> => { - if (routerEntry === null) { - // @ts-ignore when building, we currently don't respect tsconfig.ts' `include` so we are not picking up the .d.ts from start-client-core - routerEntry = await import('#tanstack-router-entry') +// Cached entries - loaded once per process +let cachedStartEntry: StartEntry | null = null +let cachedRouterEntry: RouterEntry | null = null +let cachedManifest: Manifest | null = null + +async function getEntries(): Promise<{ + startEntry: StartEntry + routerEntry: RouterEntry +}> { + if (cachedRouterEntry === null) { + // @ts-ignore when building, we currently don't respect tsconfig.ts' `include` so we are not picking up the .d.ts from start-client-core + cachedRouterEntry = await import('#tanstack-router-entry') + } + if (cachedStartEntry === null) { + // @ts-ignore when building, we currently don't respect tsconfig.ts' `include` so we are not picking up the .d.ts from start-client-core + cachedStartEntry = await import('#tanstack-start-entry') + } + return { + startEntry: cachedStartEntry as unknown as StartEntry, + routerEntry: cachedRouterEntry as unknown as RouterEntry, + } +} + +async function getManifest(): Promise { + if (cachedManifest === null) { + cachedManifest = await getStartManifest() + } + return cachedManifest +} + +// Pre-computed constants +const ROUTER_BASEPATH = process.env.TSS_ROUTER_BASEPATH || '/' +const SERVER_FN_BASE = process.env.TSS_SERVER_FN_BASE +const IS_PRERENDERING = process.env.TSS_PRERENDERING === 'true' +const IS_SHELL_ENV = process.env.TSS_SHELL === 'true' +const IS_DEV = process.env.NODE_ENV === 'development' + +// Reusable error messages +const ERR_NO_RESPONSE = IS_DEV + ? `It looks like you forgot to return a response from your server route handler. If you want to defer to the app router, make sure to have a component set in this route.` + : 'Internal Server Error' + +const ERR_NO_DEFER = IS_DEV + ? `You cannot defer to the app router if there is no component defined on this route.` + : 'Internal Server Error' + +function throwRouteHandlerError(): never { + throw new Error(ERR_NO_RESPONSE) +} + +function throwIfMayNotDefer(): never { + throw new Error(ERR_NO_DEFER) +} + +/** + * Check if a value is a special response (Response or Redirect) + */ +function isSpecialResponse(value: unknown): value is Response { + return value instanceof Response || isRedirect(value) +} + +/** + * Normalize middleware result to context shape + */ +function handleCtxResult(result: TODO) { + if (isSpecialResponse(result)) { + return { response: result } + } + return result +} + +/** + * Execute a middleware chain + */ +function executeMiddleware(middlewares: Array, ctx: TODO): Promise { + let index = -1 + + const next = async (nextCtx?: TODO): Promise => { + // Merge context if provided using safeObjectMerge for prototype pollution prevention + if (nextCtx) { + if (nextCtx.context) { + ctx.context = safeObjectMerge(ctx.context, nextCtx.context) + } + // Copy own properties except context (Object.keys returns only own enumerable properties) + for (const key of Object.keys(nextCtx)) { + if (key !== 'context') { + ctx[key] = nextCtx[key] + } + } } - if (startEntry === null) { - // @ts-ignore when building, we currently don't respect tsconfig.ts' `include` so we are not picking up the .d.ts from start-client-core - startEntry = await import('#tanstack-start-entry') + + index++ + const middleware = middlewares[index] + if (!middleware) return ctx + + let result: TODO + try { + result = await middleware({ ...ctx, next }) + } catch (err) { + if (isSpecialResponse(err)) { + ctx.response = err + return ctx + } + throw err } - return { - startEntry: startEntry as unknown as StartEntry, - routerEntry: routerEntry as unknown as RouterEntry, + + const normalized = handleCtxResult(result) + if (normalized) { + if (normalized.response !== undefined) { + ctx.response = normalized.response + } + if (normalized.context) { + ctx.context = safeObjectMerge(ctx.context, normalized.context) + } + } + + return ctx + } + + return next() +} + +/** + * Wrap a route handler as middleware + */ +function handlerToMiddleware( + handler: RouteMethodHandlerFn, + mayDefer: boolean = false, +): TODO { + if (mayDefer) { + return handler + } + return async (ctx: TODO) => { + const response = await handler({ ...ctx, next: throwIfMayNotDefer }) + if (!response) { + throwRouteHandlerError() } + return response } +} +export function createStartHandler( + cb: HandlerCallback, +): RequestHandler { const startRequestResolver: RequestHandler = async ( request, requestOpts, ) => { let router: AnyRouter | null = null as AnyRouter | null - // Track whether the callback will handle cleanup let cbWillCleanup = false as boolean - try { - const origin = getOrigin(request) + try { const url = new URL(request.url) const href = url.href.replace(url.origin, '') + const origin = getOrigin(request) + const entries = await getEntries() const startOptions: AnyStartInstanceOptions = - (await (await getEntries()).startEntry.startInstance?.getOptions()) || + (await entries.startEntry.startInstance?.getOptions()) || ({} as AnyStartInstanceOptions) const serializationAdapters = [ @@ -99,22 +228,27 @@ export function createStartHandler( serializationAdapters, } - const getRouter = async () => { + // Flatten request middlewares once + const flattenedRequestMiddlewares = startOptions.requestMiddleware + ? flattenMiddlewares(startOptions.requestMiddleware) + : [] + + // Create set for deduplication + const executedRequestMiddlewares = new Set( + flattenedRequestMiddlewares, + ) + + // Memoized router getter + const getRouter = async (): Promise => { if (router) return router - router = await (await getEntries()).routerEntry.getRouter() - - // Update the client-side router with the history - const isPrerendering = process.env.TSS_PRERENDERING === 'true' - // env var is set during dev is SPA mode is enabled - let isShell = process.env.TSS_SHELL === 'true' - if (isPrerendering && !isShell) { - // only read the shell header if we are prerendering - // to avoid runtime behavior changes by injecting this header - // the header is set by the prerender plugin + + router = await entries.routerEntry.getRouter() + + let isShell = IS_SHELL_ENV + if (IS_PRERENDERING && !isShell) { isShell = request.headers.get(HEADERS.TSS_SHELL) === 'true' } - // Create a history for the client-side router const history = createMemoryHistory({ initialEntries: [href], }) @@ -122,7 +256,7 @@ export function createStartHandler( router.update({ history, isShell, - isPrerendering, + isPrerendering: IS_PRERENDERING, origin: router.options.origin ?? origin, ...{ defaultSsr: requestStartOptions.defaultSsr, @@ -133,21 +267,22 @@ export function createStartHandler( }, basepath: ROUTER_BASEPATH, }) + return router } - const flattenedRequestMiddlewares = startOptions.requestMiddleware - ? flattenMiddlewares(startOptions.requestMiddleware) - : [] - // Track which middlewares were executed in the request phase - // to prevent duplicate execution in server functions (issue #5239) - const executedRequestMiddlewares = new Set( - flattenedRequestMiddlewares, - ) + // Check for server function requests first (early exit) + if (SERVER_FN_BASE && url.pathname.startsWith(SERVER_FN_BASE)) { + const serverFnId = url.pathname + .slice(SERVER_FN_BASE.length) + .split('/')[0] - const requestHandlerMiddleware = handlerToMiddleware( - async ({ context }) => { - const response = await runWithStartContext( + if (!serverFnId) { + throw new Error('Invalid server action param for serverFnId') + } + + const serverFnHandler = async ({ context }: TODO) => { + return runWithStartContext( { getRouter, startOptions: requestStartOptions, @@ -155,184 +290,111 @@ export function createStartHandler( request, executedRequestMiddlewares, }, - async () => { - try { - // First, let's attempt to handle server functions - // Extract the serverFnId once here and pass it through. - if (url.pathname.startsWith(process.env.TSS_SERVER_FN_BASE)) { - const base = process.env.TSS_SERVER_FN_BASE - const serverFnId = url.pathname - .slice(base.length) - .split('/')[0] - - if (!serverFnId) { - throw new Error( - 'Invalid server action param for serverFnId', - ) - } - - return await handleServerAction({ - request, - context: requestOpts?.context, - serverFnId, - }) - } - - const executeRouter = async ({ - serverContext, - }: { - serverContext: any - }) => { - const requestAcceptHeader = - request.headers.get('Accept') || '*/*' - const splitRequestAcceptHeader = - requestAcceptHeader.split(',') - - const supportedMimeTypes = ['*/*', 'text/html'] - const isRouterAcceptSupported = supportedMimeTypes.some( - (mimeType) => - splitRequestAcceptHeader.some((acceptedMimeType) => - acceptedMimeType.trim().startsWith(mimeType), - ), - ) - - if (!isRouterAcceptSupported) { - return Response.json( - { - error: 'Only HTML requests are supported here', - }, - { - status: 500, - }, - ) - } - - // if the startRoutesManifest is not loaded yet, load it once - if (startRoutesManifest === null) { - startRoutesManifest = await getStartManifest() - } - const router = await getRouter() - attachRouterServerSsrUtils({ - router, - manifest: startRoutesManifest, - }) - - router.update({ additionalContext: { serverContext } }) - await router.load() - - // If there was a redirect, skip rendering the page at all - if (router.state.redirect) { - return router.state.redirect - } - - await router.serverSsr!.dehydrate() - - const responseHeaders = getStartResponseHeaders({ router }) - // Mark that the callback will handle cleanup - cbWillCleanup = true - const response = await cb({ - request, - router, - responseHeaders, - }) - - return response - } - - const response = await handleServerRoutes({ - getRouter, - request, - executeRouter, - context, - executedRequestMiddlewares, - }) - - return response - } catch (err) { - if (err instanceof Response) { - return err - } - - throw err - } - }, + () => + handleServerAction({ + request, + context: requestOpts?.context, + serverFnId, + }), ) - return response - }, - ) + } - const middlewares = flattenedRequestMiddlewares.map( - (d) => d.options.server, - ) - const ctx = await executeMiddleware( - [...middlewares, requestHandlerMiddleware], - { + const middlewares = flattenedRequestMiddlewares.map( + (d) => d.options.server, + ) + const ctx = await executeMiddleware([...middlewares, serverFnHandler], { request, - context: requestOpts?.context || {}, - }, - ) + context: createNullProtoObject(requestOpts?.context), + }) - const response: Response = ctx.response - - if (isRedirect(response)) { - if (isResolvedRedirect(response)) { - if (request.headers.get('x-tsr-serverFn') === 'true') { - return Response.json( - { - ...response.options, - isSerializedRedirect: true, - }, - { - headers: response.headers, - }, - ) - } - return response - } - if ( - response.options.to && - typeof response.options.to === 'string' && - !response.options.to.startsWith('/') - ) { - throw new Error( - `Server side redirects must use absolute paths via the 'href' or 'to' options. The redirect() method's "to" property accepts an internal path only. Use the "href" property to provide an external URL. Received: ${JSON.stringify(response.options)}`, - ) - } + return handleRedirectResponse(ctx.response, request, getRouter) + } - if ( - ['params', 'search', 'hash'].some( - (d) => typeof (response.options as any)[d] === 'function', - ) - ) { - throw new Error( - `Server side redirects must use static search, params, and hash values and do not support functional values. Received functional values for: ${Object.keys( - response.options, - ) - .filter((d) => typeof (response.options as any)[d] === 'function') - .map((d) => `"${d}"`) - .join(', ')}`, - ) - } + // Router execution function + const executeRouter = async (serverContext: TODO): Promise => { + const acceptHeader = request.headers.get('Accept') || '*/*' + const acceptParts = acceptHeader.split(',') + const supportedMimeTypes = ['*/*', 'text/html'] - const router = await getRouter() - const redirect = router.resolveRedirect(response) + const isSupported = supportedMimeTypes.some((mimeType) => + acceptParts.some((part) => part.trim().startsWith(mimeType)), + ) - if (request.headers.get('x-tsr-serverFn') === 'true') { + if (!isSupported) { return Response.json( - { - ...response.options, - isSerializedRedirect: true, - }, - { - headers: response.headers, - }, + { error: 'Only HTML requests are supported here' }, + { status: 500 }, ) } - return redirect + const manifest = await getManifest() + const routerInstance = await getRouter() + + attachRouterServerSsrUtils({ + router: routerInstance, + manifest, + }) + + routerInstance.update({ additionalContext: { serverContext } }) + await routerInstance.load() + + if (routerInstance.state.redirect) { + return routerInstance.state.redirect + } + + await routerInstance.serverSsr!.dehydrate() + + const responseHeaders = getStartResponseHeaders({ + router: routerInstance, + }) + cbWillCleanup = true + + return cb({ + request, + router: routerInstance, + responseHeaders, + }) } - return response + // Main request handler + const requestHandlerMiddleware = async ({ context }: TODO) => { + return runWithStartContext( + { + getRouter, + startOptions: requestStartOptions, + contextAfterGlobalMiddlewares: context, + request, + executedRequestMiddlewares, + }, + async () => { + try { + return await handleServerRoutes({ + getRouter, + request, + url, + executeRouter, + context, + executedRequestMiddlewares, + }) + } catch (err) { + if (err instanceof Response) { + return err + } + throw err + } + }, + ) + } + + const middlewares = flattenedRequestMiddlewares.map( + (d) => d.options.server, + ) + const ctx = await executeMiddleware( + [...middlewares, requestHandlerMiddleware], + { request, context: createNullProtoObject(requestOpts?.context) }, + ) + + return handleRedirectResponse(ctx.response, request, getRouter) } finally { if (router && !cbWillCleanup) { // Clean up router SSR state if it was set up but won't be cleaned up by the callback @@ -348,27 +410,78 @@ export function createStartHandler( return requestHandler(startRequestResolver) } +async function handleRedirectResponse( + response: Response, + request: Request, + getRouter: () => Promise, +): Promise { + if (!isRedirect(response)) { + return response + } + + if (isResolvedRedirect(response)) { + if (request.headers.get('x-tsr-serverFn') === 'true') { + return Response.json( + { ...response.options, isSerializedRedirect: true }, + { headers: response.headers }, + ) + } + return response + } + + const opts = response.options + if (opts.to && typeof opts.to === 'string' && !opts.to.startsWith('/')) { + throw new Error( + `Server side redirects must use absolute paths via the 'href' or 'to' options. The redirect() method's "to" property accepts an internal path only. Use the "href" property to provide an external URL. Received: ${JSON.stringify(opts)}`, + ) + } + + if ( + ['params', 'search', 'hash'].some( + (d) => typeof (opts as TODO)[d] === 'function', + ) + ) { + throw new Error( + `Server side redirects must use static search, params, and hash values and do not support functional values. Received functional values for: ${Object.keys( + opts, + ) + .filter((d) => typeof (opts as TODO)[d] === 'function') + .map((d) => `"${d}"`) + .join(', ')}`, + ) + } + + const router = await getRouter() + const redirect = router.resolveRedirect(response) + + if (request.headers.get('x-tsr-serverFn') === 'true') { + return Response.json( + { ...response.options, isSerializedRedirect: true }, + { headers: response.headers }, + ) + } + + return redirect +} + async function handleServerRoutes({ getRouter, request, + url, executeRouter, context, executedRequestMiddlewares, }: { - getRouter: () => Awaitable + getRouter: () => Promise request: Request - executeRouter: ({ - serverContext, - }: { - serverContext: any - }) => Promise + url: URL + executeRouter: (serverContext: any) => Promise context: any - executedRequestMiddlewares: Set -}) { + executedRequestMiddlewares: Set +}): Promise { const router = await getRouter() - let url = new URL(request.url) - url = executeRewriteInput(router.rewrite, url) - const pathname = url.pathname + const rewrittenUrl = executeRewriteInput(router.rewrite, url) + const pathname = rewrittenUrl.pathname // this will perform a fuzzy match, however for server routes we need an exact match // if the route is not an exact match, executeRouter will handle rendering the app router // the match will be cached internally, so no extra work is done during the app router render @@ -377,162 +490,64 @@ async function handleServerRoutes({ const isExactMatch = foundRoute && routeParams['**'] === undefined - // TODO: Error handling? What happens when its `throw redirect()` vs `throw new Error()`? + // Collect and dedupe route middlewares + const routeMiddlewares: Array = [] // Collect middleware from matched routes, filtering out those already executed - // in the request phase (issue #5239 - prevent duplicate execution) - const middlewares = flattenMiddlewares( - matchedRoutes.flatMap((r) => r.options.server?.middleware).filter(Boolean), - ) - .filter((middleware) => !executedRequestMiddlewares.has(middleware)) - .map((d) => d.options.server) - - const server = foundRoute?.options.server - if (server && isExactMatch) { - if (server.handlers) { - const handlers = - typeof server.handlers === 'function' - ? server.handlers({ - createHandlers: (d: any) => d, - }) - : server.handlers - - const requestMethod = request.method.toUpperCase() as RouteMethod - - // Attempt to find the method in the handlers - const handler = handlers[requestMethod] ?? handlers['ANY'] - - // If a method is found, execute the handler - if (handler) { - const mayDefer = !!foundRoute.options.component - if (typeof handler === 'function') { - middlewares.push(handlerToMiddleware(handler, mayDefer)) - } else { - const { middleware } = handler - if (middleware && middleware.length) { - middlewares.push( - ...flattenMiddlewares(middleware).map((d) => d.options.server), - ) - } - if (handler.handler) { - middlewares.push(handlerToMiddleware(handler.handler, mayDefer)) - } + // in the request phase + for (const route of matchedRoutes) { + const serverMiddleware = route.options.server?.middleware as + | Array + | undefined + if (serverMiddleware) { + const flattened = flattenMiddlewares(serverMiddleware) + for (const m of flattened) { + if (!executedRequestMiddlewares.has(m)) { + routeMiddlewares.push(m.options.server) } } } } - // eventually, execute the router - middlewares.push( - handlerToMiddleware((ctx) => executeRouter({ serverContext: ctx.context })), - ) - - const ctx = await executeMiddleware(middlewares, { - request, - context, - params: routeParams, - pathname, - }) - - const response: Response = ctx.response - - return response -} - -function throwRouteHandlerError() { - if (process.env.NODE_ENV === 'development') { - throw new Error( - `It looks like you forgot to return a response from your server route handler. If you want to defer to the app router, make sure to have a component set in this route.`, - ) - } - throw new Error('Internal Server Error') -} - -function throwIfMayNotDefer() { - if (process.env.NODE_ENV === 'development') { - throw new Error( - `You cannot defer to the app router if there is no component defined on this route.`, - ) - } - throw new Error('Internal Server Error') -} -function handlerToMiddleware( - handler: RouteMethodHandlerFn, - mayDefer: boolean = false, -) { - if (mayDefer) { - return handler as TODO - } - return async ({ next: _next, ...rest }: TODO) => { - const response = await handler({ ...rest, next: throwIfMayNotDefer }) - if (!response) { - throwRouteHandlerError() - } - return response - } -} + // Add handler middleware if exact match + const server = foundRoute?.options.server + if (server?.handlers && isExactMatch) { + const handlers = + typeof server.handlers === 'function' + ? server.handlers({ createHandlers: (d: any) => d }) + : server.handlers -function executeMiddleware(middlewares: TODO, ctx: TODO) { - let index = -1 + const requestMethod = request.method.toUpperCase() as RouteMethod + const handler = handlers[requestMethod] ?? handlers['ANY'] - const next = async (ctx: TODO) => { - index++ - const middleware = middlewares[index] - if (!middleware) return ctx + if (handler) { + const mayDefer = !!foundRoute.options.component - let result - try { - result = await middleware({ - ...ctx, - // Allow the middleware to call the next middleware in the chain - next: async (nextCtx: TODO) => { - // Allow the caller to extend the context for the next middleware - const nextResult = await next({ - ...ctx, - ...nextCtx, - context: { - ...ctx.context, - ...(nextCtx?.context || {}), - }, - }) - - // Merge the result into the context\ - return Object.assign(ctx, handleCtxResult(nextResult)) - }, - // Allow the middleware result to extend the return context - }) - } catch (err: TODO) { - if (isSpecialResponse(err)) { - result = { - response: err, - } + if (typeof handler === 'function') { + routeMiddlewares.push(handlerToMiddleware(handler, mayDefer)) } else { - throw err + if (handler.middleware?.length) { + const handlerMiddlewares = flattenMiddlewares(handler.middleware) + for (const m of handlerMiddlewares) { + routeMiddlewares.push(m.options.server) + } + } + if (handler.handler) { + routeMiddlewares.push(handlerToMiddleware(handler.handler, mayDefer)) + } } } - - // Merge the middleware result into the context, just in case it - // returns a partial context - return Object.assign(ctx, handleCtxResult(result)) } - return handleCtxResult(next(ctx)) -} + // Final middleware: execute router + routeMiddlewares.push((ctx: TODO) => executeRouter(ctx.context)) -function handleCtxResult(result: TODO) { - if (isSpecialResponse(result)) { - return { - response: result, - } - } - - return result -} - -function isSpecialResponse(err: TODO) { - return isResponse(err) || isRedirect(err) -} + const ctx = await executeMiddleware(routeMiddlewares, { + request, + context, + params: routeParams, + pathname, + }) -function isResponse(response: Response): response is Response { - return response instanceof Response + return ctx.response } diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts index 8fc82006cf3..0c3b5cb99a9 100644 --- a/packages/start-server-core/src/server-functions-handler.ts +++ b/packages/start-server-core/src/server-functions-handler.ts @@ -5,6 +5,7 @@ import { X_TSS_RAW_RESPONSE, X_TSS_SERIALIZED, getDefaultSerovalPlugins, + safeObjectMerge, } from '@tanstack/start-client-core' import { fromJSON, toCrossJSONAsync, toCrossJSONStream } from 'seroval' import { getResponse } from './request-response' @@ -20,6 +21,9 @@ const FORM_DATA_CONTENT_TYPES = [ 'application/x-www-form-urlencoded', ] +// Maximum payload size for GET requests (1MB) +const MAX_PAYLOAD_SIZE = 1_000_000 + export const handleServerAction = async ({ request, context, @@ -86,9 +90,17 @@ export const handleServerAction = async ({ typeof deserializedContext === 'object' && deserializedContext ) { - params.context = { ...context, ...deserializedContext } + params.context = safeObjectMerge( + context, + deserializedContext as Record, + ) + } + } catch (e) { + // Log warning for debugging but don't expose to client + if (process.env.NODE_ENV === 'development') { + console.warn('Failed to parse FormData context:', e) } - } catch {} + } } return await action(params, signal) @@ -98,11 +110,15 @@ export const handleServerAction = async ({ if (methodLower === 'get') { // Get payload directly from searchParams const payloadParam = url.searchParams.get('payload') + // Reject oversized payloads to prevent DoS + if (payloadParam && payloadParam.length > MAX_PAYLOAD_SIZE) { + throw new Error('Payload too large') + } // If there's a payload, we should try to parse it const payload: any = payloadParam ? parsePayload(JSON.parse(payloadParam)) : {} - payload.context = { ...context, ...payload.context } + payload.context = safeObjectMerge(context, payload.context) // Send it through! return await action(payload, signal) } @@ -117,7 +133,7 @@ export const handleServerAction = async ({ } const payload = jsonPayload ? parsePayload(jsonPayload) : {} - payload.context = { ...payload.context, ...context } + payload.context = safeObjectMerge(payload.context, context) return await action(payload, signal) })()