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
8 changes: 8 additions & 0 deletions .changeset/fuzzy-pandas-sip.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>(['persist']),
stripSearchParams(defaultSearch),
],
},
component: IndexComponent,
})

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>(['persist']),
stripSearchParams(defaultSearch),
],
},
component: IndexComponent,
})

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>(['persist']),
stripSearchParams(defaultSearch),
],
},
component: IndexComponent,
})

Expand Down
7 changes: 7 additions & 0 deletions packages/router-core/src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,16 @@ export type RoutePathOptionsIntersection<TCustomId, TPath> = {

export type SearchFilter<TInput, TResult = TInput> = (prev: TInput) => TResult

export type SearchMiddlewareMeta = {
removed?: Map<string, unknown>
removedAny?: Set<string>
defaulted?: Map<string, unknown>
}

export type SearchMiddlewareContext<TSearchSchema> = {
search: TSearchSchema
next: (newSearch: TSearchSchema) => TSearchSchema
meta?: SearchMiddlewareMeta
}

export type SearchMiddleware<TSearchSchema> = (
Expand Down
39 changes: 18 additions & 21 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import type {
RouteLike,
RouteMask,
SearchMiddleware,
SearchMiddlewareMeta,
} from './route'
import type {
FullSearchSchema,
Expand Down Expand Up @@ -3123,8 +3124,6 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray<AnyRoute>) {
let dest: BuildNextOptions
let includeValidateSearch: boolean | undefined
const middlewares = [] as Array<SearchMiddleware<any>>
// Flat pairs: [searchBeforeValidation, validatedSearch, ...]
type ValidatedSearches = Array<Record<PropertyKey, unknown>>

for (const route of destRoutes) {
const routeOptions = route.options
Expand Down Expand Up @@ -3157,17 +3156,18 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray<AnyRoute>) {

const routeValidateSearch = routeOptions.validateSearch
if (routeValidateSearch) {
const validate: SearchMiddleware<any> = (
{ search, next },
validations?: ValidatedSearches,
) => {
const validate: SearchMiddleware<any> = ({ 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 {
Expand All @@ -3184,7 +3184,7 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray<AnyRoute>) {
const applyNext = (
index: number,
currentSearch: any,
validations?: ValidatedSearches,
meta?: SearchMiddlewareMeta,
): any => {
// no more middlewares left, return the current search
if (index >= middlewares.length) {
Expand All @@ -3197,21 +3197,18 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray<AnyRoute>) {
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 = meta || ({} as SearchMiddlewareMeta)
return {
search: applyNext(index + 1, newSearch, nextMeta),
meta: nextMeta,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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(
Expand Down
99 changes: 60 additions & 39 deletions packages/router-core/src/searchMiddleware.ts
Original file line number Diff line number Diff line change
@@ -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<TSearchSchema> = (
newSearch: TSearchSchema,
collectMeta: true,
) => { search: TSearchSchema; meta: SearchMiddlewareMeta }

/**
* Retain specified search params across navigations.
*
Expand All @@ -17,18 +26,30 @@ export function retainSearchParams<TSearchSchema extends object>(
keys: Array<keyof TSearchSchema> | true,
): SearchMiddleware<TSearchSchema> {
return ({ search, next }) => {
const [resultSearch, validations] = (next as any)(search, true) as [
TSearchSchema,
Array<Record<PropertyKey, unknown>>,
]
const defaultKeys = validations.length
? getValidationDefaultKeys(search, resultSearch, validations)
: undefined
const { search: resultSearch, meta } = (
next as unknown as SearchMiddlewareNextWithMeta<TSearchSchema>
)(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]
}
}
Expand All @@ -38,35 +59,23 @@ export function retainSearchParams<TSearchSchema extends object>(
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<Record<PropertyKey, unknown>>,
) {
let defaultKeys: Record<PropertyKey, true> | 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
}

/**
Expand All @@ -87,24 +96,36 @@ export function stripSearchParams<
? TValues | true
: TValues,
>(input: NoInfer<TInput>): SearchMiddleware<TSearchSchema> {
return ({ search, next }) => {
return (({ search, next, meta }: SearchMiddlewareContext<TSearchSchema>) => {
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<string, unknown>
const nextResult = next(search)
const result = { ...nextResult } as Record<string, unknown>
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<string, unknown>).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<TSearchSchema>
}
Loading
Loading