Repository: TanStack/router (ts)

Issue:
Search params get stripped if validateSearch throws and beforeLoad redirects
### Which project does this relate to?

Router

### Describe the bug

If a route has both a `validateSearch` and a `beforeLoad` that redirects:
```ts
export const Route = createFileRoute('/about')({
  validateSearch: zod.object({
    a: zod.number(),
  }),
  beforeLoad: async ({ search }) => {
    throw redirect({ to: '/third', search });
  },
});
```
When landing on such page with an *invalid search*, the `search` object forwarded through the redirection is an *empty object*.

### Your Example Website or App

https://stackblitz.com/edit/tanstack-router-yhzqerey?file=src%2Froutes%2Fabout.tsx

### Steps to Reproduce the Bug or Issue

1. from the `/` homepage on the stackblitz repro above
2. click on the "to about page with search" link

### Expected behavior

I would have expected either:
- to remain on the "/about" page, in an error state (`errorComponent`)
- or to be redirected to the "/third" page, with the `search` untouched

But instead, we're redirected correctly, but the search becomes an empty object. There is no indication of an error:
- no `errorComponent` gets mounted
- no `onError` callback gets called

### Screenshots or Videos

_No response_

### Platform

- OS: macOS
- Browser: Chrome
- Version: 1.95.1


### Additional context

_No response_

Pull request description:
fix(react-router, solid-router): do not strip search params if search validation fails
fixes #3120

Patch:
diff --git a/docs/router/framework/react/guide/tanstack-start.md b/docs/router/framework/react/guide/tanstack-start.md
index e5f308a9d..78a951154 100644
--- a/docs/router/framework/react/guide/tanstack-start.md
+++ b/docs/router/framework/react/guide/tanstack-start.md
@@ -203,18 +203,20 @@ import { Outlet } from '@tanstack/react-router'
 import * as React from 'react'
 
 export const Route = createRootRoute({
-  meta: () => [
-    {
-      charSet: 'utf-8',
-    },
-    {
-      name: 'viewport',
-      content: 'width=device-width, initial-scale=1',
-    },
-    {
-      title: 'TanStack Start Starter',
-    },
-  ],
+  head: () => ({
+    meta: [
+      {
+        charSet: 'utf-8',
+      },
+      {
+        name: 'viewport',
+        content: 'width=device-width, initial-scale=1',
+      },
+      {
+        title: 'TanStack Start Starter',
+      },
+    ],
+  }),
   component: RootComponent,
 })
 
diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts
index 1786a28ec..31eb4fe79 100644
--- a/packages/react-router/src/router.ts
+++ b/packages/react-router/src/router.ts
@@ -1611,8 +1611,8 @@ export class Router<
               }
               if (opts._includeValidateSearch && route.options.validateSearch) {
                 const validate: SearchMiddleware<any> = ({ search, next }) => {
+                  const result = next(search)
                   try {
-                    const result = next(search)
                     const validatedSearch = {
                       ...result,
                       ...(validateSearch(
@@ -1623,6 +1623,7 @@ export class Router<
                     return validatedSearch
                   } catch {
                     // ignore errors here because they are already handled in matchRoutes
+                    return result
                   }
                 }
                 middlewares.push(validate)
diff --git a/packages/solid-router/src/router.ts b/packages/solid-router/src/router.ts
index 79bdcc4e3..9f7815e9e 100644
--- a/packages/solid-router/src/router.ts
+++ b/packages/solid-router/src/router.ts
@@ -1586,8 +1586,8 @@ export class Router<
               }
               if (opts._includeValidateSearch && route.options.validateSearch) {
                 const validate: SearchMiddleware<any> = ({ search, next }) => {
+                  const result = next(search)
                   try {
-                    const result = next(search)
                     const validatedSearch = {
                       ...result,
                       ...(validateSearch(
@@ -1598,6 +1598,7 @@ export class Router<
                     return validatedSearch
                   } catch {
                     // ignore errors here because they are already handled in matchRoutes
+                    return result
                   }
                 }
                 middlewares.push(validate)


Test patch:
diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx
index 04a7a6f9c..eb77d36f5 100644
--- a/packages/react-router/tests/router.test.tsx
+++ b/packages/react-router/tests/router.test.tsx
@@ -1571,3 +1571,73 @@ it('does not push to history if url and state are the same', async () => {
 
   expect(history.length).toBe(1)
 })
+
+describe('does not strip search params if search validation fails', () => {
+  afterEach(() => {
+    window.history.replaceState(null, 'root', '/')
+    cleanup()
+  })
+
+  function getRouter() {
+    const rootRoute = createRootRoute({
+      validateSearch: z.object({ root: z.string() }),
+      component: () => {
+        const search = rootRoute.useSearch()
+        return (
+          <>
+            <div data-testid="search-root">{search.root ?? '$undefined'}</div>
+            <Outlet />
+          </>
+        )
+      },
+    })
+    const indexRoute = createRoute({
+      getParentRoute: () => rootRoute,
+      path: '/',
+      validateSearch: z.object({ index: z.string() }),
+      component: () => {
+        const search = rootRoute.useSearch()
+        return (
+          <>
+            <div data-testid="search-index">{search.index ?? '$undefined'}</div>
+            <Outlet />
+          </>
+        )
+      },
+    })
+
+    const routeTree = rootRoute.addChildren([indexRoute])
+
+    const router = createRouter({ routeTree })
+
+    return router
+  }
+
+  it('smoke test - all required search params are present', async () => {
+    window.history.replaceState(null, 'root', '/?root=hello&index=world')
+    const router = getRouter()
+    await act(() => render(<RouterProvider router={router} />))
+
+    expect(await screen.findByTestId('search-root')).toHaveTextContent('hello')
+    expect(await screen.findByTestId('search-index')).toHaveTextContent('world')
+
+    expect(window.location.search).toBe('?root=hello&index=world')
+  })
+
+  it('root is missing', async () => {
+    window.history.replaceState(null, 'root', '/?index=world')
+    const router = getRouter()
+    await act(() => render(<RouterProvider router={router} />))
+
+    expect(window.location.search).toBe('?index=world')
+  })
+
+  it('index is missing', async () => {
+    window.history.replaceState(null, 'root', '/?root=hello')
+    const router = getRouter()
+
+    await act(() => render(<RouterProvider router={router} />))
+
+    expect(window.location.search).toBe('?root=hello')
+  })
+})
diff --git a/packages/solid-router/tests/Scripts.test.tsx b/packages/solid-router/tests/Scripts.test.tsx
index 997ce3382..6bebcd995 100644
--- a/packages/solid-router/tests/Scripts.test.tsx
+++ b/packages/solid-router/tests/Scripts.test.tsx
@@ -2,14 +2,14 @@ import { describe, expect, test } from 'vitest'
 import { render } from '@solidjs/testing-library'
 
 import {
+  HeadContent,
   RouterProvider,
   createMemoryHistory,
   createRootRoute,
   createRoute,
   createRouter,
-} from '@tanstack/solid-router'
+} from '../src'
 import { Scripts } from '../src/Scripts'
-import { HeadContent } from '../src'
 
 describe('ssr scripts', () => {
   test('it works', async () => {
diff --git a/packages/solid-router/tests/router.test.tsx b/packages/solid-router/tests/router.test.tsx
index 227ac27c7..0733ba4d1 100644
--- a/packages/solid-router/tests/router.test.tsx
+++ b/packages/solid-router/tests/router.test.tsx
@@ -1371,8 +1371,80 @@ it('does not push to history if url and state are the same', async () => {
   const { router } = createTestRouter({
     history,
   })
-  await render(() => <RouterProvider router={router} />)
+  render(() => <RouterProvider router={router} />)
   const link = await screen.findByTestId('link')
-  await fireEvent.click(link)
+  fireEvent.click(link)
   expect(history.length).toBe(1)
 })
+
+describe('does not strip search params if search validation fails', () => {
+  afterEach(() => {
+    window.history.replaceState(null, 'root', '/')
+    cleanup()
+  })
+
+  function getRouter() {
+    const rootRoute = createRootRoute({
+      validateSearch: z.object({ root: z.string() }),
+      component: () => {
+        const search = rootRoute.useSearch()
+        return (
+          <div>
+            <div data-testid="search-root">{search().root ?? '$undefined'}</div>
+            <Outlet />
+          </div>
+        )
+      },
+    })
+    const indexRoute = createRoute({
+      getParentRoute: () => rootRoute,
+      path: '/',
+      validateSearch: z.object({ index: z.string() }),
+      component: () => {
+        const search = rootRoute.useSearch()
+        return (
+          <>
+            <div data-testid="search-index">
+              {search().index ?? '$undefined'}
+            </div>
+            <Outlet />
+          </>
+        )
+      },
+    })
+
+    const routeTree = rootRoute.addChildren([indexRoute])
+
+    const router = createRouter({ routeTree })
+
+    return router
+  }
+
+  it('smoke test - all required search params are present', async () => {
+    window.history.replaceState(null, 'root', '/?root=hello&index=world')
+    const router = getRouter()
+    render(() => <RouterProvider router={router} />)
+
+    expect(await screen.findByTestId('search-root')).toHaveTextContent('hello')
+    expect(await screen.findByTestId('search-index')).toHaveTextContent('world')
+
+    expect(window.location.search).toBe('?root=hello&index=world')
+  })
+
+  it('root is missing', async () => {
+    window.history.replaceState(null, 'root', '/?index=world')
+    const router = getRouter()
+    render(() => <RouterProvider router={router} />)
+
+    expect(window.location.search).toBe('?index=world')
+  })
+
+  it('index is missing', async () => {
+    window.history.replaceState(null, 'root', '/?root=hello')
+    const router = getRouter()
+
+    render(() => <RouterProvider router={router} />)
+
+    expect(window.location.search).toBe('?root=hello')
+  })
+})