From cf72db194af44d7bfd1e9d430a1329ba48ba58ea Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 12 Jul 2026 02:56:32 +0000
Subject: [PATCH 1/2] fix: preserve navigation state in setSearchParams and add
options
setSearchParams performed a replace navigation without passing state,
wiping per-route state stored on the current entry (via setState /
setStateSync). It now preserves the current entry's state on replace
navigations by default.
It also accepts a new second argument:
setSearchParams(params, { replace?: boolean; state?: unknown }).
replace defaults to true (previous behavior); replace: false pushes a
new entry so the change can be backed out of, and an explicit state
option takes precedence in both cases.
Closes #208
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01XyJBmbRYRVoU5WLYxUs8N6
---
packages/docs/src/pages/ApiHooksPage.tsx | 12 ++
.../docs/src/pages/ApiReferenceIndexPage.tsx | 2 +-
packages/docs/src/pages/ApiTypesPage.tsx | 16 +++
packages/router/src/__tests__/hooks.test.tsx | 107 ++++++++++++++++++
packages/router/src/hooks/useSearchParams.ts | 40 ++++++-
packages/router/src/index.ts | 2 +-
6 files changed, 173 insertions(+), 6 deletions(-)
diff --git a/packages/docs/src/pages/ApiHooksPage.tsx b/packages/docs/src/pages/ApiHooksPage.tsx
index 6031d35..0f973c3 100644
--- a/packages/docs/src/pages/ApiHooksPage.tsx
+++ b/packages/docs/src/pages/ApiHooksPage.tsx
@@ -46,6 +46,18 @@ function SearchPage() {
setSearchParams({ q: newQuery });
};
}`}
+
The setter accepts an optional second argument with navigation options:
+ {`// Push a new history entry instead of replacing the current one
+setSearchParams({ q: newQuery }, { replace: false });
+
+// Associate navigation state with the entry
+setSearchParams({ q: newQuery }, { state: { fromSearch: true } });`}
+
+ By default (replace: true), the setter replaces the current history entry and
+ preserves its navigation state, including per-route state set via setState.
+ With replace: false, a new entry is pushed without state. In both cases, an
+ explicit state option takes precedence.
+
diff --git a/packages/docs/src/pages/ApiReferenceIndexPage.tsx b/packages/docs/src/pages/ApiReferenceIndexPage.tsx
index af0bd85..6974c83 100644
--- a/packages/docs/src/pages/ApiReferenceIndexPage.tsx
+++ b/packages/docs/src/pages/ApiReferenceIndexPage.tsx
@@ -97,7 +97,7 @@ export function ApiReferenceIndexPage() {
+ Options for the setter returned by useSearchParams. When state{" "}
+ is omitted, a replace navigation preserves the current entry's state (including per-route
+ state set via setState), and a push navigation creates the new entry without
+ state.
+
+ );
+ }
+
+ const routes: RouteDefinition[] = [{ path: "/page", component: TestComponent }];
+
+ render();
+
+ await act(async () => {
+ screen.getByText("SaveState").click();
+ });
+ await act(async () => {
+ screen.getByText("Update").click();
+ });
+
+ expect(mockNavigation.navigate).toHaveBeenLastCalledWith("/page?q=new", {
+ history: "replace",
+ state: undefined,
+ });
+ });
+
it("throws when used outside Router", () => {
function TestComponent() {
useSearchParams();
diff --git a/packages/router/src/hooks/useSearchParams.ts b/packages/router/src/hooks/useSearchParams.ts
index 494e270..2251854 100644
--- a/packages/router/src/hooks/useSearchParams.ts
+++ b/packages/router/src/hooks/useSearchParams.ts
@@ -1,11 +1,31 @@
import { useCallback, useContext } from "react";
import { RouterContext } from "../context/RouterContext.js";
+/**
+ * Options for the setter returned by `useSearchParams`.
+ */
+export type SetSearchParamsOptions = {
+ /**
+ * Replace the current history entry instead of pushing a new one.
+ * @default true
+ */
+ replace?: boolean;
+ /**
+ * State to associate with the navigation.
+ *
+ * When omitted, a replace navigation preserves the current entry's state
+ * (including per-route state set via `setState`), and a push navigation
+ * creates the new entry without state.
+ */
+ state?: unknown;
+};
+
type SetSearchParams = (
params:
| URLSearchParams
| Record
| ((prev: URLSearchParams) => URLSearchParams | Record),
+ options?: SetSearchParamsOptions,
) => void;
/**
@@ -23,11 +43,11 @@ export function useSearchParams(): [URLSearchParams, SetSearchParams] {
}
const currentUrl = context.url;
- const { navigateAsync } = context;
+ const { navigateAsync, locationState } = context;
const searchParams = currentUrl.searchParams;
const setSearchParams = useCallback(
- (params) => {
+ (params, options) => {
const url = new URL(currentUrl);
let newParams: URLSearchParams;
@@ -41,11 +61,23 @@ export function useSearchParams(): [URLSearchParams, SetSearchParams] {
}
url.search = newParams.toString();
+
+ const replace = options?.replace ?? true;
+ // Replacing the current entry keeps its state by default; pushing
+ // creates a fresh entry without state, like any other push navigation.
+ const state =
+ options !== undefined && "state" in options
+ ? options.state
+ : replace
+ ? locationState
+ : undefined;
+
void navigateAsync(url.pathname + url.search + url.hash, {
- replace: true,
+ replace,
+ state,
});
},
- [currentUrl, navigateAsync],
+ [currentUrl, navigateAsync, locationState],
);
return [searchParams, setSearchParams];
diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts
index 89fc13a..d7962b5 100644
--- a/packages/router/src/index.ts
+++ b/packages/router/src/index.ts
@@ -8,7 +8,7 @@ export { Outlet } from "./Outlet.js";
// Hooks
export { useLocation } from "./hooks/useLocation.js";
-export { useSearchParams } from "./hooks/useSearchParams.js";
+export { useSearchParams, type SetSearchParamsOptions } from "./hooks/useSearchParams.js";
export { useBlocker, type UseBlockerOptions } from "./hooks/useBlocker.js";
export { useRouteParams } from "./hooks/useRouteParams.js";
export { useRouteState } from "./hooks/useRouteState.js";
From 0c2cb4cc45ab0d96fa828cb4ce804414a0858b43 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 12 Jul 2026 03:06:01 +0000
Subject: [PATCH 2/2] refactor: drop state option from setSearchParams
The entry state is an internal container for per-route state, and there
is no public API to read raw entry state back, so exposing a raw state
option would only let callers clobber route state. Keep the replace
option and the state-preservation behavior.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01XyJBmbRYRVoU5WLYxUs8N6
---
packages/docs/src/pages/ApiHooksPage.tsx | 9 ++--
packages/docs/src/pages/ApiTypesPage.tsx | 8 ++-
packages/router/src/__tests__/hooks.test.tsx | 55 --------------------
packages/router/src/hooks/useSearchParams.ts | 22 ++------
4 files changed, 10 insertions(+), 84 deletions(-)
diff --git a/packages/docs/src/pages/ApiHooksPage.tsx b/packages/docs/src/pages/ApiHooksPage.tsx
index 0f973c3..0fa14f9 100644
--- a/packages/docs/src/pages/ApiHooksPage.tsx
+++ b/packages/docs/src/pages/ApiHooksPage.tsx
@@ -48,15 +48,12 @@ function SearchPage() {
}`}
The setter accepts an optional second argument with navigation options:
{`// Push a new history entry instead of replacing the current one
-setSearchParams({ q: newQuery }, { replace: false });
-
-// Associate navigation state with the entry
-setSearchParams({ q: newQuery }, { state: { fromSearch: true } });`}
+setSearchParams({ q: newQuery }, { replace: false });`}
By default (replace: true), the setter replaces the current history entry and
preserves its navigation state, including per-route state set via setState.
- With replace: false, a new entry is pushed without state. In both cases, an
- explicit state option takes precedence.
+ With replace: false, a new entry is pushed instead, so the change can be
+ undone with the browser's Back button.
- Options for the setter returned by useSearchParams. When state{" "}
- is omitted, a replace navigation preserves the current entry's state (including per-route
- state set via setState), and a push navigation creates the new entry without
- state.
+ Options for the setter returned by useSearchParams. A replace navigation
+ preserves the current entry's state (including per-route state set via{" "}
+ setState), while a push navigation creates the new entry without state.