-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat: add search persistence middleware #5004
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: add search persistence middleware #5004
Conversation
- Add persistSearchParams middleware for automatic search parameter persistence - Add getSearchPersistenceStore() function with full type inference - Include comprehensive example with Users and Products routes - Add API documentation following project conventions - Support selective parameter exclusion with typed arrays - SSR compatible with proper route context handling - Framework agnostic core with React integration
WalkthroughAdds typed, route-aware search-parameter persistence: a SearchPersistenceStore class, create/get accessors, persistSearchParams middleware, router plumbing to pass route + router into search middleware, barrel re-exports, docs, and complete React + SSR examples (localStorage and server DB sync) demonstrating usage, exclusions, and restoration. (50 words) Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant UI as Route Component
participant Router as Router
participant MW as persistSearchParams
participant Store as SearchPersistenceStore
Note over Router,MW: Middleware invoked with route metadata {id, fullPath} and router
UI->>Router: navigate(to, search?)
Router->>MW: invoke({ search, next, route, router })
alt incoming search is empty
MW->>Store: getSearch(route.id)
Store-->>MW: savedSearch|null
MW->>MW: if savedSearch -> restore/merge
end
MW->>Router: next(merged/filteredSearch)
alt resulting search non-empty
MW->>Store: saveSearch(route.id, filteredSearch)
end
Router-->>UI: render with finalSearch
sequenceDiagram
autonumber
participant App as App bootstrap
participant LS as localStorage
participant Store as SearchPersistenceStore
App->>LS: getItem('search-persistence')
LS-->>App: serialized state
App->>Store: saveSearch(routeId, search) per entry (rehydrate)
Store-->>App: notify subscribers
App->>LS: setItem('search-persistence', serialized state) on updates
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Possibly related PRs
Suggested reviewers
Poem
Tip 🔌 Remote MCP (Model Context Protocol) integration is now available!Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats. ✨ Finishing Touches
🧪 Generate unit tests
🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
- Run prettier:write to format all files according to project standards - Fix ESLint import sorting and method signature style issues - All files now pass prettier and eslint checks
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 8
🧹 Nitpick comments (31)
examples/react/search-persistence/tsconfig.json (1)
2-8
: Consider adding Vite-friendly compiler options (moduleResolution, types, noEmit)These improve DX (env typing) and align with modern Vite setups.
Apply this diff:
"compilerOptions": { "strict": true, "esModuleInterop": true, "jsx": "react-jsx", "lib": ["DOM", "DOM.Iterable", "ES2022"], - "skipLibCheck": true + "skipLibCheck": true, + "moduleResolution": "bundler", + "module": "ESNext", + "types": ["vite/client"], + "noEmit": true }examples/react/search-persistence/README.md (4)
62-64
: Remove trailing spaces in the headingMinor Markdown cleanup flagged by tooling; trailing spaces aren’t needed after a heading.
Apply this diff:
-### Manual Restoration +### Manual Restoration
68-74
: Prefer preserving current search when no persisted state existsUsing an empty object may unintentionally reset a route’s search. Falling back to prev keeps the current state intact if nothing is persisted.
Apply this diff:
-const store = getSearchPersistenceStore() -const savedSearch = store.getSearch('/users') - -<Link to="/users" search={savedSearch || {}}> - Users (manual restoration) -</Link> +const store = getSearchPersistenceStore() +const savedSearch = store.getSearch('/users') + +<Link + to="/users" + // If nothing is persisted, keep the current search untouched instead of forcing {} + search={(prev) => savedSearch ?? prev} +> + Users (manual restoration) +</Link>
75-76
: Consider adding a quick example for clearing persisted searchYou mention manual control; a “clear” example rounds it out.
Apply this diff:
+ +### Clearing persisted search + +```tsx +import { getSearchPersistenceStore } from '@tanstack/react-router' +const store = getSearchPersistenceStore() +// Clear only the Users route persisted search +store.clearSearch('/users') +// Or clear everything +store.clearAllSearches() +```
95-95
: Minor grammar tweak for readabilityAdd “the” and simplify wording.
Apply this diff:
-Navigate between Users and Products routes to see automatic search parameter persistence in action. +Navigate between the Users and Products routes to see search parameter persistence in action.packages/router-core/src/route.ts (1)
74-78
: StrengthenSearchMiddlewareContext.route
typingThe router already threads the route object (with
id
andfullPath
) through the middleware pipeline inrouter.ts
, so no changes are needed there. To enforce immutability and reuse the existingRouteExtensions
interface, update the context type as follows:export type SearchMiddlewareContext<TSearchSchema> = { search: TSearchSchema next: (newSearch: TSearchSchema) => TSearchSchema - route: { id: string; fullPath: string } + route: Readonly<Pick<RouteExtensions<string, string>, 'id' | 'fullPath'>> }This ensures middlewares see a readonly, consistently typed route object.
examples/react/search-persistence/index.html (1)
1-12
: Use a descriptive title for the exampleMinor UX nit: “Vite App” is generic. Consider a descriptive title to help users distinguish tabs.
Apply this diff:
- <title>Vite App</title> + <title>Search Persistence – TanStack Router</title>examples/react/search-persistence/src/styles.css (1)
8-10
: Global border color via universal selector: confirm intentional scopeStyling all elements’ border color with
*
is a common pattern but can be heavy on very large DOMs and also affects elements that don’t render borders. If the intent is to set the default border color theme-wide, this is fine; otherwise consider scoping to a container or adding::before, ::after
if pseudo-elements are relied upon for borders.Would you like a scoped alternative (e.g., a wrapper class) to reduce global impact?
examples/react/search-persistence/src/main.tsx (1)
8-11
: Avoid duplicate subscriptions during HMR by disposing the localStorage sync listenerIn dev, Vite HMR will re-run module code and stack multiple subscriptions. Dispose the previous subscription to prevent duplicate writes and memory leaks.
-// Setup localStorage sync for search persistence (optional) -if (typeof window !== 'undefined') { - setupLocalStorageSync() -} +// Setup localStorage sync for search persistence (optional) +if (typeof window !== 'undefined') { + const unsubscribe = setupLocalStorageSync() + // Avoid duplicate subscriptions during Vite HMR in dev + if (import.meta?.hot) { + import.meta.hot.dispose(() => unsubscribe()) + } +}examples/react/search-persistence/src/utils/localStorage-sync.ts (2)
13-15
: Filter unknown route IDs when restoring persisted stateWithout guarding, arbitrary keys in localStorage can pollute the store. Filter restores to known route IDs.
- Object.entries(parsedState).forEach(([routeId, search]) => { - store.saveSearch(routeId as any, search as Record<string, unknown>) - }) + Object.entries(parsedState).forEach(([routeId, search]) => { + if (routeId in store.state) { + store.saveSearch(routeId as any, search as Record<string, unknown>) + } + })
21-28
: Debounce localStorage writes to reduce churn and quota-pressureSearch changes can happen quickly (typing, sliders). Debounce writes to avoid frequent syncs.
- // Subscribe to changes and sync to localStorage - return store.subscribe(() => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(store.state)) - } catch (error) { - console.warn('Failed to sync search persistence to localStorage:', error) - } - }) + // Subscribe to changes and sync to localStorage (debounced to avoid frequent writes) + let writeTimer: number | undefined + return store.subscribe(() => { + if (writeTimer) { + clearTimeout(writeTimer) + } + writeTimer = window.setTimeout(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(store.state)) + } catch (error) { + console.warn('Failed to sync search persistence to localStorage:', error) + } + }, 100) + })examples/react/search-persistence/package.json (1)
2-2
: Rename the example to reflect its purposeThe current name suggests a “basic file-based” example. Consider renaming to improve discoverability.
- "name": "tanstack-router-react-example-basic-file-based", + "name": "tanstack-router-react-example-search-persistence",examples/react/search-persistence/src/routes/__root.tsx (2)
for the primary navigation
12-12
: Use semanticSmall a11y improvement; helps assistive tech and semantics.
- <div className="p-2 flex gap-2 text-lg border-b"> + <nav className="p-2 flex gap-2 text-lg border-b"> ... - </div> + </nav>Also applies to: 40-40
43-43
: Render devtools only in development to keep prod bundles leanGate the devtools behind a dev check.
- <TanStackRouterDevtools /> + {import.meta.env.DEV && <TanStackRouterDevtools />}examples/react/search-persistence/src/routes/index.tsx (1)
1-1
: Fix ESLint sort-imports: sort named specifiersComplies with the configured sort-imports rule.
-import { createFileRoute, Link } from '@tanstack/react-router' +import { Link, createFileRoute } from '@tanstack/react-router'examples/react/search-persistence/src/routes/users.tsx (4)
1-1
: Sort imported members to satisfy lint ruleESLint reports sort-imports. Alphabetize the named imports.
-import { createFileRoute, useNavigate, persistSearchParams } from '@tanstack/react-router' +import { createFileRoute, persistSearchParams, useNavigate } from '@tanstack/react-router'
5-10
: Coerce numeric search params from the URLQuery-string values arrive as strings. Using z.coerce.number() makes validation robust across reloads/deep-links.
- page: z.number().optional().catch(0), - limit: z.number().optional().catch(10), + page: z.coerce.number().optional().catch(0), + limit: z.coerce.number().optional().catch(10),
59-63
: Avoidas any
in navigate; keep types intactLet inference work by returning a properly typed object or cast the result, not the whole navigate call.
- const updateSearch = (updates: Partial<UsersSearchSchema>) => { - navigate({ - search: (prev: UsersSearchSchema) => ({ ...prev, ...updates, page: 0 }), - } as any) - } + const updateSearch = (updates: Partial<UsersSearchSchema>) => { + navigate({ + search: (prev: UsersSearchSchema) => + ({ ...prev, ...updates, page: 0 }) as UsersSearchSchema, + }) + }
80-82
: Tighten typing for status changeAvoid
as any
. Narrow to the enum type.- onChange={(e) => updateSearch({ status: e.target.value as any })} + onChange={(e) => + updateSearch({ status: e.target.value as UsersSearchSchema['status'] }) + }docs/router/framework/react/api/router/persistSearchParamsFunction.md (2)
24-27
: Clarify and fix grammar in trigger rules; call out empty-search restorationImprove readability and explicitly mention that an explicit empty object restores.
-- **Without search prop**: `<Link to="/users">` → Middleware doesn't run → No restoration -- **With search function**: `<Link to="/users" search={(prev) => prev}>` → Middleware runs → Restoration happens -- **With explicit search**: `<Link to="/users" search={{ name: 'John' }}>` → Middleware runs → No restoration (params provided) +- Without a `search` prop: `<Link to="/users">` → Middleware does not run → No restoration +- With a search function: `<Link to="/users" search={(prev) => prev}>` → Middleware runs → Restoration happens +- With explicit non-empty search: `<Link to="/users" search={{ name: 'John' }}>` → Middleware runs → No restoration (params provided) +- With explicit empty search: `<Link to="/users" search={{}}> ` → Middleware runs → Restoration happens (empty search is treated as “restore”)
147-165
: Add a “clean reset” example that clears persistenceSince
{}
restores, show how to clear the store before navigating for a true reset.## Manual Restoration @@ function CustomNavigation() { const store = getSearchPersistenceStore() const savedUsersSearch = store.getSearch('/users') return ( <Link to="/users" search={savedUsersSearch || {}}> Users (with saved search) </Link> ) } + +// Clean reset (clear persisted state, then navigate with empty search) +function ResetUsers() { + const store = getSearchPersistenceStore() + return ( + <Link + to="/users" + search={() => { + store.clearSearch('/users') + return {} + }} + > + Reset Users (clean) + </Link> + ) +}examples/react/search-persistence/src/routes/products.tsx (4)
1-1
: Sort imported members and drop trailing comma for lint cleanlinessAlphabetize members and remove the dangling comma inside the import specifier.
-import { createFileRoute, useNavigate, persistSearchParams, } from '@tanstack/react-router' +import { createFileRoute, persistSearchParams, useNavigate } from '@tanstack/react-router'
5-10
: Coerce numeric search params from the URLUse z.coerce.number() so deep-links and reloads with numeric strings validate correctly.
- minPrice: z.number().optional().catch(0), - maxPrice: z.number().optional().catch(1000), + minPrice: z.coerce.number().optional().catch(0), + maxPrice: z.coerce.number().optional().catch(1000),
58-62
: Avoidas any
in navigate; keep types intactPrefer casting the produced object or rely on inference.
- const updateSearch = (updates: Partial<ProductsSearchSchema>) => { - navigate({ - search: (prev: ProductsSearchSchema) => ({ ...prev, ...updates }), - } as any) - } + const updateSearch = (updates: Partial<ProductsSearchSchema>) => { + navigate({ + search: (prev: ProductsSearchSchema) => + ({ ...prev, ...updates }) as ProductsSearchSchema, + }) + }
104-107
: Tighten typing for sortBy changeAvoid
as any
. Narrow to the enum union type.- onChange={(e) => updateSearch({ sortBy: e.target.value as any })} + onChange={(e) => + updateSearch({ sortBy: e.target.value as ProductsSearchSchema['sortBy'] }) + }packages/router-core/src/router.ts (1)
2758-2775
: Potential double validation of searchSearch validation is also performed earlier in buildLocation when strict mode is on. Running another validate middleware here duplicates work. Consider gating this middleware behind a flag or removing the earlier strict-block to avoid redundant compute.
packages/router-core/src/searchMiddleware.ts (5)
4-4
: Sort imported members to satisfy lint rule (sort-imports).Swap the named members so they are alphabetically ordered.
-import type { SearchMiddleware, AnyRoute } from './route' +import type { AnyRoute, SearchMiddleware } from './route'
6-6
: Sort imported members to satisfy lint rule (sort-imports).Order the named imports alphabetically.
-import type { RoutesById, RouteById } from './routeInfo' +import type { RouteById, RoutesById } from './routeInfo'
116-126
: Potential TS inconsistency: cast routeId to string for symmetry and safety.You already cast to
string
inclearSearch
; do the same here to avoid generic key issues in destructuring.- if (Object.keys(cleanedSearch).length === 0) { - const { [routeId]: _, ...rest } = prevState + if (Object.keys(cleanedSearch).length === 0) { + const { [routeId as string]: _, ...rest } = prevState return rest }
161-179
: Fix method-signature lint errors in return type (method-signature-style).Convert method signatures to function properties in the returned type to satisfy the lint rule.
subscribe: (listener: () => void) => () => void - getSearch<TRouteId extends keyof RoutesById<TRouteTree>>( - routeId: TRouteId, - ): RouteById<TRouteTree, TRouteId>['types']['fullSearchSchema'] | null - saveSearch<TRouteId extends keyof RoutesById<TRouteTree>>( - routeId: TRouteId, - search: RouteById<TRouteTree, TRouteId>['types']['fullSearchSchema'], - ): void - clearSearch<TRouteId extends keyof RoutesById<TRouteTree>>( - routeId: TRouteId, - ): void - clearAllSearches(): void + getSearch: <TRouteId extends keyof RoutesById<TRouteTree>>( + routeId: TRouteId, + ) => RouteById<TRouteTree, TRouteId>['types']['fullSearchSchema'] | null + saveSearch: <TRouteId extends keyof RoutesById<TRouteTree>>( + routeId: TRouteId, + search: RouteById<TRouteTree, TRouteId>['types']['fullSearchSchema'], + ) => void + clearSearch: <TRouteId extends keyof RoutesById<TRouteTree>>( + routeId: TRouteId, + ) => void + clearAllSearches: () => void
200-241
: Optional: centralize “empty-ish” checks for maintainability.The empty-value checks are duplicated in both the middleware and store. Consider extracting a tiny internal util (e.g., isEmptyish) and reusing it. Not a blocker.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (22)
docs/router/framework/react/api/router/persistSearchParamsFunction.md
(1 hunks)examples/react/search-persistence/README.md
(1 hunks)examples/react/search-persistence/index.html
(1 hunks)examples/react/search-persistence/package.json
(1 hunks)examples/react/search-persistence/postcss.config.mjs
(1 hunks)examples/react/search-persistence/src/main.tsx
(1 hunks)examples/react/search-persistence/src/routeTree.gen.ts
(1 hunks)examples/react/search-persistence/src/routes/__root.tsx
(1 hunks)examples/react/search-persistence/src/routes/index.tsx
(1 hunks)examples/react/search-persistence/src/routes/products.tsx
(1 hunks)examples/react/search-persistence/src/routes/users.tsx
(1 hunks)examples/react/search-persistence/src/styles.css
(1 hunks)examples/react/search-persistence/src/type-inference-test.ts
(1 hunks)examples/react/search-persistence/src/utils/localStorage-sync.ts
(1 hunks)examples/react/search-persistence/tailwind.config.mjs
(1 hunks)examples/react/search-persistence/tsconfig.json
(1 hunks)examples/react/search-persistence/vite.config.js
(1 hunks)packages/react-router/src/index.tsx
(2 hunks)packages/router-core/src/index.ts
(1 hunks)packages/router-core/src/route.ts
(1 hunks)packages/router-core/src/router.ts
(12 hunks)packages/router-core/src/searchMiddleware.ts
(3 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (10)
examples/react/search-persistence/src/routes/index.tsx (3)
examples/react/search-persistence/src/routes/__root.tsx (1)
Route
(5-7)examples/react/search-persistence/src/routes/products.tsx (1)
Route
(14-22)examples/react/search-persistence/src/routes/users.tsx (1)
Route
(14-22)
examples/react/search-persistence/src/utils/localStorage-sync.ts (1)
packages/router-core/src/searchMiddleware.ts (3)
store
(74-76)store
(184-186)getSearchPersistenceStore
(158-198)
examples/react/search-persistence/src/routes/__root.tsx (5)
examples/react/search-persistence/src/routes/index.tsx (1)
Route
(3-5)examples/react/search-persistence/src/routes/products.tsx (1)
Route
(14-22)examples/react/search-persistence/src/routes/users.tsx (1)
Route
(14-22)packages/react-router/src/index.tsx (4)
Route
(263-263)createRootRoute
(267-267)Link
(156-156)Outlet
(252-252)packages/react-router-devtools/src/TanStackRouterDevtools.tsx (1)
TanStackRouterDevtools
(46-126)
examples/react/search-persistence/src/main.tsx (2)
examples/react/search-persistence/src/utils/localStorage-sync.ts (1)
setupLocalStorageSync
(5-29)packages/router-core/src/router.ts (1)
Register
(99-101)
examples/react/search-persistence/src/routes/users.tsx (2)
examples/react/search-persistence/src/routes/products.tsx (1)
Route
(14-22)packages/router-core/src/searchMiddleware.ts (1)
persistSearchParams
(200-242)
packages/router-core/src/searchMiddleware.ts (3)
packages/router-core/src/route.ts (2)
AnyRoute
(703-718)SearchMiddleware
(80-82)packages/router-core/src/router.ts (1)
RegisteredRouter
(103-103)packages/router-core/src/utils.ts (1)
replaceEqualDeep
(212-259)
examples/react/search-persistence/src/routes/products.tsx (2)
examples/react/search-persistence/src/routes/users.tsx (1)
Route
(14-22)packages/router-core/src/searchMiddleware.ts (1)
persistSearchParams
(200-242)
packages/router-core/src/router.ts (2)
packages/router-core/src/route.ts (2)
RouteContextOptions
(936-944)SearchMiddleware
(80-82)packages/router-core/src/utils.ts (1)
functionalUpdate
(195-204)
examples/react/search-persistence/src/type-inference-test.ts (3)
packages/router-core/src/searchMiddleware.ts (5)
store
(74-76)store
(184-186)getSearchPersistenceStore
(158-198)state
(66-68)state
(181-183)packages/react-router/src/index.tsx (1)
getSearchPersistenceStore
(36-36)packages/router-core/src/index.ts (1)
getSearchPersistenceStore
(261-261)
examples/react/search-persistence/src/routeTree.gen.ts (2)
packages/react-router/src/index.tsx (2)
FileRouteTypes
(174-174)FileRoutesByPath
(116-116)packages/router-core/src/index.ts (2)
FileRouteTypes
(64-64)FileRoutesByPath
(65-65)
🪛 ESLint
examples/react/search-persistence/src/routes/index.tsx
[error] 1-1: Member 'Link' of the import declaration should be sorted alphabetically.
(sort-imports)
examples/react/search-persistence/src/routes/users.tsx
[error] 1-1: Member 'persistSearchParams' of the import declaration should be sorted alphabetically.
(sort-imports)
packages/router-core/src/searchMiddleware.ts
[error] 4-4: Member 'AnyRoute' of the import declaration should be sorted alphabetically.
(sort-imports)
[error] 6-6: Member 'RouteById' of the import declaration should be sorted alphabetically.
(sort-imports)
[error] 168-170: Shorthand method signature is forbidden. Use a function property instead.
(@typescript-eslint/method-signature-style)
[error] 171-174: Shorthand method signature is forbidden. Use a function property instead.
(@typescript-eslint/method-signature-style)
[error] 175-177: Shorthand method signature is forbidden. Use a function property instead.
(@typescript-eslint/method-signature-style)
[error] 178-178: Shorthand method signature is forbidden. Use a function property instead.
(@typescript-eslint/method-signature-style)
examples/react/search-persistence/src/routes/products.tsx
[error] 1-1: Member 'persistSearchParams' of the import declaration should be sorted alphabetically.
(sort-imports)
🪛 LanguageTool
examples/react/search-persistence/README.md
[grammar] ~62-~62: There might be a mistake here.
Context: ...ate) ``` ### Manual Restoration Access the store directly for full contr...
(QB_NEW_EN)
[grammar] ~95-~95: There might be a mistake here.
Context: ...ash pnpm install pnpm dev ``` Navigate between Users and Products routes to see automa...
(QB_NEW_EN)
docs/router/framework/react/api/router/persistSearchParamsFunction.md
[grammar] ~24-~24: There might be a mistake here.
Context: ... Middleware doesn't run → No restoration - With search function: `<Link to="/user...
(QB_NEW_EN)
[grammar] ~25-~25: There might be a mistake here.
Context: ... → Middleware runs → Restoration happens - With explicit search: `<Link to="/user...
(QB_NEW_EN)
🔇 Additional comments (24)
examples/react/search-persistence/tsconfig.json (1)
2-8
: Solid TS defaults for a Vite + React exampleStrict mode, DOM libs, and esModuleInterop look good here.
examples/react/search-persistence/tailwind.config.mjs (1)
1-4
: LGTMMinimal Tailwind config with correct content globs for Vite projects.
examples/react/search-persistence/postcss.config.mjs (1)
1-6
: LGTMPostCSS + Tailwind + Autoprefixer default export is correct for ESM config.
examples/react/search-persistence/vite.config.js (2)
1-14
: Plugin order and options look correctRouter plugin first, then React plugin, with code-splitting enabled. Good.
1-14
: Router and React Vite plugins are declaredThe example’s package.json already includes the required plugins:
- examples/react/search-persistence/package.json
•"@tanstack/router-plugin"
in dependencies
•"@vitejs/plugin-react"
in devDependenciesNo changes needed.
examples/react/search-persistence/README.md (1)
1-15
: Clear, action-oriented READMENice coverage of features, usage, and caveats. This will help users adopt the middleware quickly.
packages/react-router/src/index.tsx (1)
35-37
: Verified: core barrel exports persistence APIsConfirmed that packages/router-core/src/index.ts includes:
- persistSearchParams
- getSearchPersistenceStore
as named re-exports from
./searchMiddleware
. The React barrel’s re-exports are valid—no changes needed.packages/router-core/src/index.ts (1)
257-262
: Search persistence exports verified: no duplicates, implementations found
Confirmed thatpackages/router-core/src/index.ts
does not re-export any of these identifiers more than once, andpackages/router-core/src/searchMiddleware.ts
defines and exportsretainSearchParams
,stripSearchParams
,persistSearchParams
, andgetSearchPersistenceStore
. No further action required.examples/react/search-persistence/src/main.tsx (2)
15-19
: Type registration for the router is correctly declaredThe module augmentation pattern for Register looks correct and should fully type getSearchPersistenceStore for this app.
21-29
: If you later SSR this example, consider hydrateRootThe conditional mount is fine for CSR. For SSR, you’ll need to swap to hydrateRoot and remove the innerHTML check to hydrate existing markup.
Would you like me to provide a CSR/SSR toggle snippet showing createRoot vs hydrateRoot?
examples/react/search-persistence/src/utils/localStorage-sync.ts (1)
1-1
: Confirm getSearchPersistenceStore is re-exported from @tanstack/react-routerI wasn’t able to locate a re-export of
getSearchPersistenceStore
in the router package’s entrypoint. Please verify that the main export file (e.g.src/index.ts
) of@tanstack/react-router
includes:export { getSearchPersistenceStore } from '@tanstack/router-core'so that your import will resolve correctly at compile/runtime.
examples/react/search-persistence/package.json (1)
11-20
: Add react and react-dom to dependencies to ensure the example is runnable standaloneMonorepo hoisting may mask this locally, but adding explicit deps prevents resolution issues for consumers running the example directly.
"dependencies": { "@tanstack/react-router": "workspace:*", "@tanstack/react-router-devtools": "workspace:*", "@tanstack/react-store": "^0.7.0", "@tanstack/router-plugin": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1", "postcss": "^8.5.1", "autoprefixer": "^10.4.20", "tailwindcss": "^3.4.17", "zod": "^3.24.2" },If you intentionally rely on workspace root deps, feel free to ignore.
examples/react/search-persistence/src/routes/index.tsx (1)
16-32
: Nice demonstration of full restoration via Link.searchUsing search={(prev) => prev} clearly shows how persisted state can be restored across routes.
packages/router-core/src/router.ts (2)
1455-1455
: Dev-only warning is fineThe added console.warn for invalid from paths is helpful during development and doesn’t affect prod builds.
2707-2788
: Route-aware middleware plumbing is well-structuredCollecting middlewares per-route and passing route context enables features like search persistence without breaking legacy filters.
examples/react/search-persistence/src/type-inference-test.ts (1)
5-28
: Type inference API looks solidNice demonstration of per-route typing across getSearch/save/clear and store usage. This will help consumers understand the ergonomics.
docs/router/framework/react/api/router/persistSearchParamsFunction.md (2)
30-33
: Strong recommendation to be explicit about restoration is goodThe guidance to always specify restoration intent will save users from surprises later. Looks great.
166-209
: Store API examples are clear and usefulGood coverage of get/save/clear patterns and integrating with useStore.
examples/react/search-persistence/src/routes/users.tsx (2)
43-57
: useMemo dependencies are sufficientFiltering logic only depends on name and status; memoization scope is correct.
14-22
: Middleware usage matches core implementationpersistSearchParams() without exclusions is appropriate for this route.
examples/react/search-persistence/src/routes/products.tsx (2)
37-56
: Sorting and filtering pipeline is clear and correctCloning before sorting prevents mutating the source; predicates look good.
14-22
: Excluding sortBy from persistence is a good defaultKeeps sorting ephemeral while other filters persist. Nicely demonstrates the API.
examples/react/search-persistence/src/routeTree.gen.ts (1)
7-10
: Generated file looks correct and consistent with Router expectations.Route IDs, paths, module augmentation, and the
routeTree
construction all look good for the example. Since this is auto-generated with lint/TS checks disabled, no further action is needed here.packages/router-core/src/searchMiddleware.ts (1)
37-56
: Casting results in stripSearchParams improves type safety. LGTM.The explicit casts on empty and final results keep the middleware signature sound without over-constraining the implementation.
<button | ||
type="button" | ||
onClick={() => navigate({ search: {} } as any)} | ||
className="border p-2 rounded" | ||
> | ||
Reset | ||
</button> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Reset button will immediately restore persisted search
Because of persistence, {}
restores. Clear the persisted entry first.
- <button
- type="button"
- onClick={() => navigate({ search: {} } as any)}
+ <button
+ type="button"
+ onClick={() => {
+ getSearchPersistenceStore().clearSearch('/products')
+ navigate({ search: {} })
+ }}
className="border p-2 rounded"
>
Reset
</button>
Also add the import at the top:
-import { createFileRoute, persistSearchParams, useNavigate } from '@tanstack/react-router'
+import { createFileRoute, getSearchPersistenceStore, persistSearchParams, useNavigate } from '@tanstack/react-router'
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
<button | |
type="button" | |
onClick={() => navigate({ search: {} } as any)} | |
className="border p-2 rounded" | |
> | |
Reset | |
</button> | |
// at the top of examples/react/search-persistence/src/routes/products.tsx | |
import { createFileRoute, getSearchPersistenceStore, persistSearchParams, useNavigate } from '@tanstack/react-router' | |
// … later, in your JSX render: | |
<button | |
type="button" | |
onClick={() => { | |
getSearchPersistenceStore().clearSearch('/products') | |
navigate({ search: {} }) | |
}} | |
className="border p-2 rounded" | |
> | |
Reset | |
</button> |
🤖 Prompt for AI Agents
In examples/react/search-persistence/src/routes/products.tsx around lines
114–120, the Reset button currently calls navigate({ search: {} }) which will
immediately restore the persisted search; before navigating, clear the persisted
search entry (for example by calling the project's persistence clear helper or
removing the specific localStorage/sessionStorage key used for persisted search)
and then call navigate to an empty search. Also add the appropriate import at
the top of the file for the persistence clear helper (e.g., import {
clearPersistedSearch } from the module where persistence helpers live) and use
that helper in the button onClick before navigate.
<button | ||
type="button" | ||
onClick={() => navigate({ search: {} } as any)} | ||
className="border p-2 rounded" | ||
> | ||
Reset | ||
</button> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Reset button will immediately restore persisted search
With persistSearchParams, navigating with an empty search {}
triggers restoration. Clear the persisted entry first to truly reset.
-import React from 'react'
+import React from 'react'
+import { getSearchPersistenceStore } from '@tanstack/react-router'
@@
- <button
- type="button"
- onClick={() => navigate({ search: {} } as any)}
+ <button
+ type="button"
+ onClick={() => {
+ getSearchPersistenceStore().clearSearch('/users')
+ navigate({ search: {} })
+ }}
className="border p-2 rounded"
>
Reset
</button>
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
<button | |
type="button" | |
onClick={() => navigate({ search: {} } as any)} | |
className="border p-2 rounded" | |
> | |
Reset | |
</button> | |
import React from 'react' | |
import { getSearchPersistenceStore } from '@tanstack/react-router' | |
<button | |
type="button" | |
onClick={() => { | |
getSearchPersistenceStore().clearSearch('/users') | |
navigate({ search: {} }) | |
}} | |
className="border p-2 rounded" | |
> | |
Reset | |
</button> |
🤖 Prompt for AI Agents
In examples/react/search-persistence/src/routes/users.tsx around lines 89-95,
the Reset button currently calls navigate({ search: {} }) which immediately
restores persisted params; fix by clearing the persisted search entry first,
then navigate to an empty search. Implement this by removing the persistence
storage entry (or calling the library's clear/unpersist API) for the search
params and updating any in-memory state, then call navigate to {} (optionally
with replace:true) so the cleared state is actually applied.
import { Store } from '@tanstack/store' | ||
import { deepEqual, replaceEqualDeep } from './utils' | ||
import type { NoInfer, PickOptional } from './utils' | ||
import type { SearchMiddleware } from './route' | ||
import type { SearchMiddleware, AnyRoute } from './route' | ||
import type { IsRequiredParams } from './link' | ||
import type { RoutesById, RouteById } from './routeInfo' | ||
import type { RegisteredRouter } from './router' | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
Confirm new APIs are re-exported from router-core index.
To ensure consumers can import persistSearchParams
and getSearchPersistenceStore
, verify re-exports exist in packages/router-core/src/index.ts
.
Run this script:
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Check re-exports in packages/router-core/src/index.ts"
rg -n -C2 -g 'packages/router-core/src/index.ts' -P '\bexport\s+\{[^}]*\b(persistSearchParams|getSearchPersistenceStore)\b'
echo
echo "List of exported symbols from index.ts:"
rg -n -C1 -g 'packages/router-core/src/index.ts' -P 'export\s+\{[^}]+\}'
Length of output: 260
Add missing re-exports for search persistence APIs
The functions persistSearchParams
and getSearchPersistenceStore
are implemented in searchMiddleware.ts
but aren’t re-exported from the package entrypoint, so consumers can’t import them.
Please update packages/router-core/src/index.ts
to include:
export { persistSearchParams, getSearchPersistenceStore } from './searchMiddleware'
🧰 Tools
🪛 ESLint
[error] 4-4: Member 'AnyRoute' of the import declaration should be sorted alphabetically.
(sort-imports)
[error] 6-6: Member 'RouteById' of the import declaration should be sorted alphabetically.
(sort-imports)
🤖 Prompt for AI Agents
In packages/router-core/src/searchMiddleware.ts lines 1-8: the functions
persistSearchParams and getSearchPersistenceStore implemented here are not
exported from the package entrypoint; update packages/router-core/src/index.ts
to re-export them by adding an export that forwards persistSearchParams and
getSearchPersistenceStore from './searchMiddleware' so consumers can import
these APIs from the package root.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
♻️ Duplicate comments (2)
examples/react/search-persistence/src/routes/users.tsx (1)
124-130
: Reset will immediately restore persisted search unless you clear it firstWith persistence enabled,
{}
triggers restoration. Clear the persisted entry before navigating.Apply this diff:
<button type="button" - onClick={() => navigate({ search: {} } as any)} + onClick={() => { + getSearchPersistenceStore().clearSearch('/users') + navigate({ to: '/users', search: {}, replace: true }) + }} className="border p-2 rounded" > Reset </button>examples/react/search-persistence/src/routes/products.tsx (1)
122-128
: Reset will immediately restore persisted search unless you clear it firstClear the persisted entry before navigating to
{}
.Apply this diff:
<button type="button" - onClick={() => navigate({ search: {} } as any)} + onClick={() => { + getSearchPersistenceStore().clearSearch('/products') + navigate({ to: '/products', search: {}, replace: true }) + }} className="border p-2 rounded" > Reset </button>
🧹 Nitpick comments (11)
examples/react/search-persistence/src/routes/index.tsx (1)
1-1
: Sort named imports to satisfy lint ruleESLint flags sort-imports. Keep members alphabetical to avoid churn in CI.
Apply this diff:
-import { createFileRoute, Link } from '@tanstack/react-router' +import { Link, createFileRoute } from '@tanstack/react-router'examples/react/search-persistence/src/routes/users.tsx (3)
1-5
: Add store import and alphabetize members (lint), preparing for proper ResetYou’ll need the store to clear persisted search before a “reset”. Also fixes the sort-imports rule.
Apply this diff:
-import { - createFileRoute, - useNavigate, - persistSearchParams, -} from '@tanstack/react-router' +import { + createFileRoute, + getSearchPersistenceStore, + persistSearchParams, + useNavigate, +} from '@tanstack/react-router'
9-14
: Coerce numeric search params to play nicely with URL stringsURLs provide values as strings. Using z.coerce.number() prevents unexpected fallback to defaults when querystring contains numeric values.
Apply this diff:
const usersSearchSchema = z.object({ name: z.string().optional().catch(''), status: z.enum(['active', 'inactive', 'all']).optional().catch('all'), - page: z.number().optional().catch(0), - limit: z.number().optional().catch(10), + page: z.coerce.number().optional().catch(0), + limit: z.coerce.number().optional().catch(10), })
91-95
: Removeas any
; provideto
and usereplace
for smoother UXProviding
to
allows proper typing andreplace: true
avoids polluting history while typing.Apply this diff:
- const updateSearch = (updates: Partial<UsersSearchSchema>) => { - navigate({ - search: (prev: UsersSearchSchema) => ({ ...prev, ...updates, page: 0 }), - } as any) - } + const updateSearch = (updates: Partial<UsersSearchSchema>) => { + navigate({ + to: '/users', + replace: true, + search: (prev: UsersSearchSchema) => ({ ...prev, ...updates, page: 0 }), + }) + }docs/router/framework/react/api/router/persistSearchParamsFunction.md (2)
126-127
: Document the Reset pattern to avoid surprise restorationsAdd an explicit example to clear persisted search before navigating to
{}
. This mirrors the example app’s behavior and prevents confusion.Apply this diff to insert a new subsection after the “Automatic Restoration with Links” section:
@@ }+### Resetting persisted search to an empty state
+
+When using persistence, navigating with an empty search{}
will restore saved params. To truly reset, clear the persisted entry first, then navigate.
+
+```tsx
+import { getSearchPersistenceStore, useNavigate } from '@tanstack/react-router'
+
+function ResetButton() {
- const navigate = useNavigate()
- return (
- <button
onClick={() => {
getSearchPersistenceStore().clearSearch('/users')
navigate({ to: '/users', search: {}, replace: true })
}}
Reset
- )
+}
+```@@
Exclusion Strategies
--- `22-27`: **Tighten phrasing for bullet list consistency (optional)** Small clarity tweaks to keep list items parallel and avoid ambiguity about when the middleware runs. Apply this diff: ```diff -**Important**: The middleware only runs when search parameters are being processed. This means: +**Important**: The middleware only runs when search parameters are being processed. For example: @@ -- **Without search prop**: `<Link to="/users">` → Middleware doesn't run → No restoration -- **With search function**: `<Link to="/users" search={(prev) => prev}>` → Middleware runs → Restoration happens -- **With explicit search**: `<Link to="/users" search={{ name: 'John' }}>` → Middleware runs → No restoration (params provided) +- Without a `search` prop: `<Link to="/users">` → Middleware doesn't run → No restoration +- With a `search` function: `<Link to="/users" search={(prev) => prev}>` → Middleware runs → Restoration happens +- With an explicit `search` object: `<Link to="/users" search={{ name: 'John' }}>` → Middleware runs → No restoration (params provided)
examples/react/search-persistence/src/routes/products.tsx (3)
1-5
: Add store import and alphabetize members (lint)Brings in the store for a correct Reset and satisfies sort-imports.
Apply this diff:
-import { - createFileRoute, - useNavigate, - persistSearchParams, -} from '@tanstack/react-router' +import { + createFileRoute, + getSearchPersistenceStore, + persistSearchParams, + useNavigate, +} from '@tanstack/react-router'
9-14
: Coerce numeric search params to handle URL stringsUse z.coerce.number() so URLs like ?minPrice=100 work as intended.
Apply this diff:
const productsSearchSchema = z.object({ category: z.string().optional().catch(''), - minPrice: z.number().optional().catch(0), - maxPrice: z.number().optional().catch(1000), + minPrice: z.coerce.number().optional().catch(0), + maxPrice: z.coerce.number().optional().catch(1000), sortBy: z.enum(['name', 'price', 'rating']).optional().catch('name'), })
66-70
: Removeas any
; provideto
andreplace
for better typing and UXTyping improves and history stays clean while sliding filters.
Apply this diff:
const updateSearch = (updates: Partial<ProductsSearchSchema>) => { navigate({ - search: (prev: ProductsSearchSchema) => ({ ...prev, ...updates }), - } as any) + to: '/products', + replace: true, + search: (prev: ProductsSearchSchema) => ({ ...prev, ...updates }), + }) }examples/react/search-persistence/README.md (2)
63-76
: Add a Reset pattern to avoid surprise restorationsMirror the docs by showing how to clear persisted search before navigating to
{}
.Apply this diff to append after the Manual Restoration snippet:
</Link>+### Resetting persisted search
+
+When using persistence, navigating with an empty search{}
restores the saved state. To truly reset, clear the persisted entry first:
+
+```tsx
+import { getSearchPersistenceStore, useNavigate } from '@tanstack/react-router'
+
+function ResetUsers() {
- const navigate = useNavigate()
- return (
- <button
onClick={() => {
getSearchPersistenceStore().clearSearch('/users')
navigate({ to: '/users', search: {}, replace: true })
}}
Reset Users Search
- )
+}
+```--- `40-41`: **Wording nit: tighten “Important” note** Slight rephrase for clarity and parallelism. Apply this diff: ```diff -⚠️ **Important**: The middleware only runs when search parameters are being processed. Always be explicit about your restoration intent. +⚠️ **Important**: The middleware runs only when search parameters are being processed. Be explicit about your restoration intent.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (12)
docs/router/framework/react/api/router/persistSearchParamsFunction.md
(1 hunks)examples/react/search-persistence/README.md
(1 hunks)examples/react/search-persistence/src/routes/__root.tsx
(1 hunks)examples/react/search-persistence/src/routes/index.tsx
(1 hunks)examples/react/search-persistence/src/routes/products.tsx
(1 hunks)examples/react/search-persistence/src/routes/users.tsx
(1 hunks)examples/react/search-persistence/src/styles.css
(1 hunks)examples/react/search-persistence/src/type-inference-test.ts
(1 hunks)examples/react/search-persistence/src/utils/localStorage-sync.ts
(1 hunks)packages/react-router/src/index.tsx
(1 hunks)packages/router-core/src/router.ts
(3 hunks)packages/router-core/src/searchMiddleware.ts
(3 hunks)
🚧 Files skipped from review as they are similar to previous changes (7)
- packages/react-router/src/index.tsx
- examples/react/search-persistence/src/routes/__root.tsx
- examples/react/search-persistence/src/utils/localStorage-sync.ts
- examples/react/search-persistence/src/styles.css
- packages/router-core/src/router.ts
- packages/router-core/src/searchMiddleware.ts
- examples/react/search-persistence/src/type-inference-test.ts
🧰 Additional context used
🧬 Code Graph Analysis (3)
examples/react/search-persistence/src/routes/index.tsx (3)
examples/react/search-persistence/src/routes/__root.tsx (1)
Route
(4-6)examples/react/search-persistence/src/routes/products.tsx (1)
Route
(18-26)examples/react/search-persistence/src/routes/users.tsx (1)
Route
(18-24)
examples/react/search-persistence/src/routes/products.tsx (2)
examples/react/search-persistence/src/routes/users.tsx (1)
Route
(18-24)packages/router-core/src/searchMiddleware.ts (1)
persistSearchParams
(232-281)
examples/react/search-persistence/src/routes/users.tsx (2)
examples/react/search-persistence/src/routes/products.tsx (1)
Route
(18-26)packages/router-core/src/searchMiddleware.ts (1)
persistSearchParams
(232-281)
🪛 LanguageTool
docs/router/framework/react/api/router/persistSearchParamsFunction.md
[grammar] ~24-~24: There might be a mistake here.
Context: ... Middleware doesn't run → No restoration - With search function: `<Link to="/user...
(QB_NEW_EN)
[grammar] ~25-~25: There might be a mistake here.
Context: ... → Middleware runs → Restoration happens - With explicit search: `<Link to="/user...
(QB_NEW_EN)
examples/react/search-persistence/README.md
[grammar] ~97-~97: There might be a mistake here.
Context: ...ash pnpm install pnpm dev ``` Navigate between Users and Products routes to see automa...
(QB_NEW_EN)
[grammar] ~97-~97: There might be a mistake here.
Context: ... search parameter persistence in action.
(QB_NEW_EN)
🪛 ESLint
examples/react/search-persistence/src/routes/index.tsx
[error] 1-1: Member 'Link' of the import declaration should be sorted alphabetically.
(sort-imports)
examples/react/search-persistence/src/routes/products.tsx
[error] 4-4: Member 'persistSearchParams' of the import declaration should be sorted alphabetically.
(sort-imports)
examples/react/search-persistence/src/routes/users.tsx
[error] 4-4: Member 'persistSearchParams' of the import declaration should be sorted alphabetically.
(sort-imports)
🔇 Additional comments (4)
examples/react/search-persistence/src/routes/index.tsx (1)
7-73
: Nice, concise demo of restoration patternsClear examples showing full restoration, partial override, and clean navigation. This aligns well with the middleware behavior and the docs.
examples/react/search-persistence/src/routes/users.tsx (1)
18-24
: Middleware wiring looks correctRoute-level validateSearch + persistSearchParams() is correctly configured and will persist validated output.
examples/react/search-persistence/src/routes/products.tsx (1)
21-24
: Good use of per-route exclusionExcluding sortBy at the middleware level is a clear, type-safe demo of selective persistence.
examples/react/search-persistence/README.md (1)
97-98
: LGTMContent is clear and actionable; run linters/formatters if your pipeline flags grammar/punctuation.
2. **Restoring**: Restores saved parameters when the middleware is triggered with empty search | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Grammar nit: add article for clarity
“with empty search” → “with an empty search”.
Apply this diff:
-2. **Restoring**: Restores saved parameters when the middleware is triggered with empty search
+2. **Restoring**: Restores saved parameters when the middleware is triggered with an empty search
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
2. **Restoring**: Restores saved parameters when the middleware is triggered with empty search | |
2. **Restoring**: Restores saved parameters when the middleware is triggered with an empty search |
🤖 Prompt for AI Agents
In docs/router/framework/react/api/router/persistSearchParamsFunction.md around
lines 20 to 21, change the phrase "with empty search" to "with an empty search"
to add the missing article for grammatical clarity; update the sentence
accordingly so it reads "Restores saved parameters when the middleware is
triggered with an empty search."
hi there! we are right now in a feature freeze until the next alpha version is merged to main. we will revisit this PR after that then. |
Noticed an issue: persisted search params are being saved to routes that weren’t intended. |
i am not sure what you are trying to do here is even possible with search middlewares as they run for links pointing to and from a route, not only when a route is visited |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 6
♻️ Duplicate comments (3)
packages/router-core/src/searchMiddleware.ts (1)
177-177
: Avoid module-scoped singleton: SSR cross-request state leak.Creating
searchPersistenceStore
at module scope leaks state across Node SSR requests. This is a correctness and privacy issue.Apply this minimal fix to make the default store client-only and opt-in on the server:
-const searchPersistenceStore = new SearchPersistenceStore() +const isServer = typeof window === 'undefined' +// Client: a default shared store is OK within a single browser session. +// Server: avoid a module-scoped singleton to prevent cross-request leaks. +let defaultSearchPersistenceStore: SearchPersistenceStore | undefined = + isServer ? undefined : new SearchPersistenceStore()examples/react/search-persistence/src/routes/products.tsx (1)
122-128
: Reset immediately restores persisted search; clear it first (also fix import order)Because persistence restores on empty searches,
navigate({ search: {} })
will reinstate saved params. Clear the persisted entry for/products
before navigating. Also reorder/supplement imports to satisfy lint and add the store accessor.Apply:
import { - createFileRoute, - useNavigate, - persistSearchParams, + createFileRoute, + getSearchPersistenceStore, + persistSearchParams, + useNavigate, } from '@tanstack/react-router'<button type="button" - onClick={() => navigate({ search: {} } as any)} + onClick={() => { + getSearchPersistenceStore().clearSearch('/products') + navigate({ search: {} }) + }} className="border p-2 rounded" > Reset </button>Also applies to: 1-6
examples/react/search-persistence/src/routes/users.tsx (1)
124-130
: Reset immediately restores persisted search; clear it first (also fix import order)As with products, clear the persisted entry for
/users
before navigating to an empty search. Also adjust imports to include the store accessor and satisfy member ordering.Apply:
import { - createFileRoute, - useNavigate, - persistSearchParams, + createFileRoute, + getSearchPersistenceStore, + persistSearchParams, + useNavigate, } from '@tanstack/react-router'<button type="button" - onClick={() => navigate({ search: {} } as any)} + onClick={() => { + getSearchPersistenceStore().clearSearch('/users') + navigate({ search: {} }) + }} className="border p-2 rounded" > Reset </button>Also applies to: 1-6
🧹 Nitpick comments (8)
examples/react/search-persistence/src/main.tsx (2)
20-20
: Place the client-only sync after the router is created (exact insertion point)If you adopt the dynamic import for
setupLocalStorageSync
, place it immediately after the module augmentation (or at least after therouter
is created) to avoid any subtle ordering issues with store initialization.Insert below Line 20:
// Client-only optional localStorage sync for search persistence if (typeof window !== 'undefined') { import('./utils/localStorage-sync') .then((m) => m.setupLocalStorageSync()) .catch(() => {}) }
21-29
: Optional: hydrate when SSR markup exists instead of skipping mount; also guard for non-DOM environmentsIf this example is ever used with SSR, prefer hydrating when the container already has markup rather than skipping render. Also, a small
typeof document !== 'undefined'
guard prevents accidental server import pitfalls.Apply this diff to the mount block:
-const rootElement = document.getElementById('app') -if (rootElement && !rootElement.innerHTML) { - const root = ReactDOM.createRoot(rootElement) - root.render( - <StrictMode> - <RouterProvider router={router} /> - </StrictMode>, - ) -} +const rootElement = + typeof document !== 'undefined' ? document.getElementById('app') : null + +if (!rootElement) { + // SSR/Node: no-op. Client will mount/hydrate. +} else if (rootElement.hasChildNodes()) { + hydrateRoot( + rootElement, + <StrictMode> + <RouterProvider router={router} /> + </StrictMode>, + ) +} else { + createRoot(rootElement).render( + <StrictMode> + <RouterProvider router={router} /> + </StrictMode>, + ) +}And update the import to use the named APIs:
-import ReactDOM from 'react-dom/client' +import { createRoot, hydrateRoot } from 'react-dom/client'packages/router-core/src/searchMiddleware.ts (2)
118-144
: Deduplicate “persistable value” filtering with a private helper for consistency.You repeat the same truthiness/emptiness checks inline. Centralizing this improves readability and keeps semantics consistent if you tweak the rules later (e.g., treating whitespace-only strings as empty).
Apply this focused refactor:
saveSearch< @@ ): void { - const searchRecord = search as Record<string, unknown> - const cleanedSearch = Object.fromEntries( - Object.entries(searchRecord).filter(([_, value]) => { - if (value === null || value === undefined || value === '') return false - if (Array.isArray(value) && value.length === 0) return false - if ( - typeof value === 'object' && - value !== null && - Object.keys(value).length === 0 - ) - return false - return true - }), - ) + const searchRecord = search as Record<string, unknown> + const cleanedSearch = Object.fromEntries( + Object.entries(searchRecord).filter(([_, value]) => + this.#isPersistableValue(value), + ), + ) @@ }) } + + // Treat nullish, empty string, empty array, and empty plain object as "not persistable" + #isPersistableValue(value: unknown) { + if (value === null || value === undefined || value === '') return false + if (Array.isArray(value) && value.length === 0) return false + if (typeof value === 'object' && value !== null) { + // Save non-empty objects only + return Object.keys(value as Record<string, unknown>).length > 0 + } + return true + }
232-235
: Constrain the generic for consistency with other middlewares.Elsewhere you use
TSearchSchema extends object
. Do the same here for consistent inference and to avoid misuse with primitives.-export function persistSearchParams<TSearchSchema>( +export function persistSearchParams<TSearchSchema extends object>(examples/react/search-persistence/src/routes/__root.tsx (2)
42-42
: Gate devtools to development buildsAvoid mounting devtools in production to reduce bundle/CPU overhead and potential SSR mismatches.
Apply:
- <TanStackRouterDevtools /> + {process.env.NODE_ENV === 'development' && <TanStackRouterDevtools />}
21-27
: Consider removing explicit search propagation from top-nav linksUsing
search={(prev) => prev}
in global nav can shuttle unrelated query keys between routes before validation/middleware runs. The middleware already restores route-allowed keys when the target search is empty. Dropping the prop simplifies behavior and avoids transient URL noise. Not critical, just cleaner for the example.Example:
- <Link - to="/users" - search={(prev) => prev} - activeProps={{ - className: 'font-bold', - }} - > + <Link to="/users" activeProps={{ className: 'font-bold' }}> Users </Link>Also applies to: 30-36
examples/react/search-persistence/src/routes/products.tsx (1)
66-70
: Avoidas any
on navigate by typing the updaterYou can satisfy types without
as any
by typing the updater function’s param/return.Example:
- const updateSearch = (updates: Partial<ProductsSearchSchema>) => { - navigate({ - search: (prev: ProductsSearchSchema) => ({ ...prev, ...updates }), - } as any) - } + const updateSearch = (updates: Partial<ProductsSearchSchema>) => { + navigate({ + search: (prev: ProductsSearchSchema): ProductsSearchSchema => ({ + ...prev, + ...updates, + }), + }) + }If your router supports it, alternatively do
const navigate = useNavigate({ from: Route.fullPath })
for stronger typing.examples/react/search-persistence/src/routes/users.tsx (1)
91-95
: Dropas any
on navigateType the updater function instead of casting to
any
to preserve end-to-end search typing.- const updateSearch = (updates: Partial<UsersSearchSchema>) => { - navigate({ - search: (prev: UsersSearchSchema) => ({ ...prev, ...updates, page: 0 }), - } as any) - } + const updateSearch = (updates: Partial<UsersSearchSchema>) => { + navigate({ + search: (prev: UsersSearchSchema): UsersSearchSchema => ({ + ...prev, + ...updates, + page: 0, + }), + }) + }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (6)
examples/react/search-persistence/src/main.tsx
(1 hunks)examples/react/search-persistence/src/routes/__root.tsx
(1 hunks)examples/react/search-persistence/src/routes/index.tsx
(1 hunks)examples/react/search-persistence/src/routes/products.tsx
(1 hunks)examples/react/search-persistence/src/routes/users.tsx
(1 hunks)packages/router-core/src/searchMiddleware.ts
(3 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (6)
examples/react/search-persistence/src/routes/index.tsx (4)
examples/react/search-persistence/src/routes/__root.tsx (1)
Route
(4-6)examples/react/search-persistence/src/routes/users.tsx (1)
Route
(18-24)examples/react/search-persistence/src/routes/products.tsx (1)
Route
(18-26)packages/react-router/src/index.tsx (3)
Route
(260-260)createFileRoute
(142-142)Link
(153-153)
packages/router-core/src/searchMiddleware.ts (4)
packages/router-core/src/index.ts (8)
AnyRoute
(163-163)RegisteredRouter
(215-215)RoutesById
(56-56)RouteById
(53-53)replaceEqualDeep
(277-277)getSearchPersistenceStore
(261-261)persistSearchParams
(260-260)SearchMiddleware
(126-126)packages/router-core/src/route.ts (2)
AnyRoute
(703-718)SearchMiddleware
(80-82)packages/router-core/src/router.ts (1)
RegisteredRouter
(103-103)packages/router-core/src/utils.ts (1)
replaceEqualDeep
(212-259)
examples/react/search-persistence/src/routes/users.tsx (2)
examples/react/search-persistence/src/routes/products.tsx (1)
Route
(18-26)packages/router-core/src/searchMiddleware.ts (1)
persistSearchParams
(232-285)
examples/react/search-persistence/src/routes/__root.tsx (4)
examples/react/search-persistence/src/routes/index.tsx (1)
Route
(3-5)examples/react/search-persistence/src/routes/users.tsx (1)
Route
(18-24)examples/react/search-persistence/src/routes/products.tsx (1)
Route
(18-26)packages/react-router-devtools/src/TanStackRouterDevtools.tsx (1)
TanStackRouterDevtools
(46-126)
examples/react/search-persistence/src/main.tsx (1)
packages/router-core/src/router.ts (1)
Register
(99-101)
examples/react/search-persistence/src/routes/products.tsx (2)
examples/react/search-persistence/src/routes/users.tsx (1)
Route
(18-24)packages/router-core/src/searchMiddleware.ts (1)
persistSearchParams
(232-285)
🪛 ESLint
examples/react/search-persistence/src/routes/index.tsx
[error] 1-1: Member 'Link' of the import declaration should be sorted alphabetically.
(sort-imports)
examples/react/search-persistence/src/routes/users.tsx
[error] 4-4: Member 'persistSearchParams' of the import declaration should be sorted alphabetically.
(sort-imports)
examples/react/search-persistence/src/main.tsx
[error] 5-5: 'setupLocalStorageSync' is defined but never used.
(unused-imports/no-unused-imports)
examples/react/search-persistence/src/routes/products.tsx
[error] 4-4: Member 'persistSearchParams' of the import declaration should be sorted alphabetically.
(sort-imports)
🔇 Additional comments (8)
examples/react/search-persistence/src/main.tsx (1)
13-19
: Default TypeScript include coverssrc/
; no changes neededThe project’s tsconfig.json doesn’t explicitly list an
include
, but by TypeScript’s defaults all.ts
/.tsx
files under the project (includingsrc/
) are included. Your module-augmentation file will therefore be picked up without adding an explicit"include": ["src"]
.packages/router-core/src/searchMiddleware.ts (3)
39-57
: Typing cast for full strip is fine; behavior remains explicit short-circuit.Returning an empty object with a precise
as TSearchSchema
cast wheninput === true
is clear and consistent. No further action needed.
146-159
: Typed getter looks good.
getSearch
returns the per-route typed schema ornull
, which is ergonomic and avoids sentinel empty objects. Nice.
172-174
: Clear-all operation is correct and efficient.Resetting state via an empty object is fine and works well with structural sharing.
examples/react/search-persistence/src/routes/index.tsx (2)
67-81
: Partial override links look goodMerging previous search with an explicit
category
correctly prevents auto-restore and demonstrates the override pattern well.
1-1
: Add a lint script and apply the import‐order fixIt looks like there isn’t a
lint
script defined in your root or examplepackage.json
, so runningpnpm -w lint --fix
(or the npm/yarn equivalents) doesn’t do anything. To resolve the ESLint import-order error:• In your root
package.json
, add:"scripts": { // … "lint": "eslint . --ext .ts,.tsx" }• Then run:
pnpm lint --fix—or invoke ESLint directly without a script—
pnpm exec eslint examples/react/search-persistence/src/routes/index.tsx --ext .ts,.tsx --fix
This will reorder the named imports (
createFileRoute
,Link
) to match your configuredimport/order
rule.examples/react/search-persistence/src/routes/products.tsx (1)
18-26
: Middleware config matches intentPersisting
category/minPrice/maxPrice
while excludingsortBy
aligns with the UI copy and demonstrates selective persistence well.examples/react/search-persistence/src/routes/users.tsx (1)
18-24
: Persisting pagination and filters is sensiblePersisting
name
,status
, andpage
while leavinglimit
transient is a reasonable default for UX.
import { setupLocalStorageSync } from './utils/localStorage-sync' | ||
import './styles.css' | ||
|
||
// Setup localStorage sync for search persistence (optional) | ||
// if (typeof window !== 'undefined') { | ||
// setupLocalStorageSync() | ||
// } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Resolve ESLint error: remove unused import and prefer guarded dynamic init for the optional localStorage sync
The static import is unused (the call is commented out), which trips unused-imports/no-unused-imports. Remove the import and the commented call. If you want to keep the example, re-introduce it via a client-only dynamic import placed after the router is created.
Apply this diff to clean up the unused pieces:
-import { setupLocalStorageSync } from './utils/localStorage-sync'
@@
-// Setup localStorage sync for search persistence (optional)
-// if (typeof window !== 'undefined') {
-// setupLocalStorageSync()
-// }
Then, add the guarded dynamic init right after the router is created (see next comment for exact placement):
// Client-only optional localStorage sync for search persistence
if (typeof window !== 'undefined') {
import('./utils/localStorage-sync')
.then((m) => m.setupLocalStorageSync())
.catch(() => {
// noop: example-only optional feature
})
}
🧰 Tools
🪛 ESLint
[error] 5-5: 'setupLocalStorageSync' is defined but never used.
(unused-imports/no-unused-imports)
🤖 Prompt for AI Agents
In examples/react/search-persistence/src/main.tsx around lines 5 to 11, remove
the unused static import of setupLocalStorageSync and the commented call to
avoid the unused-imports ESLint error, and instead add a client-only guarded
dynamic import immediately after the router is created: perform a runtime
import('./utils/localStorage-sync') inside an if (typeof window !== 'undefined')
check, then call the module's setupLocalStorageSync in the then handler and
swallow errors in a catch (noop) since this is an optional example feature.
<div> | ||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center"> | ||
<span className="w-6 h-6 bg-gray-500 rounded-full text-white text-sm flex items-center justify-center mr-3"> | ||
× | ||
</span> | ||
Clean Navigation | ||
</h3> | ||
<p className="text-gray-600 mb-4"> | ||
Navigate without any parameter restoration | ||
</p> | ||
<div className="flex flex-wrap gap-3"> | ||
<Link | ||
to="/users" | ||
className="bg-slate-600 hover:bg-slate-700 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md" | ||
> | ||
Users (clean slate) | ||
</Link> | ||
<Link | ||
to="/products" | ||
className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md" | ||
> | ||
Products (clean slate) | ||
</Link> | ||
</div> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
“Clean Navigation” currently restores persisted params; clear the store before navigating
With persistence enabled, navigating with an empty search triggers restoration. To truly navigate clean, clear the persisted entry first, then navigate.
Apply:
-import { createFileRoute, Link } from '@tanstack/react-router'
+import {
+ createFileRoute,
+ getSearchPersistenceStore,
+ Link,
+ useNavigate,
+} from '@tanstack/react-router'
function HomeComponent() {
- return (
+ const navigate = useNavigate()
+ return (
- <Link
- to="/users"
- className="bg-slate-600 hover:bg-slate-700 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md"
- >
- Users (clean slate)
- </Link>
- <Link
- to="/products"
- className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md"
- >
- Products (clean slate)
- </Link>
+ <button
+ type="button"
+ onClick={() => {
+ getSearchPersistenceStore().clearSearch('/users')
+ navigate({ to: '/users', search: {} })
+ }}
+ className="bg-slate-600 hover:bg-slate-700 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md"
+ >
+ Users (clean slate)
+ </button>
+ <button
+ type="button"
+ onClick={() => {
+ getSearchPersistenceStore().clearSearch('/products')
+ navigate({ to: '/products', search: {} })
+ }}
+ className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md"
+ >
+ Products (clean slate)
+ </button>
Also applies to: 1-1, 7-8
🤖 Prompt for AI Agents
In examples/react/search-persistence/src/routes/index.tsx around lines 84 to
107, the "Clean Navigation" links still restore persisted params because the
persistence store isn't cleared before navigation; update the Link handlers to
clear the persisted state entry (e.g., call the store clear/remove API for the
relevant key) immediately before performing navigation so that navigating to
/users or /products uses a clean slate; apply the same change to the other
instances noted (lines 1-1 and 7-8) so all "clean" navigation paths clear the
persisted params first.
export function persistSearchParams<TSearchSchema>( | ||
persistedSearchParams: Array<keyof TSearchSchema>, | ||
exclude?: Array<keyof TSearchSchema>, | ||
): SearchMiddleware<TSearchSchema> { | ||
return ({ search, next, route }) => { | ||
// Filter input to only explicitly allowed keys for this route | ||
const searchRecord = search as Record<string, unknown> | ||
const allowedKeysStr = persistedSearchParams.map((key) => String(key)) | ||
const filteredSearch = Object.fromEntries( | ||
Object.entries(searchRecord).filter(([key]) => | ||
allowedKeysStr.includes(key), | ||
), | ||
) as TSearchSchema | ||
|
||
// Restore from store if current search is empty | ||
const savedSearch = searchPersistenceStore.getSearch(route.id) | ||
let searchToProcess = filteredSearch | ||
|
||
if (savedSearch && Object.keys(savedSearch).length > 0) { | ||
const currentSearch = filteredSearch as Record<string, unknown> | ||
const isEmpty = Object.keys(currentSearch).length === 0 | ||
|
||
if (isEmpty) { | ||
searchToProcess = savedSearch as TSearchSchema | ||
} | ||
} | ||
|
||
const result = next(searchToProcess) | ||
|
||
// Save only the allowed parameters for persistence | ||
const resultRecord = result as Record<string, unknown> | ||
if (Object.keys(resultRecord).length > 0) { | ||
const persistedKeysStr = persistedSearchParams.map((key) => String(key)) | ||
const paramsToSave = Object.fromEntries( | ||
Object.entries(resultRecord).filter(([key]) => | ||
persistedKeysStr.includes(key), | ||
), | ||
) | ||
|
||
const excludeKeys = exclude ? exclude.map((key) => String(key)) : [] | ||
const filteredResult = Object.fromEntries( | ||
Object.entries(paramsToSave).filter( | ||
([key]) => !excludeKeys.includes(key), | ||
), | ||
) | ||
|
||
if (Object.keys(filteredResult).length > 0) { | ||
searchPersistenceStore.saveSearch(route.id, filteredResult) | ||
} | ||
} | ||
|
||
return result | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
Add unit tests for persistence/restore semantics (empty vs. missing) and clearing.
Given the subtlety here, add tests that assert:
- Saving happens only for allowed keys (and excludes are respected).
- Restoring occurs when incoming search is empty, and optionally when keys are missing (if you adopt
restoreOn
). - Clearing: when no allowed keys are returned by
next
, the persisted entry is removed.
You can scaffold the tests with these search patterns to find integration points:
🏁 Script executed:
#!/bin/bash
# Identify middleware usages and examples to target for tests
rg -n -C3 --type=ts '\bpersistSearchParams\s*\('
rg -n -C3 --type=ts '\bgetSearchPersistenceStore\s*\('
# Check if any server/SSR paths import these (to cover injection/no-op behavior)
rg -n -C3 --type=ts -g '!**/node_modules/**' '(server|ssr|entry-server|api|routes/.*loader)\.tsx?'
Length of output: 10561
🏁 Script executed:
#!/bin/bash
# Search for existing test definitions in packages/router-core
rg -n -C3 --type=ts '(describe\(|test\()' -g 'packages/router-core/**'
Length of output: 39210
Add missing unit tests for persistSearchParams
middleware
There are currently no tests covering the persistence and restoration behavior introduced in packages/router-core/src/searchMiddleware.ts
. Please add a new test suite (e.g. packages/router-core/tests/searchMiddleware.test.ts
) that verifies the following scenarios:
- Saving only allowed keys and respecting the
exclude
list - Restoring from the store when the incoming search object is empty
- Optionally restoring when specific keys are missing (if
restoreOn
behavior is adopted) - Clearing the persisted entry when the middleware’s
next
result has no allowed keys
Suggested test locations and patterns (use vitest
):
• Target the persistSearchParams([...], [...])
invocation in your tests, mocking searchPersistenceStore.getSearch
and saveSearch
.
• Use example routes in examples/react/search-persistence/src/routes/{users,products}.tsx
as integration points.
• Scaffold with these rg
patterns to confirm coverage targets:
rg -n "'persistSearchParams'" -g 'packages/router-core/**'
rg -n "'searchPersistenceStore'" -g 'packages/router-core/**'
🤖 Prompt for AI Agents
In packages/router-core/src/searchMiddleware.ts around lines 232-285, there are
no unit tests for persistSearchParams; add a new vitest test suite at
packages/router-core/tests/searchMiddleware.test.ts that imports
persistSearchParams, mocks searchPersistenceStore.getSearch and saveSearch, and
verifies: (1) only allowed keys are saved and the exclude list is respected, (2)
when incoming search is empty the middleware restores saved values, (3) behavior
when specific keys are missing if you choose to add/verify restore-on-key logic,
and (4) persisted entry is cleared/not saved when next result contains no
allowed keys; implement tests by creating simple route stubs, calling the
middleware with controlled search/next results, asserting calls and payloads to
mocked getSearch/saveSearch, and ensure cleanup/reset of mocks between tests.
🛠️ Refactor suggestion
Injectable, typed middleware + fixed clearing semantics.
Two issues to address:
- Current signature lacks
extends object
, and there’s no way to inject a per-request store. Add an options object withstore
andexclude
. - Stale persistence bug: when the route returns no allowed keys, the middleware never clears prior saved values. Call
saveSearch
even for an empty filtered result soSearchPersistenceStore
can delete the route entry.
This also adds an optional restoreOn
switch to control restoration semantics.
-export function persistSearchParams<TSearchSchema>(
- persistedSearchParams: Array<keyof TSearchSchema>,
- exclude?: Array<keyof TSearchSchema>,
-): SearchMiddleware<TSearchSchema> {
- return ({ search, next, route }) => {
+export function persistSearchParams<TSearchSchema extends object>(
+ persistedSearchParams: Array<keyof TSearchSchema>,
+ opts?: {
+ exclude?: Array<keyof TSearchSchema>
+ store?: SearchPersistenceStore
+ // empty: restore only when no allowed keys are present in the incoming search
+ // missing: fill in missing allowed keys from persistence (can be more "sticky")
+ // never: do not restore automatically
+ restoreOn?: 'empty' | 'missing' | 'never'
+ },
+): SearchMiddleware<TSearchSchema> {
+ return ({ search, next, route }) => {
+ const isServer = typeof window === 'undefined'
+ const storeRef =
+ opts?.store ?? (isServer ? undefined : defaultSearchPersistenceStore)
@@
- const allowedKeysStr = persistedSearchParams.map((key) => String(key))
+ const allowedKeysStr = persistedSearchParams.map((key) => String(key))
const filteredSearch = Object.fromEntries(
Object.entries(searchRecord).filter(([key]) =>
allowedKeysStr.includes(key),
),
) as TSearchSchema
@@
- const savedSearch = searchPersistenceStore.getSearch(route.id)
+ const savedSearch = storeRef?.getSearch(route.id)
let searchToProcess = filteredSearch
-
- if (savedSearch && Object.keys(savedSearch).length > 0) {
- const currentSearch = filteredSearch as Record<string, unknown>
- const isEmpty = Object.keys(currentSearch).length === 0
-
- if (isEmpty) {
- searchToProcess = savedSearch as TSearchSchema
- }
- }
+ const restoreOn = opts?.restoreOn ?? 'empty'
+ if (savedSearch && restoreOn !== 'never') {
+ const current = filteredSearch as Record<string, unknown>
+ const isEmpty = Object.keys(current).length === 0
+ if (restoreOn === 'empty' && isEmpty) {
+ searchToProcess = savedSearch as TSearchSchema
+ } else if (restoreOn === 'missing') {
+ // Merge persisted keys for any missing allowed keys
+ searchToProcess = { ...(savedSearch as object), ...filteredSearch } as TSearchSchema
+ }
+ }
@@
- // Save only the allowed parameters for persistence
- const resultRecord = result as Record<string, unknown>
- if (Object.keys(resultRecord).length > 0) {
- const persistedKeysStr = persistedSearchParams.map((key) => String(key))
- const paramsToSave = Object.fromEntries(
- Object.entries(resultRecord).filter(([key]) =>
- persistedKeysStr.includes(key),
- ),
- )
-
- const excludeKeys = exclude ? exclude.map((key) => String(key)) : []
- const filteredResult = Object.fromEntries(
- Object.entries(paramsToSave).filter(
- ([key]) => !excludeKeys.includes(key),
- ),
- )
-
- if (Object.keys(filteredResult).length > 0) {
- searchPersistenceStore.saveSearch(route.id, filteredResult)
- }
- }
+ // Save only the allowed parameters (after excludes). Always call save so empty results clear prior state.
+ const resultRecord = result as Record<string, unknown>
+ const persistedKeysStr = persistedSearchParams.map((key) => String(key))
+ const paramsToSave = Object.fromEntries(
+ Object.entries(resultRecord).filter(([key]) =>
+ persistedKeysStr.includes(key),
+ ),
+ )
+ const excludeKeys = opts?.exclude ? opts.exclude.map((k) => String(k)) : []
+ const filteredResult = Object.fromEntries(
+ Object.entries(paramsToSave).filter(([key]) => !excludeKeys.includes(key)),
+ )
+ if (storeRef) {
+ storeRef.saveSearch(route.id, filteredResult)
+ }
@@
return result
}
}
Rationale:
extends object
aligns with other middlewares.- Options-based API enables per-request store injection and typed excludes without broadening the positional API.
- Clearing bug resolved by saving even when empty, leveraging
SearchPersistenceStore.saveSearch
’s deletion branch. restoreOn
lets consumers choose between minimal (“empty”) and sticky (“missing”) restoration.
Do you want me to propagate the new optional store
parameter through the React integration and example so SSR usage is explicit and leak-free?
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In packages/router-core/src/searchMiddleware.ts around lines 232-285, the
middleware signature and behavior need updating: change the generic to
TSearchSchema extends object and replace the positional
persistedSearchParams/exclude parameters with a single options object {
persistedSearchParams: Array<keyof TSearchSchema>, exclude?: Array<keyof
TSearchSchema>, store?: SearchPersistenceStore, restoreOn?: 'empty' | 'missing'
} so callers can inject a per-request store and typed excludes; use
options.store (falling back to the module-level store) for getSearch/saveSearch
calls; implement restoreOn so when restoreOn === 'empty' you only restore
savedSearch when current filtered search has zero keys, and when restoreOn ===
'missing' you restore when any allowed key is absent; when persisting, always
call store.saveSearch(route.id, filteredResult) even if filteredResult is empty
so the store can clear prior entries; keep return value as next(resultSearch)
and preserve existing filtering logic but use the new options and typed
constraints.
// Save only the allowed parameters for persistence | ||
const resultRecord = result as Record<string, unknown> | ||
if (Object.keys(resultRecord).length > 0) { | ||
const persistedKeysStr = persistedSearchParams.map((key) => String(key)) | ||
const paramsToSave = Object.fromEntries( | ||
Object.entries(resultRecord).filter(([key]) => | ||
persistedKeysStr.includes(key), | ||
), | ||
) | ||
|
||
const excludeKeys = exclude ? exclude.map((key) => String(key)) : [] | ||
const filteredResult = Object.fromEntries( | ||
Object.entries(paramsToSave).filter( | ||
([key]) => !excludeKeys.includes(key), | ||
), | ||
) | ||
|
||
if (Object.keys(filteredResult).length > 0) { | ||
searchPersistenceStore.saveSearch(route.id, filteredResult) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: persisted values aren’t cleared when results are empty.
As written, if the route returns no allowed keys, prior values remain persisted, causing stale search state on subsequent navigations.
If you prefer a minimal change (without the broader refactor above), apply this:
- // Save only the allowed parameters for persistence
- const resultRecord = result as Record<string, unknown>
- if (Object.keys(resultRecord).length > 0) {
+ // Save only the allowed parameters for persistence
+ const resultRecord = result as Record<string, unknown>
const persistedKeysStr = persistedSearchParams.map((key) => String(key))
const paramsToSave = Object.fromEntries(
Object.entries(resultRecord).filter(([key]) =>
persistedKeysStr.includes(key),
),
)
- const excludeKeys = exclude ? exclude.map((key) => String(key)) : []
+ const excludeKeys = exclude ? exclude.map((key) => String(key)) : []
const filteredResult = Object.fromEntries(
Object.entries(paramsToSave).filter(
([key]) => !excludeKeys.includes(key),
),
)
- if (Object.keys(filteredResult).length > 0) {
- searchPersistenceStore.saveSearch(route.id, filteredResult)
- }
- }
+ // Always call save; an empty object clears the entry.
+ searchPersistenceStore.saveSearch(route.id, filteredResult)
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
// Save only the allowed parameters for persistence | |
const resultRecord = result as Record<string, unknown> | |
if (Object.keys(resultRecord).length > 0) { | |
const persistedKeysStr = persistedSearchParams.map((key) => String(key)) | |
const paramsToSave = Object.fromEntries( | |
Object.entries(resultRecord).filter(([key]) => | |
persistedKeysStr.includes(key), | |
), | |
) | |
const excludeKeys = exclude ? exclude.map((key) => String(key)) : [] | |
const filteredResult = Object.fromEntries( | |
Object.entries(paramsToSave).filter( | |
([key]) => !excludeKeys.includes(key), | |
), | |
) | |
if (Object.keys(filteredResult).length > 0) { | |
searchPersistenceStore.saveSearch(route.id, filteredResult) | |
} | |
} | |
// Save only the allowed parameters for persistence | |
const resultRecord = result as Record<string, unknown> | |
const persistedKeysStr = persistedSearchParams.map((key) => String(key)) | |
const paramsToSave = Object.fromEntries( | |
Object.entries(resultRecord).filter(([key]) => | |
persistedKeysStr.includes(key), | |
), | |
) | |
const excludeKeys = exclude ? exclude.map((key) => String(key)) : [] | |
const filteredResult = Object.fromEntries( | |
Object.entries(paramsToSave).filter( | |
([key]) => !excludeKeys.includes(key), | |
), | |
) | |
// Always call save; an empty object clears the entry. | |
searchPersistenceStore.saveSearch(route.id, filteredResult) |
🤖 Prompt for AI Agents
In packages/router-core/src/searchMiddleware.ts around lines 261 to 281, the
current logic only saves filteredResult when it has keys, leaving any previously
persisted values intact when filteredResult is empty; update the code so that
after computing filteredResult you either call
searchPersistenceStore.saveSearch(route.id, filteredResult) when it has keys or
explicitly clear the persisted entry when it is empty (e.g., call
searchPersistenceStore.saveSearch(route.id, {}) or the store's delete/clear
method) so stale values are removed.
Add comprehensive search parameter persistence system with SSR support: ## Core Features - **persistSearchParams middleware**: Automatically saves/restores search params per route - **Explicit allowlists**: Required persistedSearchParams array with optional exclude - **SSR-safe architecture**: Per-router store injection prevents cross-request state leakage - **Smart restoration**: Bypasses validation for trusted restored data to prevent Zod defaults corruption ## Technical Implementation - **SearchPersistenceStore**: Client-only store with subscribe/save/restore methods - **Router integration**: Optional searchPersistenceStore injection via RouterOptions - **Middleware bypass**: Restored data skips validation to prevent SSR hydration issues - **Memory efficient**: Stores garbage collected per request in SSR environments ## Breaking Changes - persistSearchParams now requires explicit persistedSearchParams array as first argument - Removed auto-discovery from Zod schemas due to .optional().catch() permissiveness ## Added - packages/router-core: SearchPersistenceStore class and persistence utilities - examples/react/ssr-search-persistence: Complete SSR demo with server database integration - docs: Comprehensive SSR documentation with architecture examples ## Fixed - SSR validation timing issues causing restored values to be overridden by Zod defaults - Cross-request state contamination in server environments - Homepage navigation resetting persisted search parameters - Hydration mismatches in SSR applications Resolves search parameter persistence requirements for both client-only and SSR applications.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 12
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
examples/react/ssr-search-persistence/src/entry-client.tsx (1)
1-8
: Hydration target is incorrect; pass a DOM element, notdocument
.
hydrateRoot
requires a container element (e.g.,#root
). Passingdocument
will throw at runtime.-import { hydrateRoot } from 'react-dom/client' -import { RouterClient } from '@tanstack/react-router/ssr/client' -import { createRouter } from './router' - -const router = createRouter() - -hydrateRoot(document, <RouterClient router={router} />) +import { hydrateRoot } from 'react-dom/client' +import { RouterClient } from '@tanstack/react-router/ssr/client' +import { createRouter } from './router' + +const router = createRouter() +const container = document.getElementById('root') +if (!container) { + throw new Error('Root container #root not found') +} +hydrateRoot(container, <RouterClient router={router} />)packages/router-core/src/searchMiddleware.ts (1)
36-56
:stripSearchParams(true)
short-circuits the chain and skips later middlewares (including the final merge).Returning
{}
directly prevents downstream middlewares and the final step from running. Forward an empty search tonext
instead.return ({ search, next }) => { if (input === true) { - return {} as TSearchSchema + return next({} as TSearchSchema) as TSearchSchema } const result = next(search) as Record<string, unknown>
♻️ Duplicate comments (2)
docs/router/framework/react/api/router/persistSearchParamsFunction.md (1)
20-21
: Grammar nit: add missing article (“an”).“with empty search” → “with an empty search”.
-2. **Restoring**: Restores saved parameters when the middleware is triggered with empty search +2. **Restoring**: Restores saved parameters when the middleware is triggered with an empty searchpackages/router-core/src/router.ts (1)
2808-2820
: Bug: default middleware wipes search whendest.search
is undefined.This regresses preservation semantics and breaks middlewares that expect the current search to flow through. Return the incoming
search
whendest.search
is absent.const final = { middleware: ({ search }: { search: any }) => { - if (!dest.search) { - return {} - } + // No dest.search provided -> preserve current search + if (dest.search === undefined) { + return search + } if (dest.search === true) { return search } return functionalUpdate(dest.search, search) }, route: { id: '', fullPath: '' }, }
🧹 Nitpick comments (23)
examples/react/ssr-search-persistence/.gitignore (3)
1-2
: Use trailing slashes to make directory intent explicitAdding a trailing slash clarifies these are directories and avoids accidental matches of files named exactly "dist" or "node_modules".
-node_modules -dist +node_modules/ +dist/
3-3
: Optionally broaden ignores for common Node/SSR artifacts (env, logs, TS cache, Vite)If the SSR example uses TS/Vite or local env files, consider these additions to reduce accidental commits. Keep this scoped to the example folder.
.DS_Store +# Logs +npm-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Env (keep a .env.example committed if needed) +.env.local +.env.development.local +.env.test.local +.env.production.local + +# TypeScript build info +*.tsbuildinfo + +# Vite cache +.vite/ + +# Generic cache +.cache/
1-3
: Remove redundant.gitignore
entries in the exampleThe repo’s top-level
.gitignore
already ignoresnode_modules
,dist
, and.DS_Store
(recursively across all subdirectories), so the identical rules inexamples/react/ssr-search-persistence/.gitignore
are redundant. To reduce duplication, you can safely remove this local file (or at least drop those three lines). If you prefer examples to remain standalone when copied, keeping it is harmless but optional.• Path for removal:
- examples/react/ssr-search-persistence/.gitignore
examples/react/ssr-search-persistence/src/routerContext.tsx (1)
1-3
: Broadenhead
typing and consider.ts
extension.
- If
head
is used to carry rendered head content for SSR,string
may be too narrow. Typing it asReactNode
is safer and future‑proof (React 19 includes the new JSX runtime, which plays well withReactNode
).- Since there’s no JSX here,
.ts
would be a slightly cleaner extension (optional).Apply:
+import type { ReactNode } from 'react' export interface RouterContext { - head: string + head: ReactNode }examples/react/ssr-search-persistence/src/fetch-polyfill.js (1)
1-11
: Avoid eagerly importingnode-fetch
when Node already providesfetch
.Node 18+ ships a Web Fetch API (via undici). You currently import
node-fetch
unconditionally, even ifglobalThis.fetch
already exists. Switch to a conditional dynamic import so you only loadnode-fetch
on older Node versions. This reduces startup cost and avoids bundling unused code.Please confirm your minimum Node version for this example. The dynamic import below uses top‑level
await
, which requires Node ≥18 in ESM mode (this package is"type": "module"
).-import fetch, { Headers, Request, Response } from 'node-fetch' - -// Polyfill fetch for Node.js environments that don't have it built-in -// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -globalThis.fetch = globalThis.fetch || fetch -// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -globalThis.Headers = globalThis.Headers || Headers -// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -globalThis.Request = globalThis.Request || Request -// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -globalThis.Response = globalThis.Response || Response +// Polyfill fetch for Node.js environments that don't have it built-in +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ +if (!globalThis.fetch) { + const mod = await import('node-fetch') + globalThis.fetch = mod.default + globalThis.Headers = mod.Headers + globalThis.Request = mod.Request + globalThis.Response = mod.Response +}examples/react/ssr-search-persistence/tsconfig.json (1)
1-13
: Improve SSR DX with Node types and explicit target.
- Add
"node"
types so server‑side TS files (e.g.,entry-server.tsx
) can use Node globals without local type shims.- Set an explicit
target
for predictable output and editor tooling.{ "compilerOptions": { "strict": true, "esModuleInterop": true, "jsx": "react-jsx", "module": "esnext", - "types": ["vite/client"], + "types": ["vite/client", "node"], "moduleResolution": "Bundler", + "target": "ES2022", "lib": ["DOM", "DOM.Iterable", "ES2022"], "skipLibCheck": true }, "include": ["src", "vite.config.ts"] }examples/react/ssr-search-persistence/package.json (1)
1-34
: Declare Node engine and add a type‑check script.
- Declaring an engine (≥18) documents the runtime expectation and aligns with SSR + native
fetch
.- Adding a
typecheck
script helps CI catch TS issues in the example.- Optional: a conventional
start
script can proxy toserve
for familiarity.If you intend to support Node <18 via the polyfill, set the engine accordingly and keep the polyfill. Otherwise, prefer Node ≥18.
{ "name": "tanstack-router-react-example-ssr-search-persistence", "private": true, "type": "module", "scripts": { "dev": "node server", "build": "pnpm run build:client && pnpm run build:server", "build:client": "vite build", "build:server": "vite build --ssr", "serve": "NODE_ENV=production node server", - "debug": "node --inspect-brk server" + "debug": "node --inspect-brk server", + "typecheck": "tsc --noEmit", + "start": "pnpm run serve" }, + "engines": { + "node": ">=18" + }, "dependencies": { "@tanstack/react-router": "^1.131.27", "@tanstack/router-plugin": "^1.131.27", "compression": "^1.8.0", "express": "^4.21.2", "get-port": "^7.1.0", "isbot": "^5.1.28", "node-fetch": "^3.3.2", "react": "^19.0.0", "react-dom": "^19.0.0", "zod": "^3.23.8" }, "devDependencies": { "@tanstack/react-router-devtools": "^1.131.27", "@types/express": "^4.17.23", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.1", "@vitejs/plugin-react": "^4.5.2", "typescript": "^5.8.3", "vite": "^6.3.5" } }examples/react/ssr-search-persistence/README.md (2)
71-79
: Fix markdownlint MD034 (bare URL).Wrap the URL in angle brackets to satisfy MD034 and improve rendering.
-Then open http://localhost:3000 +Then open <http://localhost:3000>
82-89
: Polish step wording for clarity and consistency.Minor grammar/consistency tweaks: add conjunctions, prefer “the database”, and standardize “real time”.
-1. Navigate to Products, set some filters -2. Go to Database tab - see your search params stored in real-time! -3. Navigate to Users, set different filters -4. Check Database again - both routes have isolated records -5. Navigate back to Products - your filters persist from database! -6. Refresh the page - everything restores correctly from database -7. Each route maintains its own isolated search state in the database +1. Navigate to Products and set some filters. +2. Go to the Database tab — see your search params stored in real time. +3. Navigate to Users and set different filters. +4. Check the Database again — both routes have isolated records. +5. Navigate back to Products — your filters persist from the database. +6. Refresh the page — everything restores correctly from the database. +7. Each route maintains its own isolated search state in the database.docs/router/framework/react/api/router/persistSearchParamsFunction.md (2)
22-27
: Clarify trigger semantics and tighten grammar/punctuation.
- Make bullets parallel and explicit (avoid contractions, end with periods).
- This improves scanability and addresses the grammar warnings flagged by tooling.
-- **Without search prop**: `<Link to="/users">` → Middleware doesn't run → No restoration -- **With search function**: `<Link to="/users" search={(prev) => prev}>` → Middleware runs → Restoration happens -- **With explicit search**: `<Link to="/users" search={{ name: 'John' }}>` → Middleware runs → No restoration (params provided) +- Without a search prop: `<Link to="/users">` → Middleware does not run → No restoration. +- With a search function: `<Link to="/users" search={(prev) => prev}>` → Middleware runs → Restoration happens. +- With an explicit search object: `<Link to="/users" search={{ name: 'John' }}>` → Middleware runs → No restoration (provided params take precedence).
254-270
: Caveat: avoid calling the global getter in server code outside a request lifecycle.If
getSearchPersistenceStore()
resolves a process/global-scoped store, calling it during SSR outside a per-request context could leak state across users. Suggest adding a cautionary note in the docs and preferring router-injected stores on the server.// Get the fully typed store instance -const store = getSearchPersistenceStore() +const store = getSearchPersistenceStore() + +// Server-side note: +// Prefer a per-request store from your router instance during SSR to avoid cross-request leakage. +// Use the global getter only in browser code or within a request-scoped context.examples/react/ssr-search-persistence/src/entry-server.tsx (2)
65-74
: Preserve multiple Set-Cookie headers; useres.append
when available.Some environments emit multiple Set-Cookie headers that must not be combined. Using
setHeader
can collapse or overwrite them.- response.headers.forEach((value, name) => { - res.setHeader(name, value) - }) + // Preserve multi-valued headers (notably Set-Cookie) + response.headers.forEach((value, name) => { + if (name.toLowerCase() === 'set-cookie') { + // Express supports appending multiple Set-Cookie values + // In Node's fetch, Headers may expose getSetCookie(); fallback to setHeader otherwise. + const anyHeaders = response.headers as any + if (typeof anyHeaders.getSetCookie === 'function') { + for (const cookie of anyHeaders.getSetCookie()) { + res.append('Set-Cookie', cookie) + } + } else { + res.append('Set-Cookie', value) + } + } else { + res.setHeader(name, value) + } + })
56-64
: Optional: wrap streaming in try/catch to surface pipeline errors clearly.Improves observability in examples and prevents unhandled rejection logs during abrupt disconnects.
- const response = await handler(({ responseHeaders, router }) => - renderRouterToString({ - responseHeaders, - router, - children: <RouterServer router={router} />, - }), - ) + let response + try { + response = await handler(({ responseHeaders, router }) => + renderRouterToString({ + responseHeaders, + router, + children: <RouterServer router={router} />, + }), + ) + } catch (err) { + // Log and rethrow or map to a 500 as needed for the example + throw err + }examples/react/ssr-search-persistence/src/router.tsx (3)
14-22
: Type-safety: avoidas any
for route IDs when seeding from the DB.If possible, validate that
record.routeId
exists in the route tree before saving, or defer seeding until after the router is created so you can consultrouter.routesById
.Example (post-router creation):
for (const record of allRecords) { if (router.routesById[record.routeId]) { searchPersistenceStore!.saveSearch(record.routeId as never, record.searchParams) } }
23-35
: Reduce redundant writes to the “database”.Every store mutation currently writes each non-empty route snapshot. Consider diffing vs. the last persisted snapshot to avoid unnecessary saves.
I can sketch a small prevState cache if you want.
24-26
: Minor: redundant null-check inside the subscriber.
searchPersistenceStore
cannot be falsy inside this closure. Safe to drop the guard.- searchPersistenceStore.subscribe(() => { - if (!searchPersistenceStore) return + searchPersistenceStore.subscribe(() => {examples/react/ssr-search-persistence/server.js (1)
65-76
: Head extraction is “best effort”; guard against missing tags.
indexOf('<head>')
returning -1 will produce unexpected substrings. Low risk in dev, but a simple guard avoids noisy 500s when plugins change HTML.- viteHead = viteHead.substring( - viteHead.indexOf('<head>') + 6, - viteHead.indexOf('</head>'), - ) + const start = viteHead.indexOf('<head>') + const end = viteHead.indexOf('</head>') + if (start !== -1 && end !== -1) { + viteHead = viteHead.substring(start + 6, end) + }examples/react/ssr-search-persistence/src/routes/users.tsx (1)
29-31
: Optional UX: reset page to 1 when filters change.Users expect pagination to reset when a filter changes. Implement a small helper to reset
page
whenname
orstatus
updates.- const updateSearch = (updates: Partial<UsersSearchSchema>) => { - navigate({ search: { ...search, ...updates } }) - } + const updateSearch = ( + updates: Partial<UsersSearchSchema>, + { resetPage }: { resetPage?: boolean } = {}, + ) => { + navigate({ + search: { + ...search, + ...updates, + ...(resetPage ? { page: 1 } : {}), + }, + }) + } @@ - onChange={(e) => updateSearch({ name: e.target.value })} + onChange={(e) => updateSearch({ name: e.target.value }, { resetPage: true })} @@ - onChange={(e) => - updateSearch({ - status: e.target.value as UsersSearchSchema['status'], - }) - } + onChange={(e) => + updateSearch( + { status: e.target.value as UsersSearchSchema['status'] }, + { resetPage: true }, + ) + }Also applies to: 52-60, 69-83
examples/react/ssr-search-persistence/src/routes/__root.tsx (2)
72-112
: Consider preserving search on the Database link for parity.Products/Users preserve search via
search={(prev) => prev}
. If you want consistent UX while inspecting server-persisted entries, add the same to Database.<Link to="/database" activeProps={{ className: 'font-bold text-blue-600', }} className="hover:text-blue-500" + search={(prev) => prev} > Database </Link>
26-52
: Optional hardening: add SRI/CSP-friendly attributes to external Tailwind script.When SSRing in stricter environments, adding
integrity
,crossOrigin
, or switching to a locally hosted asset helps avoid CSP/subresource risks. Not required for the example, but worth noting.examples/react/ssr-search-persistence/src/routes/database.tsx (2)
34-46
: Clear router store more efficiently (and deterministically).Instead of iterating over
records
(which might be stale at click time), clear the whole store in one call. If you intentionally only want to clear known records, use a functional state update to avoid stale closures.const clearAllRecords = () => { serverDatabase.clear() - // Also clear from router store + // Also clear from router store const searchStore = router.options.searchPersistenceStore if (searchStore) { - records.forEach((record) => { - searchStore.clearSearch(record.routeId) - }) + // If the intent is to wipe all client-side persisted searches: + searchStore.clearAllSearches() } setRecords([]) }
112-129
: Fix potential stale state in delete handler.Use a functional state update to avoid capturing an outdated
records
array in concurrent renders.onClick={() => { serverDatabase.delete(record.routeId, record.userId) const searchStore = router.options.searchPersistenceStore if (searchStore) { searchStore.clearSearch(record.routeId) } - setRecords(records.filter((r) => r !== record)) + setRecords((prev) => prev.filter((r) => r !== record)) }}examples/react/ssr-search-persistence/src/routes/products.tsx (1)
8-13
: Coerce numeric search params to be robust with URL string inputs.Search params often arrive as strings;
z.coerce.number()
is safer thanz.number()
with.catch
.const productsSearchSchema = z.object({ category: z.string().optional().catch(''), - minPrice: z.number().optional().catch(0), - maxPrice: z.number().optional().catch(1000), + minPrice: z.coerce.number().optional().catch(0), + maxPrice: z.coerce.number().optional().catch(1000), sortBy: z.enum(['name', 'price', 'rating']).optional().catch('name'), })
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
⛔ Files ignored due to path filters (1)
pnpm-lock.yaml
is excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (25)
docs/router/framework/react/api/router/persistSearchParamsFunction.md
(1 hunks)examples/react/search-persistence/src/routes/__root.tsx
(1 hunks)examples/react/ssr-search-persistence/.gitignore
(1 hunks)examples/react/ssr-search-persistence/README.md
(1 hunks)examples/react/ssr-search-persistence/package.json
(1 hunks)examples/react/ssr-search-persistence/server.js
(1 hunks)examples/react/ssr-search-persistence/src/components/ClientOnly.tsx
(1 hunks)examples/react/ssr-search-persistence/src/entry-client.tsx
(1 hunks)examples/react/ssr-search-persistence/src/entry-server.tsx
(1 hunks)examples/react/ssr-search-persistence/src/fetch-polyfill.js
(1 hunks)examples/react/ssr-search-persistence/src/routeTree.gen.ts
(1 hunks)examples/react/ssr-search-persistence/src/router.tsx
(1 hunks)examples/react/ssr-search-persistence/src/routerContext.tsx
(1 hunks)examples/react/ssr-search-persistence/src/routes/__root.tsx
(1 hunks)examples/react/ssr-search-persistence/src/routes/database.tsx
(1 hunks)examples/react/ssr-search-persistence/src/routes/index.tsx
(1 hunks)examples/react/ssr-search-persistence/src/routes/products.tsx
(1 hunks)examples/react/ssr-search-persistence/src/routes/users.tsx
(1 hunks)examples/react/ssr-search-persistence/tsconfig.json
(1 hunks)examples/react/ssr-search-persistence/vite.config.ts
(1 hunks)packages/react-router/src/index.tsx
(1 hunks)packages/router-core/src/index.ts
(1 hunks)packages/router-core/src/route.ts
(1 hunks)packages/router-core/src/router.ts
(7 hunks)packages/router-core/src/searchMiddleware.ts
(3 hunks)
✅ Files skipped from review due to trivial changes (1)
- examples/react/ssr-search-persistence/src/routeTree.gen.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- packages/router-core/src/route.ts
- packages/react-router/src/index.tsx
- examples/react/search-persistence/src/routes/__root.tsx
🧰 Additional context used
🧬 Code graph analysis (13)
examples/react/ssr-search-persistence/src/entry-server.tsx (3)
examples/react/ssr-search-persistence/src/router.tsx (1)
createRouter
(8-62)packages/router-core/src/searchMiddleware.ts (1)
SearchPersistenceStore
(59-171)examples/react/basic-ssr-file-based/src/entry-server.tsx (1)
render
(10-61)
examples/react/ssr-search-persistence/server.js (1)
examples/react/basic-ssr-file-based/server.js (1)
createServer
(7-86)
examples/react/ssr-search-persistence/src/components/ClientOnly.tsx (1)
packages/react-router/src/index.tsx (1)
ClientOnly
(139-139)
examples/react/ssr-search-persistence/src/routes/users.tsx (2)
examples/react/ssr-search-persistence/src/routes/products.tsx (1)
Route
(17-25)packages/router-core/src/searchMiddleware.ts (1)
persistSearchParams
(231-313)
examples/react/ssr-search-persistence/vite.config.ts (1)
examples/solid/basic-ssr-streaming-file-based/vite.config.js (3)
__filename
(7-7)ssrBuildConfig
(11-25)clientBuildConfig
(28-41)
examples/react/ssr-search-persistence/src/entry-client.tsx (2)
examples/react/ssr-search-persistence/src/router.tsx (1)
createRouter
(8-62)packages/react-router/src/index.tsx (1)
createRouter
(280-280)
examples/react/ssr-search-persistence/src/routes/database.tsx (4)
examples/react/ssr-search-persistence/src/routes/__root.tsx (1)
Route
(11-55)examples/react/ssr-search-persistence/src/routes/products.tsx (1)
Route
(17-25)examples/react/ssr-search-persistence/src/routes/users.tsx (1)
Route
(17-23)examples/react/ssr-search-persistence/src/components/ClientOnly.tsx (1)
ClientOnly
(8-20)
examples/react/ssr-search-persistence/src/routes/__root.tsx (3)
examples/react/search-persistence/src/routes/__root.tsx (1)
Route
(4-6)packages/react-router/src/index.tsx (2)
Route
(261-261)createRootRouteWithContext
(266-266)examples/react/ssr-search-persistence/src/routerContext.tsx (1)
RouterContext
(1-3)
examples/react/ssr-search-persistence/src/routes/index.tsx (4)
examples/react/ssr-search-persistence/src/routes/__root.tsx (1)
Route
(11-55)examples/react/ssr-search-persistence/src/routes/database.tsx (1)
Route
(7-9)examples/react/ssr-search-persistence/src/routes/products.tsx (1)
Route
(17-25)examples/react/ssr-search-persistence/src/routes/users.tsx (1)
Route
(17-23)
examples/react/ssr-search-persistence/src/routes/products.tsx (3)
examples/react/ssr-search-persistence/src/routes/__root.tsx (1)
Route
(11-55)examples/react/ssr-search-persistence/src/routes/users.tsx (1)
Route
(17-23)packages/router-core/src/searchMiddleware.ts (1)
persistSearchParams
(231-313)
examples/react/ssr-search-persistence/src/router.tsx (2)
packages/router-core/src/searchMiddleware.ts (1)
SearchPersistenceStore
(59-171)packages/router-core/src/router.ts (1)
Register
(100-102)
packages/router-core/src/searchMiddleware.ts (3)
packages/router-core/src/route.ts (2)
AnyRoute
(710-725)SearchMiddleware
(87-89)packages/router-core/src/router.ts (1)
RegisteredRouter
(104-104)packages/router-core/src/utils.ts (1)
replaceEqualDeep
(212-259)
packages/router-core/src/router.ts (3)
packages/router-core/src/searchMiddleware.ts (1)
SearchPersistenceStore
(59-171)packages/router-core/src/route.ts (2)
AnyRoute
(710-725)SearchMiddleware
(87-89)packages/router-core/src/utils.ts (1)
functionalUpdate
(195-204)
🪛 LanguageTool
docs/router/framework/react/api/router/persistSearchParamsFunction.md
[grammar] ~24-~24: There might be a mistake here.
Context: ... Middleware doesn't run → No restoration - With search function: `<Link to="/user...
(QB_NEW_EN)
[grammar] ~25-~25: There might be a mistake here.
Context: ... → Middleware runs → Restoration happens - With explicit search: `<Link to="/user...
(QB_NEW_EN)
[grammar] ~244-~244: There might be a mistake here.
Context: ...ent-only store**: Store is only created on client-side (`typeof window !== 'undefi...
(QB_NEW_EN)
examples/react/ssr-search-persistence/README.md
[grammar] ~83-~83: There might be a mistake here.
Context: ...ate to Products, set some filters 2. Go to Database tab - see your search params s...
(QB_NEW_EN)
[grammar] ~83-~83: There might be a mistake here.
Context: ... your search params stored in real-time! 3. Navigate to Users, set different filters...
(QB_NEW_EN)
[grammar] ~85-~85: There might be a mistake here.
Context: ...erent filters 4. Check Database again - both routes have isolated records 5. Navigat...
(QB_NEW_EN)
[grammar] ~86-~86: There might be a mistake here.
Context: ...back to Products - your filters persist from database! 6. Refresh the page - everyth...
(QB_NEW_EN)
[grammar] ~86-~86: There might be a mistake here.
Context: ...ts - your filters persist from database! 6. Refresh the page - everything restores c...
(QB_NEW_EN)
[grammar] ~87-~87: There might be a mistake here.
Context: ...he page - everything restores correctly from database 7. Each route maintains its ow...
(QB_NEW_EN)
🪛 markdownlint-cli2 (0.17.2)
examples/react/ssr-search-persistence/README.md
78-78: Bare URL used
(MD034, no-bare-urls)
🔇 Additional comments (10)
examples/react/ssr-search-persistence/.gitignore (1)
1-3
: LGTM for a minimal example ignore listIgnoring node_modules, dist, and .DS_Store is sensible for an app example and avoids unnecessary churn in the example directory.
examples/react/ssr-search-persistence/README.md (1)
26-33
: Confirm API name consistency:SearchPersistenceStore
vsgetSearchPersistenceStore()
.The PR objectives mention a
getSearchPersistenceStore()
factory. The README instantiatesnew SearchPersistenceStore()
on both server and client. Please confirm the intended public API and align the examples and docs accordingly to avoid confusion.I can update the snippets once you confirm whether the factory should be preferred over direct construction.
examples/react/ssr-search-persistence/src/components/ClientOnly.tsx (1)
8-20
: LGTM overall; simple SSR-safe mount gate.The mount flag approach is straightforward and appropriate for hiding client-only UI during SSR until hydration completes.
packages/router-core/src/index.ts (1)
257-264
: Public API re-exports look consistent; ensure framework packages mirror them.Confirm
packages/react-router/src/index.tsx
also re-exportsSearchPersistenceStore
,createSearchPersistenceStore
,persistSearchParams
, andgetSearchPersistenceStore
, since the docs import them from@tanstack/react-router
.examples/react/ssr-search-persistence/src/entry-server.tsx (1)
41-52
: Good: per-requestSearchPersistenceStore
injection prevents cross-request contamination.This matches the “per-request isolation” guidance and avoids global state. No changes needed here.
examples/react/ssr-search-persistence/src/routes/index.tsx (1)
24-41
: Example links look correct and type-safe with the target routes’ schemas.Pre-filled search objects align with the Products/Users schemas and demonstrate persistence clearly. No issues spotted.
examples/react/ssr-search-persistence/src/router.tsx (1)
64-68
: Module augmentation placement LGTM.The Register augmentation matches TanStack Router patterns and will give you IntelliSense for
router
. No changes needed.examples/react/ssr-search-persistence/src/routes/users.tsx (1)
8-16
: Schema and types look solid.Good defaults via
catch
, and the persisted keys list matches the UI explanation.examples/react/ssr-search-persistence/vite.config.ts (1)
45-53
: Config selection is clean and matches Vite’sisSsrBuild
.Plugin setup (router + React) and dual-build switching look good.
packages/router-core/src/router.ts (1)
935-939
: Good: per-router client store initialization avoids SSR cross-request leakage.Auto-creating
SearchPersistenceStore
in the browser while requiring explicit injection on the server is the right trade-off for safety and DX.
## Restoration Behavior | ||
|
||
⚠️ **Unexpected behavior warning**: If you use the persistence middleware but navigate without the `search` prop, the middleware will only trigger later when you modify search parameters. This can cause unexpected restoration of saved parameters mixed with your new changes. | ||
|
||
**Recommended**: Always be explicit about restoration intent using the `search` prop. | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Document “from” vs “to” route middleware invocation to avoid confusion/leakage.
Given the known behavior that search middlewares run for both the originating (“from”) and destination (“to”) routes, the docs should call this out to explain why an explicit allow-list is required. This directly addresses the bug noted in the PR conversation.
## Restoration Behavior
@@
-**Recommended**: Always be explicit about restoration intent using the `search` prop.
+**Recommended**: Always be explicit about restoration intent using the `search` prop.
+
+Note: Search middlewares run for both the originating (“from”) and destination (“to”) routes involved in a navigation. To prevent unintended cross-route persistence, `persistSearchParams` requires an explicit allow‑list (`persistedSearchParams`) and supports an optional `exclude` list. This ensures only the intended keys are saved/restored for the target route.
If helpful, I can add a minimal diagram showing “from” and “to” execution points.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
## Restoration Behavior | |
⚠️ **Unexpected behavior warning**: If you use the persistence middleware but navigate without the `search` prop, the middleware will only trigger later when you modify search parameters. This can cause unexpected restoration of saved parameters mixed with your new changes. | |
**Recommended**: Always be explicit about restoration intent using the `search` prop. | |
## Restoration Behavior | |
⚠️ **Unexpected behavior warning**: If you use the persistence middleware but navigate without the `search` prop, the middleware will only trigger later when you modify search parameters. This can cause unexpected restoration of saved parameters mixed with your new changes. | |
**Recommended**: Always be explicit about restoration intent using the `search` prop. | |
Note: Search middlewares run for both the originating (“from”) and destination (“to”) routes involved in a navigation. To prevent unintended cross-route persistence, `persistSearchParams` requires an explicit allow-list (`persistedSearchParams`) and supports an optional `exclude` list. This ensures only the intended keys are saved/restored for the target route. |
🤖 Prompt for AI Agents
In docs/router/framework/react/api/router/persistSearchParamsFunction.md around
lines 28 to 33, the docs warn about unexpected restoration but don't explain
that search middleware is invoked for both the originating ("from") and
destination ("to") routes; update this section to explicitly state that
middleware runs at both "from" and "to" execution points, show how that can
cause parameter leakage, and clarify that this is why an explicit allow-list (or
explicit use of the search prop) is required; add a brief example or minimal
diagram showing the "from" and "to" invocation points to illustrate the flow.
### Basic SSR Setup | ||
|
||
```tsx | ||
import { createRouter, SearchPersistenceStore } from '@tanstack/react-router' | ||
import { routeTree } from './routeTree.gen' | ||
|
||
export function createAppRouter() { | ||
// Create isolated store per router instance (per SSR request) | ||
const searchPersistenceStore = | ||
typeof window !== 'undefined' ? new SearchPersistenceStore() : undefined | ||
|
||
return createRouter({ | ||
routeTree, | ||
searchPersistenceStore, // Inject the store | ||
// ... other options | ||
}) | ||
} | ||
``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SSR “Basic Setup” contradicts the per-request isolation claim; fix sample to create the store on the server too.
The comment says “per SSR request,” but the code creates a store only on the client. Align the example with the server entry that injects a per-request store.
-export function createAppRouter() {
- // Create isolated store per router instance (per SSR request)
- const searchPersistenceStore =
- typeof window !== 'undefined' ? new SearchPersistenceStore() : undefined
-
- return createRouter({
- routeTree,
- searchPersistenceStore, // Inject the store
- // ... other options
- })
-}
+export function createAppRouter() {
+ // Create a new store per router instance.
+ // On the server, ensure this is done per request.
+ const searchPersistenceStore = new SearchPersistenceStore()
+
+ return createRouter({
+ routeTree,
+ searchPersistenceStore, // Inject the store (server: per request; client: per hydration)
+ // ... other options
+ })
+}
Follow-up: ensure the server entry (e.g., your render
handler) creates a fresh router per request and does not reuse a store across requests.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
### Basic SSR Setup | |
```tsx | |
import { createRouter, SearchPersistenceStore } from '@tanstack/react-router' | |
import { routeTree } from './routeTree.gen' | |
export function createAppRouter() { | |
// Create isolated store per router instance (per SSR request) | |
const searchPersistenceStore = | |
typeof window !== 'undefined' ? new SearchPersistenceStore() : undefined | |
return createRouter({ | |
routeTree, | |
searchPersistenceStore, // Inject the store | |
// ... other options | |
}) | |
} | |
``` | |
import { createRouter, SearchPersistenceStore } from '@tanstack/react-router' | |
import { routeTree } from './routeTree.gen' | |
export function createAppRouter() { | |
// Create a new store per router instance. | |
// On the server, ensure this is done per request. | |
const searchPersistenceStore = new SearchPersistenceStore() | |
return createRouter({ | |
routeTree, | |
searchPersistenceStore, // Inject the store (server: per request; client: per hydration) | |
// ... other options | |
}) | |
} |
🤖 Prompt for AI Agents
In docs/router/framework/react/api/router/persistSearchParamsFunction.md around
lines 189 to 206, the SSR "Basic Setup" example contradicts the "per SSR
request" claim by only instantiating SearchPersistenceStore on the client;
change the sample to always create a new SearchPersistenceStore instance for
each router creation (so the server path also constructs a fresh store per
request) and update the example to show the server/render entry creating a fresh
router (and store) per request rather than reusing a global/shared store across
requests.
### SSR Considerations | ||
|
||
- **Client-only store**: Store is only created on client-side (`typeof window !== 'undefined'`) | ||
- **Hydration-safe**: No server/client mismatch issues | ||
- **Performance**: Restored data bypasses validation to prevent SSR timing issues | ||
- **Memory efficient**: Stores are garbage collected per request | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Revise “Client-only store” bullet; it conflicts with the server example and per-request isolation.
Rephrase to reflect recommended usage: create a new store per SSR request on the server; create one on the client during hydration.
-- **Client-only store**: Store is only created on client-side (`typeof window !== 'undefined'`)
- - **Hydration-safe**: No server/client mismatch issues
+- **Per-request server store**: On the server, create a new `SearchPersistenceStore` per request and pass it to the router instance.
+- **Client-side hydration**: On the client, create a `SearchPersistenceStore` during hydration (or reuse one injected via SSR) to continue from server state.
+ - **Hydration-safe**: No server/client mismatch issues
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
### SSR Considerations | |
- **Client-only store**: Store is only created on client-side (`typeof window !== 'undefined'`) | |
- **Hydration-safe**: No server/client mismatch issues | |
- **Performance**: Restored data bypasses validation to prevent SSR timing issues | |
- **Memory efficient**: Stores are garbage collected per request | |
### SSR Considerations | |
- **Per-request server store**: On the server, create a new `SearchPersistenceStore` per request and pass it to the router instance. | |
- **Client-side hydration**: On the client, create a `SearchPersistenceStore` during hydration (or reuse one injected via SSR) to continue from server state. | |
- **Hydration-safe**: No server/client mismatch issues | |
- **Performance**: Restored data bypasses validation to prevent SSR timing issues | |
- **Memory efficient**: Stores are garbage collected per request |
🧰 Tools
🪛 LanguageTool
[grammar] ~244-~244: There might be a mistake here.
Context: ...ent-only store**: Store is only created on client-side (`typeof window !== 'undefi...
(QB_NEW_EN)
🤖 Prompt for AI Agents
In docs/router/framework/react/api/router/persistSearchParamsFunction.md around
lines 242 to 248, the "Client-only store" bullet is misleading because the docs
show a server example and recommend per-request isolation; update that bullet to
state that a new store should be created per SSR request on the server and a
separate store created on the client during hydration, making clear server-side
stores are per-request (garbage collected after the request) and the client
creates its own store on hydration to avoid cross-request/state leakage.
if (!isTest) { | ||
createServer().then(async ({ app }) => | ||
app.listen(await getPort({ port: portNumbers(3000, 3100) }), () => { | ||
console.info('Client Server: http://localhost:3000') | ||
}), | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Log the actual port you bind to.
You request a free port in [3000–3100] but always log 3000. Capture and log the resolved port.
Apply this diff:
-if (!isTest) {
- createServer().then(async ({ app }) =>
- app.listen(await getPort({ port: portNumbers(3000, 3100) }), () => {
- console.info('Client Server: http://localhost:3000')
- }),
- )
-}
+if (!isTest) {
+ createServer().then(async ({ app }) => {
+ const port = await getPort({ port: portNumbers(3000, 3100) })
+ app.listen(port, () => {
+ console.info(`Client Server: http://localhost:${port}`)
+ })
+ })
+}
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
if (!isTest) { | |
createServer().then(async ({ app }) => | |
app.listen(await getPort({ port: portNumbers(3000, 3100) }), () => { | |
console.info('Client Server: http://localhost:3000') | |
}), | |
) | |
} | |
if (!isTest) { | |
createServer().then(async ({ app }) => { | |
const port = await getPort({ port: portNumbers(3000, 3100) }) | |
app.listen(port, () => { | |
console.info(`Client Server: http://localhost:${port}`) | |
}) | |
}) | |
} |
🤖 Prompt for AI Agents
In examples/react/ssr-search-persistence/server.js around lines 98–104, the code
requests a free port in the 3000–3100 range but always logs
"http://localhost:3000"; change it to capture the resolved port value from
getPort and log that. Specifically, await getPort(...) into a variable (e.g.,
port), pass that variable to app.listen, and use it in the console.info message
so the actual bound port is printed.
import { useEffect, useState } from 'react' | ||
|
||
interface ClientOnlyProps { | ||
children: React.ReactNode | ||
fallback?: React.ReactNode | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Type-only React import missing; React.ReactNode
will not resolve.
Import ReactNode
as a type or import type React
to reference React.ReactNode
.
-import { useEffect, useState } from 'react'
+import { useEffect, useState, type ReactNode } from 'react'
@@
-interface ClientOnlyProps {
- children: React.ReactNode
- fallback?: React.ReactNode
-}
+interface ClientOnlyProps {
+ children: ReactNode
+ fallback?: ReactNode
+}
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
import { useEffect, useState } from 'react' | |
interface ClientOnlyProps { | |
children: React.ReactNode | |
fallback?: React.ReactNode | |
} | |
import { useEffect, useState, type ReactNode } from 'react' | |
interface ClientOnlyProps { | |
children: ReactNode | |
fallback?: ReactNode | |
} |
🤖 Prompt for AI Agents
In examples/react/ssr-search-persistence/src/components/ClientOnly.tsx around
lines 1 to 6, the file uses React.ReactNode but does not import React as a type,
so the type will not resolve; update the imports to include the type (either add
"import type React from 'react'" and keep React.ReactNode, or import ReactNode
directly via "import { useEffect, useState, type ReactNode } from 'react'" and
change the interface to use ReactNode) to ensure the type resolves correctly.
<button | ||
onClick={() => navigate({ search: {} })} | ||
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600" | ||
> | ||
Clear All Filters | ||
</button> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Clear-all UX bug: persisted values are immediately restored. Also clear the per-route store.
persistSearchParams
restores when the incoming allowed set is empty, so navigate({ search: {} })
will bring saved filters back. Clear the per-route entry before navigating.
- <button
- onClick={() => navigate({ search: {} })}
+ <button
+ onClick={() => {
+ const store = router.options.searchPersistenceStore
+ // Route id for this page is '/products'
+ store?.clearSearch('/products' as any)
+ navigate({ search: {} })
+ }}
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600"
>
Clear All Filters
Additionally, import and use the router:
-import {
- createFileRoute,
- persistSearchParams,
- useNavigate,
-} from '@tanstack/react-router'
+import {
+ createFileRoute,
+ persistSearchParams,
+ useNavigate,
+ useRouter,
+} from '@tanstack/react-router'
@@
function ProductsComponent() {
- const search = Route.useSearch()
- const navigate = useNavigate()
+ const search = Route.useSearch()
+ const navigate = useNavigate()
+ const router = useRouter()
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
<button | |
onClick={() => navigate({ search: {} })} | |
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600" | |
> | |
Clear All Filters | |
</button> | |
// ─── At the top of src/routes/products.tsx ───────────────────────────────────── | |
-import { | |
- createFileRoute, | |
- persistSearchParams, | |
- useNavigate, | |
import { | |
createFileRoute, | |
persistSearchParams, | |
useNavigate, | |
useRouter, | |
} from '@tanstack/react-router' | |
// ─── Inside your ProductsComponent ───────────────────────────────────────────── | |
function ProductsComponent() { | |
const search = Route.useSearch() | |
const navigate = useNavigate() | |
const router = useRouter() | |
return ( | |
<div> | |
{/* ... other UI ... */} | |
<button | |
onClick={() => { | |
const store = router.options.searchPersistenceStore | |
// Route id for this page is '/products' | |
store?.clearSearch('/products' as any) | |
navigate({ search: {} }) | |
}} | |
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600" | |
> | |
Clear All Filters | |
</button> | |
{/* ... remaining UI ... */} | |
</div> | |
) | |
} |
🤖 Prompt for AI Agents
In examples/react/ssr-search-persistence/src/routes/products.tsx around lines
115-120, the "Clear All Filters" button calls navigate({ search: {} }) but
persisted search params are immediately restored and the per-route store is not
cleared; import and use the router instance, call the per-route persistence
clear method (remove the persisted entry for this route) before calling
navigate, and then navigate to an empty search; also ensure you pass an explicit
empty allowed set or use the router API that prevents rehydration so
persistSearchParams will not restore values when the allowed set is empty.
<button | ||
type="button" | ||
onClick={() => navigate({ search: {} })} | ||
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600" | ||
> | ||
Clear All Filters | ||
</button> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
“Clear All Filters” does not clear persisted values; middleware restores them.
persistSearchParams
restores saved keys when the current search is empty, so navigate({ search: {} })
won’t clear persistence. Clear the route’s persisted entry before navigating.
Apply this diff:
-import {
- createFileRoute,
- persistSearchParams,
- useNavigate,
-} from '@tanstack/react-router'
+import {
+ createFileRoute,
+ persistSearchParams,
+ useNavigate,
+ useRouter,
+} from '@tanstack/react-router'
@@
function UsersComponent() {
const search = Route.useSearch()
const navigate = useNavigate()
+ const router = useRouter()
@@
- <button
+ <button
type="button"
- onClick={() => navigate({ search: {} })}
+ onClick={() => {
+ // Clear persisted values for this route, then reset URL search
+ router.options.searchPersistenceStore?.clearSearch(Route.id)
+ navigate({ search: {} })
+ }}
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600"
>
Clear All Filters
</button>
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
<button | |
type="button" | |
onClick={() => navigate({ search: {} })} | |
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600" | |
> | |
Clear All Filters | |
</button> | |
// examples/react/ssr-search-persistence/src/routes/users.tsx | |
import { | |
createFileRoute, | |
persistSearchParams, | |
useNavigate, | |
useRouter, | |
} from '@tanstack/react-router' | |
function UsersComponent() { | |
const search = Route.useSearch() | |
const navigate = useNavigate() | |
const router = useRouter() | |
return ( | |
// …other UI… | |
<button | |
type="button" | |
onClick={() => { | |
// Clear persisted values for this route, then reset URL search | |
router.options.searchPersistenceStore?.clearSearch(Route.id) | |
navigate({ search: {} }) | |
}} | |
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600" | |
> | |
Clear All Filters | |
</button> | |
// …other UI… | |
) | |
} |
🤖 Prompt for AI Agents
In examples/react/ssr-search-persistence/src/routes/users.tsx around lines 131
to 137, the Clear All Filters button only calls navigate({ search: {} }) but
persisted search params middleware re-inserts saved values; clear the route's
persisted storage entry before navigating. Remove the saved search key used by
persistSearchParams (e.g. localStorage.removeItem(<the-route-key>) or call the
persistence helper/clear function for the "users" route) and then call
navigate({ search: {} }) so the middleware finds no saved state to restore.
import { tanstackRouter } from '@tanstack/router-plugin/vite' | ||
import { defineConfig } from 'vite' | ||
import react from '@vitejs/plugin-react' | ||
import type { BuildEnvironmentOptions } from 'vite' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Type import likely incorrect; use Vite’s BuildOptions.
BuildEnvironmentOptions
isn’t part of Vite’s public types. Use BuildOptions
instead.
-import type { BuildEnvironmentOptions } from 'vite'
+import type { BuildOptions } from 'vite'
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
import type { BuildEnvironmentOptions } from 'vite' | |
import type { BuildOptions } from 'vite' |
🤖 Prompt for AI Agents
In examples/react/ssr-search-persistence/vite.config.ts around line 6, the code
imports a non-existent Vite type BuildEnvironmentOptions; replace that import
with Vite’s correct BuildOptions type (import type { BuildOptions } from 'vite')
and update any variable/type annotations that reference BuildEnvironmentOptions
to use BuildOptions instead so the config uses the public Vite type.
const ssrBuildConfig: BuildEnvironmentOptions = { | ||
ssr: true, | ||
outDir: 'dist/server', | ||
ssrEmitAssets: true, | ||
copyPublicDir: false, | ||
emptyOutDir: true, | ||
rollupOptions: { | ||
input: path.resolve(__dirname, 'src/entry-server.tsx'), | ||
output: { | ||
entryFileNames: '[name].js', | ||
chunkFileNames: 'assets/[name]-[hash].js', | ||
assetFileNames: 'assets/[name]-[hash][extname]', | ||
}, | ||
}, | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Use valid Vite build options; drop emitAssets
.
emitAssets
is not a recognized Vite build option. The rest looks fine.
-const ssrBuildConfig: BuildEnvironmentOptions = {
+const ssrBuildConfig: BuildOptions = {
ssr: true,
outDir: 'dist/server',
ssrEmitAssets: true,
copyPublicDir: false,
emptyOutDir: true,
rollupOptions: {
input: path.resolve(__dirname, 'src/entry-server.tsx'),
output: {
entryFileNames: '[name].js',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]',
},
},
}
-const clientBuildConfig: BuildEnvironmentOptions = {
+const clientBuildConfig: BuildOptions = {
outDir: 'dist/client',
- emitAssets: true,
copyPublicDir: true,
emptyOutDir: true,
rollupOptions: {
input: path.resolve(__dirname, 'src/entry-client.tsx'),
output: {
entryFileNames: 'static/[name].js',
chunkFileNames: 'static/assets/[name]-[hash].js',
assetFileNames: 'static/assets/[name]-[hash][extname]',
},
},
}
Also applies to: 29-42
🤖 Prompt for AI Agents
In examples/react/ssr-search-persistence/vite.config.ts around lines 12 to 26
(and similarly lines 29 to 42), remove the invalid ssrEmitAssets build option
(it is not a recognized Vite build option); keep the other valid options (ssr,
outDir, copyPublicDir, emptyOutDir, rollupOptions) and if you need SSR asset
emission behavior, use the supported Vite options (e.g., generate an SSR
manifest or adjust rollup output) instead of ssrEmitAssets.
export function persistSearchParams<TSearchSchema>( | ||
persistedSearchParams: Array<keyof TSearchSchema>, | ||
exclude?: Array<keyof TSearchSchema>, | ||
): SearchMiddleware<TSearchSchema> { | ||
return ({ search, next, route, router }) => { | ||
const store = router.options.searchPersistenceStore as | ||
| SearchPersistenceStore | ||
| undefined | ||
|
||
if (!store) { | ||
return next(search) | ||
} | ||
|
||
const searchRecord = search as Record<string, unknown> | ||
const allowedKeysStr = persistedSearchParams.map((key) => String(key)) | ||
const filteredSearch = Object.fromEntries( | ||
Object.entries(searchRecord).filter(([key]) => | ||
allowedKeysStr.includes(key), | ||
), | ||
) as TSearchSchema | ||
|
||
const savedSearch = store.getSearch(route.id) | ||
const searchToProcess = filteredSearch | ||
|
||
if (savedSearch && Object.keys(savedSearch).length > 0) { | ||
const currentSearch = filteredSearch as Record<string, unknown> | ||
const isEmpty = Object.keys(currentSearch).length === 0 | ||
|
||
if (isEmpty) { | ||
// Skip router validation for restored data since we know it's valid | ||
// This prevents Zod .catch() defaults from overriding our restored values | ||
const result = savedSearch as TSearchSchema | ||
|
||
const resultRecord = result as Record<string, unknown> | ||
const persistedKeysStr = persistedSearchParams.map((key) => String(key)) | ||
const paramsToSave = Object.fromEntries( | ||
Object.entries(resultRecord).filter(([key]) => | ||
persistedKeysStr.includes(key), | ||
), | ||
) | ||
|
||
const excludeKeys = exclude ? exclude.map((key) => String(key)) : [] | ||
const filteredResult = Object.fromEntries( | ||
Object.entries(paramsToSave).filter( | ||
([key]) => !excludeKeys.includes(key), | ||
), | ||
) | ||
|
||
if (Object.keys(filteredResult).length > 0) { | ||
store.saveSearch(route.id, filteredResult) | ||
} | ||
|
||
return result | ||
} | ||
} | ||
|
||
const result = next(searchToProcess) | ||
|
||
// Save only the allowed parameters for persistence | ||
const resultRecord = result as Record<string, unknown> | ||
const persistedKeysStr = persistedSearchParams.map((key) => String(key)) | ||
const paramsToSave = Object.fromEntries( | ||
Object.entries(resultRecord).filter(([key]) => | ||
persistedKeysStr.includes(key), | ||
), | ||
) | ||
|
||
const excludeKeys = exclude ? exclude.map((key) => String(key)) : [] | ||
const filteredResult = Object.fromEntries( | ||
Object.entries(paramsToSave).filter( | ||
([key]) => !excludeKeys.includes(key), | ||
), | ||
) | ||
|
||
// Only save if we have actual search params to persist | ||
// Don't save empty objects as they overwrite existing data | ||
if (Object.keys(filteredResult).length > 0) { | ||
store.saveSearch(route.id, filteredResult) | ||
} | ||
|
||
return result | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Persistence middleware drops unrelated keys and can’t clear stale entries. Merge, don’t filter input to next
, and always save (empty clears).
- Passing only filtered keys to
next
discards unrelated search keys from other matched routes. - Early “restore” returns only the saved subset, dropping other keys.
- Saving is gated on non-empty results, so you can’t clear persisted state.
Refactor to:
- Keep
search
intact fornext
, overlay restored allowed keys when appropriate. - Apply
exclude
to what you save, not to the input you pass along. - Always call
saveSearch
(empty payload clears).
-export function persistSearchParams<TSearchSchema>(
+export function persistSearchParams<TSearchSchema extends object>(
persistedSearchParams: Array<keyof TSearchSchema>,
exclude?: Array<keyof TSearchSchema>,
): SearchMiddleware<TSearchSchema> {
return ({ search, next, route, router }) => {
const store = router.options.searchPersistenceStore as
| SearchPersistenceStore
| undefined
if (!store) {
return next(search)
}
- const searchRecord = search as Record<string, unknown>
- const allowedKeysStr = persistedSearchParams.map((key) => String(key))
- const filteredSearch = Object.fromEntries(
- Object.entries(searchRecord).filter(([key]) =>
- allowedKeysStr.includes(key),
- ),
- ) as TSearchSchema
-
- const savedSearch = store.getSearch(route.id)
- const searchToProcess = filteredSearch
+ const allowedKeysStr = persistedSearchParams.map((key) => String(key))
+ const excludeKeys = exclude ? exclude.map((key) => String(key)) : []
+ const searchRecord = search as Record<string, unknown>
+ const incomingAllowed = Object.fromEntries(
+ Object.entries(searchRecord).filter(([key]) => allowedKeysStr.includes(key)),
+ ) as Record<string, unknown>
+
+ const savedSearch = store.getSearch(route.id) as Record<string, unknown> | null
+ let mergedInput = search
- if (savedSearch && Object.keys(savedSearch).length > 0) {
- const currentSearch = filteredSearch as Record<string, unknown>
+ if (savedSearch && Object.keys(savedSearch).length > 0) {
+ const currentSearch = incomingAllowed
const isEmpty = Object.keys(currentSearch).length === 0
if (isEmpty) {
- // Skip router validation for restored data since we know it's valid
- // This prevents Zod .catch() defaults from overriding our restored values
- const result = savedSearch as TSearchSchema
-
- const resultRecord = result as Record<string, unknown>
- const persistedKeysStr = persistedSearchParams.map((key) => String(key))
- const paramsToSave = Object.fromEntries(
- Object.entries(resultRecord).filter(([key]) =>
- persistedKeysStr.includes(key),
- ),
- )
-
- const excludeKeys = exclude ? exclude.map((key) => String(key)) : []
- const filteredResult = Object.fromEntries(
- Object.entries(paramsToSave).filter(
- ([key]) => !excludeKeys.includes(key),
- ),
- )
-
- if (Object.keys(filteredResult).length > 0) {
- store.saveSearch(route.id, filteredResult)
- }
-
- return result
+ // Restore only allowed keys and overlay onto the existing search
+ const restoredAllowed = Object.fromEntries(
+ Object.entries(savedSearch).filter(
+ ([key]) => allowedKeysStr.includes(key) && !excludeKeys.includes(key),
+ ),
+ ) as Record<string, unknown>
+
+ // Save back (even if empty => clears) and continue through the chain
+ store.saveSearch(route.id, restoredAllowed as any)
+ mergedInput = { ...(search as any), ...restoredAllowed } as TSearchSchema
}
}
- const result = next(searchToProcess)
+ const result = next(mergedInput)
// Save only the allowed parameters for persistence
const resultRecord = result as Record<string, unknown>
- const persistedKeysStr = persistedSearchParams.map((key) => String(key))
const paramsToSave = Object.fromEntries(
- Object.entries(resultRecord).filter(([key]) =>
- persistedKeysStr.includes(key),
- ),
+ Object.entries(resultRecord).filter(([key]) => allowedKeysStr.includes(key)),
)
- const excludeKeys = exclude ? exclude.map((key) => String(key)) : []
const filteredResult = Object.fromEntries(
Object.entries(paramsToSave).filter(
([key]) => !excludeKeys.includes(key),
),
)
- // Only save if we have actual search params to persist
- // Don't save empty objects as they overwrite existing data
- if (Object.keys(filteredResult).length > 0) {
- store.saveSearch(route.id, filteredResult)
- }
+ // Always save; empty payload clears the entry via SearchPersistenceStore
+ store.saveSearch(route.id, filteredResult as any)
return result
}
}
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
export function persistSearchParams<TSearchSchema>( | |
persistedSearchParams: Array<keyof TSearchSchema>, | |
exclude?: Array<keyof TSearchSchema>, | |
): SearchMiddleware<TSearchSchema> { | |
return ({ search, next, route, router }) => { | |
const store = router.options.searchPersistenceStore as | |
| SearchPersistenceStore | |
| undefined | |
if (!store) { | |
return next(search) | |
} | |
const searchRecord = search as Record<string, unknown> | |
const allowedKeysStr = persistedSearchParams.map((key) => String(key)) | |
const filteredSearch = Object.fromEntries( | |
Object.entries(searchRecord).filter(([key]) => | |
allowedKeysStr.includes(key), | |
), | |
) as TSearchSchema | |
const savedSearch = store.getSearch(route.id) | |
const searchToProcess = filteredSearch | |
if (savedSearch && Object.keys(savedSearch).length > 0) { | |
const currentSearch = filteredSearch as Record<string, unknown> | |
const isEmpty = Object.keys(currentSearch).length === 0 | |
if (isEmpty) { | |
// Skip router validation for restored data since we know it's valid | |
// This prevents Zod .catch() defaults from overriding our restored values | |
const result = savedSearch as TSearchSchema | |
const resultRecord = result as Record<string, unknown> | |
const persistedKeysStr = persistedSearchParams.map((key) => String(key)) | |
const paramsToSave = Object.fromEntries( | |
Object.entries(resultRecord).filter(([key]) => | |
persistedKeysStr.includes(key), | |
), | |
) | |
const excludeKeys = exclude ? exclude.map((key) => String(key)) : [] | |
const filteredResult = Object.fromEntries( | |
Object.entries(paramsToSave).filter( | |
([key]) => !excludeKeys.includes(key), | |
), | |
) | |
if (Object.keys(filteredResult).length > 0) { | |
store.saveSearch(route.id, filteredResult) | |
} | |
return result | |
} | |
} | |
const result = next(searchToProcess) | |
// Save only the allowed parameters for persistence | |
const resultRecord = result as Record<string, unknown> | |
const persistedKeysStr = persistedSearchParams.map((key) => String(key)) | |
const paramsToSave = Object.fromEntries( | |
Object.entries(resultRecord).filter(([key]) => | |
persistedKeysStr.includes(key), | |
), | |
) | |
const excludeKeys = exclude ? exclude.map((key) => String(key)) : [] | |
const filteredResult = Object.fromEntries( | |
Object.entries(paramsToSave).filter( | |
([key]) => !excludeKeys.includes(key), | |
), | |
) | |
// Only save if we have actual search params to persist | |
// Don't save empty objects as they overwrite existing data | |
if (Object.keys(filteredResult).length > 0) { | |
store.saveSearch(route.id, filteredResult) | |
} | |
return result | |
} | |
} | |
export function persistSearchParams<TSearchSchema extends object>( | |
persistedSearchParams: Array<keyof TSearchSchema>, | |
exclude?: Array<keyof TSearchSchema>, | |
): SearchMiddleware<TSearchSchema> { | |
return ({ search, next, route, router }) => { | |
const store = router.options.searchPersistenceStore as | |
| SearchPersistenceStore | |
| undefined | |
if (!store) { | |
return next(search) | |
} | |
// Build allow-list and exclude-list once | |
const allowedKeysStr = persistedSearchParams.map((key) => String(key)) | |
const excludeKeys = exclude ? exclude.map((key) => String(key)) : [] | |
// Capture all incoming params, but note which are allowed | |
const searchRecord = search as Record<string, unknown> | |
const incomingAllowed = Object.fromEntries( | |
Object.entries(searchRecord).filter(([key]) => | |
allowedKeysStr.includes(key), | |
), | |
) as Record<string, unknown> | |
// Fetch persisted state | |
const savedSearch = store.getSearch(route.id) as | |
| Record<string, unknown> | |
| null | |
// Prepare the input we’ll pass to next() | |
let mergedInput = search as TSearchSchema | |
if (savedSearch && Object.keys(savedSearch).length > 0) { | |
const isEmpty = Object.keys(incomingAllowed).length === 0 | |
if (isEmpty) { | |
// Only restore the allowed subset, minus any excludes | |
const restoredAllowed = Object.fromEntries( | |
Object.entries(savedSearch).filter( | |
([key]) => | |
allowedKeysStr.includes(key) && !excludeKeys.includes(key), | |
), | |
) as Record<string, unknown> | |
// Persist it back (even an empty object clears prior state) | |
store.saveSearch(route.id, restoredAllowed as any) | |
// Merge restored keys back into the full search object | |
mergedInput = { | |
...(search as any), | |
...restoredAllowed, | |
} as TSearchSchema | |
} | |
} | |
// Proceed with the full (or merged) search object | |
const result = next(mergedInput) | |
// At the end, pick out only the allowed keys, apply excludes, and always save | |
const resultRecord = result as Record<string, unknown> | |
const paramsToSave = Object.fromEntries( | |
Object.entries(resultRecord).filter(([key]) => | |
allowedKeysStr.includes(key), | |
), | |
) | |
const filteredResult = Object.fromEntries( | |
Object.entries(paramsToSave).filter( | |
([key]) => !excludeKeys.includes(key), | |
), | |
) | |
// Always call saveSearch; an empty filteredResult will clear the stored entry | |
store.saveSearch(route.id, filteredResult as any) | |
return result | |
} | |
} |
🤖 Prompt for AI Agents
In packages/router-core/src/searchMiddleware.ts around lines 231 to 313, the
middleware currently filters the incoming search and calls next(filtered) and
returns restored results, which drops unrelated keys from other routes and
prevents clearing persisted state because save is skipped for empty objects;
change it to call next with the original search (or a merged object that
overlays restored allowed keys onto the original search when restoring), stop
returning early with only the saved subset, and always call
store.saveSearch(route.id, filteredSaved) (allowing an empty object to clear
persisted data); apply exclude only when building the object to save, not to the
input passed to next, and ensure the returned result preserves all original keys
merged with any validated/normalized values from next.
- Changed package name from 'tanstack-router-react-example-basic-file-based' to 'tanstack-router-react-example-search-persistence' - Resolves NX project graph conflict that prevented running tests
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
♻️ Duplicate comments (3)
examples/react/search-persistence/src/routes/index.tsx (1)
94-105
: “Clean Navigation” still restores persisted params — clear the store before navigating (repeat of prior feedback).Replace the Links with buttons that clear the persisted entry, then navigate. Also import getSearchPersistenceStore and useNavigate.
- import { createFileRoute, Link } from '@tanstack/react-router' + import { + createFileRoute, + getSearchPersistenceStore, + Link, + useNavigate, + } from '@tanstack/react-router'function HomeComponent() { - return ( + const navigate = useNavigate() + return (- <Link - to="/users" - className="bg-slate-600 hover:bg-slate-700 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md" - > - Users (clean slate) - </Link> - <Link - to="/products" - className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md" - > - Products (clean slate) - </Link> + <button + type="button" + onClick={() => { + getSearchPersistenceStore().clearSearch('/users') + navigate({ to: '/users', search: {} }) + }} + className="bg-slate-600 hover:bg-slate-700 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md" + > + Users (clean slate) + </button> + <button + type="button" + onClick={() => { + getSearchPersistenceStore().clearSearch('/products') + navigate({ to: '/products', search: {} }) + }} + className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md" + > + Products (clean slate) + </button>Also applies to: 1-1, 7-8
examples/react/search-persistence/src/routes/users.tsx (1)
152-158
: Reset immediately rehydrates persisted params — clear the store first (repeat of prior feedback).Clear the persisted entry for /users, then navigate to {}.
-import { - createFileRoute, - useNavigate, - persistSearchParams, - retainSearchParams, - stripSearchParams, - Link, - Outlet, - useLocation, -} from '@tanstack/react-router' +import { + createFileRoute, + getSearchPersistenceStore, + Link, + Outlet, + persistSearchParams, + retainSearchParams, + stripSearchParams, + useLocation, + useNavigate, +} from '@tanstack/react-router'- <button - type="button" - onClick={() => navigate({ search: {} } as any)} + <button + type="button" + onClick={() => { + getSearchPersistenceStore().clearSearch('/users') + navigate({ search: {} }) + }} className="border p-2 rounded" > Reset </button>Also applies to: 1-10
packages/router-core/src/searchMiddleware.ts (1)
288-291
: Always save (empty payload clears stale state)Skipping save on empty results leaves old values persisted indefinitely.
SearchPersistenceStore.saveSearch
already deletes the entry for empty payloads—call it unconditionally.- if (Object.keys(filteredResult).length > 0) { - store.saveSearch(storageKey, filteredResult) - } + // Always save; empty clears prior state. + store.saveSearch(storageKey, filteredResult)
🧹 Nitpick comments (11)
examples/react/search-persistence/src/routes/index.tsx (2)
36-38
: Nit: wording contradicts the section title.Change “Clean navigation - … restores …” to “Auto-restore — … restores …”.
- Clean navigation - middleware automatically restores saved - parameters + Auto-restore — middleware automatically restores saved parameters
1-1
: Fix import member order per ESLint.Keep specifiers alphabetized to satisfy sort-imports.
- import { createFileRoute, Link } from '@tanstack/react-router' + import { createFileRoute, Link } from '@tanstack/react-router'(Above is already ordered; the earlier change adds members in sorted order.)
packages/router-core/src/route.ts (1)
74-84
: Type safety: avoidany
forsearchPersistenceStore
; define a minimal interface.Prevents untyped usage without introducing a runtime import cycle.
+// Minimal interface to avoid type import cycles +export interface SearchPersistenceStoreLike { + getSearch: (key: string) => any + saveSearch: (key: string, search: Record<string, unknown>) => void + clearSearch: (key: string) => void +} + export interface SearchMiddlewareRouter { options: { - searchPersistenceStore?: any // Avoid circular dependency, will be typed at usage + searchPersistenceStore?: SearchPersistenceStoreLike }examples/react/search-persistence/src/routes/users.tsx (2)
4-4
: Import member order: ‘persistSearchParams’ should be alphabetized.Keep specifiers sorted to satisfy sort-imports.
(Handled in the import diff above.)
106-110
: Remove unnecessaryas any
.navigate({ search: updater | object }) is typed; the cast hides real issues.
- } as any) + })packages/router-core/src/router.ts (1)
58-66
: Use the exportedSearchMiddlewareRouter
type for stronger typing.Aligns applySearchMiddleware’s
router
param with the public middleware context and avoids ad-hoc shapes.- SearchMiddleware, - SearchMiddlewareContext, + SearchMiddleware, + SearchMiddlewareContext, + SearchMiddlewareRouter, } from './route'-function applySearchMiddleware({ +function applySearchMiddleware({ search, dest, destRoutes, _includeValidateSearch, - router, + router, currentLocationMatches, destPathname, }: { search: any dest: BuildNextOptions destRoutes: Array<AnyRoute> _includeValidateSearch: boolean | undefined - router: { options: { searchPersistenceStore?: any } } + router: SearchMiddlewareRouter currentLocationMatches: Array<any> destPathname: string }) {Also applies to: 2747-2750
examples/react/search-persistence/src/routes/users.$userId.tsx (2)
1-6
: Alphabetize import members.Keeps lint green.
-import { - createFileRoute, - retainSearchParams, - persistSearchParams, - Link, -} from '@tanstack/react-router' +import { + createFileRoute, + Link, + persistSearchParams, + retainSearchParams, +} from '@tanstack/react-router'
146-156
: Simplify tab update and future-proof with an updater.Avoid rebuilding the whole search object; merge with previous.
- const updateTab = (tab: UserDetailSearchSchema['tab']) => { - const newSearch = { - name: search.name, - status: search.status, - page: search.page, - tab: tab, - } - - navigate({ - search: newSearch, - }) - } + const updateTab = (tab: UserDetailSearchSchema['tab']) => { + navigate({ + search: (prev) => ({ ...prev, tab }), + }) + }packages/router-core/src/searchMiddleware.ts (3)
233-233
: Preserve generics in the returned middleware objectReturn
SearchMiddlewareObject<TSearchSchema>
instead ofany
to keep type inference for route-specific search schemas.-): SearchMiddlewareObject<any> { +): SearchMiddlewareObject<TSearchSchema> {
259-268
: Consider excludes when dropping keys on “no saved preferences”When no saved prefs exist, you delete all
persistedSearchParams
. Ifexclude
is meant to denote non-persisted-but-still-owned keys, confirm this behavior. If excluded keys shouldn’t be stripped, filter them out here.- persistedSearchParams.forEach((key) => { + persistedSearchParams + .filter((k) => !(exclude ?? []).map(String).includes(String(k))) + .forEach((key) => { delete searchWithoutOwnedParams[String(key)] })
37-41
: Optional: run next with an empty object when stripping allReturning
{}
early bypasses any route-level validation/defaulting. If you intend defaults to apply, forward{}
tonext
.- if (input === true) { - return {} as any - } + if (input === true) { + return next({} as any) + }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (10)
examples/react/search-persistence/src/routeTree.gen.ts
(1 hunks)examples/react/search-persistence/src/routes/__root.tsx
(1 hunks)examples/react/search-persistence/src/routes/index.tsx
(1 hunks)examples/react/search-persistence/src/routes/users.$userId.tsx
(1 hunks)examples/react/search-persistence/src/routes/users.tsx
(1 hunks)packages/router-core/src/route.ts
(1 hunks)packages/router-core/src/router.ts
(16 hunks)packages/router-core/src/searchMiddleware.ts
(2 hunks)port-tanstack-router-e2e-react-basic-esbuild-file-based-external.txt
(1 hunks)port-tanstack-router-e2e-react-basic-esbuild-file-based.txt
(1 hunks)
✅ Files skipped from review due to trivial changes (2)
- port-tanstack-router-e2e-react-basic-esbuild-file-based.txt
- port-tanstack-router-e2e-react-basic-esbuild-file-based-external.txt
🚧 Files skipped from review as they are similar to previous changes (1)
- examples/react/search-persistence/src/routes/__root.tsx
🧰 Additional context used
🧬 Code graph analysis (7)
examples/react/search-persistence/src/routes/users.$userId.tsx (2)
examples/react/search-persistence/src/routes/users.tsx (1)
Route
(23-33)packages/router-core/src/searchMiddleware.ts (2)
retainSearchParams
(11-31)persistSearchParams
(230-296)
examples/react/search-persistence/src/routes/users.tsx (2)
examples/react/search-persistence/src/routes/users.$userId.tsx (1)
Route
(18-27)packages/router-core/src/searchMiddleware.ts (3)
retainSearchParams
(11-31)persistSearchParams
(230-296)stripSearchParams
(33-56)
packages/router-core/src/router.ts (3)
packages/router-core/src/searchMiddleware.ts (1)
SearchPersistenceStore
(58-170)packages/router-core/src/route.ts (4)
RouteContextOptions
(958-966)AnyRoute
(725-740)SearchMiddleware
(102-104)SearchMiddlewareContext
(86-91)packages/router-core/src/utils.ts (1)
functionalUpdate
(195-204)
packages/router-core/src/route.ts (1)
packages/router-core/src/index.ts (2)
SearchMiddlewareContext
(125-125)SearchMiddleware
(126-126)
examples/react/search-persistence/src/routeTree.gen.ts (1)
packages/router-generator/tests/generator/types-disabled/routeTree.snapshot.js (1)
UsersUserIdRoute
(27-31)
packages/router-core/src/searchMiddleware.ts (3)
packages/router-core/src/route.ts (3)
SearchMiddleware
(102-104)AnyRoute
(725-740)SearchMiddlewareObject
(97-100)packages/router-core/src/utils.ts (1)
replaceEqualDeep
(212-259)packages/router-core/src/router.ts (1)
RegisteredRouter
(105-105)
examples/react/search-persistence/src/routes/index.tsx (4)
examples/react/search-persistence/src/routes/__root.tsx (1)
Route
(4-6)examples/react/search-persistence/src/routes/users.$userId.tsx (1)
Route
(18-27)examples/react/search-persistence/src/routes/users.tsx (1)
Route
(23-33)examples/react/search-persistence/src/routes/products.tsx (1)
Route
(18-26)
🪛 ESLint
examples/react/search-persistence/src/routes/users.$userId.tsx
[error] 4-4: Member 'persistSearchParams' of the import declaration should be sorted alphabetically.
(sort-imports)
examples/react/search-persistence/src/routes/users.tsx
[error] 4-4: Member 'persistSearchParams' of the import declaration should be sorted alphabetically.
(sort-imports)
examples/react/search-persistence/src/routes/index.tsx
[error] 1-1: Member 'Link' of the import declaration should be sorted alphabetically.
(sort-imports)
🔇 Additional comments (3)
packages/router-core/src/route.ts (1)
93-105
: Middleware typing looks good.Union form enabling object config with inherit flag is clear and aligns with router usage.
packages/router-core/src/router.ts (1)
2848-2856
: LGTM: default middleware now preserves search whendest.search
is undefined.Fix prevents accidental wiping of params.
packages/router-core/src/searchMiddleware.ts (1)
11-31
: LGTM: retainSearchParams behaviorMerges result over incoming search for
true
, and fills missing keys otherwise. UsesreplaceEqualDeep
for structural sharing.
const UsersUserIdRoute = UsersUserIdRouteImport.update({ | ||
id: '/$userId', | ||
path: '/$userId', | ||
getParentRoute: () => UsersRoute, | ||
} as any) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix route id for nested user route to match full path
The UsersUserIdRoute
id should be the full path (/users/$userId
) to stay consistent with FileRoutesById and module augmentation. Using '/\$userId'
will break linking/lookup by id.
-const UsersUserIdRoute = UsersUserIdRouteImport.update({
- id: '/$userId',
- path: '/$userId',
- getParentRoute: () => UsersRoute,
-} as any)
+const UsersUserIdRoute = UsersUserIdRouteImport.update({
+ id: '/users/$userId',
+ path: '/$userId',
+ getParentRoute: () => UsersRoute,
+} as any)
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
const UsersUserIdRoute = UsersUserIdRouteImport.update({ | |
id: '/$userId', | |
path: '/$userId', | |
getParentRoute: () => UsersRoute, | |
} as any) | |
const UsersUserIdRoute = UsersUserIdRouteImport.update({ | |
id: '/users/$userId', | |
path: '/$userId', | |
getParentRoute: () => UsersRoute, | |
} as any) |
🤖 Prompt for AI Agents
In examples/react/search-persistence/src/routeTree.gen.ts around lines 32 to 36,
the UsersUserIdRoute is defined with id '/$userId' which should be the full
nested path '/users/$userId'; update the id field to '/users/$userId' (and keep
path as '/$userId') so the route id matches the full path used by FileRoutesById
and module augmentation, ensuring correct linking/lookup by id.
// Routes without validateSearch get clean search (prevents contamination) | ||
filteredSearch = {} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don’t wipe search on routes without validateSearch
.
Setting filteredSearch = {}
drops retained/persisted params when the destination route has no validator. Preserve nextSearch
instead to maintain previous semantics and enable parent middlewares.
- } else {
- // Routes without validateSearch get clean search (prevents contamination)
- filteredSearch = {}
+ } else {
+ // No validateSearch: preserve current search so middlewares can operate
+ filteredSearch = nextSearch
}
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
// Routes without validateSearch get clean search (prevents contamination) | |
filteredSearch = {} | |
} | |
} else { | |
// No validateSearch: preserve current search so middlewares can operate | |
filteredSearch = nextSearch | |
} |
🤖 Prompt for AI Agents
In packages/router-core/src/router.ts around lines 1569 to 1571, the code
currently sets filteredSearch = {} for routes without validateSearch which wipes
retained/persisted params; instead preserve nextSearch so retained params and
parent middlewares still receive the prior search. Change the assignment to keep
nextSearch (e.g., filteredSearch = nextSearch or a shallow copy of it) rather
than replacing with an empty object, and ensure you don't mutate the original
nextSearch when making the copy.
middleware: ({ search, next, router }) => { | ||
const store = router.options.searchPersistenceStore as | ||
| SearchPersistenceStore | ||
| undefined | ||
|
||
if (!store) { | ||
return next(search) | ||
} | ||
|
||
const storageKey = router.destPathname || '' | ||
|
||
const savedSearch = store.getSearch(storageKey) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Key persistence by destination route id, not pathname
Using router.destPathname
stores per-URL-instance entries (e.g., /users/123
), which diverges from the typed store contract (keys are route ids like /users/$userId
). Persist by destination route id (fall back to current route id) to align with getSearchPersistenceStore
.
- middleware: ({ search, next, router }) => {
+ middleware: ({ search, next, router, route }) => {
@@
- const storageKey = router.destPathname || ''
+ const storageKey =
+ (router as any).destRoute?.id ?? (route as any).id
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
middleware: ({ search, next, router }) => { | |
const store = router.options.searchPersistenceStore as | |
| SearchPersistenceStore | |
| undefined | |
if (!store) { | |
return next(search) | |
} | |
const storageKey = router.destPathname || '' | |
const savedSearch = store.getSearch(storageKey) | |
middleware: ({ search, next, router, route }) => { | |
const store = router.options.searchPersistenceStore as | |
| SearchPersistenceStore | |
| undefined | |
if (!store) { | |
return next(search) | |
} | |
const storageKey = | |
(router as any).destRoute?.id ?? (route as any).id | |
const savedSearch = store.getSearch(storageKey) |
🤖 Prompt for AI Agents
In packages/router-core/src/searchMiddleware.ts around lines 235 to 247, the
code is using router.destPathname as the persistence key which stores
per-URL-instance entries; change it to use the destination route id with a
fallback to the current route id (and finally an empty string) so keys align
with the typed store contract. Replace the storageKey assignment to derive from
router.destRouteId || router.currentRouteId || '' (ensuring the value is a
string and properly typed), then use that storageKey when calling
store.getSearch; adjust any types if needed to satisfy the
SearchPersistenceStore signature.
✨ Summary
This PR adds a new middleware and supporting API for search parameter persistence:
persistSearchParams
middlewareAutomatically saves and restores search params across navigations, with support for selective exclusion. Works seamlessly with SSR and remains framework-agnostic, with a React integration provided.
getSearchPersistenceStore()
APIExposes a fully typed store for manual save, restore, and clear operations. Gives developers full control when they need to override the default middleware behavior.
Examples & Documentation
Includes a new
search-persistence
React example (Users/Products routes) demonstrating how persistence works in practice. The example also shows how the store can be synchronized withlocalStorage
for durability across reloads, while making it clear that other adapters (e.g. saving to a database in SSR environments) can be easily built on top of the same API.🚀 Why This Is Useful
getSearchPersistenceStore()
abstraction makes it trivial to integrate persistence into any storage layer:localStorage
, session storage)📚 Additions
persistSearchParams
middleware in@tanstack/router-core
getSearchPersistenceStore()
API with type inferenceexamples/react/search-persistence
Summary by CodeRabbit
New Features
Documentation
Chores