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() {
  • RouteDefinition, ActionArgs, LoaderArgs,{" "} - Location, NavigateOptions + Location, NavigateOptions, SetSearchParamsOptions
  • diff --git a/packages/docs/src/pages/ApiTypesPage.tsx b/packages/docs/src/pages/ApiTypesPage.tsx index 759917f..46be073 100644 --- a/packages/docs/src/pages/ApiTypesPage.tsx +++ b/packages/docs/src/pages/ApiTypesPage.tsx @@ -352,6 +352,22 @@ routeState<{ tab: string }>()({ navigation that triggered it.

    + +
    +

    + SetSearchParamsOptions +

    + {`interface SetSearchParamsOptions { + replace?: boolean; // default: true + state?: unknown; +}`} +

    + 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. +

    +
    ); } diff --git a/packages/router/src/__tests__/hooks.test.tsx b/packages/router/src/__tests__/hooks.test.tsx index e18bfc8..bca35a9 100644 --- a/packages/router/src/__tests__/hooks.test.tsx +++ b/packages/router/src/__tests__/hooks.test.tsx @@ -145,6 +145,113 @@ describe("hooks", () => { }); }); + it("preserves current entry state when updating search params", async () => { + mockNavigation = setupNavigationMock("http://localhost/page?foo=bar"); + + function TestComponent({ setStateSync }: { setStateSync?: (state: unknown) => void }) { + const [, setSearchParams] = useSearchParams(); + return ( +
    + + +
    + ); + } + + const routes: RouteDefinition[] = [{ path: "/page", component: TestComponent }]; + + render(); + + // Save route state on the current entry, then update search params + await act(async () => { + screen.getByText("SaveState").click(); + }); + await act(async () => { + screen.getByText("Update").click(); + }); + + expect(mockNavigation.navigate).toHaveBeenLastCalledWith("/page?q=new", { + history: "replace", + state: { __routeStates: [{ scroll: 42 }] }, + }); + }); + + it("pushes a new entry when replace: false", () => { + mockNavigation = setupNavigationMock("http://localhost/page?foo=bar"); + + function TestComponent() { + const [, setSearchParams] = useSearchParams(); + return ( + + ); + } + + const routes: RouteDefinition[] = [{ path: "/page", component: TestComponent }]; + + render(); + screen.getByRole("button").click(); + + expect(mockNavigation.navigate).toHaveBeenCalledWith("/page?q=new", { + history: "push", + state: undefined, + }); + }); + + it("passes the state option through to the navigation", () => { + mockNavigation = setupNavigationMock("http://localhost/page"); + + function TestComponent() { + const [, setSearchParams] = useSearchParams(); + return ( + + ); + } + + const routes: RouteDefinition[] = [{ path: "/page", component: TestComponent }]; + + render(); + screen.getByRole("button").click(); + + expect(mockNavigation.navigate).toHaveBeenCalledWith("/page?q=new", { + history: "replace", + state: { custom: true }, + }); + }); + + it("clears entry state when state: undefined is passed explicitly", async () => { + mockNavigation = setupNavigationMock("http://localhost/page"); + + function TestComponent({ setStateSync }: { setStateSync?: (state: unknown) => void }) { + const [, setSearchParams] = useSearchParams(); + return ( +
    + + +
    + ); + } + + 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.

    diff --git a/packages/docs/src/pages/ApiTypesPage.tsx b/packages/docs/src/pages/ApiTypesPage.tsx index 46be073..9d7c54b 100644 --- a/packages/docs/src/pages/ApiTypesPage.tsx +++ b/packages/docs/src/pages/ApiTypesPage.tsx @@ -359,13 +359,11 @@ routeState<{ tab: string }>()({ {`interface SetSearchParamsOptions { replace?: boolean; // default: true - state?: unknown; }`}

    - 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.

    diff --git a/packages/router/src/__tests__/hooks.test.tsx b/packages/router/src/__tests__/hooks.test.tsx index bca35a9..1ac2f83 100644 --- a/packages/router/src/__tests__/hooks.test.tsx +++ b/packages/router/src/__tests__/hooks.test.tsx @@ -197,61 +197,6 @@ describe("hooks", () => { }); }); - it("passes the state option through to the navigation", () => { - mockNavigation = setupNavigationMock("http://localhost/page"); - - function TestComponent() { - const [, setSearchParams] = useSearchParams(); - return ( - - ); - } - - const routes: RouteDefinition[] = [{ path: "/page", component: TestComponent }]; - - render(); - screen.getByRole("button").click(); - - expect(mockNavigation.navigate).toHaveBeenCalledWith("/page?q=new", { - history: "replace", - state: { custom: true }, - }); - }); - - it("clears entry state when state: undefined is passed explicitly", async () => { - mockNavigation = setupNavigationMock("http://localhost/page"); - - function TestComponent({ setStateSync }: { setStateSync?: (state: unknown) => void }) { - const [, setSearchParams] = useSearchParams(); - return ( -
    - - -
    - ); - } - - 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 2251854..d13419b 100644 --- a/packages/router/src/hooks/useSearchParams.ts +++ b/packages/router/src/hooks/useSearchParams.ts @@ -10,14 +10,6 @@ export type SetSearchParamsOptions = { * @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 = ( @@ -63,18 +55,12 @@ 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; - + // Replacing the current entry keeps its state (including per-route + // state set via setState); pushing creates a fresh entry without + // state, like any other push navigation. void navigateAsync(url.pathname + url.search + url.hash, { replace, - state, + state: replace ? locationState : undefined, }); }, [currentUrl, navigateAsync, locationState],