diff --git a/e2e/react-router/basic/src/main.tsx b/e2e/react-router/basic/src/main.tsx
index 2ffee37679..a80f78a3d0 100644
--- a/e2e/react-router/basic/src/main.tsx
+++ b/e2e/react-router/basic/src/main.tsx
@@ -7,6 +7,8 @@ import {
createRootRoute,
createRoute,
createRouter,
+ useLocation,
+ useNavigate,
} from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
import { NotFoundError, fetchPost, fetchPosts } from './posts'
@@ -60,6 +62,15 @@ function RootComponent() {
>
Layout
{' '}
+
+ Search Param Binding
+ {' '}
I'm layout B!
}
+const searchParamBindingRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/search-param-binding',
+ component: SearchParamBindingComponent,
+ validateSearch: (input): { filter?: string } => {
+ return {
+ filter: typeof input.filter === 'string' ? input.filter : undefined,
+ }
+ },
+})
+
+function SearchParamBindingComponent() {
+ const navigate = useNavigate()
+
+ const useLocationFilter = useLocation()
+
+ const useSearchFilter = searchParamBindingRoute.useSearch()
+
+ const useMatchFilter = searchParamBindingRoute.useMatch()
+
+ return (
+
+
useLocation
+
+ navigate({
+ to: '.',
+ search: { filter: e.target.value },
+ })
+ }
+ />
+
+
useSearch
+
+ navigate({
+ to: '.',
+ search: { filter: e.target.value },
+ })
+ }
+ />
+
+
useMatch
+
+ navigate({
+ to: '.',
+ search: { filter: e.target.value },
+ })
+ }
+ />
+
+ )
+}
+
const routeTree = rootRoute.addChildren([
postsRoute.addChildren([postRoute, postsIndexRoute]),
layoutRoute.addChildren([
layout2Route.addChildren([layoutARoute, layoutBRoute]),
]),
+ searchParamBindingRoute,
indexRoute,
])
diff --git a/e2e/react-router/basic/tests/app.spec.ts b/e2e/react-router/basic/tests/app.spec.ts
index 5ccdafa513..c3e6675bca 100644
--- a/e2e/react-router/basic/tests/app.spec.ts
+++ b/e2e/react-router/basic/tests/app.spec.ts
@@ -45,3 +45,41 @@ test('Navigating to a post page with viewTransition types', async ({
await page.getByRole('link', { name: 'sunt aut facere repe' }).click()
await expect(page.getByRole('heading')).toContainText('sunt aut facere')
})
+
+for (const hookName of [
+ 'useLocation',
+ 'useSearch',
+ 'useMatch',
+]) {
+ test(`#3162 - Binding an input to search params via ${hookName} with stable cursor position`, async ({
+ page,
+ }) => {
+ await page
+ .getByRole('link', { name: 'Search Param Binding', exact: true })
+ .click()
+ expect(page).toHaveURL(/.*\/search-param-binding/)
+
+ await page.getByTestId(hookName + '-filter').fill('Hello World')
+ expect(page.getByTestId(hookName + '-filter')).toHaveValue('Hello World')
+ expect(page).toHaveURL(/.*\/search-param-binding\?filter=Hello%20World/)
+
+ await page.getByTestId(hookName + '-filter').click()
+ for (let i = 0; i < 5; i++) {
+ await page.keyboard.press('ArrowLeft')
+ }
+ await page.keyboard.press('H')
+ await page.keyboard.press('A')
+ await page.keyboard.press('P')
+ await page.keyboard.press('P')
+ await page.keyboard.press('Y')
+ await page.keyboard.press('Space')
+ await page.getByTestId(hookName + '-filter').blur()
+
+ expect(page.getByTestId(hookName + '-filter')).toHaveValue(
+ 'Hello HAPPY World',
+ )
+ expect(page).toHaveURL(
+ /.*\/search-param-binding\?filter=Hello%20HAPPY%20World/,
+ )
+ })
+}
diff --git a/packages/react-router/src/useMatch.tsx b/packages/react-router/src/useMatch.tsx
index c78e3a6084..1a48e98acc 100644
--- a/packages/react-router/src/useMatch.tsx
+++ b/packages/react-router/src/useMatch.tsx
@@ -110,7 +110,12 @@ export function useMatch<
return undefined
}
- return opts.select ? opts.select(match) : match
+ const stableLocationMatch = {
+ ...match,
+ search: state.location.search,
+ }
+
+ return opts.select ? opts.select(stableLocationMatch) : stableLocationMatch
},
structuralSharing: opts.structuralSharing,
} as any)