diff --git a/packages/docs/src/pages/ApiHooksPage.tsx b/packages/docs/src/pages/ApiHooksPage.tsx index 6031d35..0fa14f9 100644 --- a/packages/docs/src/pages/ApiHooksPage.tsx +++ b/packages/docs/src/pages/ApiHooksPage.tsx @@ -46,6 +46,15 @@ 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 });`} +

+ 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 instead, so the change can be + undone with the browser's Back button. +

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..9d7c54b 100644 --- a/packages/docs/src/pages/ApiTypesPage.tsx +++ b/packages/docs/src/pages/ApiTypesPage.tsx @@ -352,6 +352,20 @@ routeState<{ tab: string }>()({ navigation that triggered it.

    + +
    +

    + SetSearchParamsOptions +

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

    + 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 e18bfc8..1ac2f83 100644 --- a/packages/router/src/__tests__/hooks.test.tsx +++ b/packages/router/src/__tests__/hooks.test.tsx @@ -145,6 +145,58 @@ 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("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..d13419b 100644 --- a/packages/router/src/hooks/useSearchParams.ts +++ b/packages/router/src/hooks/useSearchParams.ts @@ -1,11 +1,23 @@ 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; +}; + type SetSearchParams = ( params: | URLSearchParams | Record | ((prev: URLSearchParams) => URLSearchParams | Record), + options?: SetSearchParamsOptions, ) => void; /** @@ -23,11 +35,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 +53,17 @@ export function useSearchParams(): [URLSearchParams, SetSearchParams] { } url.search = newParams.toString(); + + const replace = options?.replace ?? true; + // 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: true, + replace, + state: replace ? locationState : undefined, }); }, - [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";