From de0ddd32215c0d2008753b2f42a8a43a1921ab84 Mon Sep 17 00:00:00 2001 From: Nick Lucas Date: Wed, 15 Jan 2025 14:36:13 +0000 Subject: [PATCH 1/6] Add scenario --- e2e/react-router/basic/src/main.tsx | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/e2e/react-router/basic/src/main.tsx b/e2e/react-router/basic/src/main.tsx index 16eda46d5c..0b77eff88a 100644 --- a/e2e/react-router/basic/src/main.tsx +++ b/e2e/react-router/basic/src/main.tsx @@ -7,6 +7,7 @@ import { createRootRoute, createRoute, createRouter, + useNavigate, } from '@tanstack/react-router' import { TanStackRouterDevtools } from '@tanstack/router-devtools' import { NotFoundError, fetchPost, fetchPosts } from './posts' @@ -59,6 +60,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 { filter } = searchParamBindingRoute.useSearch() + + return ( +
+ + navigate({ + to: '.', + search: { filter: e.target.value }, + }) + } + /> +
+ ) +} + const routeTree = rootRoute.addChildren([ postsRoute.addChildren([postRoute, postsIndexRoute]), layoutRoute.addChildren([ layout2Route.addChildren([layoutARoute, layoutBRoute]), ]), + searchParamBindingRoute, indexRoute, ]) From 1948bfb51d08b24589ceeb15e131d5fdb185f1e5 Mon Sep 17 00:00:00 2001 From: Nick Lucas Date: Wed, 15 Jan 2025 14:48:59 +0000 Subject: [PATCH 2/6] Write draft test --- e2e/react-router/basic/src/main.tsx | 2 +- e2e/react-router/basic/tests/app.spec.ts | 27 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/e2e/react-router/basic/src/main.tsx b/e2e/react-router/basic/src/main.tsx index 0b77eff88a..3eb8f3373f 100644 --- a/e2e/react-router/basic/src/main.tsx +++ b/e2e/react-router/basic/src/main.tsx @@ -232,7 +232,7 @@ function SearchParamBindingComponent() { return (
navigate({ diff --git a/e2e/react-router/basic/tests/app.spec.ts b/e2e/react-router/basic/tests/app.spec.ts index 5ccdafa513..927def6489 100644 --- a/e2e/react-router/basic/tests/app.spec.ts +++ b/e2e/react-router/basic/tests/app.spec.ts @@ -45,3 +45,30 @@ 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') }) + +test('#3162 - Binding an input to search params with stable cursor position', async ({ + page, +}) => { + await page + .getByRole('link', { name: 'Search Param Binding', exact: true }) + .click() + expect(page.url()).toBe('http://localhost:3000/search-param-binding') + + await page.getByRole('textbox', { name: 'Filter' }).fill('Hello World') + expect(page.getByRole('textbox', { name: 'Filter' })).toHaveValue('Hello World') + expect(page.url()).toBe('http://localhost:3000/search-param-binding?filter=Hello%20World') + + await page.getByRole('textbox', { name: 'Filter' }).click() + for (let i = 0; i < 5; i++) { + await page.keyboard.press('ArrowLeft') + } + await page.keyboard.press('Space') + 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.getByRole('textbox', { name: 'Filter' }).blur() + + expect(page.getByRole('textbox', { name: 'Filter' })).toHaveValue('Hello Happy World') + expect(page.url()).toBe('http://localhost:3000/search-param-binding?filter=Hello%20Happy%20World')}) From 2c972672d939b6c3e7346bba4d9fb53b95f1c16e Mon Sep 17 00:00:00 2001 From: Nick Lucas Date: Wed, 15 Jan 2025 14:56:08 +0000 Subject: [PATCH 3/6] Make test case run --- e2e/react-router/basic/src/main.tsx | 2 +- e2e/react-router/basic/tests/app.spec.ts | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/e2e/react-router/basic/src/main.tsx b/e2e/react-router/basic/src/main.tsx index 3eb8f3373f..756686d634 100644 --- a/e2e/react-router/basic/src/main.tsx +++ b/e2e/react-router/basic/src/main.tsx @@ -232,7 +232,7 @@ function SearchParamBindingComponent() { return (
navigate({ diff --git a/e2e/react-router/basic/tests/app.spec.ts b/e2e/react-router/basic/tests/app.spec.ts index 927def6489..6c6a069a8b 100644 --- a/e2e/react-router/basic/tests/app.spec.ts +++ b/e2e/react-router/basic/tests/app.spec.ts @@ -52,13 +52,13 @@ test('#3162 - Binding an input to search params with stable cursor position', as await page .getByRole('link', { name: 'Search Param Binding', exact: true }) .click() - expect(page.url()).toBe('http://localhost:3000/search-param-binding') + expect(page).toHaveURL(/.*\/search-param-binding/) - await page.getByRole('textbox', { name: 'Filter' }).fill('Hello World') - expect(page.getByRole('textbox', { name: 'Filter' })).toHaveValue('Hello World') - expect(page.url()).toBe('http://localhost:3000/search-param-binding?filter=Hello%20World') + await page.getByTestId('filter').fill('Hello World') + expect(page.getByTestId('filter')).toHaveValue('Hello World') + expect(page).toHaveURL(/.*\/search-param-binding\?filter=Hello%20World/) - await page.getByRole('textbox', { name: 'Filter' }).click() + await page.getByTestId('filter').click() for (let i = 0; i < 5; i++) { await page.keyboard.press('ArrowLeft') } @@ -68,7 +68,10 @@ test('#3162 - Binding an input to search params with stable cursor position', as await page.keyboard.press('P') await page.keyboard.press('P') await page.keyboard.press('Y') - await page.getByRole('textbox', { name: 'Filter' }).blur() + await page.getByTestId('filter').blur() - expect(page.getByRole('textbox', { name: 'Filter' })).toHaveValue('Hello Happy World') - expect(page.url()).toBe('http://localhost:3000/search-param-binding?filter=Hello%20Happy%20World')}) + expect(page.getByTestId('filter')).toHaveValue('Hello Happy World') + expect(page).toHaveURL( + /.*\/search-param-binding\?filter=Hello%20Happy%20World/, + ) +}) From 94d6630bfe2c15fdf0fcaad3fcfc52ea1e24f23c Mon Sep 17 00:00:00 2001 From: Nick Lucas Date: Sat, 25 Jan 2025 15:52:25 +0000 Subject: [PATCH 4/6] Expand test case to other hooks, 2 of 3 cases failing --- e2e/react-router/basic/src/main.tsx | 43 +++++++++++++++-- e2e/react-router/basic/tests/app.spec.ts | 60 ++++++++++++++---------- 2 files changed, 74 insertions(+), 29 deletions(-) diff --git a/e2e/react-router/basic/src/main.tsx b/e2e/react-router/basic/src/main.tsx index 756686d634..050d1c0960 100644 --- a/e2e/react-router/basic/src/main.tsx +++ b/e2e/react-router/basic/src/main.tsx @@ -7,6 +7,7 @@ import { createRootRoute, createRoute, createRouter, + useLocation, useNavigate, } from '@tanstack/react-router' import { TanStackRouterDevtools } from '@tanstack/router-devtools' @@ -227,13 +228,49 @@ const searchParamBindingRoute = createRoute({ function SearchParamBindingComponent() { const navigate = useNavigate() - const { filter } = searchParamBindingRoute.useSearch() + + const useLocationFilter = useLocation({ + select(state) { + return state.search.filter + }, + }) + + const { filter: useSearchFilter } = searchParamBindingRoute.useSearch() + + const { + search: { filter: useMatchFilter }, + } = searchParamBindingRoute.useMatch() return (
+
useLocation
+ + navigate({ + to: '.', + search: { filter: e.target.value }, + }) + } + /> + +
useSearch
+ + navigate({ + to: '.', + search: { filter: e.target.value }, + }) + } + /> + +
useMatch
navigate({ to: '.', diff --git a/e2e/react-router/basic/tests/app.spec.ts b/e2e/react-router/basic/tests/app.spec.ts index 6c6a069a8b..c3e6675bca 100644 --- a/e2e/react-router/basic/tests/app.spec.ts +++ b/e2e/react-router/basic/tests/app.spec.ts @@ -46,32 +46,40 @@ test('Navigating to a post page with viewTransition types', async ({ await expect(page.getByRole('heading')).toContainText('sunt aut facere') }) -test('#3162 - Binding an input to search params with stable cursor position', async ({ - page, -}) => { - await page - .getByRole('link', { name: 'Search Param Binding', exact: true }) - .click() - expect(page).toHaveURL(/.*\/search-param-binding/) +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('filter').fill('Hello World') - expect(page.getByTestId('filter')).toHaveValue('Hello World') - expect(page).toHaveURL(/.*\/search-param-binding\?filter=Hello%20World/) + 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('filter').click() - for (let i = 0; i < 5; i++) { - await page.keyboard.press('ArrowLeft') - } - await page.keyboard.press('Space') - 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.getByTestId('filter').blur() + 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('filter')).toHaveValue('Hello Happy World') - expect(page).toHaveURL( - /.*\/search-param-binding\?filter=Hello%20Happy%20World/, - ) -}) + expect(page.getByTestId(hookName + '-filter')).toHaveValue( + 'Hello HAPPY World', + ) + expect(page).toHaveURL( + /.*\/search-param-binding\?filter=Hello%20HAPPY%20World/, + ) + }) +} From ffcfd0cd579e2d8a86cc8d3af89b9520da7b4f62 Mon Sep 17 00:00:00 2001 From: Nick Lucas Date: Sat, 25 Jan 2025 15:58:08 +0000 Subject: [PATCH 5/6] Simplify selectors --- e2e/react-router/basic/src/main.tsx | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/e2e/react-router/basic/src/main.tsx b/e2e/react-router/basic/src/main.tsx index 050d1c0960..d516ba3af2 100644 --- a/e2e/react-router/basic/src/main.tsx +++ b/e2e/react-router/basic/src/main.tsx @@ -229,24 +229,18 @@ const searchParamBindingRoute = createRoute({ function SearchParamBindingComponent() { const navigate = useNavigate() - const useLocationFilter = useLocation({ - select(state) { - return state.search.filter - }, - }) + const useLocationFilter = useLocation() - const { filter: useSearchFilter } = searchParamBindingRoute.useSearch() + const useSearchFilter = searchParamBindingRoute.useSearch() - const { - search: { filter: useMatchFilter }, - } = searchParamBindingRoute.useMatch() + const useMatchFilter = searchParamBindingRoute.useMatch() return (
useLocation
navigate({ to: '.', @@ -258,7 +252,7 @@ function SearchParamBindingComponent() {
useSearch
navigate({ to: '.', @@ -270,7 +264,7 @@ function SearchParamBindingComponent() {
useMatch
navigate({ to: '.', From 0cb8db692b04fb1dedbc756e4c2c988beac1dc6a Mon Sep 17 00:00:00 2001 From: Nick Lucas Date: Sat, 25 Jan 2025 16:30:44 +0000 Subject: [PATCH 6/6] Patch search on useMatch to fix useSearch and useMatch.search binding to inputs --- packages/react-router/src/useMatch.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/react-router/src/useMatch.tsx b/packages/react-router/src/useMatch.tsx index 367ae4f46d..b871017a05 100644 --- a/packages/react-router/src/useMatch.tsx +++ b/packages/react-router/src/useMatch.tsx @@ -109,7 +109,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)