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)