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..a78732d833 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,11 @@ 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 +65,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({