-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
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?
Changes from 3 commits
30f6404
41d598f
fe839f2
27c8222
94082e3
6887082
320ba6e
ea29c02
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,215 @@ | ||||||||||||||||||||||||||
--- | ||||||||||||||||||||||||||
id: persistSearchParams | ||||||||||||||||||||||||||
title: Search middleware to persist search params | ||||||||||||||||||||||||||
--- | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
`persistSearchParams` is a search middleware that automatically saves and restores search parameters when navigating between routes. | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
## persistSearchParams props | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
`persistSearchParams` accepts one of the following inputs: | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
- `undefined` (no arguments): persist all search params | ||||||||||||||||||||||||||
- a list of keys of those search params that shall be excluded from persistence | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
## How it works | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
The middleware has two main functions: | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
1. **Saving**: Automatically saves search parameters when they change | ||||||||||||||||||||||||||
2. **Restoring**: Restores saved parameters when the middleware is triggered with empty search | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
**Important**: The middleware only runs when search parameters are being processed. This means: | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
- **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) | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
## 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. | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
Comment on lines
+28
to
+33
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
🤖 Prompt for AI Agents
|
||||||||||||||||||||||||||
## Examples | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
```tsx | ||||||||||||||||||||||||||
import { z } from 'zod' | ||||||||||||||||||||||||||
import { createFileRoute, persistSearchParams } from '@tanstack/react-router' | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
const usersSearchSchema = z.object({ | ||||||||||||||||||||||||||
name: z.string().optional().catch(''), | ||||||||||||||||||||||||||
status: z.enum(['active', 'inactive', 'all']).optional().catch('all'), | ||||||||||||||||||||||||||
page: z.number().optional().catch(0), | ||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
export const Route = createFileRoute('/users')({ | ||||||||||||||||||||||||||
validateSearch: usersSearchSchema, | ||||||||||||||||||||||||||
search: { | ||||||||||||||||||||||||||
// persist all search params | ||||||||||||||||||||||||||
middlewares: [persistSearchParams()], | ||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
```tsx | ||||||||||||||||||||||||||
import { z } from 'zod' | ||||||||||||||||||||||||||
import { createFileRoute, persistSearchParams } from '@tanstack/react-router' | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
const productsSearchSchema = z.object({ | ||||||||||||||||||||||||||
category: z.string().optional(), | ||||||||||||||||||||||||||
minPrice: z.number().optional(), | ||||||||||||||||||||||||||
maxPrice: z.number().optional(), | ||||||||||||||||||||||||||
tempFilter: z.string().optional(), | ||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
export const Route = createFileRoute('/products')({ | ||||||||||||||||||||||||||
validateSearch: productsSearchSchema, | ||||||||||||||||||||||||||
search: { | ||||||||||||||||||||||||||
// exclude tempFilter from persistence | ||||||||||||||||||||||||||
middlewares: [persistSearchParams(['tempFilter'])], | ||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
```tsx | ||||||||||||||||||||||||||
import { z } from 'zod' | ||||||||||||||||||||||||||
import { createFileRoute, persistSearchParams } from '@tanstack/react-router' | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
const searchSchema = z.object({ | ||||||||||||||||||||||||||
category: z.string().optional(), | ||||||||||||||||||||||||||
sortBy: z.string().optional(), | ||||||||||||||||||||||||||
sortOrder: z.string().optional(), | ||||||||||||||||||||||||||
tempFilter: z.string().optional(), | ||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
export const Route = createFileRoute('/products')({ | ||||||||||||||||||||||||||
validateSearch: searchSchema, | ||||||||||||||||||||||||||
search: { | ||||||||||||||||||||||||||
// exclude tempFilter and sortBy from persistence | ||||||||||||||||||||||||||
middlewares: [persistSearchParams(['tempFilter', 'sortBy'])], | ||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
## Restoration Patterns | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
### Automatic Restoration with Links | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
Use `search={(prev) => prev}` to trigger middleware restoration: | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
```tsx | ||||||||||||||||||||||||||
import { Link } from '@tanstack/react-router' | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
function Navigation() { | ||||||||||||||||||||||||||
return ( | ||||||||||||||||||||||||||
<div> | ||||||||||||||||||||||||||
{/* Full restoration - restores all saved parameters */} | ||||||||||||||||||||||||||
<Link to="/users" search={(prev) => prev}> | ||||||||||||||||||||||||||
Users | ||||||||||||||||||||||||||
</Link> | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
{/* Partial override - restore saved params but override specific ones */} | ||||||||||||||||||||||||||
<Link | ||||||||||||||||||||||||||
to="/products" | ||||||||||||||||||||||||||
search={(prev) => ({ ...prev, category: 'Electronics' })} | ||||||||||||||||||||||||||
> | ||||||||||||||||||||||||||
Electronics Products | ||||||||||||||||||||||||||
</Link> | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
{/* Clean navigation - no restoration */} | ||||||||||||||||||||||||||
<Link to="/users">Users (clean slate)</Link> | ||||||||||||||||||||||||||
</div> | ||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
### Exclusion Strategies | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
You have two ways to exclude parameters from persistence: | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
**1. Middleware-level exclusion** (permanent): | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
```tsx | ||||||||||||||||||||||||||
// These parameters are never saved | ||||||||||||||||||||||||||
middlewares: [persistSearchParams(['tempFilter', 'sortBy'])] | ||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
**2. Link-level exclusion** (per navigation): | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
```tsx | ||||||||||||||||||||||||||
// Restore saved params but exclude specific ones | ||||||||||||||||||||||||||
<Link | ||||||||||||||||||||||||||
to="/products" | ||||||||||||||||||||||||||
search={(prev) => { | ||||||||||||||||||||||||||
const { tempFilter, ...rest } = prev || {} | ||||||||||||||||||||||||||
return rest | ||||||||||||||||||||||||||
}} | ||||||||||||||||||||||||||
> | ||||||||||||||||||||||||||
Products (excluding temp filter) | ||||||||||||||||||||||||||
</Link> | ||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
### Manual Restoration | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
Access the store directly for full control: | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
```tsx | ||||||||||||||||||||||||||
import { getSearchPersistenceStore, Link } from '@tanstack/react-router' | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
function CustomNavigation() { | ||||||||||||||||||||||||||
const store = getSearchPersistenceStore() | ||||||||||||||||||||||||||
const savedUsersSearch = store.getSearch('/users') | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
return ( | ||||||||||||||||||||||||||
<Link to="/users" search={savedUsersSearch || {}}> | ||||||||||||||||||||||||||
Users (with saved search) | ||||||||||||||||||||||||||
</Link> | ||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
## Using the search persistence store | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
You can also access the search persistence store directly for manual control: | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
```tsx | ||||||||||||||||||||||||||
import { getSearchPersistenceStore } from '@tanstack/react-router' | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
// Get the fully typed store instance | ||||||||||||||||||||||||||
const store = getSearchPersistenceStore() | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
// Get persisted search for a route | ||||||||||||||||||||||||||
const savedSearch = store.getSearch('/users') | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
// Clear persisted search for a specific route | ||||||||||||||||||||||||||
store.clearSearch('/users') | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
// Clear all persisted searches | ||||||||||||||||||||||||||
store.clearAllSearches() | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
// Manually save search for a route | ||||||||||||||||||||||||||
store.saveSearch('/users', { name: 'John', status: 'active' }) | ||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
```tsx | ||||||||||||||||||||||||||
import { getSearchPersistenceStore } from '@tanstack/react-router' | ||||||||||||||||||||||||||
import { useStore } from '@tanstack/react-store' | ||||||||||||||||||||||||||
import React from 'react' | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
function MyComponent() { | ||||||||||||||||||||||||||
const store = getSearchPersistenceStore() | ||||||||||||||||||||||||||
const storeState = useStore(store.store) | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
const clearUserSearch = () => { | ||||||||||||||||||||||||||
store.clearSearch('/users') | ||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
return ( | ||||||||||||||||||||||||||
<div> | ||||||||||||||||||||||||||
<p>Saved search: {JSON.stringify(storeState['/users'])}</p> | ||||||||||||||||||||||||||
<button onClick={clearUserSearch}>Clear saved search</button> | ||||||||||||||||||||||||||
</div> | ||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
# Search Persistence Example | ||
|
||
This example demonstrates TanStack Router's search persistence middleware, which automatically saves and restores search parameters when navigating between routes. | ||
|
||
## Overview | ||
|
||
The `persistSearchParams` middleware provides seamless search parameter persistence across route navigation. Search parameters are automatically saved when you leave a route and restored when you return, maintaining user context and improving UX. | ||
|
||
## Key Features | ||
|
||
- **Automatic Persistence**: Search parameters are saved/restored automatically | ||
- **Selective Exclusion**: Choose which parameters to exclude from persistence | ||
- **Type Safety**: Full TypeScript support with automatic type inference | ||
- **Manual Control**: Direct store access for advanced use cases | ||
|
||
## Basic Usage | ||
|
||
```tsx | ||
import { createFileRoute, persistSearchParams } from '@tanstack/react-router' | ||
|
||
// Persist all search parameters | ||
export const Route = createFileRoute('/users')({ | ||
validateSearch: usersSearchSchema, | ||
search: { | ||
middlewares: [persistSearchParams()], | ||
}, | ||
}) | ||
|
||
// Exclude specific parameters from persistence | ||
export const Route = createFileRoute('/products')({ | ||
validateSearch: productsSearchSchema, | ||
search: { | ||
middlewares: [persistSearchParams(['tempFilter', 'sortBy'])], | ||
}, | ||
}) | ||
``` | ||
|
||
## Restoration Patterns | ||
|
||
⚠️ **Important**: The middleware only runs when search parameters are being processed. Always be explicit about your restoration intent. | ||
|
||
### Automatic Restoration | ||
|
||
```tsx | ||
import { Link } from '@tanstack/react-router' | ||
|
||
// Full restoration - restores all saved parameters | ||
<Link to="/users" search={(prev) => prev}> | ||
Users (restore all) | ||
</Link> | ||
|
||
// Partial override - restore but override specific parameters | ||
<Link to="/products" search={(prev) => ({ ...prev, category: 'Electronics' })}> | ||
Electronics Products | ||
</Link> | ||
|
||
// Clean navigation - no restoration | ||
<Link to="/users"> | ||
Users (clean slate) | ||
</Link> | ||
``` | ||
|
||
### Manual Restoration | ||
|
||
Access the store directly for full control: | ||
|
||
```tsx | ||
import { getSearchPersistenceStore } from '@tanstack/react-router' | ||
|
||
const store = getSearchPersistenceStore() | ||
const savedSearch = store.getSearch('/users') | ||
|
||
<Link to="/users" search={savedSearch || {}}> | ||
Users (manual restoration) | ||
</Link> | ||
``` | ||
|
||
### ⚠️ Unexpected Behavior Warning | ||
|
||
If you use the persistence middleware but navigate without the `search` prop, restoration will only trigger later when you modify search parameters. This can cause saved parameters to unexpectedly appear mixed with your new changes. | ||
|
||
**Recommended**: Always use the `search` prop to be explicit about restoration intent. | ||
|
||
## Try It | ||
|
||
1. Navigate to `/users` and search for a name | ||
2. Navigate to `/products` and set some filters | ||
3. Use the test links on the homepage to see both restoration patterns! | ||
|
||
## Running the Example | ||
|
||
```bash | ||
pnpm install | ||
pnpm dev | ||
``` | ||
|
||
Navigate between Users and Products routes to see automatic search parameter persistence in action. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
<!doctype html> | ||
<html lang="en"> | ||
<head> | ||
<meta charset="UTF-8" /> | ||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
<title>Vite App</title> | ||
</head> | ||
<body> | ||
<div id="app"></div> | ||
<script type="module" src="/src/main.tsx"></script> | ||
</body> | ||
</html> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
{ | ||
"name": "tanstack-router-react-example-basic-file-based", | ||
"private": true, | ||
"type": "module", | ||
"scripts": { | ||
"dev": "vite --port 3000", | ||
"build": "vite build && tsc --noEmit", | ||
"serve": "vite preview", | ||
"start": "vite" | ||
}, | ||
"dependencies": { | ||
"@tanstack/react-router": "workspace:*", | ||
"@tanstack/react-router-devtools": "workspace:*", | ||
"@tanstack/react-store": "^0.7.0", | ||
"@tanstack/router-plugin": "workspace:*", | ||
"postcss": "^8.5.1", | ||
"autoprefixer": "^10.4.20", | ||
"tailwindcss": "^3.4.17", | ||
"zod": "^3.24.2" | ||
}, | ||
"devDependencies": { | ||
"@vitejs/plugin-react": "^4.3.4" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
export default { | ||
plugins: { | ||
tailwindcss: {}, | ||
autoprefixer: {}, | ||
}, | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import { StrictMode } from 'react' | ||
import ReactDOM from 'react-dom/client' | ||
import { RouterProvider, createRouter } from '@tanstack/react-router' | ||
import { routeTree } from './routeTree.gen' | ||
import { setupLocalStorageSync } from './utils/localStorage-sync' | ||
import './styles.css' | ||
|
||
// Setup localStorage sync for search persistence (optional) | ||
// if (typeof window !== 'undefined') { | ||
// setupLocalStorageSync() | ||
// } | ||
Comment on lines
+5
to
+11
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||
|
||
const router = createRouter({ routeTree }) | ||
|
||
declare module '@tanstack/react-router' { | ||
interface Register { | ||
router: typeof router | ||
} | ||
} | ||
|
||
const rootElement = document.getElementById('app') | ||
if (rootElement && !rootElement.innerHTML) { | ||
const root = ReactDOM.createRoot(rootElement) | ||
root.render( | ||
<StrictMode> | ||
<RouterProvider router={router} /> | ||
</StrictMode>, | ||
) | ||
} |
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:
📝 Committable suggestion
🤖 Prompt for AI Agents