Skip to content

Commit 3cca793

Browse files
bvdmitriclaude
andcommitted
fix(frontend): commit selection via History API so deep-linked switches work
Opening a shared link that already carries query params (e.g. ?b=basic/coin_toss&hw=rpi5-8gb) left every switch dead — selecting a value did nothing, while a fresh load with no params worked. useSelection committed each selection through router.replace, which runs the App Router's navigation/ transition machinery. Under static export (output: export), that path fails to update the URL on a deep-linked load (most visibly in Safari), so useSearchParams never changes and the controlled Selects stay frozen. Switch to window.history.replaceState, which Next.js syncs with useSearchParams/usePathname without a route transition — the documented idiom for query-param-only state updates. URL-building logic is unchanged. Tests now spy on history.replaceState instead of router.replace. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5dd60b4 commit 3cca793

3 files changed

Lines changed: 29 additions & 16 deletions

File tree

frontend/src/components/dashboard/DashboardPage.test.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,16 @@ import {
1212
makeResultFile,
1313
} from "@/test/fixtures";
1414

15-
const replaceMock = vi.fn();
1615
let currentParams = new URLSearchParams();
1716

1817
vi.mock("next/navigation", () => ({
19-
useRouter: () => ({ replace: replaceMock }),
2018
usePathname: () => "/",
2119
useSearchParams: () => currentParams,
2220
}));
2321

22+
// Selections are committed via the native History API (see useSelection).
23+
const replaceState = vi.spyOn(window.history, "replaceState");
24+
2425
import { DashboardPage } from "./DashboardPage";
2526

2627
function stubFetch() {
@@ -43,7 +44,7 @@ function stubFetch() {
4344

4445
describe("DashboardPage", () => {
4546
beforeEach(() => {
46-
replaceMock.mockClear();
47+
replaceState.mockClear();
4748
currentParams = new URLSearchParams();
4849
stubFetch();
4950
});
@@ -88,7 +89,7 @@ describe("DashboardPage", () => {
8889
expect(within(drawer).getByRole("link", { name: /how it works/i })).toBeInTheDocument();
8990

9091
await user.click(within(drawer).getByRole("button", { name: "Coin Toss Model" }));
91-
expect(replaceMock).toHaveBeenCalled();
92+
expect(replaceState).toHaveBeenCalled();
9293
});
9394

9495
it("renders an error card when the index cannot be fetched", async () => {

frontend/src/lib/hooks/useSelection.test.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,31 @@
1-
import { describe, expect, it, vi, beforeEach } from "vitest";
1+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
22
import { renderHook, act } from "@testing-library/react";
33

4-
const replaceMock = vi.fn();
54
let currentParams = new URLSearchParams();
65

76
vi.mock("next/navigation", () => ({
8-
useRouter: () => ({ replace: replaceMock }),
97
usePathname: () => "/",
108
useSearchParams: () => currentParams,
119
}));
1210

1311
import { useSelection } from "./useSelection";
1412

13+
// Selections are committed via the native History API (Next.js syncs useSearchParams
14+
// with window.history.replaceState) rather than the router's navigation machinery,
15+
// which breaks on deep-linked static-export loads.
16+
const replaceState = vi.spyOn(window.history, "replaceState");
17+
const replacedUrl = () => replaceState.mock.calls[0][2] as string;
18+
1519
describe("useSelection", () => {
1620
beforeEach(() => {
17-
replaceMock.mockClear();
21+
replaceState.mockClear();
1822
currentParams = new URLSearchParams();
1923
});
2024

25+
afterEach(() => {
26+
replaceState.mockReset();
27+
});
28+
2129
it("exposes defaults when no params are set", () => {
2230
const { result } = renderHook(() => useSelection());
2331
expect(result.current.benchmark).toBeNull();
@@ -39,11 +47,11 @@ describe("useSelection", () => {
3947
expect(result.current.julia).toBe("1.12");
4048
});
4149

42-
it("select() writes params via router.replace and drops defaults", () => {
50+
it("select() writes params via history.replaceState and drops defaults", () => {
4351
const { result } = renderHook(() => useSelection());
4452
act(() => result.current.select({ benchmark: "basic/coin_toss", metric: "all" }));
45-
expect(replaceMock).toHaveBeenCalledTimes(1);
46-
const url = replaceMock.mock.calls[0][0] as string;
53+
expect(replaceState).toHaveBeenCalledTimes(1);
54+
const url = replacedUrl();
4755
expect(url).toContain("b=basic%2Fcoin_toss");
4856
expect(url).not.toContain("m="); // "all" is the default -> omitted
4957
});
@@ -52,7 +60,7 @@ describe("useSelection", () => {
5260
currentParams = new URLSearchParams("b=basic%2Fkalman&m=cold_run_ms");
5361
const { result } = renderHook(() => useSelection());
5462
act(() => result.current.select({ benchmark: null }));
55-
const url = replaceMock.mock.calls[0][0] as string;
63+
const url = replacedUrl();
5664
expect(url).not.toContain("b=");
5765
expect(url).toContain("m=cold_run_ms"); // untouched params survive
5866
});

frontend/src/lib/hooks/useSelection.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// All dashboard state lives in query params (design/frontend.md): the
44
// benchmark universe is runtime-fetched, so static-export dynamic routes are
55
// impossible — and query params make every view a shareable link.
6-
import { usePathname, useRouter, useSearchParams } from "next/navigation";
6+
import { usePathname, useSearchParams } from "next/navigation";
77
import { useCallback } from "react";
88

99
export interface Selection {
@@ -40,7 +40,6 @@ const PARAM_KEYS = {
4040
} as const;
4141

4242
export function useSelection() {
43-
const router = useRouter();
4443
const pathname = usePathname();
4544
const searchParams = useSearchParams();
4645

@@ -71,9 +70,14 @@ export function useSelection() {
7170
else params.delete(PARAM_KEYS.compare);
7271
}
7372
const query = params.toString();
74-
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
73+
// Commit via the native History API, not router.replace: Next.js syncs
74+
// useSearchParams/usePathname with pushState/replaceState, and this avoids the
75+
// App Router navigation/transition machinery, which fails to update the URL on
76+
// deep-linked static-export loads (most visibly in Safari) — freezing every switch.
77+
const url = query ? `${pathname}?${query}` : pathname;
78+
window.history.replaceState(null, "", url);
7579
},
76-
[router, pathname, searchParams],
80+
[pathname, searchParams],
7781
);
7882

7983
return { ...selection, select };

0 commit comments

Comments
 (0)