From 4ed6d8e6e38eda7bc120034f9169a95cdd86b5f6 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sat, 6 Jun 2026 00:35:15 +0200 Subject: [PATCH 1/3] fix(router-core): respect stripped params in retainSearchParams fixes #7551 --- .changeset/fuzzy-pandas-sip.md | 8 + .../react-router-full/src/routes/index.tsx | 14 +- .../solid-router-full/src/routes/index.tsx | 14 +- .../vue-router-full/src/routes/index.tsx | 14 +- packages/router-core/src/route.ts | 7 + packages/router-core/src/router.ts | 39 +++-- packages/router-core/src/searchMiddleware.ts | 101 +++++++----- .../router-core/tests/build-location.test.ts | 145 +++++++++++++++++- .../tests/searchMiddleware.test.ts | 24 ++- 9 files changed, 298 insertions(+), 68 deletions(-) create mode 100644 .changeset/fuzzy-pandas-sip.md diff --git a/.changeset/fuzzy-pandas-sip.md b/.changeset/fuzzy-pandas-sip.md new file mode 100644 index 0000000000..6e6440315d --- /dev/null +++ b/.changeset/fuzzy-pandas-sip.md @@ -0,0 +1,8 @@ +--- +'@tanstack/router-core': patch +'@tanstack/react-router': patch +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch +--- + +Fix search middleware composition so `retainSearchParams` does not restore search params that a downstream `stripSearchParams` removed. diff --git a/benchmarks/bundle-size/scenarios/react-router-full/src/routes/index.tsx b/benchmarks/bundle-size/scenarios/react-router-full/src/routes/index.tsx index 76fb363538..aeea465e04 100644 --- a/benchmarks/bundle-size/scenarios/react-router-full/src/routes/index.tsx +++ b/benchmarks/bundle-size/scenarios/react-router-full/src/routes/index.tsx @@ -1,6 +1,18 @@ -import { createFileRoute } from '@tanstack/react-router' +import { + createFileRoute, + retainSearchParams, + stripSearchParams, +} from '@tanstack/react-router' + +const defaultSearch = { page: 1 } export const Route = createFileRoute('/')({ + search: { + middlewares: [ + retainSearchParams>(['persist']), + stripSearchParams(defaultSearch), + ], + }, component: IndexComponent, }) diff --git a/benchmarks/bundle-size/scenarios/solid-router-full/src/routes/index.tsx b/benchmarks/bundle-size/scenarios/solid-router-full/src/routes/index.tsx index 01d3b701ae..de52291cac 100644 --- a/benchmarks/bundle-size/scenarios/solid-router-full/src/routes/index.tsx +++ b/benchmarks/bundle-size/scenarios/solid-router-full/src/routes/index.tsx @@ -1,6 +1,18 @@ -import { createFileRoute } from '@tanstack/solid-router' +import { + createFileRoute, + retainSearchParams, + stripSearchParams, +} from '@tanstack/solid-router' + +const defaultSearch = { page: 1 } export const Route = createFileRoute('/')({ + search: { + middlewares: [ + retainSearchParams>(['persist']), + stripSearchParams(defaultSearch), + ], + }, component: IndexComponent, }) diff --git a/benchmarks/bundle-size/scenarios/vue-router-full/src/routes/index.tsx b/benchmarks/bundle-size/scenarios/vue-router-full/src/routes/index.tsx index 3899022bdd..bf165a33b5 100644 --- a/benchmarks/bundle-size/scenarios/vue-router-full/src/routes/index.tsx +++ b/benchmarks/bundle-size/scenarios/vue-router-full/src/routes/index.tsx @@ -1,6 +1,18 @@ -import { createFileRoute } from '@tanstack/vue-router' +import { + createFileRoute, + retainSearchParams, + stripSearchParams, +} from '@tanstack/vue-router' + +const defaultSearch = { page: 1 } export const Route = createFileRoute('/')({ + search: { + middlewares: [ + retainSearchParams>(['persist']), + stripSearchParams(defaultSearch), + ], + }, component: IndexComponent, }) diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 5a92cab7ed..7d94a2ebac 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -74,9 +74,16 @@ export type RoutePathOptionsIntersection = { export type SearchFilter = (prev: TInput) => TResult +export type SearchMiddlewareMeta = { + removed?: Map + removedAny?: Set + defaulted?: Map +} + export type SearchMiddlewareContext = { search: TSearchSchema next: (newSearch: TSearchSchema) => TSearchSchema + meta?: SearchMiddlewareMeta } export type SearchMiddleware = ( diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 7e97f85480..d739407665 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -79,6 +79,7 @@ import type { RouteLike, RouteMask, SearchMiddleware, + SearchMiddlewareMeta, } from './route' import type { FullSearchSchema, @@ -3123,8 +3124,6 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray) { let dest: BuildNextOptions let includeValidateSearch: boolean | undefined const middlewares = [] as Array> - // Flat pairs: [searchBeforeValidation, validatedSearch, ...] - type ValidatedSearches = Array> for (const route of destRoutes) { const routeOptions = route.options @@ -3157,17 +3156,18 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray) { const routeValidateSearch = routeOptions.validateSearch if (routeValidateSearch) { - const validate: SearchMiddleware = ( - { search, next }, - validations?: ValidatedSearches, - ) => { + const validate: SearchMiddleware = ({ search, next, meta }) => { const result = next(search) if (includeValidateSearch) { try { const validated = validateSearch(routeValidateSearch, result) as any - if (validations && validated) { - validations.push(result, validated) + if (meta && validated) { + for (const key in validated) { + if (!(key in result)) { + ;(meta.defaulted ||= new Map()).set(key, validated[key]) + } + } } return { ...result, ...validated } } catch { @@ -3184,7 +3184,7 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray) { const applyNext = ( index: number, currentSearch: any, - validations?: ValidatedSearches, + meta?: SearchMiddlewareMeta, ): any => { // no more middlewares left, return the current search if (index >= middlewares.length) { @@ -3197,21 +3197,18 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray) { return functionalUpdate(dest.search, currentSearch) } - const next = (newSearch: any, collectValidations?: true): any => { - if (collectValidations) { - const nextValidations: ValidatedSearches = [] - return [ - applyNext(index + 1, newSearch, nextValidations), - nextValidations, - ] + const next = (newSearch: any, collectMeta?: true): any => { + if (collectMeta) { + const nextMeta = {} as SearchMiddlewareMeta + return { + search: applyNext(index + 1, newSearch, nextMeta), + meta: nextMeta, + } } - return applyNext(index + 1, newSearch, validations) + return applyNext(index + 1, newSearch, meta) } - return (middlewares[index]! as any)( - { search: currentSearch, next }, - validations, - ) + return (middlewares[index]! as any)({ search: currentSearch, next, meta }) } return function middleware( diff --git a/packages/router-core/src/searchMiddleware.ts b/packages/router-core/src/searchMiddleware.ts index b8ac1a732e..e41188dfd5 100644 --- a/packages/router-core/src/searchMiddleware.ts +++ b/packages/router-core/src/searchMiddleware.ts @@ -1,8 +1,17 @@ -import { createNull, deepEqual } from './utils' +import { deepEqual } from './utils' import type { NoInfer, PickOptional } from './utils' -import type { SearchMiddleware } from './route' +import type { + SearchMiddleware, + SearchMiddlewareContext, + SearchMiddlewareMeta, +} from './route' import type { IsRequiredParams } from './link' +type SearchMiddlewareNextWithMeta = ( + newSearch: TSearchSchema, + collectMeta: true, +) => { search: TSearchSchema; meta: SearchMiddlewareMeta } + /** * Retain specified search params across navigations. * @@ -17,18 +26,30 @@ export function retainSearchParams( keys: Array | true, ): SearchMiddleware { return ({ search, next }) => { - const [resultSearch, validations] = (next as any)(search, true) as [ - TSearchSchema, - Array>, - ] - const defaultKeys = validations.length - ? getValidationDefaultKeys(search, resultSearch, validations) - : undefined + const { search: resultSearch, meta } = ( + next as unknown as SearchMiddlewareNextWithMeta + )(search, true) if (keys === true) { const copy = { ...search, ...resultSearch } - if (defaultKeys) { - for (const key in defaultKeys) { + const removed = meta.removed + for (const key of removed?.keys() || []) { + if (deepEqual(search[key as keyof TSearchSchema], removed!.get(key))) { + delete copy[key as keyof TSearchSchema] + } + } + for (const key of meta.removedAny || []) { + delete copy[key as keyof TSearchSchema] + } + for (const key of meta.defaulted?.keys() || []) { + if ( + key in search && + !meta.removedAny?.has(key) && + !( + meta.removed?.has(key) && + deepEqual(search[key as keyof TSearchSchema], meta.removed.get(key)) + ) + ) { copy[key as keyof TSearchSchema] = search[key as keyof TSearchSchema] } } @@ -38,35 +59,23 @@ export function retainSearchParams( const copy = { ...resultSearch } // add missing keys from search to copy for (const key of keys) { - if (!(key in copy) || (defaultKeys ? key in defaultKeys : false)) { - copy[key] = search[key] - } - } - return copy - } -} - -function getValidationDefaultKeys( - search: any, - resultSearch: any, - validations: Array>, -) { - let defaultKeys: Record | undefined - for (let i = 0; i < validations.length; i += 2) { - const baseSearch = validations[i]! - const validatedSearch = validations[i + 1]! - for (const key in validatedSearch) { + const stringKey = key as string + const removed = + meta.removedAny?.has(stringKey) || + (meta.removed?.has(stringKey) && + deepEqual(search[key], meta.removed.get(stringKey))) if ( - key in search && - !(key in baseSearch) && - resultSearch[key] === validatedSearch[key] + !removed && + (!(key in copy) || + (key in search && + meta.defaulted?.has(stringKey) && + deepEqual(copy[key], meta.defaulted.get(stringKey)))) ) { - const target = defaultKeys || (defaultKeys = createNull()) - target[key] = true + copy[key] = search[key] } } + return copy } - return defaultKeys } /** @@ -87,24 +96,38 @@ export function stripSearchParams< ? TValues | true : TValues, >(input: NoInfer): SearchMiddleware { - return ({ search, next }) => { + return (( + { search, next, meta }: SearchMiddlewareContext, + ) => { if (input === true) { + Object.keys(search as object).forEach((key) => { + if (meta) { + ;(meta.removedAny ||= new Set()).add(key) + } + }) return {} } - const result = { ...next(search) } as Record + const nextResult = next(search) + const result = { ...nextResult } as Record if (Array.isArray(input)) { input.forEach((key) => { - delete result[key] + delete result[key as string] + if (meta) { + ;(meta.removedAny ||= new Set()).add(key as string) + } }) } else { Object.entries(input as Record).forEach( ([key, value]) => { if (deepEqual(result[key], value)) { delete result[key] + if (meta) { + ;(meta.removed ||= new Map()).set(key, value) + } } }, ) } return result as any - } + }) as SearchMiddleware } diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index 73ed022782..0724d87073 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' -import { BaseRootRoute, BaseRoute, retainSearchParams } from '../src' +import { + BaseRootRoute, + BaseRoute, + retainSearchParams, + stripSearchParams, +} from '../src' import { createTestRouter } from './routerTestUtils' describe('buildLocation - params function receives parsed params', () => { @@ -248,6 +253,144 @@ describe('buildLocation - search params', () => { expect(location.search).toEqual({ Auth: 'false' }) }) + test('retainSearchParams should not replace defaults with missing current search', async () => { + const rootRoute = new BaseRootRoute({ + validateSearch: (search: Record) => ({ + Auth: search.Auth === undefined ? 'true' : `${search.Auth}`, + }), + search: { + middlewares: [retainSearchParams(['Auth'])], + }, + }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + const location = router.buildLocation({ + to: '/', + _includeValidateSearch: true, + } as any) + + expect(location.search).toEqual({ Auth: 'true' }) + }) + + test('retainSearchParams should not restore default-valued params removed by stripSearchParams', 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(['param2']), + stripSearchParams(defaults), + ], + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/?param2=2'] }), + }) + + await router.load() + + const location = router.buildLocation({ + to: '/', + search: { param1: 10 }, + _includeValidateSearch: true, + } as any) + + expect(location.search).toEqual({ param1: 10 }) + }) + + test('retainSearchParams should not restore params explicitly removed by stripSearchParams', async () => { + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + search: { + middlewares: [ + retainSearchParams(['param2']), + stripSearchParams>(['param2']), + ], + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/?param2=not-default'] }), + }) + + await router.load() + + const location = router.buildLocation({ + to: '/', + search: { param1: 10 }, + } as any) + + expect(location.search).toEqual({ param1: 10 }) + }) + + test('retainSearchParams should preserve non-default params when defaults are stripped', async () => { + const rootRoute = new BaseRootRoute({ + validateSearch: (search: Record) => ({ + Auth: search.Auth === undefined ? 'true' : `${search.Auth}`, + }), + search: { + middlewares: [ + retainSearchParams(['Auth']), + stripSearchParams({ Auth: 'true' }), + ], + }, + }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const aboutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/about', + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/about?Auth=false'] }), + }) + + await router.load() + + const location = router.buildLocation({ + to: '/', + _includeValidateSearch: true, + } as any) + + expect(location.search).toEqual({ Auth: 'false' }) + }) + test('retainSearchParams should keep downstream values added after validation', async () => { const rootRoute = new BaseRootRoute({ validateSearch: (search: Record) => ({ diff --git a/packages/router-core/tests/searchMiddleware.test.ts b/packages/router-core/tests/searchMiddleware.test.ts index b22004ed3d..ee05ef8abc 100644 --- a/packages/router-core/tests/searchMiddleware.test.ts +++ b/packages/router-core/tests/searchMiddleware.test.ts @@ -13,7 +13,11 @@ describe('searchMiddleware - mutation prevention', () => { // so retainSearchParams should add them from search const result = middleware({ search: originalSearch, - next: () => [{ page: 'new' }, []] as any, + next: () => + ({ + search: { page: 'new' }, + meta: {}, + }) as any, }) expect(originalSearch).toEqual(originalCopy) @@ -29,7 +33,11 @@ describe('searchMiddleware - mutation prevention', () => { // next() returns object without 'id' and 'filter' keys const result1 = middleware({ search: sharedSearch, - next: () => [{ page: '2' }, []] as any, + next: () => + ({ + search: { page: '2' }, + meta: {}, + }) as any, }) expect(sharedSearch).toEqual({ id: '1', filter: 'active', page: '1' }) @@ -37,7 +45,11 @@ describe('searchMiddleware - mutation prevention', () => { const result2 = middleware({ search: sharedSearch, - next: () => [{ page: '3' }, []] as any, + next: () => + ({ + search: { page: '3' }, + meta: {}, + }) as any, }) expect(sharedSearch).toEqual({ id: '1', filter: 'active', page: '1' }) @@ -52,7 +64,11 @@ describe('searchMiddleware - mutation prevention', () => { const result = middleware({ search: originalSearch, - next: () => [{ id: '2' }, []] as any, + next: () => + ({ + search: { id: '2' }, + meta: {}, + }) as any, }) expect(originalSearch).toEqual(originalCopy) From a6a9ba6f01634c9addc738193d0a3160b1a7289e Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sat, 6 Jun 2026 00:48:23 +0200 Subject: [PATCH 2/3] fix --- packages/router-core/src/router.ts | 2 +- .../router-core/tests/build-location.test.ts | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index d739407665..a438dbc626 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -3199,7 +3199,7 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray) { const next = (newSearch: any, collectMeta?: true): any => { if (collectMeta) { - const nextMeta = {} as SearchMiddlewareMeta + const nextMeta = meta || ({} as SearchMiddlewareMeta) return { search: applyNext(index + 1, newSearch, nextMeta), meta: nextMeta, diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index 0724d87073..e1db1e4c9c 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -353,6 +353,39 @@ describe('buildLocation - search params', () => { expect(location.search).toEqual({ param1: 10 }) }) + test('nested retainSearchParams should preserve stripped param metadata', async () => { + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + search: { + middlewares: [ + retainSearchParams(['param1']), + retainSearchParams(['param2']), + stripSearchParams>(['param1']), + ], + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/?param1=stripped¶m2=retained'], + }), + }) + + await router.load() + + const location = router.buildLocation({ + to: '/', + search: { param3: 'next' }, + } as any) + + expect(location.search).toEqual({ param2: 'retained', param3: 'next' }) + }) + test('retainSearchParams should preserve non-default params when defaults are stripped', async () => { const rootRoute = new BaseRootRoute({ validateSearch: (search: Record) => ({ From 9714630afa6465d7e948cad5c574a25dceb09b15 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sat, 6 Jun 2026 00:48:42 +0200 Subject: [PATCH 3/3] format --- packages/router-core/src/searchMiddleware.ts | 4 +--- packages/router-core/tests/build-location.test.ts | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/router-core/src/searchMiddleware.ts b/packages/router-core/src/searchMiddleware.ts index e41188dfd5..27ff86af8d 100644 --- a/packages/router-core/src/searchMiddleware.ts +++ b/packages/router-core/src/searchMiddleware.ts @@ -96,9 +96,7 @@ export function stripSearchParams< ? TValues | true : TValues, >(input: NoInfer): SearchMiddleware { - return (( - { search, next, meta }: SearchMiddlewareContext, - ) => { + return (({ search, next, meta }: SearchMiddlewareContext) => { if (input === true) { Object.keys(search as object).forEach((key) => { if (meta) { diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index e1db1e4c9c..7e7e6e3816 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -340,7 +340,9 @@ describe('buildLocation - search params', () => { const router = createTestRouter({ routeTree, - history: createMemoryHistory({ initialEntries: ['/?param2=not-default'] }), + history: createMemoryHistory({ + initialEntries: ['/?param2=not-default'], + }), }) await router.load()