From f95b3d0a3051089f5adb4c72005425ea3b4baa87 Mon Sep 17 00:00:00 2001 From: devtechedge Date: Wed, 9 Sep 2026 21:03:26 +0000 Subject: [PATCH] fix(router-core): omit defaulted search params for retain-then-strip Links When retainSearchParams runs before stripSearchParams, Link builds without a search option returned empty next() results, so strip never recorded default removals and retain put validated defaults back into the href. Mark those defaults as removed so hrefs stay clean and active matching works. Fixes #8309 --- .changeset/retain-strip-link-defaults.md | 5 ++ packages/react-router/tests/link.test.tsx | 59 ++++++++++++++++ packages/router-core/src/searchMiddleware.ts | 10 +++ .../router-core/tests/build-location.test.ts | 70 +++++++++++++++++++ 4 files changed, 144 insertions(+) create mode 100644 .changeset/retain-strip-link-defaults.md diff --git a/.changeset/retain-strip-link-defaults.md b/.changeset/retain-strip-link-defaults.md new file mode 100644 index 00000000000..01feb9ae4c4 --- /dev/null +++ b/.changeset/retain-strip-link-defaults.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Fix `retainSearchParams` before `stripSearchParams` so Link hrefs omit default search params instead of keeping them and breaking active matching. diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 1bc556d8858..8202a424118 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -6293,6 +6293,65 @@ describe('search middleware', () => { expect(postsLink).toHaveAttribute('data-status', 'active') }) + test('retainSearchParams before stripSearchParams omits defaults from Link href and stays active', async () => { + // Regression for https://github.com/TanStack/router/issues/8309 + const defaults = { myParam: 'foo' } + const rootRoute = createRootRoute({ + validateSearch: z.object({ + myParam: z.string().default('foo'), + }), + search: { + middlewares: [retainSearchParams(true), stripSearchParams(defaults)], + }, + component: () => { + const { myParam } = rootRoute.useSearch() + return ( + <> +
{myParam}
+ + Home + + + About + + + + ) + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index

, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: () =>

About

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + + expect(await screen.findByTestId('search-value')).toHaveTextContent('foo') + expect(router.state.location.search).toEqual({}) + + const homeLink = await screen.findByTestId('home-link') + const homeHref = homeLink.getAttribute('href') + expect(homeHref).toBe('/') + expect(getSearchParamsFromURI(homeHref!).size).toBe(0) + expect(homeLink).toHaveAttribute('data-status', 'active') + + const aboutLink = await screen.findByTestId('about-link') + const aboutHref = aboutLink.getAttribute('href') + expect(aboutHref).toBe('/about') + expect(getSearchParamsFromURI(aboutHref!).size).toBe(0) + }) + describe('reloadDocument', () => { test('link to /posts with params', async () => { const rootRoute = createRootRoute() diff --git a/packages/router-core/src/searchMiddleware.ts b/packages/router-core/src/searchMiddleware.ts index a78732d8338..cab0c9ce394 100644 --- a/packages/router-core/src/searchMiddleware.ts +++ b/packages/router-core/src/searchMiddleware.ts @@ -132,6 +132,16 @@ export function stripSearchParams< if (meta) { ;(meta.removed ||= new Map()).set(key, value) } + } else if ( + meta && + !(key in result) && + hasOwn.call(search as object, key) && + deepEqual((search as Record)[key], value) + ) { + // next() dropped this key (e.g. Link with no search prop returns {}). + // Still mark the default as removed so outer retainSearchParams does + // not put the defaulted value back into the built href. + ;(meta.removed ||= new Map()).set(key, value) } }, ) diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index edb763fa03a..2d423a0ca1f 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -475,6 +475,76 @@ describe('buildLocation - search params', () => { expect(location.search).toEqual({}) }) + test('retainSearchParams(true) before stripSearchParams should omit defaulted params when search is unset', async () => { + // Regression for https://github.com/TanStack/router/issues/8309 + // Link builds locations without a search option; fromSearch still includes + // validated defaults. retain then strip must not put those defaults in the href. + const defaults = { myParam: 'foo' } + const rootRoute = new BaseRootRoute({ + validateSearch: (search: Record) => ({ + myParam: + search.myParam === undefined ? defaults.myParam : search.myParam, + }), + search: { + middlewares: [retainSearchParams(true), stripSearchParams(defaults)], + }, + }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + expect(router.state.location.search).toEqual({}) + expect(router.state.matches.at(-1)?.search).toEqual(defaults) + + const location = router.buildLocation({ to: '/' } as any) + + expect(location.search).toEqual({}) + expect(location.href).toBe('/') + }) + + test('retainSearchParams(true) before stripSearchParams should still retain non-default params when search is unset', async () => { + const defaults = { myParam: 'foo' } + const rootRoute = new BaseRootRoute({ + validateSearch: (search: Record) => ({ + myParam: + search.myParam === undefined + ? defaults.myParam + : String(search.myParam), + }), + search: { + middlewares: [retainSearchParams(true), stripSearchParams(defaults)], + }, + }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const aboutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/about', + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + history: createMemoryHistory({ initialEntries: ['/?myParam=bar'] }), + }) + + await router.load() + + const location = router.buildLocation({ to: '/about' } as any) + + expect(location.search).toEqual({ myParam: 'bar' }) + expect(location.href).toBe('/about?myParam=bar') + }) + test('retainSearchParams should not restore params explicitly removed by stripSearchParams', async () => { const rootRoute = new BaseRootRoute({}) const indexRoute = new BaseRoute({