diff --git a/docs/router/framework/react/api/router/persistSearchParamsFunction.md b/docs/router/framework/react/api/router/persistSearchParamsFunction.md new file mode 100644 index 0000000000..b161e2fc8f --- /dev/null +++ b/docs/router/framework/react/api/router/persistSearchParamsFunction.md @@ -0,0 +1,292 @@ +--- +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 the following parameters: + +- `persistedSearchParams` (required): Array of search param keys to persist +- `exclude` (optional): Array of search param keys to exclude 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**: `` → Middleware doesn't run → No restoration +- **With search function**: ` prev}>` → Middleware runs → Restoration happens +- **With explicit search**: `` → 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. + +## 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 name, status, and page + middlewares: [persistSearchParams(['name', 'status', 'page'])], + }, +}) +``` + +```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: { + // persist category, minPrice, maxPrice but exclude tempFilter + middlewares: [ + persistSearchParams(['category', 'minPrice', 'maxPrice'], ['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: { + // persist category and sortOrder, exclude tempFilter and sortBy + middlewares: [ + persistSearchParams(['category', 'sortOrder'], ['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 ( +
+ {/* Full restoration - restores all saved parameters */} + prev}> + Users + + + {/* Partial override - restore saved params but override specific ones */} + ({ ...prev, category: 'Electronics' })} + > + Electronics Products + + + {/* Clean navigation - no restoration */} + Users (clean slate) +
+ ) +} +``` + +### Exclusion Strategies + +You have two ways to exclude parameters from persistence: + +**1. Middleware-level exclusion** (permanent): + +```tsx +// Persist category and minPrice, exclude tempFilter and sortBy +middlewares: [ + persistSearchParams(['category', 'minPrice'], ['tempFilter', 'sortBy']), +] +``` + +**2. Link-level exclusion** (per navigation): + +```tsx +// Restore saved params but exclude specific ones + { + const { tempFilter, ...rest } = prev || {} + return rest + }} +> + Products (excluding temp filter) + +``` + +### 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 ( + + Users (with saved search) + + ) +} +``` + +## Server-Side Rendering (SSR) + +The search persistence middleware is **SSR-safe** and automatically creates isolated store instances per request to prevent state leakage between users. + +### Key SSR Features + +- **Per-request isolation**: Each SSR request gets its own `SearchPersistenceStore` instance +- **Automatic hydration**: Client seamlessly takes over from server-rendered state +- **No global state**: Prevents cross-request contamination in server environments +- **Custom store injection**: Integrate with your own persistence backend + +### 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 + }) +} +``` + +### Custom Persistence Backend + +For production SSR applications, integrate with your own persistence layer: + +```tsx +import { createRouter, SearchPersistenceStore } from '@tanstack/react-router' + +export function createAppRouter(userId?: string) { + let searchPersistenceStore: SearchPersistenceStore | undefined + + if (typeof window !== 'undefined') { + searchPersistenceStore = new SearchPersistenceStore() + + // Load user's saved searches from your backend + loadUserSearches(userId).then((savedSearches) => { + Object.entries(savedSearches).forEach(([routeId, searchParams]) => { + searchPersistenceStore.saveSearch(routeId, searchParams) + }) + }) + + // Save changes back to your backend + searchPersistenceStore.subscribe(() => { + const state = searchPersistenceStore.state + saveUserSearches(userId, state) + }) + } + + return createRouter({ + routeTree, + searchPersistenceStore, + }) +} +``` + +### 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 + +## 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 ( +
+

Saved search: {JSON.stringify(storeState['/users'])}

+ +
+ ) +} +``` diff --git a/examples/react/search-persistence/README.md b/examples/react/search-persistence/README.md new file mode 100644 index 0000000000..531e5b1399 --- /dev/null +++ b/examples/react/search-persistence/README.md @@ -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 + prev}> + Users (restore all) + + +// Partial override - restore but override specific parameters + ({ ...prev, category: 'Electronics' })}> + Electronics Products + + +// Clean navigation - no restoration + + Users (clean slate) + +``` + +### Manual Restoration + +Access the store directly for full control: + +```tsx +import { getSearchPersistenceStore } from '@tanstack/react-router' + +const store = getSearchPersistenceStore() +const savedSearch = store.getSearch('/users') + + + Users (manual restoration) + +``` + +### ⚠️ 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. diff --git a/examples/react/search-persistence/index.html b/examples/react/search-persistence/index.html new file mode 100644 index 0000000000..9b6335c0ac --- /dev/null +++ b/examples/react/search-persistence/index.html @@ -0,0 +1,12 @@ + + + + + + Vite App + + +
+ + + diff --git a/examples/react/search-persistence/package.json b/examples/react/search-persistence/package.json new file mode 100644 index 0000000000..57a21faa40 --- /dev/null +++ b/examples/react/search-persistence/package.json @@ -0,0 +1,24 @@ +{ + "name": "tanstack-router-react-example-search-persistence", + "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" + } +} diff --git a/examples/react/search-persistence/postcss.config.mjs b/examples/react/search-persistence/postcss.config.mjs new file mode 100644 index 0000000000..2e7af2b7f1 --- /dev/null +++ b/examples/react/search-persistence/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/examples/react/search-persistence/src/main.tsx b/examples/react/search-persistence/src/main.tsx new file mode 100644 index 0000000000..de10fa4018 --- /dev/null +++ b/examples/react/search-persistence/src/main.tsx @@ -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() +// } + +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( + + + , + ) +} diff --git a/examples/react/search-persistence/src/routeTree.gen.ts b/examples/react/search-persistence/src/routeTree.gen.ts new file mode 100644 index 0000000000..0bc4bd38b3 --- /dev/null +++ b/examples/react/search-persistence/src/routeTree.gen.ts @@ -0,0 +1,121 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as UsersRouteImport } from './routes/users' +import { Route as ProductsRouteImport } from './routes/products' +import { Route as IndexRouteImport } from './routes/index' +import { Route as UsersUserIdRouteImport } from './routes/users.$userId' + +const UsersRoute = UsersRouteImport.update({ + id: '/users', + path: '/users', + getParentRoute: () => rootRouteImport, +} as any) +const ProductsRoute = ProductsRouteImport.update({ + id: '/products', + path: '/products', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const UsersUserIdRoute = UsersUserIdRouteImport.update({ + id: '/$userId', + path: '/$userId', + getParentRoute: () => UsersRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/products': typeof ProductsRoute + '/users': typeof UsersRouteWithChildren + '/users/$userId': typeof UsersUserIdRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/products': typeof ProductsRoute + '/users': typeof UsersRouteWithChildren + '/users/$userId': typeof UsersUserIdRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/products': typeof ProductsRoute + '/users': typeof UsersRouteWithChildren + '/users/$userId': typeof UsersUserIdRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/products' | '/users' | '/users/$userId' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/products' | '/users' | '/users/$userId' + id: '__root__' | '/' | '/products' | '/users' | '/users/$userId' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ProductsRoute: typeof ProductsRoute + UsersRoute: typeof UsersRouteWithChildren +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/users': { + id: '/users' + path: '/users' + fullPath: '/users' + preLoaderRoute: typeof UsersRouteImport + parentRoute: typeof rootRouteImport + } + '/products': { + id: '/products' + path: '/products' + fullPath: '/products' + preLoaderRoute: typeof ProductsRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/users/$userId': { + id: '/users/$userId' + path: '/$userId' + fullPath: '/users/$userId' + preLoaderRoute: typeof UsersUserIdRouteImport + parentRoute: typeof UsersRoute + } + } +} + +interface UsersRouteChildren { + UsersUserIdRoute: typeof UsersUserIdRoute +} + +const UsersRouteChildren: UsersRouteChildren = { + UsersUserIdRoute: UsersUserIdRoute, +} + +const UsersRouteWithChildren = UsersRoute._addFileChildren(UsersRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ProductsRoute: ProductsRoute, + UsersRoute: UsersRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/examples/react/search-persistence/src/routes/__root.tsx b/examples/react/search-persistence/src/routes/__root.tsx new file mode 100644 index 0000000000..ce72602460 --- /dev/null +++ b/examples/react/search-persistence/src/routes/__root.tsx @@ -0,0 +1,45 @@ +import { Link, Outlet, createRootRoute } from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return ( + <> +
+ + Home + {' '} + + Users + {' '} + + Products + +
+
+ + + + ) +} diff --git a/examples/react/search-persistence/src/routes/index.tsx b/examples/react/search-persistence/src/routes/index.tsx new file mode 100644 index 0000000000..fd79fe684b --- /dev/null +++ b/examples/react/search-persistence/src/routes/index.tsx @@ -0,0 +1,120 @@ +import { createFileRoute, Link } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: HomeComponent, +}) + +function HomeComponent() { + return ( +
+
+

+ Search Persistence Middleware +

+

+ Navigate to Users or Products, filter some data, then navigate back to + see persistence in action! +

+
+ +
+
+

+ Test Restoration Patterns +

+
+ +
+
+

+ + ✓ + + Full Restoration +

+

+ Clean navigation - middleware automatically restores saved + parameters +

+
+ + Users (auto-restore) + + + Products (auto-restore) + +
+
+ +
+

+ + ~ + + Partial Override +

+

+ Restore saved parameters but override specific ones +

+
+ ({ ...prev, category: 'Electronics' })} + className="bg-amber-600 hover:bg-amber-700 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md" + > + Products → Electronics + + ({ ...prev, category: 'Books' })} + className="bg-amber-700 hover:bg-amber-800 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md" + > + Products → Books + +
+
+ +
+

+ + × + + Clean Navigation +

+

+ Navigate without any parameter restoration +

+
+ + Users (clean slate) + + + Products (clean slate) + +
+
+
+ +
+

+ 💡 Tip: Try filtering data on the Users or Products + pages, then use these buttons to test different restoration + behaviors. +

+
+
+
+ ) +} diff --git a/examples/react/search-persistence/src/routes/products.tsx b/examples/react/search-persistence/src/routes/products.tsx new file mode 100644 index 0000000000..4665648a2f --- /dev/null +++ b/examples/react/search-persistence/src/routes/products.tsx @@ -0,0 +1,144 @@ +import { + createFileRoute, + useNavigate, + persistSearchParams, +} from '@tanstack/react-router' +import { z } from 'zod' +import React from 'react' + +const productsSearchSchema = z.object({ + category: z.string().optional().catch(''), + minPrice: z.number().optional().catch(0), + maxPrice: z.number().optional().catch(1000), + sortBy: z.enum(['name', 'price', 'rating']).optional().catch('name'), +}) + +export type ProductsSearchSchema = z.infer + +export const Route = createFileRoute('/products')({ + validateSearch: productsSearchSchema, + search: { + middlewares: [ + persistSearchParams(['category', 'minPrice', 'maxPrice'], ['sortBy']), + ], + }, + component: ProductsComponent, +}) + +const mockProducts = [ + { id: 1, name: 'Laptop', category: 'Electronics', price: 999, rating: 4.5 }, + { id: 2, name: 'Chair', category: 'Home', price: 199, rating: 4.2 }, + { id: 3, name: 'Phone', category: 'Electronics', price: 699, rating: 4.7 }, + { id: 4, name: 'Desk', category: 'Home', price: 299, rating: 4.0 }, + { id: 5, name: 'Tablet', category: 'Electronics', price: 399, rating: 4.3 }, + { id: 6, name: 'Lamp', category: 'Home', price: 79, rating: 4.1 }, +] + +function ProductsComponent() { + const search = Route.useSearch() + const navigate = useNavigate() + + const filteredProducts = React.useMemo(() => { + let products = [...mockProducts] + + if (search.category) { + products = products.filter( + (product) => product.category === search.category, + ) + } + + products = products.filter( + (product) => + product.price >= (search.minPrice ?? 0) && + product.price <= (search.maxPrice ?? 1000), + ) + + products = products.sort((a, b) => { + if (search.sortBy === 'name') return a.name.localeCompare(b.name) + if (search.sortBy === 'price') return a.price - b.price + if (search.sortBy === 'rating') return b.rating - a.rating + return 0 + }) + + return products + }, [search.category, search.minPrice, search.maxPrice, search.sortBy]) + + const updateSearch = (updates: Partial) => { + navigate({ + search: (prev: ProductsSearchSchema) => ({ ...prev, ...updates }), + } as any) + } + + return ( +
+

Products

+

Advanced filtering with excluded parameters (sortBy won't persist)

+ +
+ + +
+ updateSearch({ minPrice: Number(e.target.value) })} + className="border" + /> + Min: ${search.minPrice ?? 0} +
+ +
+ updateSearch({ maxPrice: Number(e.target.value) })} + className="border" + /> + Max: ${search.maxPrice ?? 1000} +
+ + + + +
+ +
+ {filteredProducts.map((product) => ( +
+
{product.name}
+
{product.category}
+
+ ${product.price} - ⭐ {product.rating} +
+
+ ))} +
+
+ ) +} diff --git a/examples/react/search-persistence/src/routes/users.$userId.tsx b/examples/react/search-persistence/src/routes/users.$userId.tsx new file mode 100644 index 0000000000..f3e2f96d75 --- /dev/null +++ b/examples/react/search-persistence/src/routes/users.$userId.tsx @@ -0,0 +1,357 @@ +import { + createFileRoute, + retainSearchParams, + persistSearchParams, + Link, +} from '@tanstack/react-router' +import { z } from 'zod' + +const userDetailSearchSchema = z.object({ + name: z.string().optional().catch(''), + status: z.enum(['active', 'inactive', 'all']).optional().catch('all'), + page: z.number().optional().catch(0), + tab: z.enum(['profile', 'activity', 'settings']).optional().catch('profile'), +}) + +export type UserDetailSearchSchema = z.infer + +export const Route = createFileRoute('/users/$userId')({ + validateSearch: userDetailSearchSchema, + search: { + middlewares: [ + retainSearchParams(['name', 'status', 'page']), + persistSearchParams(['tab']), + ], + }, + component: UserDetailComponent, +}) + +const mockUsers = [ + { + id: 1, + name: 'Alice Johnson', + email: 'alice@example.com', + status: 'active', + department: 'Engineering', + role: 'Senior Developer', + }, + { + id: 2, + name: 'Bob Smith', + email: 'bob@example.com', + status: 'inactive', + department: 'Marketing', + role: 'Marketing Manager', + }, + { + id: 3, + name: 'Charlie Brown', + email: 'charlie@example.com', + status: 'active', + department: 'Design', + role: 'UI Designer', + }, + { + id: 4, + name: 'Diana Ross', + email: 'diana@example.com', + status: 'active', + department: 'Sales', + role: 'Sales Director', + }, + { + id: 5, + name: 'Edward Norton', + email: 'edward@example.com', + status: 'inactive', + department: 'Engineering', + role: 'DevOps Engineer', + }, + { + id: 6, + name: 'Fiona Apple', + email: 'fiona@example.com', + status: 'active', + department: 'Product', + role: 'Product Manager', + }, + { + id: 7, + name: 'George Lucas', + email: 'george@example.com', + status: 'active', + department: 'Engineering', + role: 'Tech Lead', + }, + { + id: 8, + name: 'Helen Hunt', + email: 'helen@example.com', + status: 'inactive', + department: 'HR', + role: 'HR Manager', + }, + { + id: 9, + name: 'Ian McKellen', + email: 'ian@example.com', + status: 'active', + department: 'Legal', + role: 'Legal Counsel', + }, + { + id: 10, + name: 'Julia Roberts', + email: 'julia@example.com', + status: 'active', + department: 'Finance', + role: 'CFO', + }, + { + id: 11, + name: 'Kevin Costner', + email: 'kevin@example.com', + status: 'inactive', + department: 'Operations', + role: 'Operations Manager', + }, + { + id: 12, + name: 'Lisa Simpson', + email: 'lisa@example.com', + status: 'active', + department: 'Engineering', + role: 'Junior Developer', + }, +] + +function UserDetailComponent() { + const { userId } = Route.useParams() + const search = Route.useSearch() + const navigate = Route.useNavigate() + + const user = mockUsers.find((u) => u.id.toString() === userId) + + if (!user) { + return ( +
+

User Not Found

+ + ← Back to Users + +
+ ) + } + + const updateTab = (tab: UserDetailSearchSchema['tab']) => { + const newSearch = { + name: search.name, + status: search.status, + page: search.page, + tab: tab, + } + + navigate({ + search: newSearch, + }) + } + + return ( +
+
+
+

+ 🔄 Retained from Users List (retainSearchParams): +

+
+
+ Name Filter:{' '} + + {search.name || '(none)'} + +
+
+ Status Filter:{' '} + + {search.status || 'all'} + +
+
+ Page:{' '} + + {search.page || 0} + +
+
+

+ These search parameters were retained from the Users list when you + navigated here! +

+
+ +
+

+ 💾 Persisted on This Route (persistSearchParams): +

+
+
+ Active Tab:{' '} + + {search.tab || 'profile'} + +
+
+ Route: /users/{userId} +
+
+

+ This tab selection will be restored when you navigate back to this + user! +

+
+
+ +
+
+
+ {user.name + .split(' ') + .map((n) => n[0]) + .join('')} +
+
+

{user.name}

+

+ {user.role} • {user.department} +

+
+
+ + {user.status} + +
+
+ +
+ +
+ +
+ {search.tab === 'profile' && ( +
+

Profile Information

+
+
+ + Email + +

{user.email}

+
+
+ + Department + +

+ {user.department} +

+
+
+ + Role + +

{user.role}

+
+
+ + User ID + +

{user.id}

+
+
+
+ )} + + {search.tab === 'activity' && ( +
+

Recent Activity

+
+
+

+ Logged in to the system +

+

2 hours ago

+
+
+

+ Updated profile information +

+

1 day ago

+
+
+

Changed password

+

3 days ago

+
+
+
+ )} + + {search.tab === 'settings' && ( +
+

User Settings

+
+
+ + Email Notifications + + +
+
+ + Two-Factor Authentication + + +
+
+ + Profile Visibility + + +
+
+
+ )} +
+
+
+ ) +} diff --git a/examples/react/search-persistence/src/routes/users.tsx b/examples/react/search-persistence/src/routes/users.tsx new file mode 100644 index 0000000000..14438ed77d --- /dev/null +++ b/examples/react/search-persistence/src/routes/users.tsx @@ -0,0 +1,198 @@ +import { + createFileRoute, + useNavigate, + persistSearchParams, + retainSearchParams, + stripSearchParams, + Link, + Outlet, + useLocation, +} from '@tanstack/react-router' +import { z } from 'zod' +import React from 'react' + +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), +}) + +export type UsersSearchSchema = z.infer + +export const Route = createFileRoute('/users')({ + validateSearch: usersSearchSchema, + search: { + middlewares: [ + retainSearchParams(['name', 'status', 'page']), + persistSearchParams(['name', 'status', 'page']), + stripSearchParams(['limit']), + ], + }, + component: UsersComponent, +}) + +const mockUsers = [ + { + id: 1, + name: 'Alice Johnson', + email: 'alice@example.com', + status: 'active', + }, + { id: 2, name: 'Bob Smith', email: 'bob@example.com', status: 'inactive' }, + { + id: 3, + name: 'Charlie Brown', + email: 'charlie@example.com', + status: 'active', + }, + { id: 4, name: 'Diana Ross', email: 'diana@example.com', status: 'active' }, + { + id: 5, + name: 'Edward Norton', + email: 'edward@example.com', + status: 'inactive', + }, + { id: 6, name: 'Fiona Apple', email: 'fiona@example.com', status: 'active' }, + { + id: 7, + name: 'George Lucas', + email: 'george@example.com', + status: 'active', + }, + { id: 8, name: 'Helen Hunt', email: 'helen@example.com', status: 'inactive' }, + { id: 9, name: 'Ian McKellen', email: 'ian@example.com', status: 'active' }, + { + id: 10, + name: 'Julia Roberts', + email: 'julia@example.com', + status: 'active', + }, + { + id: 11, + name: 'Kevin Costner', + email: 'kevin@example.com', + status: 'inactive', + }, + { id: 12, name: 'Lisa Simpson', email: 'lisa@example.com', status: 'active' }, +] + +function UsersComponent() { + const search = Route.useSearch() + const navigate = useNavigate() + const location = useLocation() + + // Extract userId from pathname like "/users/123" + const currentUserId = location.pathname.startsWith('/users/') + ? location.pathname.split('/users/')[1]?.split('?')[0] + : null + + const filteredUsers = React.useMemo(() => { + let users = mockUsers + + if (search.name) { + users = users.filter((user) => + user.name.toLowerCase().includes(search.name?.toLowerCase() || ''), + ) + } + + if (search.status && search.status !== 'all') { + users = users.filter((user) => user.status === search.status) + } + + return users + }, [search.name, search.status]) + + const updateSearch = (updates: Partial) => { + navigate({ + search: (prev: UsersSearchSchema) => ({ ...prev, ...updates, page: 0 }), + } as any) + } + + return ( +
+

Users

+

+ Search parameters are automatically persisted when you navigate away and + back +

+ +
+

+ Testing Middleware Chain: +

+

+ 1. Apply filters below (name, status)
+ 2. Click "View Details" on any user
+ 3. Switch between tabs (Profile, Activity, Settings)
+ 4. Click "← Back" and then "View Details" again
+ 5. See both retained filters AND persisted tab selections! +

+
+ +
+ updateSearch({ name: e.target.value })} + className="border p-2 rounded" + /> + + + + +
+ +
+ {filteredUsers.map((user) => ( +
+
+
+
{user.name}
+
{user.email}
+
{user.status}
+
+ {currentUserId === user.id.toString() ? ( + prev} + className="bg-gray-500 hover:bg-gray-600 text-white px-3 py-1 rounded text-sm" + > + ← Back + + ) : ( + + View Details + + )} +
+ {currentUserId === user.id.toString() && ( +
+ +
+ )} +
+ ))} +
+
+ ) +} diff --git a/examples/react/search-persistence/src/styles.css b/examples/react/search-persistence/src/styles.css new file mode 100644 index 0000000000..0b8e317099 --- /dev/null +++ b/examples/react/search-persistence/src/styles.css @@ -0,0 +1,13 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html { + color-scheme: light dark; +} +* { + @apply border-gray-200 dark:border-gray-800; +} +body { + @apply bg-gray-50 text-gray-950 dark:bg-gray-900 dark:text-gray-200; +} diff --git a/examples/react/search-persistence/src/type-inference-test.ts b/examples/react/search-persistence/src/type-inference-test.ts new file mode 100644 index 0000000000..df19b7bc3a --- /dev/null +++ b/examples/react/search-persistence/src/type-inference-test.ts @@ -0,0 +1,35 @@ +// Test file to verify type inference is working with existing TanStack Router types +import { getSearchPersistenceStore } from '@tanstack/react-router' + +// ✨ CLEAN API: 100% typed by default! ✨ +const store = getSearchPersistenceStore() + +// Test 1: Store state is automatically 100% typed +const state = store.state // 🎉 Automatically typed: {'/users': UsersSchema, '/products': ProductsSchema, ...} + +// Test 2: Store for useStore hook is automatically 100% typed +const storeForUseStore = store.store // 🎉 Automatically typed: Store<{mapped route schemas}> + +// Test 3: All methods are automatically 100% typed +const usersSearch = store.getSearch('/users') // 🎉 Automatically infers Users route search schema +const productsSearch = store.getSearch('/products') // 🎉 Automatically infers Products route search schema +const homeSearch = store.getSearch('/') // 🎉 Automatically infers home route search schema + +// Test 4: saveSearch automatically enforces proper route-specific search schemas +store.saveSearch('/users', { name: 'Alice', page: 0 }) // 🎉 Fully typed, no manual annotations needed +store.saveSearch('/products', { category: 'Electronics', minPrice: 100 }) // 🎉 Fully typed + +// Test 5: Other methods are also perfectly typed +store.clearSearch('/users') // 🎉 Route ID is typed +store.subscribe(() => {}) // 🎉 Works perfectly + +// 🎉 Perfect! Clean API with 100% type inference by default! + +export { + store, + state, + storeForUseStore, + usersSearch, + productsSearch, + homeSearch, +} diff --git a/examples/react/search-persistence/src/utils/localStorage-sync.ts b/examples/react/search-persistence/src/utils/localStorage-sync.ts new file mode 100644 index 0000000000..3a7a9581c2 --- /dev/null +++ b/examples/react/search-persistence/src/utils/localStorage-sync.ts @@ -0,0 +1,32 @@ +import { getSearchPersistenceStore } from '@tanstack/react-router' + +const STORAGE_KEY = 'search-persistence' + +export function setupLocalStorageSync() { + const store = getSearchPersistenceStore() + + // Restore from localStorage on initialization + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + try { + const parsedState = JSON.parse(stored) + Object.entries(parsedState).forEach(([routeId, search]) => { + store.saveSearch(routeId as any, search as Record) + }) + } catch (error) { + console.warn( + 'Failed to restore search persistence from localStorage:', + error, + ) + } + } + + // 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) + } + }) +} diff --git a/examples/react/search-persistence/tailwind.config.mjs b/examples/react/search-persistence/tailwind.config.mjs new file mode 100644 index 0000000000..4986094b9d --- /dev/null +++ b/examples/react/search-persistence/tailwind.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html'], +} diff --git a/examples/react/search-persistence/tsconfig.json b/examples/react/search-persistence/tsconfig.json new file mode 100644 index 0000000000..93048aa449 --- /dev/null +++ b/examples/react/search-persistence/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "skipLibCheck": true + } +} diff --git a/examples/react/search-persistence/vite.config.js b/examples/react/search-persistence/vite.config.js new file mode 100644 index 0000000000..47e327b746 --- /dev/null +++ b/examples/react/search-persistence/vite.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { tanstackRouter } from '@tanstack/router-plugin/vite' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + tanstackRouter({ + target: 'react', + autoCodeSplitting: true, + }), + react(), + ], +}) diff --git a/examples/react/ssr-search-persistence/.gitignore b/examples/react/ssr-search-persistence/.gitignore new file mode 100644 index 0000000000..0ca39c007c --- /dev/null +++ b/examples/react/ssr-search-persistence/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.DS_Store diff --git a/examples/react/ssr-search-persistence/README.md b/examples/react/ssr-search-persistence/README.md new file mode 100644 index 0000000000..79e5cb2eb4 --- /dev/null +++ b/examples/react/ssr-search-persistence/README.md @@ -0,0 +1,88 @@ +# SSR Search Persistence with Database Example + +This example demonstrates TanStack Router's SSR-safe search parameter persistence using the new per-router store injection pattern, complete with a demo database that syncs with the SearchPersistenceStore. + +## Features + +- **SSR-Safe**: Each SSR request gets its own `SearchPersistenceStore` instance +- **Route Isolation**: Search parameters are isolated per route (no contamination) +- **Selective Persistence**: Choose which search params to persist vs. exclude +- **Client Hydration**: Seamless client-side hydration maintains search state +- **Database Integration**: Demo database automatically syncs with search persistence store +- **Real-time Updates**: Database records update live as you navigate and filter + +## Key Implementation Details + +### Per-Request Store Creation (SSR) + +```tsx +// entry-server.tsx +const requestSearchStore = new SearchPersistenceStore() +router.update({ + searchPersistenceStore: requestSearchStore, +}) +``` + +### Client-Side Store (Browser) + +```tsx +// router.tsx +const searchPersistenceStore = + typeof window !== 'undefined' ? new SearchPersistenceStore() : undefined +``` + +### Route Configuration + +```tsx +// routes/products.tsx +search: { + middlewares: [ + persistSearchParams(['category', 'minPrice', 'maxPrice'], ['sortBy']) + ], +} +``` + +### Database Integration + +```tsx +// lib/searchDatabase.ts +export class SearchDatabase { + // Syncs with SearchPersistenceStore + syncWithStore(store: SearchPersistenceStore, userId = 'anonymous'): () => void + + // Subscribe to changes + subscribe(callback: () => void): () => void + + // CRUD operations + saveSearchParams(routeId: string, searchParams: Record): void + getSearchParams(routeId: string): Record | null +} +``` + +### Database Provider + +```tsx +// lib/SearchDatabaseProvider.tsx + + {/* Your app with automatic database sync */} + +``` + +## Running the Example + +```bash +pnpm install +pnpm run dev +``` + +Then open http://localhost:3000 + +## Testing Search Persistence + Database + +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 diff --git a/examples/react/ssr-search-persistence/package.json b/examples/react/ssr-search-persistence/package.json new file mode 100644 index 0000000000..264ba78485 --- /dev/null +++ b/examples/react/ssr-search-persistence/package.json @@ -0,0 +1,34 @@ +{ + "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" + }, + "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" + } +} diff --git a/examples/react/ssr-search-persistence/server.js b/examples/react/ssr-search-persistence/server.js new file mode 100644 index 0000000000..5686edc048 --- /dev/null +++ b/examples/react/ssr-search-persistence/server.js @@ -0,0 +1,104 @@ +import path from 'node:path' +import express from 'express' +import getPort, { portNumbers } from 'get-port' +import * as zlib from 'node:zlib' + +const isTest = process.env.NODE_ENV === 'test' || !!process.env.VITE_TEST_BUILD + +export async function createServer( + root = process.cwd(), + isProd = process.env.NODE_ENV === 'production', + hmrPort, +) { + const app = express() + + /** + * @type {import('vite').ViteDevServer} + */ + let vite + if (!isProd) { + vite = await ( + await import('vite') + ).createServer({ + root, + logLevel: isTest ? 'error' : 'info', + server: { + middlewareMode: true, + watch: { + // During tests we edit the files too fast and sometimes chokidar + // misses change events, so enforce polling for consistency + usePolling: true, + interval: 100, + }, + hmr: { + port: hmrPort, + }, + }, + appType: 'custom', + }) + // use vite's connect instance as middleware + app.use(vite.middlewares) + } else { + app.use( + (await import('compression')).default({ + brotli: { + flush: zlib.constants.BROTLI_OPERATION_FLUSH, + }, + flush: zlib.constants.Z_SYNC_FLUSH, + }), + ) + } + + if (isProd) app.use(express.static('./dist/client')) + + app.use('*', async (req, res) => { + try { + const url = req.originalUrl + + if (path.extname(url) !== '') { + console.warn(`${url} is not valid router path`) + res.status(404) + res.end(`${url} is not valid router path`) + return + } + + // Best effort extraction of the head from vite's index transformation hook + let viteHead = !isProd + ? await vite.transformIndexHtml( + url, + ``, + ) + : '' + + viteHead = viteHead.substring( + viteHead.indexOf('') + 6, + viteHead.indexOf(''), + ) + + const entry = await (async () => { + if (!isProd) { + return vite.ssrLoadModule('/src/entry-server.tsx') + } else { + return import('./dist/server/entry-server.js') + } + })() + + console.info('Rendering: ', url, '...') + entry.render({ req, res, head: viteHead }) + } catch (e) { + !isProd && vite.ssrFixStacktrace(e) + console.info(e.stack) + res.status(500).end(e.stack) + } + }) + + return { app, vite } +} + +if (!isTest) { + createServer().then(async ({ app }) => + app.listen(await getPort({ port: portNumbers(3000, 3100) }), () => { + console.info('Client Server: http://localhost:3000') + }), + ) +} diff --git a/examples/react/ssr-search-persistence/src/components/ClientOnly.tsx b/examples/react/ssr-search-persistence/src/components/ClientOnly.tsx new file mode 100644 index 0000000000..39bac41083 --- /dev/null +++ b/examples/react/ssr-search-persistence/src/components/ClientOnly.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react' + +interface ClientOnlyProps { + children: React.ReactNode + fallback?: React.ReactNode +} + +export function ClientOnly({ children, fallback = null }: ClientOnlyProps) { + const [hasMounted, setHasMounted] = useState(false) + + useEffect(() => { + setHasMounted(true) + }, []) + + if (!hasMounted) { + return <>{fallback} + } + + return <>{children} +} diff --git a/examples/react/ssr-search-persistence/src/entry-client.tsx b/examples/react/ssr-search-persistence/src/entry-client.tsx new file mode 100644 index 0000000000..5b19c4a66a --- /dev/null +++ b/examples/react/ssr-search-persistence/src/entry-client.tsx @@ -0,0 +1,7 @@ +import { hydrateRoot } from 'react-dom/client' +import { RouterClient } from '@tanstack/react-router/ssr/client' +import { createRouter } from './router' + +const router = createRouter() + +hydrateRoot(document, ) diff --git a/examples/react/ssr-search-persistence/src/entry-server.tsx b/examples/react/ssr-search-persistence/src/entry-server.tsx new file mode 100644 index 0000000000..0da9f3a561 --- /dev/null +++ b/examples/react/ssr-search-persistence/src/entry-server.tsx @@ -0,0 +1,75 @@ +import { pipeline } from 'node:stream/promises' +import { + RouterServer, + createRequestHandler, + renderRouterToString, +} from '@tanstack/react-router/ssr/server' +import { SearchPersistenceStore } from '@tanstack/react-router' +import { createRouter } from './router' +import type express from 'express' +import './fetch-polyfill' + +export async function render({ + req, + res, + head, +}: { + head: string + req: express.Request + res: express.Response +}) { + // Convert the express request to a fetch request + const url = new URL(req.originalUrl || req.url, 'https://localhost:3000').href + + const request = new Request(url, { + method: req.method, + headers: (() => { + const headers = new Headers() + for (const [key, value] of Object.entries(req.headers)) { + headers.set(key, value as any) + } + return headers + })(), + }) + + // Create a request handler + const handler = createRequestHandler({ + request, + createRouter: () => { + const router = createRouter() + + // For SSR: Create a fresh SearchPersistenceStore per request to avoid cross-request contamination + const requestSearchStore = new SearchPersistenceStore() + + // Update each router instance with the head info from vite and per-request store + router.update({ + context: { + ...router.options.context, + head: head, + }, + searchPersistenceStore: requestSearchStore, + }) + return router + }, + }) + + // Let's use the default stream handler to create the response + const response = await handler(({ responseHeaders, router }) => + renderRouterToString({ + responseHeaders, + router, + children: , + }), + ) + + // Convert the fetch response back to an express response + res.statusMessage = response.statusText + res.status(response.status) + + response.headers.forEach((value, name) => { + res.setHeader(name, value) + }) + + // Stream the response body + return pipeline(response.body as any, res) +} diff --git a/examples/react/ssr-search-persistence/src/fetch-polyfill.js b/examples/react/ssr-search-persistence/src/fetch-polyfill.js new file mode 100644 index 0000000000..9893c4c209 --- /dev/null +++ b/examples/react/ssr-search-persistence/src/fetch-polyfill.js @@ -0,0 +1,11 @@ +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 diff --git a/examples/react/ssr-search-persistence/src/routeTree.gen.ts b/examples/react/ssr-search-persistence/src/routeTree.gen.ts new file mode 100644 index 0000000000..189ea5eea8 --- /dev/null +++ b/examples/react/ssr-search-persistence/src/routeTree.gen.ts @@ -0,0 +1,113 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as UsersRouteImport } from './routes/users' +import { Route as ProductsRouteImport } from './routes/products' +import { Route as DatabaseRouteImport } from './routes/database' +import { Route as IndexRouteImport } from './routes/index' + +const UsersRoute = UsersRouteImport.update({ + id: '/users', + path: '/users', + getParentRoute: () => rootRouteImport, +} as any) +const ProductsRoute = ProductsRouteImport.update({ + id: '/products', + path: '/products', + getParentRoute: () => rootRouteImport, +} as any) +const DatabaseRoute = DatabaseRouteImport.update({ + id: '/database', + path: '/database', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/database': typeof DatabaseRoute + '/products': typeof ProductsRoute + '/users': typeof UsersRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/database': typeof DatabaseRoute + '/products': typeof ProductsRoute + '/users': typeof UsersRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/database': typeof DatabaseRoute + '/products': typeof ProductsRoute + '/users': typeof UsersRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/database' | '/products' | '/users' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/database' | '/products' | '/users' + id: '__root__' | '/' | '/database' | '/products' | '/users' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + DatabaseRoute: typeof DatabaseRoute + ProductsRoute: typeof ProductsRoute + UsersRoute: typeof UsersRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/users': { + id: '/users' + path: '/users' + fullPath: '/users' + preLoaderRoute: typeof UsersRouteImport + parentRoute: typeof rootRouteImport + } + '/products': { + id: '/products' + path: '/products' + fullPath: '/products' + preLoaderRoute: typeof ProductsRouteImport + parentRoute: typeof rootRouteImport + } + '/database': { + id: '/database' + path: '/database' + fullPath: '/database' + preLoaderRoute: typeof DatabaseRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + DatabaseRoute: DatabaseRoute, + ProductsRoute: ProductsRoute, + UsersRoute: UsersRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/examples/react/ssr-search-persistence/src/router.tsx b/examples/react/ssr-search-persistence/src/router.tsx new file mode 100644 index 0000000000..595aa9ef8f --- /dev/null +++ b/examples/react/ssr-search-persistence/src/router.tsx @@ -0,0 +1,68 @@ +import { + SearchPersistenceStore, + createRouter as createReactRouter, +} from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +import { serverDatabase } from './lib/serverDatabase' + +export function createRouter() { + let searchPersistenceStore: SearchPersistenceStore | undefined + + if (typeof window !== 'undefined') { + searchPersistenceStore = new SearchPersistenceStore() + + // Load initial data from server database into router store + const allRecords = serverDatabase.getAllRecords() + allRecords.forEach((record) => { + searchPersistenceStore!.saveSearch( + record.routeId as any, + record.searchParams, + ) + }) + + // Subscribe to router store changes and save to server database + searchPersistenceStore.subscribe(() => { + if (!searchPersistenceStore) return + const storeState = searchPersistenceStore.state + + // Save routes that have non-empty search params + Object.entries(storeState).forEach(([routeId, searchParams]) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (Object.keys(searchParams || {}).length > 0) { + serverDatabase.save(routeId, searchParams, 'demo-user') + } + }) + + // Restore routes from database if they're missing from store + const allDbRecords = serverDatabase.getAllRecords() + const missingRoutes = allDbRecords.filter( + (record) => !(record.routeId in storeState), + ) + + if (missingRoutes.length > 0) { + missingRoutes.forEach((record) => { + searchPersistenceStore!.saveSearch( + record.routeId as any, + record.searchParams, + ) + }) + } + }) + } + + return createReactRouter({ + routeTree, + context: { + head: '', + }, + searchPersistenceStore, + defaultPreload: 'intent', + scrollRestoration: true, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/examples/react/ssr-search-persistence/src/routerContext.tsx b/examples/react/ssr-search-persistence/src/routerContext.tsx new file mode 100644 index 0000000000..d452eb8140 --- /dev/null +++ b/examples/react/ssr-search-persistence/src/routerContext.tsx @@ -0,0 +1,3 @@ +export interface RouterContext { + head: string +} diff --git a/examples/react/ssr-search-persistence/src/routes/__root.tsx b/examples/react/ssr-search-persistence/src/routes/__root.tsx new file mode 100644 index 0000000000..b9dd38f1a1 --- /dev/null +++ b/examples/react/ssr-search-persistence/src/routes/__root.tsx @@ -0,0 +1,122 @@ +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRouteWithContext, +} from '@tanstack/react-router' +import type { RouterContext } from '../routerContext' + +export const Route = createRootRouteWithContext()({ + head: () => ({ + links: [{ rel: 'icon', href: '/images/favicon.ico' }], + meta: [ + { + title: 'TanStack Router SSR Search Persistence Example', + }, + { + charSet: 'UTF-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1.0', + }, + ], + scripts: [ + { + src: 'https://unpkg.com/@tailwindcss/browser@4', + }, + ...(!import.meta.env.PROD + ? [ + { + type: 'module', + children: `import RefreshRuntime from "/@react-refresh" + RefreshRuntime.injectIntoGlobalHook(window) + window.$RefreshReg$ = () => {} + window.$RefreshSig$ = () => (type) => type + window.__vite_plugin_react_preamble_installed__ = true`, + }, + { + type: 'module', + src: '/@vite/client', + }, + ] + : []), + { + type: 'module', + src: import.meta.env.PROD + ? '/static/entry-client.js' + : '/src/entry-client.tsx', + }, + ], + }), + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + +
+

SSR Search Persistence

+

+ This example demonstrates server-side search parameter persistence + using TanStack Router's search middleware with a custom database + adapter. +

+ +
+ + Home + + prev} + className="hover:text-blue-500" + > + Products + + prev} + > + Users + + + Database + +
+ +
+ + +
+ + + + ) +} diff --git a/examples/react/ssr-search-persistence/src/routes/database.tsx b/examples/react/ssr-search-persistence/src/routes/database.tsx new file mode 100644 index 0000000000..2bb9efd004 --- /dev/null +++ b/examples/react/ssr-search-persistence/src/routes/database.tsx @@ -0,0 +1,145 @@ +import { createFileRoute, useRouter } from '@tanstack/react-router' +import { useEffect, useState } from 'react' +import { serverDatabase } from '../lib/serverDatabase' +import { ClientOnly } from '../components/ClientOnly' +import type { ServerSearchRecord } from '../lib/serverDatabase' + +export const Route = createFileRoute('/database')({ + component: DatabaseComponent, +}) + +function DatabaseComponent() { + return ( + Loading database...}> + + + ) +} + +function DatabaseContent() { + const router = useRouter() + const [records, setRecords] = useState>([]) + useEffect(() => { + const loadRecords = () => { + const allRecords = serverDatabase.getAllRecords() + setRecords(allRecords) + } + + loadRecords() + const interval = setInterval(loadRecords, 2000) + + return () => clearInterval(interval) + }, []) + + const clearAllRecords = () => { + serverDatabase.clear() + + // Also clear from router store + const searchStore = router.options.searchPersistenceStore + if (searchStore) { + records.forEach((record) => { + searchStore.clearSearch(record.routeId) + }) + } + + setRecords([]) + } + + const formatTimestamp = (timestamp: number) => { + return new Date(timestamp).toLocaleTimeString() + } + + return ( +
+
+

Search Database

+
+ {records.length} record{records.length !== 1 ? 's' : ''} +
+
+ +
+

+ This demonstrates server-side search parameter persistence. Search + parameters are saved to a server database and restored across SSR + requests. +

+
+ +
+

Persisted Search Parameters

+ {records.length > 0 && ( + + )} +
+ + {records.length === 0 ? ( +
+

No search parameters saved

+

+ Navigate to Products or Users and set some filters to see them here. +

+
+ ) : ( +
+ {records.map((record, index) => ( +
+
+
+ {record.routeId} + {record.userId} +
+
+ {formatTimestamp(record.timestamp)} +
+
+ +
+
+                  {JSON.stringify(record.searchParams, null, 2)}
+                
+
+ +
+ +
+
+ ))} +
+ )} + +
+

+ How it works: Search parameters are automatically + saved to a server database and restored when you navigate back to the + same route. This works across page refreshes and new tabs because the + data is stored server-side. +

+
+
+ ) +} diff --git a/examples/react/ssr-search-persistence/src/routes/index.tsx b/examples/react/ssr-search-persistence/src/routes/index.tsx new file mode 100644 index 0000000000..acf33c2f8b --- /dev/null +++ b/examples/react/ssr-search-persistence/src/routes/index.tsx @@ -0,0 +1,56 @@ +import { Link, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: IndexComponent, +}) + +function IndexComponent() { + return ( +
+

+ Welcome to SSR Search Persistence! +

+ +
+

🔍 How It Works

+
    +
  • • Search parameters are persisted per route using middleware
  • +
  • • Each route isolates its search params (no contamination!)
  • +
  • • SSR-safe: per-request store instances prevent state leakage
  • +
  • • Client hydration maintains search state seamlessly
  • +
+
+ +
+

Try the examples:

+
+ + Products with Search + + + Users with Search + +
+
+ +
+

🧪 Test Instructions

+
    +
  1. Navigate to Products, set search filters
  2. +
  3. Navigate to Users, set different search filters
  4. +
  5. Navigate back to Products - your filters persist!
  6. +
  7. Navigate back to Users - different filters persist!
  8. +
  9. Refresh the page - search params restore correctly
  10. +
+
+
+ ) +} diff --git a/examples/react/ssr-search-persistence/src/routes/products.tsx b/examples/react/ssr-search-persistence/src/routes/products.tsx new file mode 100644 index 0000000000..02c5cff5ac --- /dev/null +++ b/examples/react/ssr-search-persistence/src/routes/products.tsx @@ -0,0 +1,142 @@ +import { + createFileRoute, + persistSearchParams, + useNavigate, +} from '@tanstack/react-router' +import { z } from 'zod' + +const productsSearchSchema = z.object({ + category: z.string().optional().catch(''), + minPrice: z.number().optional().catch(0), + maxPrice: z.number().optional().catch(1000), + sortBy: z.enum(['name', 'price', 'rating']).optional().catch('name'), +}) + +export type ProductsSearchSchema = z.infer + +export const Route = createFileRoute('/products')({ + validateSearch: productsSearchSchema, + search: { + middlewares: [ + persistSearchParams(['category', 'minPrice', 'maxPrice'], ['sortBy']), + ], + }, + component: ProductsComponent, +}) + +function ProductsComponent() { + const search = Route.useSearch() + const navigate = useNavigate() + + const updateSearch = (updates: Partial) => { + navigate({ search: { ...search, ...updates } }) + } + + return ( +
+
+

Products

+
+ Persisted: category, minPrice, maxPrice | + Excluded: sortBy (temporary filter) +
+
+ +
+
+
+ + +
+ +
+ + + updateSearch({ minPrice: Number(e.target.value) }) + } + className="w-full" + /> +
+ +
+ + + updateSearch({ maxPrice: Number(e.target.value) }) + } + className="w-full" + /> +
+ +
+ + +
+ + +
+ +
+

Current Search State:

+
+            {JSON.stringify(search, null, 2)}
+          
+
+
+ +
+

🎯 Test Search Persistence

+
    +
  1. Set some filters above (category, price range)
  2. +
  3. Navigate to Users page
  4. +
  5. Come back - your filters should be restored!
  6. +
  7. Notice: sortBy resets (not persisted) but others remain
  8. +
+
+
+ ) +} diff --git a/examples/react/ssr-search-persistence/src/routes/users.tsx b/examples/react/ssr-search-persistence/src/routes/users.tsx new file mode 100644 index 0000000000..59e43eaee8 --- /dev/null +++ b/examples/react/ssr-search-persistence/src/routes/users.tsx @@ -0,0 +1,168 @@ +import { + createFileRoute, + persistSearchParams, + useNavigate, +} from '@tanstack/react-router' +import { z } from 'zod' + +const usersSearchSchema = z.object({ + name: z.string().optional().catch(''), + status: z.enum(['active', 'inactive', 'pending']).optional().catch('active'), + page: z.number().optional().catch(1), + limit: z.number().optional().catch(10), +}) + +export type UsersSearchSchema = z.infer + +export const Route = createFileRoute('/users')({ + validateSearch: usersSearchSchema, + search: { + middlewares: [persistSearchParams(['name', 'status', 'page'])], + }, + component: UsersComponent, +}) + +function UsersComponent() { + const search = Route.useSearch() + const navigate = useNavigate() + + const updateSearch = (updates: Partial) => { + navigate({ search: { ...search, ...updates } }) + } + + return ( +
+
+

Users

+
+ Persisted: name, status, page | + Not persisted: limit (resets to default) +
+
+ +
+
+
+ + updateSearch({ name: e.target.value })} + placeholder="Enter user name..." + className="w-full border rounded px-3 py-2" + /> +
+ +
+ + +
+ +
+ +
+ + + Page {search.page || 1} + + +
+
+ +
+ + +
+ + +
+ +
+

Current Search State:

+
+            {JSON.stringify(search, null, 2)}
+          
+
+
+ +
+

🎯 Test Search Isolation

+
    +
  1. Set filters here (name, status, page)
  2. +
  3. Go to Products, set completely different filters
  4. +
  5. Come back - your Users filters are isolated & restored!
  6. +
  7. Notice: limit resets to 10 (not in persisted params)
  8. +
+
+ +
+

🔧 SSR Safety

+

+ Each SSR request gets its own SearchPersistenceStore instance, + preventing cross-request state contamination. Try refreshing the page + with search params - they'll be restored correctly! +

+
+
+ ) +} diff --git a/examples/react/ssr-search-persistence/tsconfig.json b/examples/react/ssr-search-persistence/tsconfig.json new file mode 100644 index 0000000000..4aaf48e263 --- /dev/null +++ b/examples/react/ssr-search-persistence/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "esnext", + "types": ["vite/client"], + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "skipLibCheck": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/react/ssr-search-persistence/vite.config.ts b/examples/react/ssr-search-persistence/vite.config.ts new file mode 100644 index 0000000000..09cc95baa8 --- /dev/null +++ b/examples/react/ssr-search-persistence/vite.config.ts @@ -0,0 +1,53 @@ +import path from 'node:path' +import url from 'node:url' +import { tanstackRouter } from '@tanstack/router-plugin/vite' +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import type { BuildEnvironmentOptions } from 'vite' + +const __filename = url.fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// SSR configuration +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]', + }, + }, +} + +// Client-specific configuration +const clientBuildConfig: BuildEnvironmentOptions = { + 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]', + }, + }, +} + +// https://vitejs.dev/config/ +export default defineConfig((configEnv) => { + return { + plugins: [ + tanstackRouter({ target: 'react', autoCodeSplitting: true }), + react(), + ], + build: configEnv.isSsrBuild ? ssrBuildConfig : clientBuildConfig, + } +}) diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index ed78317315..3f000f74a2 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -28,10 +28,13 @@ export { isPlainObject, isPlainArray, deepEqual, + SearchPersistenceStore, shallow, createControlledPromise, retainSearchParams, stripSearchParams, + persistSearchParams, + getSearchPersistenceStore, } from '@tanstack/router-core' export type { diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index ca34da1722..b7a2a33ce4 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -254,7 +254,14 @@ export type { BuildLocationFn, } from './RouterProvider' -export { retainSearchParams, stripSearchParams } from './searchMiddleware' +export { + SearchPersistenceStore, + createSearchPersistenceStore, + retainSearchParams, + stripSearchParams, + persistSearchParams, + getSearchPersistenceStore, +} from './searchMiddleware' export { defaultParseSearch, diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index bdda040845..b88e043b31 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -71,15 +71,38 @@ export type RoutePathOptionsIntersection = { export type SearchFilter = (prev: TInput) => TResult +export interface SearchMiddlewareRouter { + options: { + searchPersistenceStore?: any // Avoid circular dependency, will be typed at usage + } + state?: { + location?: { + pathname?: string + } + } + destPathname?: string // 🎯 Destination pathname for per-user storage keys +} + export type SearchMiddlewareContext = { search: TSearchSchema next: (newSearch: TSearchSchema) => TSearchSchema + route: { id: string; fullPath: string } + router: SearchMiddlewareRouter } -export type SearchMiddleware = ( +export type SearchMiddlewareFunction = ( ctx: SearchMiddlewareContext, ) => TSearchSchema +export type SearchMiddlewareObject = { + middleware: SearchMiddlewareFunction + inheritParentMiddlewares?: boolean +} + +export type SearchMiddleware = + | SearchMiddlewareFunction + | SearchMiddlewareObject + export type ResolveId< TParentRoute, TCustomId extends string, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 6a132b5783..65be3b9e21 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -31,6 +31,7 @@ import { import { isNotFound } from './not-found' import { setupScrollRestoration } from './scroll-restoration' import { defaultParseSearch, defaultStringifySearch } from './searchParams' +import { SearchPersistenceStore } from './searchMiddleware' import { rootRouteId } from './root' import { isRedirect, redirect } from './redirect' import { createLRUCache } from './lru-cache' @@ -61,6 +62,7 @@ import type { RouteContextOptions, RouteMask, SearchMiddleware, + SearchMiddlewareContext, } from './route' import type { FullSearchSchema, @@ -276,6 +278,15 @@ export interface RouterOptions< * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/router-context) */ context?: InferRouterContext + /** + * A store instance that will be used to persist search parameters across route navigations. + * + * If not provided, a new SearchPersistenceStore instance will be created automatically for this router. + * On the server, search persistence will be disabled unless an explicit store is provided. + * + * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/search-params-persistence) + */ + searchPersistenceStore?: SearchPersistenceStore /** * A function that will be called when the router is dehydrated. * @@ -922,6 +933,11 @@ export class RouterCore< setupScrollRestoration(this) } + // Initialize searchPersistenceStore if not provided and not on server + if (!this.options.searchPersistenceStore && typeof window !== 'undefined') { + this.options.searchPersistenceStore = new SearchPersistenceStore() + } + if ( typeof window !== 'undefined' && 'CSS' in window && @@ -1337,20 +1353,20 @@ export class RouterCore< // Update the match's context if (route.options.context) { - const contextFnContext: RouteContextOptions = { - deps: match.loaderDeps, - params: match.params, + const contextFnContext: RouteContextOptions = { + deps: match.loaderDeps, + params: match.params, context: parentContext ?? {}, - location: next, - navigate: (opts: any) => - this.navigate({ ...opts, _fromLocation: next }), - buildLocation: this.buildLocation, - cause: match.cause, - abortController: match.abortController, - preload: !!match.preload, - matches, - } - // Get the route context + location: next, + navigate: (opts: any) => + this.navigate({ ...opts, _fromLocation: next }), + buildLocation: this.buildLocation, + cause: match.cause, + abortController: match.abortController, + preload: !!match.preload, + matches, + } + // Get the route context match.__routeContext = route.options.context(contextFnContext) ?? undefined } @@ -1452,7 +1468,7 @@ export class RouterCore< // for from to be invalid it shouldn't just be unmatched to currentLocation // but the currentLocation should also be unmatched to from if (!matchedFrom && !matchedCurrent) { - console.warn(`Could not find match for from: ${fromPath}`) + console.warn(`Could not find match for from: ${fromPath}`) } } } @@ -1474,7 +1490,7 @@ export class RouterCore< dest.params === false || dest.params === null ? {} : (dest.params ?? true) === true - ? fromParams + ? fromParams : Object.assign( fromParams, functionalUpdate(dest.params as any, fromParams), @@ -1488,14 +1504,14 @@ export class RouterCore< }).interpolatedPath const destRoutes = this.matchRoutes(interpolatedNextTo, undefined, { - _buildLocation: true, + _buildLocation: true, }).map((d) => this.looseRoutesById[d.routeId]!) // If there are any params, we need to stringify them if (Object.keys(nextParams).length > 0) { for (const route of destRoutes) { const fn = - route.options.params?.stringify ?? route.options.stringifyParams + route.options.params?.stringify ?? route.options.stringifyParams if (fn) { Object.assign(nextParams, fn(nextParams)) } @@ -1515,10 +1531,11 @@ export class RouterCore< // Resolve the next search let nextSearch = fromSearch + if (opts._includeValidateSearch && this.options.search?.strict) { const validatedSearch = {} destRoutes.forEach((route) => { - if (route.options.validateSearch) { + if (route.options.validateSearch) { try { Object.assign( validatedSearch, @@ -1527,19 +1544,40 @@ export class RouterCore< ...nextSearch, }), ) - } catch { - // ignore errors here because they are already handled in matchRoutes + } catch { + // ignore errors here because they are already handled in matchRoutes } } }) nextSearch = validatedSearch } + // Filter search params through destination route's validateSearch before middlewares + const finalRoute = destRoutes[destRoutes.length - 1] + let filteredSearch = nextSearch + + if (finalRoute?.options.validateSearch) { + try { + // Apply validateSearch to filter out invalid params for this route + filteredSearch = + validateSearch(finalRoute.options.validateSearch, nextSearch) ?? {} + } catch { + // If validation fails, start with empty search + filteredSearch = {} + } + } else { + // Routes without validateSearch get clean search (prevents contamination) + filteredSearch = {} + } + nextSearch = applySearchMiddleware({ - search: nextSearch, + search: filteredSearch, dest, destRoutes, _includeValidateSearch: opts._includeValidateSearch, + router: this, + currentLocationMatches: allCurrentLocationMatches, + destPathname: nextPathname, }) // Replace the equal deep @@ -1598,9 +1636,9 @@ export class RouterCore< this.basepath, next.pathname, { - to: d.from, - caseSensitive: false, - fuzzy: false, + to: d.from, + caseSensitive: false, + fuzzy: false, }, this.parsePathnameCache, ) @@ -2279,8 +2317,8 @@ export class RouterCore< this.basepath, baseLocation.pathname, { - ...opts, - to: next.pathname, + ...opts, + to: next.pathname, }, this.parsePathnameCache, ) as any @@ -2635,10 +2673,10 @@ export function getMatchedRoutes({ basepath, trimmedPath, { - to: route.fullPath, - caseSensitive: route.options?.caseSensitive ?? caseSensitive, + to: route.fullPath, + caseSensitive: route.options?.caseSensitive ?? caseSensitive, // we need fuzzy matching for `notFoundMode: 'fuzzy'` - fuzzy: true, + fuzzy: true, }, parseCache, ) @@ -2668,7 +2706,7 @@ export function getMatchedRoutes({ } } else { foundRoute = route - routeParams = matchedParams + routeParams = matchedParams break } } @@ -2698,93 +2736,125 @@ function applySearchMiddleware({ dest, destRoutes, _includeValidateSearch, + router, + currentLocationMatches, + destPathname, }: { search: any dest: BuildNextOptions destRoutes: Array _includeValidateSearch: boolean | undefined + router: { options: { searchPersistenceStore?: any } } + currentLocationMatches: Array + destPathname: string }) { - const allMiddlewares = - destRoutes.reduce( - (acc, route) => { - const middlewares: Array> = [] - - if ('search' in route.options) { - if (route.options.search?.middlewares) { - middlewares.push(...route.options.search.middlewares) + const allMiddlewares: Array<{ + middleware: SearchMiddleware + route: { id: string; fullPath: string } + }> = destRoutes.reduce( + (acc, route, routeIndex) => { + const middlewares: Array> = [] + const isDestinationRoute = routeIndex === destRoutes.length - 1 + + if ('search' in route.options) { + if (route.options.search?.middlewares) { + for (const middleware of route.options.search.middlewares) { + const isFunction = typeof middleware === 'function' + const inheritFlag = isFunction + ? undefined + : middleware.inheritParentMiddlewares + const shouldInclude = isFunction + ? true + : middleware.inheritParentMiddlewares !== false + + if (isDestinationRoute || shouldInclude) { + middlewares.push(middleware) + } } } - // TODO remove preSearchFilters and postSearchFilters in v2 - else if ( - route.options.preSearchFilters || - route.options.postSearchFilters - ) { - const legacyMiddleware: SearchMiddleware = ({ - search, - next, - }) => { - let nextSearch = search - - if ( - 'preSearchFilters' in route.options && - route.options.preSearchFilters - ) { - nextSearch = route.options.preSearchFilters.reduce( - (prev, next) => next(prev), - search, - ) - } + } + // TODO remove preSearchFilters and postSearchFilters in v2 + else if ( + route.options.preSearchFilters || + route.options.postSearchFilters + ) { + const legacyMiddleware = ({ + search, + next, + }: SearchMiddlewareContext) => { + let nextSearch = search - const result = next(nextSearch) + if ( + 'preSearchFilters' in route.options && + route.options.preSearchFilters + ) { + nextSearch = route.options.preSearchFilters.reduce( + (prev, next) => next(prev), + search, + ) + } - if ( - 'postSearchFilters' in route.options && - route.options.postSearchFilters - ) { - return route.options.postSearchFilters.reduce( - (prev, next) => next(prev), - result, - ) - } + const result = next(nextSearch) - return result + if ( + 'postSearchFilters' in route.options && + route.options.postSearchFilters + ) { + return route.options.postSearchFilters.reduce( + (prev, next) => next(prev), + result, + ) } - middlewares.push(legacyMiddleware) + + return result } - if (_includeValidateSearch && route.options.validateSearch) { - const validate: SearchMiddleware = ({ search, next }) => { - const result = next(search) - try { - const validatedSearch = { - ...result, - ...(validateSearch(route.options.validateSearch, result) ?? - undefined), - } - return validatedSearch - } catch { - // ignore errors here because they are already handled in matchRoutes - return result + middlewares.push(legacyMiddleware) + } + + if (_includeValidateSearch && route.options.validateSearch) { + const validate = ({ search, next }: SearchMiddlewareContext) => { + const result = next(search) + try { + const validatedSearch = { + ...result, + ...(validateSearch(route.options.validateSearch, result) ?? + undefined), } + return validatedSearch + } catch { + // ignore errors here because they are already handled in matchRoutes + return result } - - middlewares.push(validate) } - return acc.concat(middlewares) - }, - [] as Array>, - ) ?? [] + middlewares.push(validate) + } - // the chain ends here since `next` is not called - const final: SearchMiddleware = ({ search }) => { - if (!dest.search) { - return {} - } - if (dest.search === true) { - return search - } - return functionalUpdate(dest.search, search) + return acc.concat( + middlewares.map((middleware) => ({ + middleware, + route: { id: route.id, fullPath: route.fullPath }, + })), + ) + }, + [] as Array<{ + middleware: SearchMiddleware + route: { id: string; fullPath: string } + }>, + ) + + const final = { + middleware: ({ search }: { search: any }) => { + if (!dest.search) { + return search + } + if (dest.search === true) { + return search + } + return functionalUpdate(dest.search, search) + }, + route: { id: '', fullPath: '' }, } allMiddlewares.push(final) @@ -2795,13 +2865,25 @@ function applySearchMiddleware({ return currentSearch } - const middleware = allMiddlewares[index]! + const { middleware, route } = allMiddlewares[index]! const next = (newSearch: any): any => { return applyNext(index + 1, newSearch) } - return middleware({ search: currentSearch, next }) + const middlewareFunction = + typeof middleware === 'function' ? middleware : middleware.middleware + + return middlewareFunction({ + search: currentSearch, + next, + route: { id: route.id, fullPath: route.fullPath }, + router: { + ...router, + state: (router as any).__store?.state, + destPathname, + }, + }) } // Start applying middlewares diff --git a/packages/router-core/src/searchMiddleware.ts b/packages/router-core/src/searchMiddleware.ts index 03ebbe213f..a74380163e 100644 --- a/packages/router-core/src/searchMiddleware.ts +++ b/packages/router-core/src/searchMiddleware.ts @@ -1,44 +1,46 @@ -import { deepEqual } from './utils' -import type { NoInfer, PickOptional } from './utils' -import type { SearchMiddleware } from './route' -import type { IsRequiredParams } from './link' +import { Store } from '@tanstack/store' +import { deepEqual, replaceEqualDeep } from './utils' +import type { + AnyRoute, + SearchMiddleware, + SearchMiddlewareObject, +} from './route' +import type { RouteById, RoutesById } from './routeInfo' +import type { RegisteredRouter } from './router' -export function retainSearchParams( +export function retainSearchParams>( keys: Array | true, -): SearchMiddleware { +): SearchMiddleware { return ({ search, next }) => { const result = next(search) + if (keys === true) { - return { ...search, ...result } + return replaceEqualDeep({}, { ...search, ...result }) } - // add missing keys from search to result + + const newResult = { ...result } as Record keys.forEach((key) => { - if (!(key in result)) { - result[key] = search[key] + if (!(key in newResult)) { + newResult[key as string] = (search as Record)[ + key as string + ] } }) - return result + return replaceEqualDeep({}, newResult) } } -export function stripSearchParams< - TSearchSchema, - TOptionalProps = PickOptional>, - const TValues = - | Partial> - | Array, - const TInput = IsRequiredParams extends never - ? TValues | true - : TValues, ->(input: NoInfer): SearchMiddleware { +export function stripSearchParams>( + input: Partial | Array | true, +): SearchMiddleware { return ({ search, next }) => { if (input === true) { - return {} + return {} as any } const result = next(search) as Record if (Array.isArray(input)) { input.forEach((key) => { - delete result[key] + delete result[key as string] }) } else { Object.entries(input as Record).forEach( @@ -52,3 +54,243 @@ export function stripSearchParams< return result as any } } + +export class SearchPersistenceStore { + private __store: Store>> + + constructor() { + this.__store = new Store({}) + } + + get state() { + return this.__store.state + } + + subscribe(listener: () => void) { + return this.__store.subscribe(listener) + } + + get store() { + return this.__store + } + + getTypedStore< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + >(): Store<{ + [K in keyof RoutesById]: RouteById< + TRouteTree, + K + >['types']['fullSearchSchema'] + }> { + return this.__store as Store<{ + [K in keyof RoutesById]: RouteById< + TRouteTree, + K + >['types']['fullSearchSchema'] + }> + } + + getTypedState< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + >(): { + [K in keyof RoutesById]: RouteById< + TRouteTree, + K + >['types']['fullSearchSchema'] + } { + return this.__store.state as { + [K in keyof RoutesById]: RouteById< + TRouteTree, + K + >['types']['fullSearchSchema'] + } + } + + saveSearch< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TRouteId extends + keyof RoutesById = keyof RoutesById, + >( + routeId: TRouteId, + search: RouteById['types']['fullSearchSchema'], + ): void { + const searchRecord = search as Record + 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' && Object.keys(value).length === 0) + return false + return true + }), + ) + + this.__store.setState((prevState) => { + return Object.keys(cleanedSearch).length === 0 + ? (() => { + const { [routeId]: _, ...rest } = prevState + return rest + })() + : replaceEqualDeep(prevState, { + ...prevState, + [routeId]: cleanedSearch, + }) + }) + } + + getSearch< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TRouteId extends + keyof RoutesById = keyof RoutesById, + >( + routeId: TRouteId, + ): RouteById['types']['fullSearchSchema'] | null { + return ( + (this.state[routeId as string] as RouteById< + TRouteTree, + TRouteId + >['types']['fullSearchSchema']) || null + ) + } + + clearSearch< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TRouteId extends + keyof RoutesById = keyof RoutesById, + >(routeId: TRouteId): void { + this.__store.setState((prevState) => { + const { [routeId as string]: _, ...rest } = prevState + return rest + }) + } + + clearAllSearches(): void { + this.__store.setState(() => ({})) + } +} + +// Factory function to create a new SearchPersistenceStore instance +export function createSearchPersistenceStore(): SearchPersistenceStore { + return new SearchPersistenceStore() +} + +// Get a typed interface for an existing SearchPersistenceStore instance +export function getSearchPersistenceStore< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], +>( + store: SearchPersistenceStore, +): { + state: { + [K in keyof RoutesById]: RouteById< + TRouteTree, + K + >['types']['fullSearchSchema'] + } + store: Store<{ + [K in keyof RoutesById]: RouteById< + TRouteTree, + K + >['types']['fullSearchSchema'] + }> + subscribe: (listener: () => void) => () => void + getSearch: >( + routeId: TRouteId, + ) => RouteById['types']['fullSearchSchema'] | null + saveSearch: >( + routeId: TRouteId, + search: RouteById['types']['fullSearchSchema'], + ) => void + clearSearch: >( + routeId: TRouteId, + ) => void + clearAllSearches: () => void +} { + return { + get state() { + return store.getTypedState() + }, + get store() { + return store.getTypedStore() + }, + subscribe: (listener: () => void) => store.subscribe(listener), + getSearch: >( + routeId: TRouteId, + ) => store.getSearch(routeId), + saveSearch: >( + routeId: TRouteId, + search: RouteById['types']['fullSearchSchema'], + ) => store.saveSearch(routeId, search), + clearSearch: >( + routeId: TRouteId, + ) => store.clearSearch(routeId), + clearAllSearches: () => store.clearAllSearches(), + } +} + +export function persistSearchParams>( + persistedSearchParams: Array, + exclude?: Array, +): SearchMiddlewareObject { + return { + 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) + + let searchToProcess = search + + if (savedSearch && Object.keys(savedSearch).length > 0) { + // User has saved preferences - restore them + const onlyOwnedParams = Object.fromEntries( + persistedSearchParams + .map((key) => [String(key), savedSearch[String(key)]]) + .filter(([_, value]) => value !== undefined), + ) + searchToProcess = { ...search, ...onlyOwnedParams } + } else { + // No saved preferences - remove our parameters to let validateSearch set defaults + const searchWithoutOwnedParams = { ...search } as Record< + string, + unknown + > + persistedSearchParams.forEach((key) => { + delete searchWithoutOwnedParams[String(key)] + }) + searchToProcess = searchWithoutOwnedParams as any + } + + const result = next(searchToProcess) + + // Save only this route's parameters + const resultRecord = result as Record + 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(storageKey, filteredResult) + } + + return result + }, + inheritParentMiddlewares: false, + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a822a6677..408e6f1c32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4649,6 +4649,37 @@ importers: specifier: 6.3.5 version: 6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0) + examples/react/search-persistence: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-router-devtools': + specifier: workspace:^ + version: link:../../../packages/react-router-devtools + '@tanstack/react-store': + specifier: ^0.7.0 + version: 0.7.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@tanstack/router-plugin': + specifier: workspace:* + version: link:../../../packages/router-plugin + autoprefixer: + specifier: ^10.4.20 + version: 10.4.20(postcss@8.5.3) + postcss: + specifier: ^8.5.1 + version: 8.5.3 + tailwindcss: + specifier: ^3.4.17 + version: 3.4.17 + zod: + specifier: ^3.24.2 + version: 3.25.57 + devDependencies: + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.6.0(vite@6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0)) + examples/react/search-validator-adapters: dependencies: '@tanstack/arktype-adapter': @@ -4719,6 +4750,61 @@ importers: specifier: 6.3.5 version: 6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0) + examples/react/ssr-search-persistence: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/router-plugin': + specifier: workspace:* + version: link:../../../packages/router-plugin + compression: + specifier: ^1.8.0 + version: 1.8.0 + express: + specifier: ^4.21.2 + version: 4.21.2 + get-port: + specifier: ^7.1.0 + version: 7.1.0 + isbot: + specifier: ^5.1.28 + version: 5.1.28 + node-fetch: + specifier: ^3.3.2 + version: 3.3.2 + react: + specifier: ^19.0.0 + version: 19.0.0 + react-dom: + specifier: ^19.0.0 + version: 19.0.0(react@19.0.0) + zod: + specifier: ^3.23.8 + version: 3.25.57 + devDependencies: + '@tanstack/react-router-devtools': + specifier: workspace:^ + version: link:../../../packages/react-router-devtools + '@types/express': + specifier: ^4.17.23 + version: 4.17.23 + '@types/react': + specifier: ^19.0.8 + version: 19.0.8 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.0.3(@types/react@19.0.8) + '@vitejs/plugin-react': + specifier: ^4.5.2 + version: 4.6.0(vite@6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0)) + typescript: + specifier: ^5.8.3 + version: 5.9.2 + vite: + specifier: 6.3.5 + version: 6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0) + examples/react/start-bare: dependencies: '@tanstack/react-router': diff --git a/port-tanstack-router-e2e-react-basic-esbuild-file-based-external.txt b/port-tanstack-router-e2e-react-basic-esbuild-file-based-external.txt new file mode 100644 index 0000000000..64c54dd52c --- /dev/null +++ b/port-tanstack-router-e2e-react-basic-esbuild-file-based-external.txt @@ -0,0 +1 @@ +49614 \ No newline at end of file diff --git a/port-tanstack-router-e2e-react-basic-esbuild-file-based.txt b/port-tanstack-router-e2e-react-basic-esbuild-file-based.txt new file mode 100644 index 0000000000..0e4201dff4 --- /dev/null +++ b/port-tanstack-router-e2e-react-basic-esbuild-file-based.txt @@ -0,0 +1 @@ +49613 \ No newline at end of file