Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-retain-strip-explicit-defaults.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-core': patch
---

Fix retained search params overriding explicit default-valued navigations when used with `stripSearchParams`.
1 change: 1 addition & 0 deletions packages/router-core/src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export type SearchMiddlewareMeta = {
removed?: Map<string, unknown>
removedAny?: Set<string>
defaulted?: Map<string, unknown>
explicit?: unknown
}

export type SearchMiddlewareContext<TSearchSchema> = {
Expand Down
6 changes: 5 additions & 1 deletion packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3194,7 +3194,11 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray<AnyRoute>) {
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 => {
Expand Down
18 changes: 14 additions & 4 deletions packages/router-core/src/searchMiddleware.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { deepEqual } from './utils'
import { deepEqual, hasOwn } from './utils'
import type { NoInfer, PickOptional } from './utils'
import type {
SearchMiddleware,
Expand Down Expand Up @@ -33,8 +33,12 @@ export function retainSearchParams<TSearchSchema extends object>(
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]
}
}
Expand All @@ -47,7 +51,11 @@ export function retainSearchParams<TSearchSchema extends object>(
!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]
Expand All @@ -57,13 +65,15 @@ export function retainSearchParams<TSearchSchema extends object>(
}

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) ||
Expand Down
2 changes: 1 addition & 1 deletion packages/router-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export function functionalUpdate<TPrevious, TResult = TPrevious>(
return updater
}

const hasOwn = Object.prototype.hasOwnProperty
export const hasOwn = Object.prototype.hasOwnProperty
const isEnumerable = Object.prototype.propertyIsEnumerable

export function hasKeys(obj: Record<string, unknown>) {
Expand Down
75 changes: 75 additions & 0 deletions packages/router-core/tests/build-location.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => ({
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<string, unknown>) => ({
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({
Expand Down
Loading