From b073a1d57cb87290f31573f1438822f52401cd70 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sat, 6 Jun 2026 10:12:14 +0200 Subject: [PATCH 1/2] fix(router-core): allow retained search params to reset to defaults --- .../fix-retain-strip-explicit-defaults.md | 5 ++ packages/router-core/src/route.ts | 1 + packages/router-core/src/router.ts | 6 +- packages/router-core/src/searchMiddleware.ts | 15 +++- packages/router-core/src/utils.ts | 2 +- .../router-core/tests/build-location.test.ts | 75 +++++++++++++++++++ 6 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 .changeset/fix-retain-strip-explicit-defaults.md diff --git a/.changeset/fix-retain-strip-explicit-defaults.md b/.changeset/fix-retain-strip-explicit-defaults.md new file mode 100644 index 0000000000..7089be5d2c --- /dev/null +++ b/.changeset/fix-retain-strip-explicit-defaults.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Fix retained search params overriding explicit default-valued navigations when used with `stripSearchParams`. diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 7d94a2ebac..2de7dc57f0 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -78,6 +78,7 @@ export type SearchMiddlewareMeta = { removed?: Map removedAny?: Set defaulted?: Map + explicit?: unknown } export type SearchMiddlewareContext = { diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index a438dbc626..5e3f1d675e 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -3194,7 +3194,11 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray) { if (dest.search === true) { return currentSearch } - return functionalUpdate(dest.search, currentSearch) + const result = functionalUpdate(dest.search, currentSearch) + if (meta) { + meta.explicit = result + } + return result } const next = (newSearch: any, collectMeta?: true): any => { diff --git a/packages/router-core/src/searchMiddleware.ts b/packages/router-core/src/searchMiddleware.ts index 27ff86af8d..c015313ad4 100644 --- a/packages/router-core/src/searchMiddleware.ts +++ b/packages/router-core/src/searchMiddleware.ts @@ -1,4 +1,4 @@ -import { deepEqual } from './utils' +import { deepEqual, hasOwn } from './utils' import type { NoInfer, PickOptional } from './utils' import type { SearchMiddleware, @@ -33,8 +33,12 @@ export function retainSearchParams( if (keys === true) { const copy = { ...search, ...resultSearch } const removed = meta.removed + const explicit = meta.explicit for (const key of removed?.keys() || []) { - if (deepEqual(search[key as keyof TSearchSchema], removed!.get(key))) { + if ( + (explicit && hasOwn.call(explicit, key)) || + deepEqual(search[key as keyof TSearchSchema], removed!.get(key)) + ) { delete copy[key as keyof TSearchSchema] } } @@ -47,7 +51,8 @@ export function retainSearchParams( !meta.removedAny?.has(key) && !( meta.removed?.has(key) && - deepEqual(search[key as keyof TSearchSchema], meta.removed.get(key)) + ((explicit && hasOwn.call(explicit, key)) || + deepEqual(search[key as keyof TSearchSchema], meta.removed.get(key))) ) ) { copy[key as keyof TSearchSchema] = search[key as keyof TSearchSchema] @@ -57,13 +62,15 @@ export function retainSearchParams( } const copy = { ...resultSearch } + const explicit = meta.explicit // add missing keys from search to copy for (const key of keys) { const stringKey = key as string const removed = meta.removedAny?.has(stringKey) || (meta.removed?.has(stringKey) && - deepEqual(search[key], meta.removed.get(stringKey))) + ((explicit && hasOwn.call(explicit, stringKey)) || + deepEqual(search[key], meta.removed.get(stringKey)))) if ( !removed && (!(key in copy) || diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index 0c72c76c18..f017215b5c 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -212,7 +212,7 @@ export function functionalUpdate( return updater } -const hasOwn = Object.prototype.hasOwnProperty +export const hasOwn = Object.prototype.hasOwnProperty const isEnumerable = Object.prototype.propertyIsEnumerable export function hasKeys(obj: Record) { diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index 862f473cac..131fccf095 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -323,6 +323,81 @@ describe('buildLocation - search params', () => { expect(location.search).toEqual({ param1: 10 }) }) + test('retainSearchParams should allow explicit default params to reset current params when stripSearchParams removes them', async () => { + const defaults = { + param1: 1, + param2: 2, + } + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: (search: Record) => ({ + param1: search.param1 === undefined ? defaults.param1 : search.param1, + param2: search.param2 === undefined ? defaults.param2 : search.param2, + }), + search: { + middlewares: [ + retainSearchParams(['param1', 'param2']), + stripSearchParams(defaults), + ], + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/?param1=10'] }), + }) + + await router.load() + + const location = router.buildLocation({ + to: '/', + search: defaults, + _includeValidateSearch: true, + } as any) + + expect(location.search).toEqual({}) + }) + + test('retainSearchParams(true) should allow explicit default params to reset current params when stripSearchParams removes them', async () => { + const defaults = { + param1: 1, + param2: 2, + } + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: (search: Record) => ({ + param1: search.param1 === undefined ? defaults.param1 : search.param1, + param2: search.param2 === undefined ? defaults.param2 : search.param2, + }), + search: { + middlewares: [retainSearchParams(true), stripSearchParams(defaults)], + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/?param1=10'] }), + }) + + await router.load() + + const location = router.buildLocation({ + to: '/', + search: defaults, + _includeValidateSearch: true, + } as any) + + expect(location.search).toEqual({}) + }) + test('retainSearchParams should not restore params explicitly removed by stripSearchParams', async () => { const rootRoute = new BaseRootRoute({}) const indexRoute = new BaseRoute({ From 869fab1c8c86b1fb19e31be03026f74ce184222a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 6 Jun 2026 08:13:49 +0000 Subject: [PATCH 2/2] ci: apply automated fixes --- packages/router-core/src/searchMiddleware.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/router-core/src/searchMiddleware.ts b/packages/router-core/src/searchMiddleware.ts index c015313ad4..a78732d833 100644 --- a/packages/router-core/src/searchMiddleware.ts +++ b/packages/router-core/src/searchMiddleware.ts @@ -52,7 +52,10 @@ export function retainSearchParams( !( meta.removed?.has(key) && ((explicit && hasOwn.call(explicit, key)) || - deepEqual(search[key as keyof TSearchSchema], meta.removed.get(key))) + deepEqual( + search[key as keyof TSearchSchema], + meta.removed.get(key), + )) ) ) { copy[key as keyof TSearchSchema] = search[key as keyof TSearchSchema]