diff --git a/src/__tests__/baselines/test-source-reads.json b/src/__tests__/baselines/test-source-reads.json
index bad51e959..4cafc7881 100644
--- a/src/__tests__/baselines/test-source-reads.json
+++ b/src/__tests__/baselines/test-source-reads.json
@@ -1,7 +1,7 @@
{
"$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
- "count": 359,
- "total": 359,
+ "count": 358,
+ "total": 358,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 1,
"__tests__/admin-data-wipe-completeness.test.ts": 1,
@@ -263,7 +263,6 @@
"components/timeline/__tests__/no-template-key-guard.test.ts": 1,
"components/ui/__tests__/card-density.test.tsx": 1,
"components/ui/__tests__/card-header-rows.test.ts": 1,
- "components/ui/__tests__/filter-bar.test.tsx": 1,
"hooks/__tests__/use-insight-status.test.ts": 1,
"lib/__tests__/geo-offline-detection.test.ts": 1,
"lib/__tests__/query-keys.test.ts": 1,
diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json
index e546e423d..8e942a370 100644
--- a/src/__tests__/baselines/version-stamp-comments.json
+++ b/src/__tests__/baselines/version-stamp-comments.json
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
- "count": 2950,
- "total": 10294,
+ "count": 2949,
+ "total": 10293,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -1448,7 +1448,6 @@
"components/timeline/__tests__/timeline-view-keys.test.tsx": 1,
"components/timeline/__tests__/value-options.test.ts": 1,
"components/timeline/life-event-form.lazy.tsx": 1,
- "components/ui/__tests__/filter-bar.test.tsx": 1,
"components/ui/back-link.tsx": 1,
"components/ui/button.tsx": 1,
"components/ui/card.tsx": 1,
diff --git a/src/components/ui/__tests__/filter-bar.test.tsx b/src/components/ui/__tests__/filter-bar.test.tsx
deleted file mode 100644
index 8ab5fd593..000000000
--- a/src/components/ui/__tests__/filter-bar.test.tsx
+++ /dev/null
@@ -1,166 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { readFileSync } from "node:fs";
-import { join } from "node:path";
-import { renderToStaticMarkup } from "react-dom/server";
-
-import { I18nProvider } from "@/lib/i18n/context";
-import { FilterBar, FilterBarDateRange, FilterBarSelect } from "../filter-bar";
-import { primeLocales } from "@/__tests__/helpers/prime-locales";
-
-primeLocales("de");
-
-/**
- * v1.16.1 — unified filter rail. SSR-markup guards for the shared
- * grammar: pill = trigger + active chip in one element, reset only
- * while filtered, count slot, per-pill clear button with a translated
- * aria-label.
- */
-
-const options = [
- { value: "WEIGHT", label: "Weight" },
- { value: "PULSE", label: "Pulse" },
-];
-
-function render(node: React.ReactElement, locale: "en" | "de" = "en") {
- return renderToStaticMarkup(
- {node},
- );
-}
-
-describe("FilterBarSelect", () => {
- it("inactive: shows only the dimension name, no clear button", () => {
- const html = render(
- {}}>
- {}}
- allLabel="All types"
- options={options}
- />
- ,
- );
- expect(html).toContain('data-slot="filter-bar-pill"');
- expect(html).toContain('data-active="false"');
- expect(html).toContain("Type");
- expect(html).not.toContain("Clear filter");
- });
-
- it("active: shows `label: value`, active styling and a labelled clear button", () => {
- const html = render(
- {}}>
- {}}
- allLabel="All types"
- options={options}
- />
- ,
- );
- expect(html).toContain('data-active="true"');
- expect(html).toContain("Weight");
- expect(html).toContain('aria-label="Clear filter: Type"');
- });
-});
-
-describe("FilterBarDateRange", () => {
- it("inactive: pill shows the dimension name only", () => {
- const html = render(
- {}}>
- {}}
- onToChange={() => {}}
- idPrefix="probe"
- />
- ,
- );
- expect(html).toContain('data-active="false"');
- expect(html).toContain("Date range");
- });
-
- it("active: pill shows the formatted bounds and a clear button", () => {
- const html = render(
- {}}>
- {}}
- onToChange={() => {}}
- idPrefix="probe"
- />
- ,
- );
- expect(html).toContain('data-active="true"');
- // en dateShort: "05/12" / "06/10" — assert both day fragments are in.
- expect(html).toMatch(/05\/12/);
- expect(html).toMatch(/06\/10/);
- expect(html).toContain('aria-label="Clear filter: Date range"');
- });
-});
-
-describe("FilterBar shell", () => {
- it("shows the reset action only while filtered", () => {
- const inactive = render(
- {}}>
-
- ,
- );
- const active = render(
- {}}>
-
- ,
- );
- expect(inactive).not.toContain("Reset");
- expect(active).toContain("Reset");
- });
-
- it("renders the result count slot", () => {
- const html = render(
- {}} count="42 readings">
-
- ,
- );
- expect(html).toContain("42 readings");
- expect(html).toContain("tabular-nums");
- });
-
- it("localises reset + clear labels in German", () => {
- const html = render(
- {}}>
- {}}
- allLabel="Alle Quellen"
- options={[{ value: "MANUAL", label: "Manuell" }]}
- />
- ,
- "de",
- );
- expect(html).toContain("Zurücksetzen");
- expect(html).toContain('aria-label="Filter entfernen: Quelle"');
- });
-});
-
-describe("FilterBarSelect — menu positioning (source pin)", () => {
- // The pill trigger paints its own label spans and mounts no
- // ``. Radix's default item-aligned placement silently
- // skips ALL positioning when the value node is absent — the open
- // menu then lays out below the height-locked app shell, fully
- // off-viewport, which users report as "clicking does nothing". SSR
- // markup cannot exercise an open portal, so this pins the source:
- // the content must request popper placement, which has no
- // value-node dependency.
- it("requests popper placement on the select content", () => {
- const src = readFileSync(
- join(process.cwd(), "src/components/ui/filter-bar.tsx"),
- "utf8",
- );
- expect(src).toMatch(//);
- });
-});
diff --git a/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx b/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx
new file mode 100644
index 000000000..6897b0b2e
--- /dev/null
+++ b/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx
@@ -0,0 +1,268 @@
+/**
+ * The URL-owned filter contract, driven the way a list page uses it: a
+ * `FilterBar` of pills over `useUrlFilterSync`, in a real browser with a real
+ * address bar. What these pin and the markup tests could not: a facet picked
+ * from the open menu lands in the query as a new history entry, typed input
+ * rewrites the current one, the per-pill clear and Reset leave a bare URL, two
+ * writes in a row both land, and Back or an edited address re-seeds the pills.
+ */
+import { describe, expect, it } from "vitest";
+import { userEvent } from "vitest/browser";
+
+import {
+ FilterBar,
+ FilterBarDateRange,
+ FilterBarSelect,
+} from "@/components/ui/filter-bar";
+import { Input } from "@/components/ui/input";
+
+import { renderWithApp } from "../../../tests/browser/render";
+import {
+ currentSearch,
+ navigateExternally,
+ recordHistory,
+ setSearch,
+} from "../../../tests/browser/url";
+import { useUrlFilterSync } from "../use-url-filter-sync";
+
+interface Filters {
+ type?: string;
+ source?: string;
+ q?: string;
+ from?: string;
+ to?: string;
+}
+
+const KEYS = ["type", "source", "q", "from", "to"] as const;
+
+function parse(params: URLSearchParams): Filters {
+ const filters: Filters = {};
+ for (const key of KEYS) {
+ const value = params.get(key);
+ if (value) filters[key] = value;
+ }
+ return filters;
+}
+
+function serialise(filters: Filters): string {
+ const sp = new URLSearchParams();
+ for (const key of KEYS) {
+ const value = filters[key];
+ if (value) sp.set(key, value);
+ }
+ return sp.toString();
+}
+
+const TYPES = [
+ { value: "WEIGHT", label: "Weight" },
+ { value: "PULSE", label: "Pulse" },
+];
+const SOURCES = [
+ { value: "MANUAL", label: "Manual" },
+ { value: "WITHINGS", label: "Withings" },
+];
+
+/** A list page's filter rail, as the measurements and mood lists build it. */
+function FilterRail({ syncToUrl = true }: { syncToUrl?: boolean }) {
+ const { filters, applyFilters, patchFilters } = useUrlFilterSync({
+ parse,
+ serialise,
+ syncToUrl,
+ });
+ const isFiltered = Object.keys(filters).length > 0;
+ return (
+ <>
+ patchFilters({ q: e.target.value }, "replace")}
+ />
+ applyFilters({})}
+ count={`${Object.keys(filters).length} active`}
+ >
+ patchFilters({ from })}
+ onToChange={(to) => patchFilters({ to })}
+ idPrefix="probe"
+ />
+
+ patchFilters({ type: v === "ALL" ? undefined : v })
+ }
+ allLabel="All types"
+ options={TYPES}
+ />
+
+ patchFilters({ source: v === "ALL" ? undefined : v })
+ }
+ allLabel="All sources"
+ options={SOURCES}
+ />
+
+ >
+ );
+}
+
+async function mount(search = "", props: { syncToUrl?: boolean } = {}) {
+ setSearch(search);
+ const history = recordHistory();
+ const { screen } = await renderWithApp();
+ const pill = (name: string | RegExp) =>
+ screen.getByRole("combobox", { name });
+ const reset = () => screen.getByRole("button", { name: "Reset" });
+ const pick = async (pillName: string | RegExp, option: string) => {
+ await pill(pillName).click();
+ await screen.getByRole("option", { name: option }).click();
+ };
+ return { screen, history, pill, reset, pick };
+}
+
+/** The URL settles a task after the write; wait for the expected query. */
+async function expectSearch(search: string) {
+ await expect.poll(currentSearch).toBe(search);
+}
+
+describe("useUrlFilterSync with FilterBar", () => {
+ it("seeds the pills from the address it mounts on", async () => {
+ const { screen, pill, reset } = await mount("?type=WEIGHT&source=WITHINGS");
+ await expect
+ .element(pill("Type: Weight"))
+ .toHaveAttribute("data-active", "true");
+ await expect.element(pill("Source: Withings")).toBeInTheDocument();
+ await expect.element(reset()).toBeVisible();
+ await expect.element(screen.getByText("2 active")).toBeVisible();
+ });
+
+ it("shows no Reset and no clear button while unfiltered", async () => {
+ const { screen, pill } = await mount();
+ await expect.element(pill("Type")).toHaveAttribute("data-active", "false");
+ await expect
+ .element(screen.getByRole("button", { name: "Reset" }))
+ .not.toBeInTheDocument();
+ await expect
+ .element(screen.getByRole("button", { name: /^Clear filter/ }))
+ .not.toBeInTheDocument();
+ });
+
+ it("writes a facet picked from the open menu as a new history entry", async () => {
+ const { pill, history, pick } = await mount();
+ const entries = window.history.length;
+ await pick("Type", "Pulse");
+ await expectSearch("?type=PULSE");
+ expect(history.pushes()).toEqual(["?type=PULSE"]);
+ expect(history.replaces()).toEqual([]);
+ expect(window.history.length).toBe(entries + 1);
+ await expect.element(pill("Type: Pulse")).toBeInTheDocument();
+ });
+
+ it("opens the menu inside the viewport, under its pill", async () => {
+ const { screen, pill } = await mount();
+ // Read before opening: the open menu hides the rest of the page from
+ // the accessibility tree, the pill included.
+ const trigger = pill("Source").element().getBoundingClientRect();
+ await pill("Source").click();
+ const listbox = screen.getByRole("listbox");
+ await expect.element(listbox).toBeVisible();
+ // The menu slides in from above its resting place; measure where it rests.
+ await Promise.all(document.getAnimations().map((a) => a.finished));
+ const menu = listbox.element().getBoundingClientRect();
+ expect(menu.top).toBeGreaterThanOrEqual(trigger.bottom - 1);
+ expect(menu.bottom).toBeLessThanOrEqual(window.innerHeight);
+ expect(menu.left).toBeGreaterThanOrEqual(0);
+ await userEvent.keyboard("{Escape}");
+ await expect.element(listbox).not.toBeInTheDocument();
+ });
+
+ it("rewrites the current entry for typed input instead of stacking one per key", async () => {
+ const { screen, history } = await mount("?type=WEIGHT");
+ const entries = window.history.length;
+ await screen.getByRole("textbox", { name: "Search notes" }).fill("knee");
+ await expectSearch("?type=WEIGHT&q=knee");
+ expect(history.pushes()).toEqual([]);
+ expect(history.replaces().at(-1)).toBe("?type=WEIGHT&q=knee");
+ expect(window.history.length).toBe(entries);
+ });
+
+ it("drops one facet with its clear button and keeps the others", async () => {
+ const { screen, pill } = await mount("?type=WEIGHT&source=MANUAL");
+ await screen.getByRole("button", { name: "Clear filter: Type" }).click();
+ await expectSearch("?source=MANUAL");
+ await expect.element(pill("Type")).toHaveAttribute("data-active", "false");
+ await expect.element(pill("Source: Manual")).toBeInTheDocument();
+ });
+
+ it("clears both date bounds in one press, two writes in a row", async () => {
+ const { screen } = await mount("?from=2026-05-12&to=2026-06-10&type=PULSE");
+ const range = screen.getByRole("button", { name: /^Date range: / });
+ await expect.element(range).toHaveTextContent("Date range: 05/12–06/10");
+ await screen
+ .getByRole("button", { name: "Clear filter: Date range" })
+ .click();
+ await expectSearch("?type=PULSE");
+ await expect
+ .element(screen.getByRole("button", { name: "Date range" }))
+ .toHaveAttribute("data-active", "false");
+ });
+
+ it("resets every facet to a bare URL and hides Reset", async () => {
+ const { reset, pill, history } = await mount("?type=WEIGHT&source=MANUAL");
+ await reset().click();
+ await expectSearch("");
+ expect(history.pushes()).toEqual([""]);
+ await expect.element(pill("Type")).toHaveAttribute("data-active", "false");
+ await expect
+ .element(pill("Source"))
+ .toHaveAttribute("data-active", "false");
+ await expect.element(reset()).not.toBeInTheDocument();
+ });
+
+ it("re-seeds from an address changed outside the component", async () => {
+ const { pill, reset } = await mount("?type=WEIGHT");
+ navigateExternally("?source=WITHINGS");
+ await expect.element(pill("Source: Withings")).toBeInTheDocument();
+ await expect.element(pill("Type")).toHaveAttribute("data-active", "false");
+ navigateExternally("");
+ await expect.element(reset()).not.toBeInTheDocument();
+ });
+
+ it("steps back through filter states with the browser's Back", async () => {
+ const { pill, pick } = await mount("?type=WEIGHT");
+ await pick("Type: Weight", "Pulse");
+ await expectSearch("?type=PULSE");
+ window.history.back();
+ await expectSearch("?type=WEIGHT");
+ await expect.element(pill("Type: Weight")).toBeInTheDocument();
+ });
+
+ it("keeps the state in memory and the address untouched with syncToUrl off", async () => {
+ const { pill, pick, history } = await mount("?type=WEIGHT", {
+ syncToUrl: false,
+ });
+ await expect.element(pill("Type")).toHaveAttribute("data-active", "false");
+ await pick("Type", "Pulse");
+ await expect.element(pill("Type: Pulse")).toBeInTheDocument();
+ expect(currentSearch()).toBe("?type=WEIGHT");
+ expect(history.writes).toEqual([]);
+ });
+
+ it("names Reset and the clear buttons in the reader's language", async () => {
+ setSearch("?source=MANUAL");
+ const { screen } = await renderWithApp(, { locale: "de" });
+ await expect
+ .element(screen.getByRole("button", { name: "Zurücksetzen" }))
+ .toBeVisible();
+ await expect
+ .element(screen.getByRole("button", { name: "Filter entfernen: Source" }))
+ .toBeInTheDocument();
+ });
+});
diff --git a/src/hooks/__tests__/use-url-filter-sync.test.tsx b/src/hooks/__tests__/use-url-filter-sync.test.tsx
deleted file mode 100644
index 81e5a00bd..000000000
--- a/src/hooks/__tests__/use-url-filter-sync.test.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-/**
- * The shared URL-owned-filter hook (`use-url-filter-sync.ts`) — the
- * documents-vault pattern extracted for the measurements and mood lists.
- * Pinned here: mount parses the live query string, a filter change writes
- * the serialised form back through the router (push by default, replace on
- * request), an empty filter set restores the bare pathname, and
- * `syncToUrl: false` detaches the state from the URL entirely.
- */
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { renderToStaticMarkup } from "react-dom/server";
-
-let mockSearch = "";
-const pushMock = vi.fn();
-const replaceMock = vi.fn();
-vi.mock("next/navigation", () => ({
- useRouter: () => ({ push: pushMock, replace: replaceMock }),
- usePathname: () => "/measurements",
- useSearchParams: () => new URLSearchParams(mockSearch),
-}));
-
-import { useUrlFilterSync } from "../use-url-filter-sync";
-import {
- installUrlStateBrowser,
- type UrlStateBrowser,
-} from "@/__tests__/helpers/url-state-browser";
-
-let browser: UrlStateBrowser = installUrlStateBrowser("/");
-afterEach(() => browser.uninstall());
-
-interface ProbeFilters {
- type?: string;
- source?: string;
-}
-
-function parse(params: URLSearchParams): ProbeFilters {
- const filters: ProbeFilters = {};
- const type = params.get("type");
- if (type) filters.type = type;
- const source = params.get("source");
- if (source) filters.source = source;
- return filters;
-}
-
-function serialise(filters: ProbeFilters): string {
- const sp = new URLSearchParams();
- if (filters.type) sp.set("type", filters.type);
- if (filters.source) sp.set("source", filters.source);
- return sp.toString();
-}
-
-interface ProbeResult {
- filters: ProbeFilters;
- applyFilters: (next: ProbeFilters, mode?: "push" | "replace") => void;
-}
-
-function mount(search: string, syncToUrl?: boolean): ProbeResult {
- mockSearch = search;
- // Ref-shaped capture cell: the render-scoped assignment happens inside a
- // closure the control-flow analysis cannot see through.
- const captured: { current: ProbeResult | null } = { current: null };
- function Probe() {
- captured.current = useUrlFilterSync({
- parse,
- serialise,
- syncToUrl,
- });
- return null;
- }
- renderToStaticMarkup();
- if (!captured.current) throw new Error("probe did not render");
- // The live address the writes start from (`@/lib/url-state`).
- browser.uninstall();
- browser = installUrlStateBrowser(
- search ? `/measurements?${search}` : "/measurements",
- );
- return captured.current;
-}
-
-beforeEach(() => {
- pushMock.mockClear();
- replaceMock.mockClear();
-});
-
-describe("useUrlFilterSync", () => {
- it("parses the mounted query string into the filter object", () => {
- const { filters } = mount("type=WEIGHT&source=WITHINGS");
- expect(filters).toEqual({ type: "WEIGHT", source: "WITHINGS" });
- });
-
- it("writes a filter change to the URL via push by default", () => {
- const { applyFilters } = mount("");
- applyFilters({ type: "WEIGHT" });
- expect(browser.push).toHaveBeenCalledWith("/measurements?type=WEIGHT");
- expect(browser.replace).not.toHaveBeenCalled();
- });
-
- it("writes via replace when asked (high-frequency input)", () => {
- const { applyFilters } = mount("");
- applyFilters({ source: "MANUAL" }, "replace");
- expect(browser.replace).toHaveBeenCalledWith("/measurements?source=MANUAL");
- expect(browser.push).not.toHaveBeenCalled();
- });
-
- it("restores the bare pathname when every facet clears", () => {
- const { applyFilters } = mount("type=WEIGHT");
- applyFilters({});
- expect(browser.push).toHaveBeenCalledWith("/measurements");
- });
-
- it("syncToUrl: false ignores the URL and never navigates", () => {
- const { filters, applyFilters } = mount("type=WEIGHT", false);
- expect(filters).toEqual({});
- applyFilters({ type: "PULSE" });
- expect(browser.push).not.toHaveBeenCalled();
- expect(browser.replace).not.toHaveBeenCalled();
- });
-});
diff --git a/vitest.config.mts b/vitest.config.mts
index 996edbdec..51fa3430b 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -132,8 +132,13 @@ export default defineConfig({
// Pre-bundle every dependency the browser files reach before the
// first one loads. Discovered late, a dependency makes Vite re-bundle
// and reload the page mid-run, which fails the file that was
- // importing it; CI starts every run on an empty cache.
+ // importing it; CI starts every run on an empty cache. `force`
+ // makes a local run start there too: a cache warmed before a new
+ // browser file existed does not know that file's dependencies, and
+ // the late re-bundle loads a second React ("Invalid hook call").
+ // A cold bundle costs about half a second.
optimizeDeps: {
+ force: true,
entries: ["src/**/*.browser.test.tsx", "tests/browser/**/*.{ts,tsx}"],
},
test: {