Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/docs/src/pages/ApiHooksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ function SearchPage() {
setSearchParams({ q: newQuery });
};
}`}</CodeBlock>
<p>The setter accepts an optional second argument with navigation options:</p>
<CodeBlock language="tsx">{`// Push a new history entry instead of replacing the current one
setSearchParams({ q: newQuery }, { replace: false });`}</CodeBlock>
<p>
By default (<code>replace: true</code>), the setter replaces the current history entry and
preserves its navigation state, including per-route state set via <code>setState</code>.
With <code>replace: false</code>, a new entry is pushed instead, so the change can be
undone with the browser's Back button.
</p>
</article>

<article className="api-item">
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/pages/ApiReferenceIndexPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export function ApiReferenceIndexPage() {
</li>
<li>
<code>RouteDefinition</code>, <code>ActionArgs</code>, <code>LoaderArgs</code>,{" "}
<code>Location</code>, <code>NavigateOptions</code>
<code>Location</code>, <code>NavigateOptions</code>, <code>SetSearchParamsOptions</code>
</li>
</ul>
</section>
Expand Down
14 changes: 14 additions & 0 deletions packages/docs/src/pages/ApiTypesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,20 @@ routeState<{ tab: string }>()({
navigation that triggered it.
</p>
</article>

<article className="api-item">
<h3>
<code>SetSearchParamsOptions</code>
</h3>
<CodeBlock language="typescript">{`interface SetSearchParamsOptions {
replace?: boolean; // default: true
}`}</CodeBlock>
<p>
Options for the setter returned by <code>useSearchParams</code>. A replace navigation
preserves the current entry's state (including per-route state set via{" "}
<code>setState</code>), while a push navigation creates the new entry without state.
</p>
</article>
</div>
);
}
52 changes: 52 additions & 0 deletions packages/router/src/__tests__/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<button onClick={() => setStateSync?.({ scroll: 42 })}>SaveState</button>
<button onClick={() => setSearchParams({ q: "new" })}>Update</button>
</div>
);
}

const routes: RouteDefinition[] = [{ path: "/page", component: TestComponent }];

render(<Router routes={routes} />);

// 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 (
<button onClick={() => setSearchParams({ q: "new" }, { replace: false })}>Update</button>
);
}

const routes: RouteDefinition[] = [{ path: "/page", component: TestComponent }];

render(<Router routes={routes} />);
screen.getByRole("button").click();

expect(mockNavigation.navigate).toHaveBeenCalledWith("/page?q=new", {
history: "push",
state: undefined,
});
});

it("throws when used outside Router", () => {
function TestComponent() {
useSearchParams();
Expand Down
26 changes: 22 additions & 4 deletions packages/router/src/hooks/useSearchParams.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
| ((prev: URLSearchParams) => URLSearchParams | Record<string, string>),
options?: SetSearchParamsOptions,
) => void;

/**
Expand All @@ -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<SetSearchParams>(
(params) => {
(params, options) => {
const url = new URL(currentUrl);

let newParams: URLSearchParams;
Expand All @@ -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];
Expand Down
2 changes: 1 addition & 1 deletion packages/router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down