Skip to content

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

Open
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

NitsanCohen770
Copy link

@NitsanCohen770 NitsanCohen770 commented Aug 20, 2025

✨ Summary

This PR adds a new middleware and supporting API for search parameter persistence:

  • persistSearchParams middleware
    Automatically saves and restores search params across navigations, with support for selective exclusion. Works seamlessly with SSR and remains framework-agnostic, with a React integration provided.

  • getSearchPersistenceStore() API
    Exposes a fully typed store for manual save, restore, and clear operations. Gives developers full control when they need to override the default middleware behavior.

  • Examples & Documentation
    Includes a new search-persistence React example (Users/Products routes) demonstrating how persistence works in practice. The example also shows how the store can be synchronized with localStorage for durability across reloads, while making it clear that other adapters (e.g. saving to a database in SSR environments) can be easily built on top of the same API.


🚀 Why This Is Useful

  • Improved UX – Users no longer lose their filters, pagination, or search state when navigating away and back.
  • Flexibility – Developers can choose which parameters persist, or clear them entirely, depending on the use case.
  • Extensible – The getSearchPersistenceStore() abstraction makes it trivial to integrate persistence into any storage layer:
    • Client-side (e.g. localStorage, session storage)
    • Server-side (e.g. saving to a database or session store during SSR)
  • Typed by default – All persistence APIs automatically infer route-specific search schemas for safety and DX.

📚 Additions

  • persistSearchParams middleware in @tanstack/router-core
  • getSearchPersistenceStore() API with type inference
  • New React example: examples/react/search-persistence
  • Documentation for usage patterns, caveats, and extension points

Summary by CodeRabbit

  • New Features

    • Search-parameter persistence across routes via a new middleware and public helpers to save/restore/clear per-route search state.
    • Route-aware middleware behavior enabling per-route persistence and restoration.
  • Documentation

    • Comprehensive guide covering usage, restoration patterns, exclusions, manual store access, and warnings.
    • Typed examples showing validation-schema integration.
  • Chores

    • Added a React example app (Vite/Tailwind/PostCSS) demonstrating persistence with optional localStorage sync and walkthrough steps.

- Add persistSearchParams middleware for automatic search parameter persistence
- Add getSearchPersistenceStore() function with full type inference
- Include comprehensive example with Users and Products routes
- Add API documentation following project conventions
- Support selective parameter exclusion with typed arrays
- SSR compatible with proper route context handling
- Framework agnostic core with React integration
Copy link

coderabbitai bot commented Aug 20, 2025

Walkthrough

Adds route-aware search-parameter persistence: a persistSearchParams middleware, a typed SearchPersistenceStore with accessor, wiring of route metadata into search middleware execution, re-exports in React/core barrels, and a complete React example + docs demonstrating usage, exclusions, restoration, and optional localStorage sync. (50 words)

Changes

Cohort / File(s) Summary
Docs
docs/router/framework/react/api/router/persistSearchParamsFunction.md
New documentation page describing persistSearchParams, getSearchPersistenceStore, middleware behavior, trigger rules, exclusion patterns, restoration examples, and store APIs.
Example: Project config & scaffolding
examples/react/search-persistence/index.html, examples/react/search-persistence/package.json, examples/react/search-persistence/tsconfig.json, examples/react/search-persistence/vite.config.js, examples/react/search-persistence/tailwind.config.mjs, examples/react/search-persistence/postcss.config.mjs, examples/react/search-persistence/src/styles.css
Adds a Vite + React + Tailwind example project with build/dev scripts and config files.
Example: App entry & route tree
examples/react/search-persistence/src/main.tsx, examples/react/search-persistence/src/routeTree.gen.ts
App bootstrap (router creation, optional localStorage sync) and generated routeTree with typed route mappings and module augmentation.
Example: Routes & UI
examples/react/search-persistence/src/routes/__root.tsx, .../src/routes/index.tsx, .../src/routes/users.tsx, .../src/routes/products.tsx
Root and file routes demonstrating persistSearchParams usage, Zod search schemas, UI components for filtering, restoration patterns, and exclusion examples.
Example: Utilities & persistence
examples/react/search-persistence/src/utils/localStorage-sync.ts, examples/react/search-persistence/README.md
localStorage sync helper for the persistence store and README explaining the example and usage patterns.
Example: Type tests
examples/react/search-persistence/src/type-inference-test.ts
Type-inference test that exercises getSearchPersistenceStore and per-route typed get/save/clear operations.
Core: Router public barrels
packages/router-core/src/index.ts, packages/react-router/src/index.tsx
Re-exports added: persistSearchParams and getSearchPersistenceStore from searchMiddleware (router-core) and re-exported from React barrel.
Core: Middleware context & pipeline
packages/router-core/src/route.ts, packages/router-core/src/router.ts
SearchMiddlewareContext extended to include route: { id, fullPath }; router middleware pipeline updated to pass per-route metadata into middleware execution and represent middlewares as { middleware, route }.
Core: Search persistence implementation
packages/router-core/src/searchMiddleware.ts
Adds SearchPersistenceStore class, getSearchPersistenceStore accessor, and persistSearchParams middleware; implements per-route typed save/get/clear, persistence logic (restore when incoming search empty, persist allowed keys), and typing updates to stripSearchParams.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant UI as Route Component
  participant Router as Router
  participant MW as persistSearchParams (Middleware)
  participant Store as SearchPersistenceStore
  Note over Router,MW: Middleware receives route metadata {id, fullPath}

  UI->>Router: navigate(to, search?)
  Router->>MW: invoke({ search, next, route })
  alt incoming search is empty
    MW->>Store: getSearch(route.id)
    Store-->>MW: savedSearch|null
    MW->>MW: use savedSearch if present
  end
  MW->>Router: next(merged/filteredSearch)
  MW->>Store: saveSearch(route.id, filteredSearch)  -- when non-empty
  Router-->>UI: render with finalSearch
Loading
sequenceDiagram
  autonumber
  participant App as App boot
  participant Store as SearchPersistenceStore
  participant LS as localStorage

  App->>LS: getItem('search-persistence')
  LS-->>App: serialized state
  App->>Store: saveSearch(routeId, search) per entry
  Store-->>App: notify subscribers
  App->>LS: setItem('search-persistence', serialized state) on updates
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • Search Params as Actual State #4973 — Matches: implements per-param persistence middleware and a typed persistence store and wires route metadata into middleware pipeline as requested.

Poem

I hop through routes with gentle care,
Packing queries in my tiny lair.
A pocketed store to keep them tight,
Restored by moon or morning light.
Nibble a key, skip or keep—off I dash, search held fast! 🐇✨

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

- Run prettier:write to format all files according to project standards
- Fix ESLint import sorting and method signature style issues
- All files now pass prettier and eslint checks
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (31)
examples/react/search-persistence/tsconfig.json (1)

2-8: Consider adding Vite-friendly compiler options (moduleResolution, types, noEmit)

These improve DX (env typing) and align with modern Vite setups.

Apply this diff:

   "compilerOptions": {
     "strict": true,
     "esModuleInterop": true,
     "jsx": "react-jsx",
     "lib": ["DOM", "DOM.Iterable", "ES2022"],
-    "skipLibCheck": true
+    "skipLibCheck": true,
+    "moduleResolution": "bundler",
+    "module": "ESNext",
+    "types": ["vite/client"],
+    "noEmit": true
   }
examples/react/search-persistence/README.md (4)

62-64: Remove trailing spaces in the heading

Minor Markdown cleanup flagged by tooling; trailing spaces aren’t needed after a heading.

Apply this diff:

-### Manual Restoration  
+### Manual Restoration

68-74: Prefer preserving current search when no persisted state exists

Using an empty object may unintentionally reset a route’s search. Falling back to prev keeps the current state intact if nothing is persisted.

Apply this diff:

-const store = getSearchPersistenceStore()
-const savedSearch = store.getSearch('/users')
-
-<Link to="/users" search={savedSearch || {}}>
-  Users (manual restoration)
-</Link>
+const store = getSearchPersistenceStore()
+const savedSearch = store.getSearch('/users')
+
+<Link
+  to="/users"
+  // If nothing is persisted, keep the current search untouched instead of forcing {}
+  search={(prev) => savedSearch ?? prev}
+>
+  Users (manual restoration)
+</Link>

75-76: Consider adding a quick example for clearing persisted search

You mention manual control; a “clear” example rounds it out.

Apply this diff:

+
+### Clearing persisted search
+
+```tsx
+import { getSearchPersistenceStore } from '@tanstack/react-router'
+const store = getSearchPersistenceStore()
+// Clear only the Users route persisted search
+store.clearSearch('/users')
+// Or clear everything
+store.clearAllSearches()
+```

95-95: Minor grammar tweak for readability

Add “the” and simplify wording.

Apply this diff:

-Navigate between Users and Products routes to see automatic search parameter persistence in action.
+Navigate between the Users and Products routes to see search parameter persistence in action.
packages/router-core/src/route.ts (1)

74-78: Strengthen SearchMiddlewareContext.route typing

The router already threads the route object (with id and fullPath) through the middleware pipeline in router.ts, so no changes are needed there. To enforce immutability and reuse the existing RouteExtensions interface, update the context type as follows:

 export type SearchMiddlewareContext<TSearchSchema> = {
   search: TSearchSchema
   next: (newSearch: TSearchSchema) => TSearchSchema
-  route: { id: string; fullPath: string }
+  route: Readonly<Pick<RouteExtensions<string, string>, 'id' | 'fullPath'>>
 }

This ensures middlewares see a readonly, consistently typed route object.

examples/react/search-persistence/index.html (1)

1-12: Use a descriptive title for the example

Minor UX nit: “Vite App” is generic. Consider a descriptive title to help users distinguish tabs.

Apply this diff:

-    <title>Vite App</title>
+    <title>Search Persistence – TanStack Router</title>
examples/react/search-persistence/src/styles.css (1)

8-10: Global border color via universal selector: confirm intentional scope

Styling all elements’ border color with * is a common pattern but can be heavy on very large DOMs and also affects elements that don’t render borders. If the intent is to set the default border color theme-wide, this is fine; otherwise consider scoping to a container or adding ::before, ::after if pseudo-elements are relied upon for borders.

Would you like a scoped alternative (e.g., a wrapper class) to reduce global impact?

examples/react/search-persistence/src/main.tsx (1)

8-11: Avoid duplicate subscriptions during HMR by disposing the localStorage sync listener

In dev, Vite HMR will re-run module code and stack multiple subscriptions. Dispose the previous subscription to prevent duplicate writes and memory leaks.

-// Setup localStorage sync for search persistence (optional)
-if (typeof window !== 'undefined') {
-  setupLocalStorageSync()
-}
+// Setup localStorage sync for search persistence (optional)
+if (typeof window !== 'undefined') {
+  const unsubscribe = setupLocalStorageSync()
+  // Avoid duplicate subscriptions during Vite HMR in dev
+  if (import.meta?.hot) {
+    import.meta.hot.dispose(() => unsubscribe())
+  }
+}
examples/react/search-persistence/src/utils/localStorage-sync.ts (2)

13-15: Filter unknown route IDs when restoring persisted state

Without guarding, arbitrary keys in localStorage can pollute the store. Filter restores to known route IDs.

-      Object.entries(parsedState).forEach(([routeId, search]) => {
-        store.saveSearch(routeId as any, search as Record<string, unknown>)
-      })
+      Object.entries(parsedState).forEach(([routeId, search]) => {
+        if (routeId in store.state) {
+          store.saveSearch(routeId as any, search as Record<string, unknown>)
+        }
+      })

21-28: Debounce localStorage writes to reduce churn and quota-pressure

Search changes can happen quickly (typing, sliders). Debounce writes to avoid frequent syncs.

-  // Subscribe to changes and sync to localStorage
-  return store.subscribe(() => {
-    try {
-      localStorage.setItem(STORAGE_KEY, JSON.stringify(store.state))
-    } catch (error) {
-      console.warn('Failed to sync search persistence to localStorage:', error)
-    }
-  })
+  // Subscribe to changes and sync to localStorage (debounced to avoid frequent writes)
+  let writeTimer: number | undefined
+  return store.subscribe(() => {
+    if (writeTimer) {
+      clearTimeout(writeTimer)
+    }
+    writeTimer = window.setTimeout(() => {
+      try {
+        localStorage.setItem(STORAGE_KEY, JSON.stringify(store.state))
+      } catch (error) {
+        console.warn('Failed to sync search persistence to localStorage:', error)
+      }
+    }, 100)
+  })
examples/react/search-persistence/package.json (1)

2-2: Rename the example to reflect its purpose

The current name suggests a “basic file-based” example. Consider renaming to improve discoverability.

-  "name": "tanstack-router-react-example-basic-file-based",
+  "name": "tanstack-router-react-example-search-persistence",
examples/react/search-persistence/src/routes/__root.tsx (2)

12-12: Use semantic

for the primary navigation

Small a11y improvement; helps assistive tech and semantics.

-      <div className="p-2 flex gap-2 text-lg border-b">
+      <nav className="p-2 flex gap-2 text-lg border-b">
...
-      </div>
+      </nav>

Also applies to: 40-40


43-43: Render devtools only in development to keep prod bundles lean

Gate the devtools behind a dev check.

-      <TanStackRouterDevtools />
+      {import.meta.env.DEV && <TanStackRouterDevtools />}
examples/react/search-persistence/src/routes/index.tsx (1)

1-1: Fix ESLint sort-imports: sort named specifiers

Complies with the configured sort-imports rule.

-import { createFileRoute, Link } from '@tanstack/react-router'
+import { Link, createFileRoute } from '@tanstack/react-router'
examples/react/search-persistence/src/routes/users.tsx (4)

1-1: Sort imported members to satisfy lint rule

ESLint reports sort-imports. Alphabetize the named imports.

-import { createFileRoute, useNavigate, persistSearchParams } from '@tanstack/react-router'
+import { createFileRoute, persistSearchParams, useNavigate } from '@tanstack/react-router'

5-10: Coerce numeric search params from the URL

Query-string values arrive as strings. Using z.coerce.number() makes validation robust across reloads/deep-links.

-  page: z.number().optional().catch(0),
-  limit: z.number().optional().catch(10),
+  page: z.coerce.number().optional().catch(0),
+  limit: z.coerce.number().optional().catch(10),

59-63: Avoid as any in navigate; keep types intact

Let inference work by returning a properly typed object or cast the result, not the whole navigate call.

-  const updateSearch = (updates: Partial<UsersSearchSchema>) => {
-    navigate({
-      search: (prev: UsersSearchSchema) => ({ ...prev, ...updates, page: 0 }),
-    } as any)
-  }
+  const updateSearch = (updates: Partial<UsersSearchSchema>) => {
+    navigate({
+      search: (prev: UsersSearchSchema) =>
+        ({ ...prev, ...updates, page: 0 }) as UsersSearchSchema,
+    })
+  }

80-82: Tighten typing for status change

Avoid as any. Narrow to the enum type.

-          onChange={(e) => updateSearch({ status: e.target.value as any })}
+          onChange={(e) =>
+            updateSearch({ status: e.target.value as UsersSearchSchema['status'] })
+          }
docs/router/framework/react/api/router/persistSearchParamsFunction.md (2)

24-27: Clarify and fix grammar in trigger rules; call out empty-search restoration

Improve readability and explicitly mention that an explicit empty object restores.

-- **Without search prop**: `<Link to="/users">` → Middleware doesn't run → No restoration
-- **With search function**: `<Link to="/users" search={(prev) => prev}>` → Middleware runs → Restoration happens
-- **With explicit search**: `<Link to="/users" search={{ name: 'John' }}>` → Middleware runs → No restoration (params provided)
+- Without a `search` prop: `<Link to="/users">` → Middleware does not run → No restoration
+- With a search function: `<Link to="/users" search={(prev) => prev}>` → Middleware runs → Restoration happens
+- With explicit non-empty search: `<Link to="/users" search={{ name: 'John' }}>` → Middleware runs → No restoration (params provided)
+- With explicit empty search: `<Link to="/users" search={{}}> ` → Middleware runs → Restoration happens (empty search is treated as “restore”)

147-165: Add a “clean reset” example that clears persistence

Since {} restores, show how to clear the store before navigating for a true reset.

 ## Manual Restoration
@@
 function CustomNavigation() {
   const store = getSearchPersistenceStore()
   const savedUsersSearch = store.getSearch('/users')
   
   return (
     <Link to="/users" search={savedUsersSearch || {}}>
       Users (with saved search)
     </Link>
   )
 }
+
+// Clean reset (clear persisted state, then navigate with empty search)
+function ResetUsers() {
+  const store = getSearchPersistenceStore()
+  return (
+    <Link
+      to="/users"
+      search={() => {
+        store.clearSearch('/users')
+        return {}
+      }}
+    >
+      Reset Users (clean)
+    </Link>
+  )
+}
examples/react/search-persistence/src/routes/products.tsx (4)

1-1: Sort imported members and drop trailing comma for lint cleanliness

Alphabetize members and remove the dangling comma inside the import specifier.

-import { createFileRoute, useNavigate, persistSearchParams, } from '@tanstack/react-router'
+import { createFileRoute, persistSearchParams, useNavigate } from '@tanstack/react-router'

5-10: Coerce numeric search params from the URL

Use z.coerce.number() so deep-links and reloads with numeric strings validate correctly.

-  minPrice: z.number().optional().catch(0),
-  maxPrice: z.number().optional().catch(1000),
+  minPrice: z.coerce.number().optional().catch(0),
+  maxPrice: z.coerce.number().optional().catch(1000),

58-62: Avoid as any in navigate; keep types intact

Prefer casting the produced object or rely on inference.

-  const updateSearch = (updates: Partial<ProductsSearchSchema>) => {
-    navigate({
-      search: (prev: ProductsSearchSchema) => ({ ...prev, ...updates }),
-    } as any)
-  }
+  const updateSearch = (updates: Partial<ProductsSearchSchema>) => {
+    navigate({
+      search: (prev: ProductsSearchSchema) =>
+        ({ ...prev, ...updates }) as ProductsSearchSchema,
+    })
+  }

104-107: Tighten typing for sortBy change

Avoid as any. Narrow to the enum union type.

-          onChange={(e) => updateSearch({ sortBy: e.target.value as any })}
+          onChange={(e) =>
+            updateSearch({ sortBy: e.target.value as ProductsSearchSchema['sortBy'] })
+          }
packages/router-core/src/router.ts (1)

2758-2775: Potential double validation of search

Search validation is also performed earlier in buildLocation when strict mode is on. Running another validate middleware here duplicates work. Consider gating this middleware behind a flag or removing the earlier strict-block to avoid redundant compute.

packages/router-core/src/searchMiddleware.ts (5)

4-4: Sort imported members to satisfy lint rule (sort-imports).

Swap the named members so they are alphabetically ordered.

-import type { SearchMiddleware, AnyRoute } from './route'
+import type { AnyRoute, SearchMiddleware } from './route'

6-6: Sort imported members to satisfy lint rule (sort-imports).

Order the named imports alphabetically.

-import type { RoutesById, RouteById } from './routeInfo'
+import type { RouteById, RoutesById } from './routeInfo'

116-126: Potential TS inconsistency: cast routeId to string for symmetry and safety.

You already cast to string in clearSearch; do the same here to avoid generic key issues in destructuring.

-      if (Object.keys(cleanedSearch).length === 0) {
-        const { [routeId]: _, ...rest } = prevState
+      if (Object.keys(cleanedSearch).length === 0) {
+        const { [routeId as string]: _, ...rest } = prevState
         return rest
       }

161-179: Fix method-signature lint errors in return type (method-signature-style).

Convert method signatures to function properties in the returned type to satisfy the lint rule.

   subscribe: (listener: () => void) => () => void
-  getSearch<TRouteId extends keyof RoutesById<TRouteTree>>(
-    routeId: TRouteId,
-  ): RouteById<TRouteTree, TRouteId>['types']['fullSearchSchema'] | null
-  saveSearch<TRouteId extends keyof RoutesById<TRouteTree>>(
-    routeId: TRouteId,
-    search: RouteById<TRouteTree, TRouteId>['types']['fullSearchSchema'],
-  ): void
-  clearSearch<TRouteId extends keyof RoutesById<TRouteTree>>(
-    routeId: TRouteId,
-  ): void
-  clearAllSearches(): void
+  getSearch: <TRouteId extends keyof RoutesById<TRouteTree>>(
+    routeId: TRouteId,
+  ) => RouteById<TRouteTree, TRouteId>['types']['fullSearchSchema'] | null
+  saveSearch: <TRouteId extends keyof RoutesById<TRouteTree>>(
+    routeId: TRouteId,
+    search: RouteById<TRouteTree, TRouteId>['types']['fullSearchSchema'],
+  ) => void
+  clearSearch: <TRouteId extends keyof RoutesById<TRouteTree>>(
+    routeId: TRouteId,
+  ) => void
+  clearAllSearches: () => void

200-241: Optional: centralize “empty-ish” checks for maintainability.

The empty-value checks are duplicated in both the middleware and store. Consider extracting a tiny internal util (e.g., isEmptyish) and reusing it. Not a blocker.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 3f05c0b and 30f6404.

📒 Files selected for processing (22)
  • docs/router/framework/react/api/router/persistSearchParamsFunction.md (1 hunks)
  • examples/react/search-persistence/README.md (1 hunks)
  • examples/react/search-persistence/index.html (1 hunks)
  • examples/react/search-persistence/package.json (1 hunks)
  • examples/react/search-persistence/postcss.config.mjs (1 hunks)
  • examples/react/search-persistence/src/main.tsx (1 hunks)
  • examples/react/search-persistence/src/routeTree.gen.ts (1 hunks)
  • examples/react/search-persistence/src/routes/__root.tsx (1 hunks)
  • examples/react/search-persistence/src/routes/index.tsx (1 hunks)
  • examples/react/search-persistence/src/routes/products.tsx (1 hunks)
  • examples/react/search-persistence/src/routes/users.tsx (1 hunks)
  • examples/react/search-persistence/src/styles.css (1 hunks)
  • examples/react/search-persistence/src/type-inference-test.ts (1 hunks)
  • examples/react/search-persistence/src/utils/localStorage-sync.ts (1 hunks)
  • examples/react/search-persistence/tailwind.config.mjs (1 hunks)
  • examples/react/search-persistence/tsconfig.json (1 hunks)
  • examples/react/search-persistence/vite.config.js (1 hunks)
  • packages/react-router/src/index.tsx (2 hunks)
  • packages/router-core/src/index.ts (1 hunks)
  • packages/router-core/src/route.ts (1 hunks)
  • packages/router-core/src/router.ts (12 hunks)
  • packages/router-core/src/searchMiddleware.ts (3 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (10)
examples/react/search-persistence/src/routes/index.tsx (3)
examples/react/search-persistence/src/routes/__root.tsx (1)
  • Route (5-7)
examples/react/search-persistence/src/routes/products.tsx (1)
  • Route (14-22)
examples/react/search-persistence/src/routes/users.tsx (1)
  • Route (14-22)
examples/react/search-persistence/src/utils/localStorage-sync.ts (1)
packages/router-core/src/searchMiddleware.ts (3)
  • store (74-76)
  • store (184-186)
  • getSearchPersistenceStore (158-198)
examples/react/search-persistence/src/routes/__root.tsx (5)
examples/react/search-persistence/src/routes/index.tsx (1)
  • Route (3-5)
examples/react/search-persistence/src/routes/products.tsx (1)
  • Route (14-22)
examples/react/search-persistence/src/routes/users.tsx (1)
  • Route (14-22)
packages/react-router/src/index.tsx (4)
  • Route (263-263)
  • createRootRoute (267-267)
  • Link (156-156)
  • Outlet (252-252)
packages/react-router-devtools/src/TanStackRouterDevtools.tsx (1)
  • TanStackRouterDevtools (46-126)
examples/react/search-persistence/src/main.tsx (2)
examples/react/search-persistence/src/utils/localStorage-sync.ts (1)
  • setupLocalStorageSync (5-29)
packages/router-core/src/router.ts (1)
  • Register (99-101)
examples/react/search-persistence/src/routes/users.tsx (2)
examples/react/search-persistence/src/routes/products.tsx (1)
  • Route (14-22)
packages/router-core/src/searchMiddleware.ts (1)
  • persistSearchParams (200-242)
packages/router-core/src/searchMiddleware.ts (3)
packages/router-core/src/route.ts (2)
  • AnyRoute (703-718)
  • SearchMiddleware (80-82)
packages/router-core/src/router.ts (1)
  • RegisteredRouter (103-103)
packages/router-core/src/utils.ts (1)
  • replaceEqualDeep (212-259)
examples/react/search-persistence/src/routes/products.tsx (2)
examples/react/search-persistence/src/routes/users.tsx (1)
  • Route (14-22)
packages/router-core/src/searchMiddleware.ts (1)
  • persistSearchParams (200-242)
packages/router-core/src/router.ts (2)
packages/router-core/src/route.ts (2)
  • RouteContextOptions (936-944)
  • SearchMiddleware (80-82)
packages/router-core/src/utils.ts (1)
  • functionalUpdate (195-204)
examples/react/search-persistence/src/type-inference-test.ts (3)
packages/router-core/src/searchMiddleware.ts (5)
  • store (74-76)
  • store (184-186)
  • getSearchPersistenceStore (158-198)
  • state (66-68)
  • state (181-183)
packages/react-router/src/index.tsx (1)
  • getSearchPersistenceStore (36-36)
packages/router-core/src/index.ts (1)
  • getSearchPersistenceStore (261-261)
examples/react/search-persistence/src/routeTree.gen.ts (2)
packages/react-router/src/index.tsx (2)
  • FileRouteTypes (174-174)
  • FileRoutesByPath (116-116)
packages/router-core/src/index.ts (2)
  • FileRouteTypes (64-64)
  • FileRoutesByPath (65-65)
🪛 ESLint
examples/react/search-persistence/src/routes/index.tsx

[error] 1-1: Member 'Link' of the import declaration should be sorted alphabetically.

(sort-imports)

examples/react/search-persistence/src/routes/users.tsx

[error] 1-1: Member 'persistSearchParams' of the import declaration should be sorted alphabetically.

(sort-imports)

packages/router-core/src/searchMiddleware.ts

[error] 4-4: Member 'AnyRoute' of the import declaration should be sorted alphabetically.

(sort-imports)


[error] 6-6: Member 'RouteById' of the import declaration should be sorted alphabetically.

(sort-imports)


[error] 168-170: Shorthand method signature is forbidden. Use a function property instead.

(@typescript-eslint/method-signature-style)


[error] 171-174: Shorthand method signature is forbidden. Use a function property instead.

(@typescript-eslint/method-signature-style)


[error] 175-177: Shorthand method signature is forbidden. Use a function property instead.

(@typescript-eslint/method-signature-style)


[error] 178-178: Shorthand method signature is forbidden. Use a function property instead.

(@typescript-eslint/method-signature-style)

examples/react/search-persistence/src/routes/products.tsx

[error] 1-1: Member 'persistSearchParams' of the import declaration should be sorted alphabetically.

(sort-imports)

🪛 LanguageTool
examples/react/search-persistence/README.md

[grammar] ~62-~62: There might be a mistake here.
Context: ...ate) ``` ### Manual Restoration Access the store directly for full contr...

(QB_NEW_EN)


[grammar] ~95-~95: There might be a mistake here.
Context: ...ash pnpm install pnpm dev ``` Navigate between Users and Products routes to see automa...

(QB_NEW_EN)

docs/router/framework/react/api/router/persistSearchParamsFunction.md

[grammar] ~24-~24: There might be a mistake here.
Context: ... Middleware doesn't run → No restoration - With search function: `<Link to="/user...

(QB_NEW_EN)


[grammar] ~25-~25: There might be a mistake here.
Context: ... → Middleware runs → Restoration happens - With explicit search: `<Link to="/user...

(QB_NEW_EN)

🔇 Additional comments (24)
examples/react/search-persistence/tsconfig.json (1)

2-8: Solid TS defaults for a Vite + React example

Strict mode, DOM libs, and esModuleInterop look good here.

examples/react/search-persistence/tailwind.config.mjs (1)

1-4: LGTM

Minimal Tailwind config with correct content globs for Vite projects.

examples/react/search-persistence/postcss.config.mjs (1)

1-6: LGTM

PostCSS + Tailwind + Autoprefixer default export is correct for ESM config.

examples/react/search-persistence/vite.config.js (2)

1-14: Plugin order and options look correct

Router plugin first, then React plugin, with code-splitting enabled. Good.


1-14: Router and React Vite plugins are declared

The example’s package.json already includes the required plugins:

  • examples/react/search-persistence/package.json
    "@tanstack/router-plugin" in dependencies
    "@vitejs/plugin-react" in devDependencies

No changes needed.

examples/react/search-persistence/README.md (1)

1-15: Clear, action-oriented README

Nice coverage of features, usage, and caveats. This will help users adopt the middleware quickly.

packages/react-router/src/index.tsx (1)

35-37: Verified: core barrel exports persistence APIs

Confirmed that packages/router-core/src/index.ts includes:

  • persistSearchParams
  • getSearchPersistenceStore

as named re-exports from ./searchMiddleware. The React barrel’s re-exports are valid—no changes needed.

packages/router-core/src/index.ts (1)

257-262: Search persistence exports verified: no duplicates, implementations found
Confirmed that packages/router-core/src/index.ts does not re-export any of these identifiers more than once, and packages/router-core/src/searchMiddleware.ts defines and exports retainSearchParams, stripSearchParams, persistSearchParams, and getSearchPersistenceStore. No further action required.

examples/react/search-persistence/src/main.tsx (2)

15-19: Type registration for the router is correctly declared

The module augmentation pattern for Register looks correct and should fully type getSearchPersistenceStore for this app.


21-29: If you later SSR this example, consider hydrateRoot

The conditional mount is fine for CSR. For SSR, you’ll need to swap to hydrateRoot and remove the innerHTML check to hydrate existing markup.

Would you like me to provide a CSR/SSR toggle snippet showing createRoot vs hydrateRoot?

examples/react/search-persistence/src/utils/localStorage-sync.ts (1)

1-1: Confirm getSearchPersistenceStore is re-exported from @tanstack/react-router

I wasn’t able to locate a re-export of getSearchPersistenceStore in the router package’s entrypoint. Please verify that the main export file (e.g. src/index.ts) of @tanstack/react-router includes:

export { getSearchPersistenceStore } from '@tanstack/router-core'

so that your import will resolve correctly at compile/runtime.

examples/react/search-persistence/package.json (1)

11-20: Add react and react-dom to dependencies to ensure the example is runnable standalone

Monorepo hoisting may mask this locally, but adding explicit deps prevents resolution issues for consumers running the example directly.

   "dependencies": {
     "@tanstack/react-router": "workspace:*",
     "@tanstack/react-router-devtools": "workspace:*",
     "@tanstack/react-store": "^0.7.0",
     "@tanstack/router-plugin": "workspace:*",
+    "react": "^18.3.1",
+    "react-dom": "^18.3.1",
     "postcss": "^8.5.1",
     "autoprefixer": "^10.4.20",
     "tailwindcss": "^3.4.17",
     "zod": "^3.24.2"
   },

If you intentionally rely on workspace root deps, feel free to ignore.

examples/react/search-persistence/src/routes/index.tsx (1)

16-32: Nice demonstration of full restoration via Link.search

Using search={(prev) => prev} clearly shows how persisted state can be restored across routes.

packages/router-core/src/router.ts (2)

1455-1455: Dev-only warning is fine

The added console.warn for invalid from paths is helpful during development and doesn’t affect prod builds.


2707-2788: Route-aware middleware plumbing is well-structured

Collecting middlewares per-route and passing route context enables features like search persistence without breaking legacy filters.

examples/react/search-persistence/src/type-inference-test.ts (1)

5-28: Type inference API looks solid

Nice demonstration of per-route typing across getSearch/save/clear and store usage. This will help consumers understand the ergonomics.

docs/router/framework/react/api/router/persistSearchParamsFunction.md (2)

30-33: Strong recommendation to be explicit about restoration is good

The guidance to always specify restoration intent will save users from surprises later. Looks great.


166-209: Store API examples are clear and useful

Good coverage of get/save/clear patterns and integrating with useStore.

examples/react/search-persistence/src/routes/users.tsx (2)

43-57: useMemo dependencies are sufficient

Filtering logic only depends on name and status; memoization scope is correct.


14-22: Middleware usage matches core implementation

persistSearchParams() without exclusions is appropriate for this route.

examples/react/search-persistence/src/routes/products.tsx (2)

37-56: Sorting and filtering pipeline is clear and correct

Cloning before sorting prevents mutating the source; predicates look good.


14-22: Excluding sortBy from persistence is a good default

Keeps sorting ephemeral while other filters persist. Nicely demonstrates the API.

examples/react/search-persistence/src/routeTree.gen.ts (1)

7-10: Generated file looks correct and consistent with Router expectations.

Route IDs, paths, module augmentation, and the routeTree construction all look good for the example. Since this is auto-generated with lint/TS checks disabled, no further action is needed here.

packages/router-core/src/searchMiddleware.ts (1)

37-56: Casting results in stripSearchParams improves type safety. LGTM.

The explicit casts on empty and final results keep the middleware signature sound without over-constraining the implementation.

Comment on lines +114 to +120
<button
type="button"
onClick={() => navigate({ search: {} } as any)}
className="border p-2 rounded"
>
Reset
</button>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Reset button will immediately restore persisted search

Because of persistence, {} restores. Clear the persisted entry first.

-        <button
-          type="button"
-          onClick={() => navigate({ search: {} } as any)}
+        <button
+          type="button"
+          onClick={() => {
+            getSearchPersistenceStore().clearSearch('/products')
+            navigate({ search: {} })
+          }}
           className="border p-2 rounded"
         >
           Reset
         </button>

Also add the import at the top:

-import { createFileRoute, persistSearchParams, useNavigate } from '@tanstack/react-router'
+import { createFileRoute, getSearchPersistenceStore, persistSearchParams, useNavigate } from '@tanstack/react-router'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
type="button"
onClick={() => navigate({ search: {} } as any)}
className="border p-2 rounded"
>
Reset
</button>
// at the top of examples/react/search-persistence/src/routes/products.tsx
import { createFileRoute, getSearchPersistenceStore, persistSearchParams, useNavigate } from '@tanstack/react-router'
// … later, in your JSX render:
<button
type="button"
onClick={() => {
getSearchPersistenceStore().clearSearch('/products')
navigate({ search: {} })
}}
className="border p-2 rounded"
>
Reset
</button>
🤖 Prompt for AI Agents
In examples/react/search-persistence/src/routes/products.tsx around lines
114–120, the Reset button currently calls navigate({ search: {} }) which will
immediately restore the persisted search; before navigating, clear the persisted
search entry (for example by calling the project's persistence clear helper or
removing the specific localStorage/sessionStorage key used for persisted search)
and then call navigate to an empty search. Also add the appropriate import at
the top of the file for the persistence clear helper (e.g., import {
clearPersistedSearch } from the module where persistence helpers live) and use
that helper in the button onClick before navigate.

Comment on lines +89 to +95
<button
type="button"
onClick={() => navigate({ search: {} } as any)}
className="border p-2 rounded"
>
Reset
</button>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Reset button will immediately restore persisted search

With persistSearchParams, navigating with an empty search {} triggers restoration. Clear the persisted entry first to truly reset.

-import React from 'react'
+import React from 'react'
+import { getSearchPersistenceStore } from '@tanstack/react-router'
@@
-        <button
-          type="button"
-          onClick={() => navigate({ search: {} } as any)}
+        <button
+          type="button"
+          onClick={() => {
+            getSearchPersistenceStore().clearSearch('/users')
+            navigate({ search: {} })
+          }}
           className="border p-2 rounded"
         >
           Reset
         </button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
type="button"
onClick={() => navigate({ search: {} } as any)}
className="border p-2 rounded"
>
Reset
</button>
import React from 'react'
import { getSearchPersistenceStore } from '@tanstack/react-router'
<button
type="button"
onClick={() => {
getSearchPersistenceStore().clearSearch('/users')
navigate({ search: {} })
}}
className="border p-2 rounded"
>
Reset
</button>
🤖 Prompt for AI Agents
In examples/react/search-persistence/src/routes/users.tsx around lines 89-95,
the Reset button currently calls navigate({ search: {} }) which immediately
restores persisted params; fix by clearing the persisted search entry first,
then navigate to an empty search. Implement this by removing the persistence
storage entry (or calling the library's clear/unpersist API) for the search
params and updating any in-memory state, then call navigate to {} (optionally
with replace:true) so the cleared state is actually applied.

Comment on lines +2792 to 2802
middleware: ({ search }: { search: any }) => {
if (!dest.search) {
return {}
}
if (dest.search === true) {
return search
}
return functionalUpdate(dest.search, search)
},
route: { id: '', fullPath: '' },
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Bug: default middleware returns empty search when dest.search is undefined

When callers don't pass dest.search (the common case), the final middleware returns {}, discarding the current search. This breaks navigations that should preserve existing search or allow upstream middlewares (e.g., persistence) to operate on it. The default must return the incoming search unchanged unless an explicit updater or true is provided.

-  const final = {
-    middleware: ({ search }: { search: any }) => {
-      if (!dest.search) {
-        return {}
-      }
-      if (dest.search === true) {
-        return search
-      }
-      return functionalUpdate(dest.search, search)
-    },
+  const final = {
+    middleware: ({ search }: { search: any }) => {
+      // No dest.search provided -> preserve current search
+      if (dest.search === undefined) {
+        return search
+      }
+      // Pass-through
+      if (dest.search === true) {
+        return search
+      }
+      // Apply updater/object
+      return functionalUpdate(dest.search, search)
+    },
     route: { id: '', fullPath: '' },
   }

This aligns behavior with previous semantics and prevents accidental wiping of search params.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
middleware: ({ search }: { search: any }) => {
if (!dest.search) {
return {}
}
if (dest.search === true) {
return search
}
return functionalUpdate(dest.search, search)
},
route: { id: '', fullPath: '' },
}
const final = {
middleware: ({ search }: { search: any }) => {
// No dest.search provided -> preserve current search
if (dest.search === undefined) {
return search
}
// Pass-through
if (dest.search === true) {
return search
}
// Apply updater/object
return functionalUpdate(dest.search, search)
},
route: { id: '', fullPath: '' },
}
🤖 Prompt for AI Agents
In packages/router-core/src/router.ts around lines 2792 to 2802, the default
middleware currently returns {} when dest.search is undefined which wipes the
incoming search; change the logic so that if dest.search is undefined (or null)
the middleware returns the incoming search unchanged, keep the existing behavior
that if dest.search === true it returns search, and otherwise call
functionalUpdate(dest.search, search); update the conditional checks accordingly
so absence of dest.search preserves the original search.

Comment on lines 1 to 8
import { Store } from '@tanstack/store'
import { deepEqual, replaceEqualDeep } from './utils'
import type { NoInfer, PickOptional } from './utils'
import type { SearchMiddleware } from './route'
import type { SearchMiddleware, AnyRoute } from './route'
import type { IsRequiredParams } from './link'
import type { RoutesById, RouteById } from './routeInfo'
import type { RegisteredRouter } from './router'

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Confirm new APIs are re-exported from router-core index.

To ensure consumers can import persistSearchParams and getSearchPersistenceStore, verify re-exports exist in packages/router-core/src/index.ts.

Run this script:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "Check re-exports in packages/router-core/src/index.ts"
rg -n -C2 -g 'packages/router-core/src/index.ts' -P '\bexport\s+\{[^}]*\b(persistSearchParams|getSearchPersistenceStore)\b'
echo
echo "List of exported symbols from index.ts:"
rg -n -C1 -g 'packages/router-core/src/index.ts' -P 'export\s+\{[^}]+\}'

Length of output: 260


Add missing re-exports for search persistence APIs

The functions persistSearchParams and getSearchPersistenceStore are implemented in searchMiddleware.ts but aren’t re-exported from the package entrypoint, so consumers can’t import them.

Please update packages/router-core/src/index.ts to include:

export { persistSearchParams, getSearchPersistenceStore } from './searchMiddleware'
🧰 Tools
🪛 ESLint

[error] 4-4: Member 'AnyRoute' of the import declaration should be sorted alphabetically.

(sort-imports)


[error] 6-6: Member 'RouteById' of the import declaration should be sorted alphabetically.

(sort-imports)

🤖 Prompt for AI Agents
In packages/router-core/src/searchMiddleware.ts lines 1-8: the functions
persistSearchParams and getSearchPersistenceStore implemented here are not
exported from the package entrypoint; update packages/router-core/src/index.ts
to re-export them by adding an export that forwards persistSearchParams and
getSearchPersistenceStore from './searchMiddleware' so consumers can import
these APIs from the package root.

}
}

const searchPersistenceStore = new SearchPersistenceStore()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Global singleton store can leak state across SSR requests.

Creating a module-level singleton introduces cross-request state in Node SSR, potentially leaking one user's search to another. This is a correctness and privacy concern.

Recommend making the store per-router/request or allowing explicit injection to avoid global state on the server. See suggested changes to persistSearchParams below to accept an optional store and no-op on server unless a store is provided.

🤖 Prompt for AI Agents
In packages/router-core/src/searchMiddleware.ts around line 155, a module-level
singleton SearchPersistenceStore is created which can leak state across SSR
requests; remove the module-level instantiation and refactor consumers to accept
an optional SearchPersistenceStore (injected per-router or per-request). Update
persistSearchParams and any callers to take an optional store parameter, treat
the operation as a no-op on server when no store is provided, and on the client
create a short-lived instance if nothing is injected; also update router
initialization to pass a per-router/request store when needed so no global state
is shared across requests.

Comment on lines 200 to 206
export function persistSearchParams<TSearchSchema>(
exclude?: Array<keyof TSearchSchema>,
): SearchMiddleware<TSearchSchema> {
return ({ search, next, route }) => {
// Check if we should restore from store (when search is empty - initial navigation)
const savedSearch = searchPersistenceStore.getSearch(route.id)
let searchToProcess = search
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Harden typing and avoid server-side global state by allowing store injection and no-op behavior on SSR.

  • Constrain TSearchSchema to object (consistent with other middleware).
  • Accept an optional { exclude, store } and avoid using the global store on the server unless one is explicitly provided.
  • This prevents cross-request leakage while keeping DX nice on the client.
-export function persistSearchParams<TSearchSchema>(
-  exclude?: Array<keyof TSearchSchema>,
-): SearchMiddleware<TSearchSchema> {
-  return ({ search, next, route }) => {
+export function persistSearchParams<TSearchSchema extends object>(
+  opts?: { exclude?: Array<keyof TSearchSchema>; store?: SearchPersistenceStore },
+): SearchMiddleware<TSearchSchema> {
+  return ({ search, next, route }) => {
+    const isServer = typeof window === 'undefined'
+    // Use injected store if provided. On the server, default to no-op unless a store is supplied.
+    const store = opts?.store ?? (isServer ? undefined : searchPersistenceStore)
+    if (!store) {
+      return next(search)
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function persistSearchParams<TSearchSchema>(
exclude?: Array<keyof TSearchSchema>,
): SearchMiddleware<TSearchSchema> {
return ({ search, next, route }) => {
// Check if we should restore from store (when search is empty - initial navigation)
const savedSearch = searchPersistenceStore.getSearch(route.id)
let searchToProcess = search
export function persistSearchParams<TSearchSchema extends object>(
opts?: { exclude?: Array<keyof TSearchSchema>; store?: SearchPersistenceStore },
): SearchMiddleware<TSearchSchema> {
return ({ search, next, route }) => {
const isServer = typeof window === 'undefined'
// Use injected store if provided. On the server, default to no-op unless a store is supplied.
const store = opts?.store ?? (isServer ? undefined : searchPersistenceStore)
if (!store) {
return next(search)
}
// Check if we should restore from store (when search is empty - initial navigation)
const savedSearch = searchPersistenceStore.getSearch(route.id)
let searchToProcess = search
// …rest of the logic…

Comment on lines 205 to 222
const savedSearch = searchPersistenceStore.getSearch(route.id)
let searchToProcess = search

// If search is empty and we have saved search, restore it
if (savedSearch && Object.keys(savedSearch).length > 0) {
const currentSearch = search as Record<string, unknown>
const shouldRestore = Object.keys(currentSearch).length === 0 ||
Object.values(currentSearch).every(value => {
if (value === null || value === undefined || value === '') return true
if (Array.isArray(value) && value.length === 0) return true
if (typeof value === 'object' && value !== null && Object.keys(value).length === 0) return true
return false
})

if (shouldRestore) {
searchToProcess = { ...search, ...savedSearch } as TSearchSchema
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use the injected store; keep restore semantics unchanged.

Wire the retrieval and merge to the selected store. Also prefer consistent naming with opts.exclude.

-    const savedSearch = searchPersistenceStore.getSearch(route.id)
+    const savedSearch = store.getSearch(route.id)
@@
-      if (shouldRestore) {
-        searchToProcess = { ...search, ...savedSearch } as TSearchSchema
-      }
+      if (shouldRestore) {
+        searchToProcess = { ...search, ...savedSearch } as TSearchSchema
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const savedSearch = searchPersistenceStore.getSearch(route.id)
let searchToProcess = search
// If search is empty and we have saved search, restore it
if (savedSearch && Object.keys(savedSearch).length > 0) {
const currentSearch = search as Record<string, unknown>
const shouldRestore = Object.keys(currentSearch).length === 0 ||
Object.values(currentSearch).every(value => {
if (value === null || value === undefined || value === '') return true
if (Array.isArray(value) && value.length === 0) return true
if (typeof value === 'object' && value !== null && Object.keys(value).length === 0) return true
return false
})
if (shouldRestore) {
searchToProcess = { ...search, ...savedSearch } as TSearchSchema
}
}
const savedSearch = store.getSearch(route.id)
let searchToProcess = search
// If search is empty and we have saved search, restore it
if (savedSearch && Object.keys(savedSearch).length > 0) {
const currentSearch = search as Record<string, unknown>
const shouldRestore = Object.keys(currentSearch).length === 0 ||
Object.values(currentSearch).every(value => {
if (value === null || value === undefined || value === '') return true
if (Array.isArray(value) && value.length === 0) return true
if (typeof value === 'object' && value !== null && Object.keys(value).length === 0) return true
return false
})
if (shouldRestore) {
searchToProcess = { ...search, ...savedSearch } as TSearchSchema
}
}
🤖 Prompt for AI Agents
In packages/router-core/src/searchMiddleware.ts around lines 205 to 222, replace
the direct use of searchPersistenceStore with the injected/selected store from
options (e.g., opts.store or the variable you set earlier for the chosen
persistence store) when calling getSearch(route.id), keep the existing
"shouldRestore" checks and the restore merge logic identical (searchToProcess =
{ ...search, ...savedSearch }), and ensure any "exclude" reference uses the
consistent opts.exclude naming (rename local variables to opts.exclude if
needed) so retrieval and merge are performed against the injected store while
preserving current restore semantics.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
examples/react/search-persistence/src/routes/users.tsx (1)

124-130: Reset will immediately restore persisted search unless you clear it first

With persistence enabled, {} triggers restoration. Clear the persisted entry before navigating.

Apply this diff:

         <button
           type="button"
-          onClick={() => navigate({ search: {} } as any)}
+          onClick={() => {
+            getSearchPersistenceStore().clearSearch('/users')
+            navigate({ to: '/users', search: {}, replace: true })
+          }}
           className="border p-2 rounded"
         >
           Reset
         </button>
examples/react/search-persistence/src/routes/products.tsx (1)

122-128: Reset will immediately restore persisted search unless you clear it first

Clear the persisted entry before navigating to {}.

Apply this diff:

         <button
           type="button"
-          onClick={() => navigate({ search: {} } as any)}
+          onClick={() => {
+            getSearchPersistenceStore().clearSearch('/products')
+            navigate({ to: '/products', search: {}, replace: true })
+          }}
           className="border p-2 rounded"
         >
           Reset
         </button>
🧹 Nitpick comments (11)
examples/react/search-persistence/src/routes/index.tsx (1)

1-1: Sort named imports to satisfy lint rule

ESLint flags sort-imports. Keep members alphabetical to avoid churn in CI.

Apply this diff:

-import { createFileRoute, Link } from '@tanstack/react-router'
+import { Link, createFileRoute } from '@tanstack/react-router'
examples/react/search-persistence/src/routes/users.tsx (3)

1-5: Add store import and alphabetize members (lint), preparing for proper Reset

You’ll need the store to clear persisted search before a “reset”. Also fixes the sort-imports rule.

Apply this diff:

-import {
-  createFileRoute,
-  useNavigate,
-  persistSearchParams,
-} from '@tanstack/react-router'
+import {
+  createFileRoute,
+  getSearchPersistenceStore,
+  persistSearchParams,
+  useNavigate,
+} from '@tanstack/react-router'

9-14: Coerce numeric search params to play nicely with URL strings

URLs provide values as strings. Using z.coerce.number() prevents unexpected fallback to defaults when querystring contains numeric values.

Apply this diff:

 const usersSearchSchema = z.object({
   name: z.string().optional().catch(''),
   status: z.enum(['active', 'inactive', 'all']).optional().catch('all'),
-  page: z.number().optional().catch(0),
-  limit: z.number().optional().catch(10),
+  page: z.coerce.number().optional().catch(0),
+  limit: z.coerce.number().optional().catch(10),
 })

91-95: Remove as any; provide to and use replace for smoother UX

Providing to allows proper typing and replace: true avoids polluting history while typing.

Apply this diff:

-  const updateSearch = (updates: Partial<UsersSearchSchema>) => {
-    navigate({
-      search: (prev: UsersSearchSchema) => ({ ...prev, ...updates, page: 0 }),
-    } as any)
-  }
+  const updateSearch = (updates: Partial<UsersSearchSchema>) => {
+    navigate({
+      to: '/users',
+      replace: true,
+      search: (prev: UsersSearchSchema) => ({ ...prev, ...updates, page: 0 }),
+    })
+  }
docs/router/framework/react/api/router/persistSearchParamsFunction.md (2)

126-127: Document the Reset pattern to avoid surprise restorations

Add an explicit example to clear persisted search before navigating to {}. This mirrors the example app’s behavior and prevents confusion.

Apply this diff to insert a new subsection after the “Automatic Restoration with Links” section:

@@
 }

+### Resetting persisted search to an empty state
+
+When using persistence, navigating with an empty search {} will restore saved params. To truly reset, clear the persisted entry first, then navigate.
+
+```tsx
+import { getSearchPersistenceStore, useNavigate } from '@tanstack/react-router'
+
+function ResetButton() {

  • const navigate = useNavigate()
  • return (
  • <button
  •  onClick={() => {
    
  •    getSearchPersistenceStore().clearSearch('/users')
    
  •    navigate({ to: '/users', search: {}, replace: true })
    
  •  }}
    
  •  Reset
    
  • )
    +}
    +```

@@

Exclusion Strategies


---

`22-27`: **Tighten phrasing for bullet list consistency (optional)**

Small clarity tweaks to keep list items parallel and avoid ambiguity about when the middleware runs.

Apply this diff:

```diff
-**Important**: The middleware only runs when search parameters are being processed. This means:
+**Important**: The middleware only runs when search parameters are being processed. For example:
@@
-- **Without search prop**: `<Link to="/users">` → Middleware doesn't run → No restoration
-- **With search function**: `<Link to="/users" search={(prev) => prev}>` → Middleware runs → Restoration happens
-- **With explicit search**: `<Link to="/users" search={{ name: 'John' }}>` → Middleware runs → No restoration (params provided)
+- Without a `search` prop: `<Link to="/users">` → Middleware doesn't run → No restoration
+- With a `search` function: `<Link to="/users" search={(prev) => prev}>` → Middleware runs → Restoration happens
+- With an explicit `search` object: `<Link to="/users" search={{ name: 'John' }}>` → Middleware runs → No restoration (params provided)
examples/react/search-persistence/src/routes/products.tsx (3)

1-5: Add store import and alphabetize members (lint)

Brings in the store for a correct Reset and satisfies sort-imports.

Apply this diff:

-import {
-  createFileRoute,
-  useNavigate,
-  persistSearchParams,
-} from '@tanstack/react-router'
+import {
+  createFileRoute,
+  getSearchPersistenceStore,
+  persistSearchParams,
+  useNavigate,
+} from '@tanstack/react-router'

9-14: Coerce numeric search params to handle URL strings

Use z.coerce.number() so URLs like ?minPrice=100 work as intended.

Apply this diff:

 const productsSearchSchema = z.object({
   category: z.string().optional().catch(''),
-  minPrice: z.number().optional().catch(0),
-  maxPrice: z.number().optional().catch(1000),
+  minPrice: z.coerce.number().optional().catch(0),
+  maxPrice: z.coerce.number().optional().catch(1000),
   sortBy: z.enum(['name', 'price', 'rating']).optional().catch('name'),
 })

66-70: Remove as any; provide to and replace for better typing and UX

Typing improves and history stays clean while sliding filters.

Apply this diff:

   const updateSearch = (updates: Partial<ProductsSearchSchema>) => {
     navigate({
-      search: (prev: ProductsSearchSchema) => ({ ...prev, ...updates }),
-    } as any)
+      to: '/products',
+      replace: true,
+      search: (prev: ProductsSearchSchema) => ({ ...prev, ...updates }),
+    })
   }
examples/react/search-persistence/README.md (2)

63-76: Add a Reset pattern to avoid surprise restorations

Mirror the docs by showing how to clear persisted search before navigating to {}.

Apply this diff to append after the Manual Restoration snippet:

 </Link>

+### Resetting persisted search
+
+When using persistence, navigating with an empty search {} restores the saved state. To truly reset, clear the persisted entry first:
+
+```tsx
+import { getSearchPersistenceStore, useNavigate } from '@tanstack/react-router'
+
+function ResetUsers() {

  • const navigate = useNavigate()
  • return (
  • <button
  •  onClick={() => {
    
  •    getSearchPersistenceStore().clearSearch('/users')
    
  •    navigate({ to: '/users', search: {}, replace: true })
    
  •  }}
    
  •  Reset Users Search
    
  • )
    +}
    +```

---

`40-41`: **Wording nit: tighten “Important” note**

Slight rephrase for clarity and parallelism.

Apply this diff:

```diff
-⚠️ **Important**: The middleware only runs when search parameters are being processed. Always be explicit about your restoration intent.
+⚠️ **Important**: The middleware runs only when search parameters are being processed. Be explicit about your restoration intent.
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 30f6404 and 41d598f.

📒 Files selected for processing (12)
  • docs/router/framework/react/api/router/persistSearchParamsFunction.md (1 hunks)
  • examples/react/search-persistence/README.md (1 hunks)
  • examples/react/search-persistence/src/routes/__root.tsx (1 hunks)
  • examples/react/search-persistence/src/routes/index.tsx (1 hunks)
  • examples/react/search-persistence/src/routes/products.tsx (1 hunks)
  • examples/react/search-persistence/src/routes/users.tsx (1 hunks)
  • examples/react/search-persistence/src/styles.css (1 hunks)
  • examples/react/search-persistence/src/type-inference-test.ts (1 hunks)
  • examples/react/search-persistence/src/utils/localStorage-sync.ts (1 hunks)
  • packages/react-router/src/index.tsx (1 hunks)
  • packages/router-core/src/router.ts (3 hunks)
  • packages/router-core/src/searchMiddleware.ts (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (7)
  • packages/react-router/src/index.tsx
  • examples/react/search-persistence/src/routes/__root.tsx
  • examples/react/search-persistence/src/utils/localStorage-sync.ts
  • examples/react/search-persistence/src/styles.css
  • packages/router-core/src/router.ts
  • packages/router-core/src/searchMiddleware.ts
  • examples/react/search-persistence/src/type-inference-test.ts
🧰 Additional context used
🧬 Code Graph Analysis (3)
examples/react/search-persistence/src/routes/index.tsx (3)
examples/react/search-persistence/src/routes/__root.tsx (1)
  • Route (4-6)
examples/react/search-persistence/src/routes/products.tsx (1)
  • Route (18-26)
examples/react/search-persistence/src/routes/users.tsx (1)
  • Route (18-24)
examples/react/search-persistence/src/routes/products.tsx (2)
examples/react/search-persistence/src/routes/users.tsx (1)
  • Route (18-24)
packages/router-core/src/searchMiddleware.ts (1)
  • persistSearchParams (232-281)
examples/react/search-persistence/src/routes/users.tsx (2)
examples/react/search-persistence/src/routes/products.tsx (1)
  • Route (18-26)
packages/router-core/src/searchMiddleware.ts (1)
  • persistSearchParams (232-281)
🪛 LanguageTool
docs/router/framework/react/api/router/persistSearchParamsFunction.md

[grammar] ~24-~24: There might be a mistake here.
Context: ... Middleware doesn't run → No restoration - With search function: `<Link to="/user...

(QB_NEW_EN)


[grammar] ~25-~25: There might be a mistake here.
Context: ... → Middleware runs → Restoration happens - With explicit search: `<Link to="/user...

(QB_NEW_EN)

examples/react/search-persistence/README.md

[grammar] ~97-~97: There might be a mistake here.
Context: ...ash pnpm install pnpm dev ``` Navigate between Users and Products routes to see automa...

(QB_NEW_EN)


[grammar] ~97-~97: There might be a mistake here.
Context: ... search parameter persistence in action.

(QB_NEW_EN)

🪛 ESLint
examples/react/search-persistence/src/routes/index.tsx

[error] 1-1: Member 'Link' of the import declaration should be sorted alphabetically.

(sort-imports)

examples/react/search-persistence/src/routes/products.tsx

[error] 4-4: Member 'persistSearchParams' of the import declaration should be sorted alphabetically.

(sort-imports)

examples/react/search-persistence/src/routes/users.tsx

[error] 4-4: Member 'persistSearchParams' of the import declaration should be sorted alphabetically.

(sort-imports)

🔇 Additional comments (4)
examples/react/search-persistence/src/routes/index.tsx (1)

7-73: Nice, concise demo of restoration patterns

Clear examples showing full restoration, partial override, and clean navigation. This aligns well with the middleware behavior and the docs.

examples/react/search-persistence/src/routes/users.tsx (1)

18-24: Middleware wiring looks correct

Route-level validateSearch + persistSearchParams() is correctly configured and will persist validated output.

examples/react/search-persistence/src/routes/products.tsx (1)

21-24: Good use of per-route exclusion

Excluding sortBy at the middleware level is a clear, type-safe demo of selective persistence.

examples/react/search-persistence/README.md (1)

97-98: LGTM

Content is clear and actionable; run linters/formatters if your pipeline flags grammar/punctuation.

Comment on lines +20 to +21
2. **Restoring**: Restores saved parameters when the middleware is triggered with empty search

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Grammar nit: add article for clarity

“with empty search” → “with an empty search”.

Apply this diff:

-2. **Restoring**: Restores saved parameters when the middleware is triggered with empty search
+2. **Restoring**: Restores saved parameters when the middleware is triggered with an empty search
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
2. **Restoring**: Restores saved parameters when the middleware is triggered with empty search
2. **Restoring**: Restores saved parameters when the middleware is triggered with an empty search
🤖 Prompt for AI Agents
In docs/router/framework/react/api/router/persistSearchParamsFunction.md around
lines 20 to 21, change the phrase "with empty search" to "with an empty search"
to add the missing article for grammatical clarity; update the sentence
accordingly so it reads "Restores saved parameters when the middleware is
triggered with an empty search."

@schiller-manuel
Copy link
Contributor

hi there! we are right now in a feature freeze until the next alpha version is merged to main. we will revisit this PR after that then.

@NitsanCohen770
Copy link
Author

Noticed an issue: persisted search params are being saved to routes that weren’t intended.
Cause: the middleware currently runs on both the from and to routes.
Working on a fix ASAP.

@schiller-manuel
Copy link
Contributor

i am not sure what you are trying to do here is even possible with search middlewares as they run for links pointing to and from a route, not only when a route is visited

@NitsanCohen770
Copy link
Author

i am not sure what you are trying to do here is even possible with search middlewares as they run for links pointing to and from a route, not only when a route is visited

Yes, this is exactly what I noticed.

I’ve found a solution by making the search params you want to persist an explicit parameter:

export function persistSearchParams<TSearchSchema>(
  persistedSearchParams: Array<keyof TSearchSchema>,
  exclude?: Array<keyof TSearchSchema>,
): SearchMiddleware<TSearchSchema> {

Then you would use it like this and safely save only the intended params.

It’s not the best DX, but since we have both the persistedSearchParams param and the typed exclude list, it’s a reasonable balance.

Screenshot 2025-08-21 at 1 08 17

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

♻️ Duplicate comments (3)
packages/router-core/src/searchMiddleware.ts (1)

177-177: Avoid module-scoped singleton: SSR cross-request state leak.

Creating searchPersistenceStore at module scope leaks state across Node SSR requests. This is a correctness and privacy issue.

Apply this minimal fix to make the default store client-only and opt-in on the server:

-const searchPersistenceStore = new SearchPersistenceStore()
+const isServer = typeof window === 'undefined'
+// Client: a default shared store is OK within a single browser session.
+// Server: avoid a module-scoped singleton to prevent cross-request leaks.
+let defaultSearchPersistenceStore: SearchPersistenceStore | undefined =
+  isServer ? undefined : new SearchPersistenceStore()
examples/react/search-persistence/src/routes/products.tsx (1)

122-128: Reset immediately restores persisted search; clear it first (also fix import order)

Because persistence restores on empty searches, navigate({ search: {} }) will reinstate saved params. Clear the persisted entry for /products before navigating. Also reorder/supplement imports to satisfy lint and add the store accessor.

Apply:

 import {
-  createFileRoute,
-  useNavigate,
-  persistSearchParams,
+  createFileRoute,
+  getSearchPersistenceStore,
+  persistSearchParams,
+  useNavigate,
 } from '@tanstack/react-router'
         <button
           type="button"
-          onClick={() => navigate({ search: {} } as any)}
+          onClick={() => {
+            getSearchPersistenceStore().clearSearch('/products')
+            navigate({ search: {} })
+          }}
           className="border p-2 rounded"
         >
           Reset
         </button>

Also applies to: 1-6

examples/react/search-persistence/src/routes/users.tsx (1)

124-130: Reset immediately restores persisted search; clear it first (also fix import order)

As with products, clear the persisted entry for /users before navigating to an empty search. Also adjust imports to include the store accessor and satisfy member ordering.

Apply:

 import {
-  createFileRoute,
-  useNavigate,
-  persistSearchParams,
+  createFileRoute,
+  getSearchPersistenceStore,
+  persistSearchParams,
+  useNavigate,
 } from '@tanstack/react-router'
         <button
           type="button"
-          onClick={() => navigate({ search: {} } as any)}
+          onClick={() => {
+            getSearchPersistenceStore().clearSearch('/users')
+            navigate({ search: {} })
+          }}
           className="border p-2 rounded"
         >
           Reset
         </button>

Also applies to: 1-6

🧹 Nitpick comments (8)
examples/react/search-persistence/src/main.tsx (2)

20-20: Place the client-only sync after the router is created (exact insertion point)

If you adopt the dynamic import for setupLocalStorageSync, place it immediately after the module augmentation (or at least after the router is created) to avoid any subtle ordering issues with store initialization.

Insert below Line 20:

// Client-only optional localStorage sync for search persistence
if (typeof window !== 'undefined') {
  import('./utils/localStorage-sync')
    .then((m) => m.setupLocalStorageSync())
    .catch(() => {})
}

21-29: Optional: hydrate when SSR markup exists instead of skipping mount; also guard for non-DOM environments

If this example is ever used with SSR, prefer hydrating when the container already has markup rather than skipping render. Also, a small typeof document !== 'undefined' guard prevents accidental server import pitfalls.

Apply this diff to the mount block:

-const rootElement = document.getElementById('app')
-if (rootElement && !rootElement.innerHTML) {
-  const root = ReactDOM.createRoot(rootElement)
-  root.render(
-    <StrictMode>
-      <RouterProvider router={router} />
-    </StrictMode>,
-  )
-}
+const rootElement =
+  typeof document !== 'undefined' ? document.getElementById('app') : null
+
+if (!rootElement) {
+  // SSR/Node: no-op. Client will mount/hydrate.
+} else if (rootElement.hasChildNodes()) {
+  hydrateRoot(
+    rootElement,
+    <StrictMode>
+      <RouterProvider router={router} />
+    </StrictMode>,
+  )
+} else {
+  createRoot(rootElement).render(
+    <StrictMode>
+      <RouterProvider router={router} />
+    </StrictMode>,
+  )
+}

And update the import to use the named APIs:

-import ReactDOM from 'react-dom/client'
+import { createRoot, hydrateRoot } from 'react-dom/client'
packages/router-core/src/searchMiddleware.ts (2)

118-144: Deduplicate “persistable value” filtering with a private helper for consistency.

You repeat the same truthiness/emptiness checks inline. Centralizing this improves readability and keeps semantics consistent if you tweak the rules later (e.g., treating whitespace-only strings as empty).

Apply this focused refactor:

   saveSearch<
@@
   ): void {
-    const searchRecord = search as Record<string, unknown>
-    const cleanedSearch = Object.fromEntries(
-      Object.entries(searchRecord).filter(([_, value]) => {
-        if (value === null || value === undefined || value === '') return false
-        if (Array.isArray(value) && value.length === 0) return false
-        if (
-          typeof value === 'object' &&
-          value !== null &&
-          Object.keys(value).length === 0
-        )
-          return false
-        return true
-      }),
-    )
+    const searchRecord = search as Record<string, unknown>
+    const cleanedSearch = Object.fromEntries(
+      Object.entries(searchRecord).filter(([_, value]) =>
+        this.#isPersistableValue(value),
+      ),
+    )
@@
     })
   }
+
+  // Treat nullish, empty string, empty array, and empty plain object as "not persistable"
+  #isPersistableValue(value: unknown) {
+    if (value === null || value === undefined || value === '') return false
+    if (Array.isArray(value) && value.length === 0) return false
+    if (typeof value === 'object' && value !== null) {
+      // Save non-empty objects only
+      return Object.keys(value as Record<string, unknown>).length > 0
+    }
+    return true
+  }

232-235: Constrain the generic for consistency with other middlewares.

Elsewhere you use TSearchSchema extends object. Do the same here for consistent inference and to avoid misuse with primitives.

-export function persistSearchParams<TSearchSchema>(
+export function persistSearchParams<TSearchSchema extends object>(
examples/react/search-persistence/src/routes/__root.tsx (2)

42-42: Gate devtools to development builds

Avoid mounting devtools in production to reduce bundle/CPU overhead and potential SSR mismatches.

Apply:

-      <TanStackRouterDevtools />
+      {process.env.NODE_ENV === 'development' && <TanStackRouterDevtools />}

21-27: Consider removing explicit search propagation from top-nav links

Using search={(prev) => prev} in global nav can shuttle unrelated query keys between routes before validation/middleware runs. The middleware already restores route-allowed keys when the target search is empty. Dropping the prop simplifies behavior and avoids transient URL noise. Not critical, just cleaner for the example.

Example:

-        <Link
-          to="/users"
-          search={(prev) => prev}
-          activeProps={{
-            className: 'font-bold',
-          }}
-        >
+        <Link to="/users" activeProps={{ className: 'font-bold' }}>
           Users
         </Link>

Also applies to: 30-36

examples/react/search-persistence/src/routes/products.tsx (1)

66-70: Avoid as any on navigate by typing the updater

You can satisfy types without as any by typing the updater function’s param/return.

Example:

-  const updateSearch = (updates: Partial<ProductsSearchSchema>) => {
-    navigate({
-      search: (prev: ProductsSearchSchema) => ({ ...prev, ...updates }),
-    } as any)
-  }
+  const updateSearch = (updates: Partial<ProductsSearchSchema>) => {
+    navigate({
+      search: (prev: ProductsSearchSchema): ProductsSearchSchema => ({
+        ...prev,
+        ...updates,
+      }),
+    })
+  }

If your router supports it, alternatively do const navigate = useNavigate({ from: Route.fullPath }) for stronger typing.

examples/react/search-persistence/src/routes/users.tsx (1)

91-95: Drop as any on navigate

Type the updater function instead of casting to any to preserve end-to-end search typing.

-  const updateSearch = (updates: Partial<UsersSearchSchema>) => {
-    navigate({
-      search: (prev: UsersSearchSchema) => ({ ...prev, ...updates, page: 0 }),
-    } as any)
-  }
+  const updateSearch = (updates: Partial<UsersSearchSchema>) => {
+    navigate({
+      search: (prev: UsersSearchSchema): UsersSearchSchema => ({
+        ...prev,
+        ...updates,
+        page: 0,
+      }),
+    })
+  }
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 41d598f and fe839f2.

📒 Files selected for processing (6)
  • examples/react/search-persistence/src/main.tsx (1 hunks)
  • examples/react/search-persistence/src/routes/__root.tsx (1 hunks)
  • examples/react/search-persistence/src/routes/index.tsx (1 hunks)
  • examples/react/search-persistence/src/routes/products.tsx (1 hunks)
  • examples/react/search-persistence/src/routes/users.tsx (1 hunks)
  • packages/router-core/src/searchMiddleware.ts (3 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (6)
examples/react/search-persistence/src/routes/index.tsx (4)
examples/react/search-persistence/src/routes/__root.tsx (1)
  • Route (4-6)
examples/react/search-persistence/src/routes/users.tsx (1)
  • Route (18-24)
examples/react/search-persistence/src/routes/products.tsx (1)
  • Route (18-26)
packages/react-router/src/index.tsx (3)
  • Route (260-260)
  • createFileRoute (142-142)
  • Link (153-153)
packages/router-core/src/searchMiddleware.ts (4)
packages/router-core/src/index.ts (8)
  • AnyRoute (163-163)
  • RegisteredRouter (215-215)
  • RoutesById (56-56)
  • RouteById (53-53)
  • replaceEqualDeep (277-277)
  • getSearchPersistenceStore (261-261)
  • persistSearchParams (260-260)
  • SearchMiddleware (126-126)
packages/router-core/src/route.ts (2)
  • AnyRoute (703-718)
  • SearchMiddleware (80-82)
packages/router-core/src/router.ts (1)
  • RegisteredRouter (103-103)
packages/router-core/src/utils.ts (1)
  • replaceEqualDeep (212-259)
examples/react/search-persistence/src/routes/users.tsx (2)
examples/react/search-persistence/src/routes/products.tsx (1)
  • Route (18-26)
packages/router-core/src/searchMiddleware.ts (1)
  • persistSearchParams (232-285)
examples/react/search-persistence/src/routes/__root.tsx (4)
examples/react/search-persistence/src/routes/index.tsx (1)
  • Route (3-5)
examples/react/search-persistence/src/routes/users.tsx (1)
  • Route (18-24)
examples/react/search-persistence/src/routes/products.tsx (1)
  • Route (18-26)
packages/react-router-devtools/src/TanStackRouterDevtools.tsx (1)
  • TanStackRouterDevtools (46-126)
examples/react/search-persistence/src/main.tsx (1)
packages/router-core/src/router.ts (1)
  • Register (99-101)
examples/react/search-persistence/src/routes/products.tsx (2)
examples/react/search-persistence/src/routes/users.tsx (1)
  • Route (18-24)
packages/router-core/src/searchMiddleware.ts (1)
  • persistSearchParams (232-285)
🪛 ESLint
examples/react/search-persistence/src/routes/index.tsx

[error] 1-1: Member 'Link' of the import declaration should be sorted alphabetically.

(sort-imports)

examples/react/search-persistence/src/routes/users.tsx

[error] 4-4: Member 'persistSearchParams' of the import declaration should be sorted alphabetically.

(sort-imports)

examples/react/search-persistence/src/main.tsx

[error] 5-5: 'setupLocalStorageSync' is defined but never used.

(unused-imports/no-unused-imports)

examples/react/search-persistence/src/routes/products.tsx

[error] 4-4: Member 'persistSearchParams' of the import declaration should be sorted alphabetically.

(sort-imports)

🔇 Additional comments (8)
examples/react/search-persistence/src/main.tsx (1)

13-19: Default TypeScript include covers src/; no changes needed

The project’s tsconfig.json doesn’t explicitly list an include, but by TypeScript’s defaults all .ts/.tsx files under the project (including src/) are included. Your module-augmentation file will therefore be picked up without adding an explicit "include": ["src"].

packages/router-core/src/searchMiddleware.ts (3)

39-57: Typing cast for full strip is fine; behavior remains explicit short-circuit.

Returning an empty object with a precise as TSearchSchema cast when input === true is clear and consistent. No further action needed.


146-159: Typed getter looks good.

getSearch returns the per-route typed schema or null, which is ergonomic and avoids sentinel empty objects. Nice.


172-174: Clear-all operation is correct and efficient.

Resetting state via an empty object is fine and works well with structural sharing.

examples/react/search-persistence/src/routes/index.tsx (2)

67-81: Partial override links look good

Merging previous search with an explicit category correctly prevents auto-restore and demonstrates the override pattern well.


1-1: Add a lint script and apply the import‐order fix

It looks like there isn’t a lint script defined in your root or example package.json, so running pnpm -w lint --fix (or the npm/yarn equivalents) doesn’t do anything. To resolve the ESLint import-order error:

• In your root package.json, add:

"scripts": {
  // …
  "lint": "eslint . --ext .ts,.tsx"
}

• Then run:

pnpm lint --fix

—or invoke ESLint directly without a script—

pnpm exec eslint examples/react/search-persistence/src/routes/index.tsx --ext .ts,.tsx --fix

This will reorder the named imports (createFileRoute, Link) to match your configured import/order rule.

examples/react/search-persistence/src/routes/products.tsx (1)

18-26: Middleware config matches intent

Persisting category/minPrice/maxPrice while excluding sortBy aligns with the UI copy and demonstrates selective persistence well.

examples/react/search-persistence/src/routes/users.tsx (1)

18-24: Persisting pagination and filters is sensible

Persisting name, status, and page while leaving limit transient is a reasonable default for UX.

Comment on lines +5 to +11
import { setupLocalStorageSync } from './utils/localStorage-sync'
import './styles.css'

// Setup localStorage sync for search persistence (optional)
// if (typeof window !== 'undefined') {
// setupLocalStorageSync()
// }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Resolve ESLint error: remove unused import and prefer guarded dynamic init for the optional localStorage sync

The static import is unused (the call is commented out), which trips unused-imports/no-unused-imports. Remove the import and the commented call. If you want to keep the example, re-introduce it via a client-only dynamic import placed after the router is created.

Apply this diff to clean up the unused pieces:

-import { setupLocalStorageSync } from './utils/localStorage-sync'
@@
-// Setup localStorage sync for search persistence (optional)
-// if (typeof window !== 'undefined') {
-//   setupLocalStorageSync()
-// }

Then, add the guarded dynamic init right after the router is created (see next comment for exact placement):

// Client-only optional localStorage sync for search persistence
if (typeof window !== 'undefined') {
  import('./utils/localStorage-sync')
    .then((m) => m.setupLocalStorageSync())
    .catch(() => {
      // noop: example-only optional feature
    })
}
🧰 Tools
🪛 ESLint

[error] 5-5: 'setupLocalStorageSync' is defined but never used.

(unused-imports/no-unused-imports)

🤖 Prompt for AI Agents
In examples/react/search-persistence/src/main.tsx around lines 5 to 11, remove
the unused static import of setupLocalStorageSync and the commented call to
avoid the unused-imports ESLint error, and instead add a client-only guarded
dynamic import immediately after the router is created: perform a runtime
import('./utils/localStorage-sync') inside an if (typeof window !== 'undefined')
check, then call the module's setupLocalStorageSync in the then handler and
swallow errors in a catch (noop) since this is an optional example feature.

Comment on lines +84 to +107
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
<span className="w-6 h-6 bg-gray-500 rounded-full text-white text-sm flex items-center justify-center mr-3">
×
</span>
Clean Navigation
</h3>
<p className="text-gray-600 mb-4">
Navigate without any parameter restoration
</p>
<div className="flex flex-wrap gap-3">
<Link
to="/users"
className="bg-slate-600 hover:bg-slate-700 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md"
>
Users (clean slate)
</Link>
<Link
to="/products"
className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md"
>
Products (clean slate)
</Link>
</div>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

“Clean Navigation” currently restores persisted params; clear the store before navigating

With persistence enabled, navigating with an empty search triggers restoration. To truly navigate clean, clear the persisted entry first, then navigate.

Apply:

-import { createFileRoute, Link } from '@tanstack/react-router'
+import {
+  createFileRoute,
+  getSearchPersistenceStore,
+  Link,
+  useNavigate,
+} from '@tanstack/react-router'
 function HomeComponent() {
-  return (
+  const navigate = useNavigate()
+  return (
-              <Link
-                to="/users"
-                className="bg-slate-600 hover:bg-slate-700 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md"
-              >
-                Users (clean slate)
-              </Link>
-              <Link
-                to="/products"
-                className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md"
-              >
-                Products (clean slate)
-              </Link>
+              <button
+                type="button"
+                onClick={() => {
+                  getSearchPersistenceStore().clearSearch('/users')
+                  navigate({ to: '/users', search: {} })
+                }}
+                className="bg-slate-600 hover:bg-slate-700 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md"
+              >
+                Users (clean slate)
+              </button>
+              <button
+                type="button"
+                onClick={() => {
+                  getSearchPersistenceStore().clearSearch('/products')
+                  navigate({ to: '/products', search: {} })
+                }}
+                className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-3 rounded-lg font-medium transition-colors shadow-md"
+              >
+                Products (clean slate)
+              </button>

Also applies to: 1-1, 7-8

🤖 Prompt for AI Agents
In examples/react/search-persistence/src/routes/index.tsx around lines 84 to
107, the "Clean Navigation" links still restore persisted params because the
persistence store isn't cleared before navigation; update the Link handlers to
clear the persisted state entry (e.g., call the store clear/remove API for the
relevant key) immediately before performing navigation so that navigating to
/users or /products uses a clean slate; apply the same change to the other
instances noted (lines 1-1 and 7-8) so all "clean" navigation paths clear the
persisted params first.

Comment on lines +180 to +230
export function getSearchPersistenceStore<
TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
>(): {
state: {
[K in keyof RoutesById<TRouteTree>]: RouteById<
TRouteTree,
K
>['types']['fullSearchSchema']
}
store: Store<{
[K in keyof RoutesById<TRouteTree>]: RouteById<
TRouteTree,
K
>['types']['fullSearchSchema']
}>
subscribe: (listener: () => void) => () => void
getSearch: <TRouteId extends keyof RoutesById<TRouteTree>>(
routeId: TRouteId,
) => RouteById<TRouteTree, TRouteId>['types']['fullSearchSchema'] | null
saveSearch: <TRouteId extends keyof RoutesById<TRouteTree>>(
routeId: TRouteId,
search: RouteById<TRouteTree, TRouteId>['types']['fullSearchSchema'],
) => void
clearSearch: <TRouteId extends keyof RoutesById<TRouteTree>>(
routeId: TRouteId,
) => void
clearAllSearches: () => void
} {
return {
get state() {
return searchPersistenceStore.getTypedState<TRouteTree>()
},
get store() {
return searchPersistenceStore.getTypedStore<TRouteTree>()
},
subscribe: (listener: () => void) =>
searchPersistenceStore.subscribe(listener),
getSearch: <TRouteId extends keyof RoutesById<TRouteTree>>(
routeId: TRouteId,
) => searchPersistenceStore.getSearch<TRouteTree, TRouteId>(routeId),
saveSearch: <TRouteId extends keyof RoutesById<TRouteTree>>(
routeId: TRouteId,
search: RouteById<TRouteTree, TRouteId>['types']['fullSearchSchema'],
) =>
searchPersistenceStore.saveSearch<TRouteTree, TRouteId>(routeId, search),
clearSearch: <TRouteId extends keyof RoutesById<TRouteTree>>(
routeId: TRouteId,
) => searchPersistenceStore.clearSearch<TRouteTree, TRouteId>(routeId),
clearAllSearches: () => searchPersistenceStore.clearAllSearches(),
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Make getSearchPersistenceStore injectable and safe for SSR; avoid relying on a global on the server.

To fully resolve SSR leakage and enable per-request stores, accept an optional store and fall back to the client default only in the browser. For server calls without injection, return an ephemeral store instance so usage remains non-throwing.

-export function getSearchPersistenceStore<
-  TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
->(): {
+export function getSearchPersistenceStore<
+  TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
+>(store?: SearchPersistenceStore): {
@@
-} {
-  return {
-    get state() {
-      return searchPersistenceStore.getTypedState<TRouteTree>()
-    },
-    get store() {
-      return searchPersistenceStore.getTypedStore<TRouteTree>()
-    },
-    subscribe: (listener: () => void) =>
-      searchPersistenceStore.subscribe(listener),
-    getSearch: <TRouteId extends keyof RoutesById<TRouteTree>>(
-      routeId: TRouteId,
-    ) => searchPersistenceStore.getSearch<TRouteTree, TRouteId>(routeId),
-    saveSearch: <TRouteId extends keyof RoutesById<TRouteTree>>(
-      routeId: TRouteId,
-      search: RouteById<TRouteTree, TRouteId>['types']['fullSearchSchema'],
-    ) =>
-      searchPersistenceStore.saveSearch<TRouteTree, TRouteId>(routeId, search),
-    clearSearch: <TRouteId extends keyof RoutesById<TRouteTree>>(
-      routeId: TRouteId,
-    ) => searchPersistenceStore.clearSearch<TRouteTree, TRouteId>(routeId),
-    clearAllSearches: () => searchPersistenceStore.clearAllSearches(),
-  }
-}
+} {
+  const selected =
+    store ?? defaultSearchPersistenceStore ?? new SearchPersistenceStore()
+
+  return {
+    get state() {
+      return selected.getTypedState<TRouteTree>()
+    },
+    get store() {
+      return selected.getTypedStore<TRouteTree>()
+    },
+    subscribe: (listener: () => void) => selected.subscribe(listener),
+    getSearch: <TRouteId extends keyof RoutesById<TRouteTree>>(routeId: TRouteId) =>
+      selected.getSearch<TRouteTree, TRouteId>(routeId),
+    saveSearch: <TRouteId extends keyof RoutesById<TRouteTree>>(
+      routeId: TRouteId,
+      search: RouteById<TRouteTree, TRouteId>['types']['fullSearchSchema'],
+    ) => selected.saveSearch<TRouteTree, TRouteId>(routeId, search),
+    clearSearch: <TRouteId extends keyof RoutesById<TRouteTree>>(routeId: TRouteId) =>
+      selected.clearSearch<TRouteTree, TRouteId>(routeId),
+    clearAllSearches: () => selected.clearAllSearches(),
+  }
+}

Notes:

  • On the server, when no store is injected, the function now yields an ephemeral instance scoped to the call (no cross-request sharing).
  • Consumers that want durable SSR persistence (e.g., DB-backed) can construct a SearchPersistenceStore per request and pass it in.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
packages/router-core/src/searchMiddleware.ts lines 180-230: make
getSearchPersistenceStore accept an optional SearchPersistenceStore parameter
and avoid using a global on the server; if a store is provided, use it,
otherwise if running in the browser fall back to the existing client default
store, and if running on the server create and return a new ephemeral per-call
store instance (scoped to the request) so SSR doesn't leak state across
requests; preserve all existing generic typings and exported API (state, store,
subscribe, getSearch, saveSearch, clearSearch, clearAllSearches) and ensure the
ephemeral store implements the same interface and is non-throwing when used.

Comment on lines +232 to 285
export function persistSearchParams<TSearchSchema>(
persistedSearchParams: Array<keyof TSearchSchema>,
exclude?: Array<keyof TSearchSchema>,
): SearchMiddleware<TSearchSchema> {
return ({ search, next, route }) => {
// Filter input to only explicitly allowed keys for this route
const searchRecord = search as Record<string, unknown>
const allowedKeysStr = persistedSearchParams.map((key) => String(key))
const filteredSearch = Object.fromEntries(
Object.entries(searchRecord).filter(([key]) =>
allowedKeysStr.includes(key),
),
) as TSearchSchema

// Restore from store if current search is empty
const savedSearch = searchPersistenceStore.getSearch(route.id)
let searchToProcess = filteredSearch

if (savedSearch && Object.keys(savedSearch).length > 0) {
const currentSearch = filteredSearch as Record<string, unknown>
const isEmpty = Object.keys(currentSearch).length === 0

if (isEmpty) {
searchToProcess = savedSearch as TSearchSchema
}
}

const result = next(searchToProcess)

// Save only the allowed parameters for persistence
const resultRecord = result as Record<string, unknown>
if (Object.keys(resultRecord).length > 0) {
const persistedKeysStr = persistedSearchParams.map((key) => String(key))
const paramsToSave = Object.fromEntries(
Object.entries(resultRecord).filter(([key]) =>
persistedKeysStr.includes(key),
),
)

const excludeKeys = exclude ? exclude.map((key) => String(key)) : []
const filteredResult = Object.fromEntries(
Object.entries(paramsToSave).filter(
([key]) => !excludeKeys.includes(key),
),
)

if (Object.keys(filteredResult).length > 0) {
searchPersistenceStore.saveSearch(route.id, filteredResult)
}
}

return result
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Add unit tests for persistence/restore semantics (empty vs. missing) and clearing.

Given the subtlety here, add tests that assert:

  • Saving happens only for allowed keys (and excludes are respected).
  • Restoring occurs when incoming search is empty, and optionally when keys are missing (if you adopt restoreOn).
  • Clearing: when no allowed keys are returned by next, the persisted entry is removed.

You can scaffold the tests with these search patterns to find integration points:


🏁 Script executed:

#!/bin/bash
# Identify middleware usages and examples to target for tests
rg -n -C3 --type=ts '\bpersistSearchParams\s*\('
rg -n -C3 --type=ts '\bgetSearchPersistenceStore\s*\('

# Check if any server/SSR paths import these (to cover injection/no-op behavior)
rg -n -C3 --type=ts -g '!**/node_modules/**' '(server|ssr|entry-server|api|routes/.*loader)\.tsx?'

Length of output: 10561


🏁 Script executed:

#!/bin/bash
# Search for existing test definitions in packages/router-core
rg -n -C3 --type=ts '(describe\(|test\()' -g 'packages/router-core/**'

Length of output: 39210


Add missing unit tests for persistSearchParams middleware

There are currently no tests covering the persistence and restoration behavior introduced in packages/router-core/src/searchMiddleware.ts. Please add a new test suite (e.g. packages/router-core/tests/searchMiddleware.test.ts) that verifies the following scenarios:

  • Saving only allowed keys and respecting the exclude list
  • Restoring from the store when the incoming search object is empty
  • Optionally restoring when specific keys are missing (if restoreOn behavior is adopted)
  • Clearing the persisted entry when the middleware’s next result has no allowed keys

Suggested test locations and patterns (use vitest):
• Target the persistSearchParams([...], [...]) invocation in your tests, mocking searchPersistenceStore.getSearch and saveSearch.
• Use example routes in examples/react/search-persistence/src/routes/{users,products}.tsx as integration points.
• Scaffold with these rg patterns to confirm coverage targets:

rg -n "'persistSearchParams'" -g 'packages/router-core/**'
rg -n "'searchPersistenceStore'" -g 'packages/router-core/**'
🤖 Prompt for AI Agents
In packages/router-core/src/searchMiddleware.ts around lines 232-285, there are
no unit tests for persistSearchParams; add a new vitest test suite at
packages/router-core/tests/searchMiddleware.test.ts that imports
persistSearchParams, mocks searchPersistenceStore.getSearch and saveSearch, and
verifies: (1) only allowed keys are saved and the exclude list is respected, (2)
when incoming search is empty the middleware restores saved values, (3) behavior
when specific keys are missing if you choose to add/verify restore-on-key logic,
and (4) persisted entry is cleared/not saved when next result contains no
allowed keys; implement tests by creating simple route stubs, calling the
middleware with controlled search/next results, asserting calls and payloads to
mocked getSearch/saveSearch, and ensure cleanup/reset of mocks between tests.

🛠️ Refactor suggestion

Injectable, typed middleware + fixed clearing semantics.

Two issues to address:

  • Current signature lacks extends object, and there’s no way to inject a per-request store. Add an options object with store and exclude.
  • Stale persistence bug: when the route returns no allowed keys, the middleware never clears prior saved values. Call saveSearch even for an empty filtered result so SearchPersistenceStore can delete the route entry.

This also adds an optional restoreOn switch to control restoration semantics.

-export function persistSearchParams<TSearchSchema>(
-  persistedSearchParams: Array<keyof TSearchSchema>,
-  exclude?: Array<keyof TSearchSchema>,
-): SearchMiddleware<TSearchSchema> {
-  return ({ search, next, route }) => {
+export function persistSearchParams<TSearchSchema extends object>(
+  persistedSearchParams: Array<keyof TSearchSchema>,
+  opts?: {
+    exclude?: Array<keyof TSearchSchema>
+    store?: SearchPersistenceStore
+    // empty: restore only when no allowed keys are present in the incoming search
+    // missing: fill in missing allowed keys from persistence (can be more "sticky")
+    // never: do not restore automatically
+    restoreOn?: 'empty' | 'missing' | 'never'
+  },
+): SearchMiddleware<TSearchSchema> {
+  return ({ search, next, route }) => {
+    const isServer = typeof window === 'undefined'
+    const storeRef =
+      opts?.store ?? (isServer ? undefined : defaultSearchPersistenceStore)
@@
-    const allowedKeysStr = persistedSearchParams.map((key) => String(key))
+    const allowedKeysStr = persistedSearchParams.map((key) => String(key))
     const filteredSearch = Object.fromEntries(
       Object.entries(searchRecord).filter(([key]) =>
         allowedKeysStr.includes(key),
       ),
     ) as TSearchSchema
@@
-    const savedSearch = searchPersistenceStore.getSearch(route.id)
+    const savedSearch = storeRef?.getSearch(route.id)
     let searchToProcess = filteredSearch
-
-    if (savedSearch && Object.keys(savedSearch).length > 0) {
-      const currentSearch = filteredSearch as Record<string, unknown>
-      const isEmpty = Object.keys(currentSearch).length === 0
-
-      if (isEmpty) {
-        searchToProcess = savedSearch as TSearchSchema
-      }
-    }
+    const restoreOn = opts?.restoreOn ?? 'empty'
+    if (savedSearch && restoreOn !== 'never') {
+      const current = filteredSearch as Record<string, unknown>
+      const isEmpty = Object.keys(current).length === 0
+      if (restoreOn === 'empty' && isEmpty) {
+        searchToProcess = savedSearch as TSearchSchema
+      } else if (restoreOn === 'missing') {
+        // Merge persisted keys for any missing allowed keys
+        searchToProcess = { ...(savedSearch as object), ...filteredSearch } as TSearchSchema
+      }
+    }
@@
-    // Save only the allowed parameters for persistence
-    const resultRecord = result as Record<string, unknown>
-    if (Object.keys(resultRecord).length > 0) {
-      const persistedKeysStr = persistedSearchParams.map((key) => String(key))
-      const paramsToSave = Object.fromEntries(
-        Object.entries(resultRecord).filter(([key]) =>
-          persistedKeysStr.includes(key),
-        ),
-      )
-
-      const excludeKeys = exclude ? exclude.map((key) => String(key)) : []
-      const filteredResult = Object.fromEntries(
-        Object.entries(paramsToSave).filter(
-          ([key]) => !excludeKeys.includes(key),
-        ),
-      )
-
-      if (Object.keys(filteredResult).length > 0) {
-        searchPersistenceStore.saveSearch(route.id, filteredResult)
-      }
-    }
+    // Save only the allowed parameters (after excludes). Always call save so empty results clear prior state.
+    const resultRecord = result as Record<string, unknown>
+    const persistedKeysStr = persistedSearchParams.map((key) => String(key))
+    const paramsToSave = Object.fromEntries(
+      Object.entries(resultRecord).filter(([key]) =>
+        persistedKeysStr.includes(key),
+      ),
+    )
+    const excludeKeys = opts?.exclude ? opts.exclude.map((k) => String(k)) : []
+    const filteredResult = Object.fromEntries(
+      Object.entries(paramsToSave).filter(([key]) => !excludeKeys.includes(key)),
+    )
+    if (storeRef) {
+      storeRef.saveSearch(route.id, filteredResult)
+    }
@@
     return result
   }
 }

Rationale:

  • extends object aligns with other middlewares.
  • Options-based API enables per-request store injection and typed excludes without broadening the positional API.
  • Clearing bug resolved by saving even when empty, leveraging SearchPersistenceStore.saveSearch’s deletion branch.
  • restoreOn lets consumers choose between minimal (“empty”) and sticky (“missing”) restoration.

Do you want me to propagate the new optional store parameter through the React integration and example so SSR usage is explicit and leak-free?

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/router-core/src/searchMiddleware.ts around lines 232-285, the
middleware signature and behavior need updating: change the generic to
TSearchSchema extends object and replace the positional
persistedSearchParams/exclude parameters with a single options object {
persistedSearchParams: Array<keyof TSearchSchema>, exclude?: Array<keyof
TSearchSchema>, store?: SearchPersistenceStore, restoreOn?: 'empty' | 'missing'
} so callers can inject a per-request store and typed excludes; use
options.store (falling back to the module-level store) for getSearch/saveSearch
calls; implement restoreOn so when restoreOn === 'empty' you only restore
savedSearch when current filtered search has zero keys, and when restoreOn ===
'missing' you restore when any allowed key is absent; when persisting, always
call store.saveSearch(route.id, filteredResult) even if filteredResult is empty
so the store can clear prior entries; keep return value as next(resultSearch)
and preserve existing filtering logic but use the new options and typed
constraints.

Comment on lines +261 to +281
// Save only the allowed parameters for persistence
const resultRecord = result as Record<string, unknown>
if (Object.keys(resultRecord).length > 0) {
const persistedKeysStr = persistedSearchParams.map((key) => String(key))
const paramsToSave = Object.fromEntries(
Object.entries(resultRecord).filter(([key]) =>
persistedKeysStr.includes(key),
),
)

const excludeKeys = exclude ? exclude.map((key) => String(key)) : []
const filteredResult = Object.fromEntries(
Object.entries(paramsToSave).filter(
([key]) => !excludeKeys.includes(key),
),
)

if (Object.keys(filteredResult).length > 0) {
searchPersistenceStore.saveSearch(route.id, filteredResult)
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Bug: persisted values aren’t cleared when results are empty.

As written, if the route returns no allowed keys, prior values remain persisted, causing stale search state on subsequent navigations.

If you prefer a minimal change (without the broader refactor above), apply this:

-    // Save only the allowed parameters for persistence
-    const resultRecord = result as Record<string, unknown>
-    if (Object.keys(resultRecord).length > 0) {
+    // Save only the allowed parameters for persistence
+    const resultRecord = result as Record<string, unknown>
       const persistedKeysStr = persistedSearchParams.map((key) => String(key))
       const paramsToSave = Object.fromEntries(
         Object.entries(resultRecord).filter(([key]) =>
           persistedKeysStr.includes(key),
         ),
       )
 
-      const excludeKeys = exclude ? exclude.map((key) => String(key)) : []
+      const excludeKeys = exclude ? exclude.map((key) => String(key)) : []
       const filteredResult = Object.fromEntries(
         Object.entries(paramsToSave).filter(
           ([key]) => !excludeKeys.includes(key),
         ),
       )
 
-      if (Object.keys(filteredResult).length > 0) {
-        searchPersistenceStore.saveSearch(route.id, filteredResult)
-      }
-    }
+      // Always call save; an empty object clears the entry.
+      searchPersistenceStore.saveSearch(route.id, filteredResult)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Save only the allowed parameters for persistence
const resultRecord = result as Record<string, unknown>
if (Object.keys(resultRecord).length > 0) {
const persistedKeysStr = persistedSearchParams.map((key) => String(key))
const paramsToSave = Object.fromEntries(
Object.entries(resultRecord).filter(([key]) =>
persistedKeysStr.includes(key),
),
)
const excludeKeys = exclude ? exclude.map((key) => String(key)) : []
const filteredResult = Object.fromEntries(
Object.entries(paramsToSave).filter(
([key]) => !excludeKeys.includes(key),
),
)
if (Object.keys(filteredResult).length > 0) {
searchPersistenceStore.saveSearch(route.id, filteredResult)
}
}
// Save only the allowed parameters for persistence
const resultRecord = result as Record<string, unknown>
const persistedKeysStr = persistedSearchParams.map((key) => String(key))
const paramsToSave = Object.fromEntries(
Object.entries(resultRecord).filter(([key]) =>
persistedKeysStr.includes(key),
),
)
const excludeKeys = exclude ? exclude.map((key) => String(key)) : []
const filteredResult = Object.fromEntries(
Object.entries(paramsToSave).filter(
([key]) => !excludeKeys.includes(key),
),
)
// Always call save; an empty object clears the entry.
searchPersistenceStore.saveSearch(route.id, filteredResult)
🤖 Prompt for AI Agents
In packages/router-core/src/searchMiddleware.ts around lines 261 to 281, the
current logic only saves filteredResult when it has keys, leaving any previously
persisted values intact when filteredResult is empty; update the code so that
after computing filteredResult you either call
searchPersistenceStore.saveSearch(route.id, filteredResult) when it has keys or
explicitly clear the persisted entry when it is empty (e.g., call
searchPersistenceStore.saveSearch(route.id, {}) or the store's delete/clear
method) so stale values are removed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants