Repository: TanStack/router (ts)

Issue:
Errors from createServerFn is being wrapped and tanstack/query is receiving that error.
I wish to transform the errors coming from createServerFn. Those are actually zod validation errors that are server-side.

```ts
export const objectUpsertOptions: UseMutationOptions<
  void,
  void,
  z.infer<typeof upsertParamsSchema>
> = {
  mutationKey: ['objectUpsert'],
  mutationFn: objectUpsert,
  onSuccess: (data, variables, context) => {
    queryClient.invalidateQueries({ queryKey: ['objects'] })
  },
  onError: (error, variables, context) => {
    console.log(SuperJSON.stringify(error) as string)
    toast.error(SuperJSON.stringify(error) as string)
  },
}
```

Unfortunately, in the fetcher

https://github.com/TanStack/router/blob/a57398e1c4278c637100b6080e858d94b9538cf6/packages/start/src/client-runtime/fetcher.tsx#L134-L139

The response is being wrapped, is there a way to expose the JSON body and not have a string with a concatenated prefix of "Request failed with status 500"



![Image](https://github.com/user-attachments/assets/3dfe480c-5c86-4863-9010-affe1b149b6b)



Pull request:
refactor(start): return JSON response when 'content-type' matches
This change, has the error being thrown by the client fetcher be a JSON object instead of a plain string. This makes it so, that when its consumed on the client it'll have the error content stored in an object.

```jsonc
{
  "message": "Response failed with a status 500",
  "body": { "foo": "bar" } // this is the actual error content
}
```

closes #2238 

Patch:
diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx
index e6359a32b..fd58cab15 100644
--- a/packages/react-router/src/Matches.tsx
+++ b/packages/react-router/src/Matches.tsx
@@ -352,8 +352,8 @@ export function MatchRoute<
   return params ? props.children : null
 }
 
-export type MakeRouteMatches<
-  TRouter extends AnyRouter = AnyRouter,
+export type MakeRouteMatchUnion<
+  TRouter extends AnyRouter = RegisteredRouter,
   TRoute extends AnyRoute = ParseRoute<TRouter['routeTree']>,
 > = TRoute extends any
   ? RouteMatch<
@@ -369,7 +369,7 @@ export type MakeRouteMatches<
 
 export function useMatches<
   TRouter extends AnyRouter = RegisteredRouter,
-  TRouteMatch = MakeRouteMatches<TRouter>,
+  TRouteMatch = MakeRouteMatchUnion<TRouter>,
   T = Array<TRouteMatch>,
 >(opts?: { select?: (matches: Array<TRouteMatch>) => T }): T {
   return useRouterState({
diff --git a/packages/react-router/src/route.ts b/packages/react-router/src/route.ts
index 79d0cf3d3..6256d83d4 100644
--- a/packages/react-router/src/route.ts
+++ b/packages/react-router/src/route.ts
@@ -11,7 +11,7 @@ import { rootRouteId } from './root'
 import type * as React from 'react'
 import type { RootRouteId } from './root'
 import type { UseNavigateResult } from './useNavigate'
-import type { MakeRouteMatch, RouteMatch } from './Matches'
+import type { MakeRouteMatch, MakeRouteMatchUnion, RouteMatch } from './Matches'
 import type { NavigateOptions, ParsePathParams, ToMaskOptions } from './link'
 import type { ParsedLocation } from './location'
 import type { RouteById, RouteIds, RoutePaths } from './routeInfo'
@@ -285,6 +285,7 @@ export interface ContextOptions<
   navigate: NavigateFn
   buildLocation: BuildLocationFn
   cause: 'preload' | 'enter' | 'stay'
+  matches: Array<MakeRouteMatchUnion>
 }
 
 export interface RouteContextOptions<
diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts
index 0de4f86f1..60821a02b 100644
--- a/packages/react-router/src/router.ts
+++ b/packages/react-router/src/router.ts
@@ -1264,6 +1264,7 @@ export class Router<
         cause: match.cause,
         abortController: match.abortController,
         preload: !!match.preload,
+        matches,
       }
 
       // Get the route context
@@ -2080,6 +2081,7 @@ export class Router<
                       this.navigate({ ...opts, _fromLocation: location }),
                     buildLocation: this.buildLocation,
                     cause: preload ? 'preload' : cause,
+                    matches,
                   }
 
                   let beforeLoadContext =
diff --git a/packages/start/src/client-runtime/fetcher.tsx b/packages/start/src/client-runtime/fetcher.tsx
index 9b5fc3630..1e4ef342b 100644
--- a/packages/start/src/client-runtime/fetcher.tsx
+++ b/packages/start/src/client-runtime/fetcher.tsx
@@ -123,20 +123,30 @@ export async function fetcher<TPayload>(
 
 async function handleResponseErrors(response: Response) {
   if (!response.ok) {
+    const contentType = response.headers.get('content-type')
+    const isJson = contentType && contentType.includes('application/json')
+
     const body = await (async () => {
-      const contentType = response.headers.get('content-type')
-      if (contentType && contentType.includes('application/json')) {
+      if (isJson) {
         return await response.json()
       }
       return await response.text()
     })()
 
-    throw new Error(
-      [
-        `Request failed with status ${response.status}`,
-        `${JSON.stringify(body, null, 2)}`,
-      ].join('\n\n'),
-    )
+    const message = `Request failed with status ${response.status}`
+
+    if (isJson) {
+      throw new Error(
+        JSON.stringify({
+          message,
+          body,
+        }),
+      )
+    } else {
+      throw new Error(
+        [message, `${JSON.stringify(body, null, 2)}`].join('\n\n'),
+      )
+    }
   }
 
   return response


Test patch:
diff --git a/packages/react-router/tests/route.test-d.tsx b/packages/react-router/tests/route.test-d.tsx
index c005f052d..3fdfb5d2b 100644
--- a/packages/react-router/tests/route.test-d.tsx
+++ b/packages/react-router/tests/route.test-d.tsx
@@ -16,6 +16,7 @@ import type {
   Route,
   SearchSchemaInput,
 } from '../src'
+import type { MakeRouteMatchUnion } from '../src/Matches'
 
 test('when creating the root', () => {
   const rootRoute = createRootRoute()
@@ -38,6 +39,7 @@ test('when creating the root with routeContext', () => {
         cause: 'preload' | 'enter' | 'stay'
         context: {}
         search: {}
+        matches: Array<MakeRouteMatchUnion>
       }>()
     },
   })
@@ -60,6 +62,7 @@ test('when creating the root with beforeLoad', () => {
         cause: 'preload' | 'enter' | 'stay'
         context: {}
         search: {}
+        matches: Array<MakeRouteMatchUnion>
       }>()
     },
   })
@@ -107,6 +110,7 @@ test('when creating the root route with context and routeContext', () => {
         cause: 'preload' | 'enter' | 'stay'
         context: { userId: string }
         search: {}
+        matches: Array<MakeRouteMatchUnion>
       }>()
     },
   })
@@ -141,6 +145,7 @@ test('when creating the root route with context and beforeLoad', () => {
         cause: 'preload' | 'enter' | 'stay'
         context: { userId: string }
         search: {}
+        matches: Array<MakeRouteMatchUnion>
       }>()
     },
   })
@@ -210,6 +215,7 @@ test('when creating the root route with context, routeContext, beforeLoad and a
         cause: 'preload' | 'enter' | 'stay'
         context: { userId: string }
         search: {}
+        matches: Array<MakeRouteMatchUnion>
       }>()
 
       return {
@@ -227,6 +233,7 @@ test('when creating the root route with context, routeContext, beforeLoad and a
         cause: 'preload' | 'enter' | 'stay'
         context: { userId: string; env: 'env1' }
         search: {}
+        matches: Array<MakeRouteMatchUnion>
       }>()
       return { permission: 'view' as const }
     },
@@ -319,6 +326,7 @@ test('when creating a child route with routeContext from the root route with con
         cause: 'preload' | 'enter' | 'stay'
         context: { userId: string }
         search: {}
+        matches: Array<MakeRouteMatchUnion>
       }>()
 
       return {
@@ -345,6 +353,7 @@ test('when creating a child route with beforeLoad from the root route with conte
         cause: 'preload' | 'enter' | 'stay'
         context: { userId: string }
         search: {}
+        matches: Array<MakeRouteMatchUnion>
       }>()
     },
   })
@@ -614,6 +623,7 @@ test('when creating a child route with params, search with routeContext from the
         cause: 'preload' | 'enter' | 'stay'
         context: { userId: string }
         search: { page: number }
+        matches: Array<MakeRouteMatchUnion>
       }>()
     },
   })
@@ -637,6 +647,7 @@ test('when creating a child route with params, search with beforeLoad from the r
         cause: 'preload' | 'enter' | 'stay'
         context: { userId: string }
         search: { page: number }
+        matches: Array<MakeRouteMatchUnion>
       }>()
     },
   })
@@ -660,6 +671,7 @@ test('when creating a child route with params, search with routeContext, beforeL
         cause: 'preload' | 'enter' | 'stay'
         context: { userId: string }
         search: { page: number }
+        matches: Array<MakeRouteMatchUnion>
       }>()
       return {
         env: 'env1',
@@ -676,6 +688,7 @@ test('when creating a child route with params, search with routeContext, beforeL
         cause: 'preload' | 'enter' | 'stay'
         context: { userId: string; env: string }
         search: { page: number }
+        matches: Array<MakeRouteMatchUnion>
       }>()
       return { permission: 'view' } as const
     },
@@ -774,6 +787,7 @@ test('when creating a child route with routeContext from a parent with routeCont
         cause: 'preload' | 'enter' | 'stay'
         context: { userId: string }
         search: {}
+        matches: Array<MakeRouteMatchUnion>
       }>()
 
       return { invoiceId: 'invoiceId1' }
@@ -794,6 +808,7 @@ test('when creating a child route with routeContext from a parent with routeCont
         cause: 'preload' | 'enter' | 'stay'
         context: { userId: string; invoiceId: string }
         search: {}
+        matches: Array<MakeRouteMatchUnion>
       }>()
 
       return { detailId: 'detailId1' }
@@ -837,6 +852,7 @@ test('when creating a child route with beforeLoad from a parent with beforeLoad'
         cause: 'preload' | 'enter' | 'stay'
         context: { userId: string }
         search: {}
+        matches: Array<MakeRouteMatchUnion>
       }>()
       return { invoiceId: 'invoiceId1' }
     },
@@ -856,6 +872,7 @@ test('when creating a child route with beforeLoad from a parent with beforeLoad'
         cause: 'preload' | 'enter' | 'stay'
         context: { userId: string; invoiceId: string }
         search: {}
+        matches: Array<MakeRouteMatchUnion>
       }>()
       return { detailId: 'detailId1' }
     },
@@ -899,6 +916,7 @@ test('when creating a child route with routeContext, beforeLoad, search, params,
         cause: 'preload' | 'enter' | 'stay'
         context: { userId: string }
         search: { page: number }
+        matches: Array<MakeRouteMatchUnion>
       }>()
       return { env: 'env1' }
     },
@@ -913,6 +931,7 @@ test('when creating a child route with routeContext, beforeLoad, search, params,
         cause: 'preload' | 'enter' | 'stay'
         context: { userId: string; env: string }
         search: { page: number }
+        matches: Array<MakeRouteMatchUnion>
       }>()
       return { invoicePermissions: ['view'] as const }
     },
@@ -942,6 +961,7 @@ test('when creating a child route with routeContext, beforeLoad, search, params,
           invoicePermissions: readonly ['view']
         }
         search: { page: number; detailPage: number }
+        matches: Array<MakeRouteMatchUnion>
       }>()
       return { detailEnv: 'detailEnv' }
     },
@@ -961,6 +981,7 @@ test('when creating a child route with routeContext, beforeLoad, search, params,
           invoicePermissions: readonly ['view']
         }
         search: { page: number; detailPage: number }
+        matches: Array<MakeRouteMatchUnion>
       }>()
       return { detailsPermissions: ['view'] as const }
     },