Skip to content
Closed
73 changes: 73 additions & 0 deletions e2e/react-router/basic/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -60,6 +62,15 @@ function RootComponent() {
>
Layout
</Link>{' '}
<Link
to="/search-param-binding"
search={{}}
activeProps={{
className: 'font-bold',
}}
>
Search Param Binding
</Link>{' '}
<Link
// @ts-expect-error
to="/this-route-does-not-exist"
Expand Down Expand Up @@ -205,11 +216,73 @@ function LayoutBComponent() {
return <div>I'm layout B!</div>
}

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 (
<div>
<div>useLocation</div>
<input
data-testid="useLocation-filter"
value={useLocationFilter.search.filter}
onChange={(e) =>
navigate({
to: '.',
search: { filter: e.target.value },
})
}
/>

<div>useSearch</div>
<input
data-testid="useSearch-filter"
value={useSearchFilter.filter}
onChange={(e) =>
navigate({
to: '.',
search: { filter: e.target.value },
})
}
/>

<div>useMatch</div>
<input
data-testid="useMatch-filter"
value={useMatchFilter.search.filter}
onChange={(e) =>
navigate({
to: '.',
search: { filter: e.target.value },
})
}
/>
</div>
)
}

const routeTree = rootRoute.addChildren([
postsRoute.addChildren([postRoute, postsIndexRoute]),
layoutRoute.addChildren([
layout2Route.addChildren([layoutARoute, layoutBRoute]),
]),
searchParamBindingRoute,
indexRoute,
])

Expand Down
38 changes: 38 additions & 0 deletions e2e/react-router/basic/tests/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/,
)
})
}
7 changes: 6 additions & 1 deletion packages/react-router/src/useMatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down