mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
fix(url): write page state into the query string through one serialised helper
Next 16.4 adopts a native history write a render later. A second write inside that gap was undone when the router adopted the first, and a write computed from useSearchParams started from the URL before the last one. Clearing the vault's filters could therefore leave ?encounter= behind: the debounced search wrote the filters it had rendered back over the clear. @/lib/url-state is now the one way a page writes its own query: each write starts from the live address bar, at most one waits for the router at a time, and later updates queue on top of it and go together once the router has it (or after a short grace). useUrlSearch reads the same live value and reports each adoption. The filter hook (documents, measurements, mood), the open document, the day layer, the timeline's zoom and range, the medication tabs, the AI provider choice, the Coach's ?c= and the one-shot parameter strips all go through it; the filter changes merge into the live filters. A guard refuses raw history writes and router writes built from the rendered params outside the helper. The timeline spec now waits for the values menu to hand focus back before scrolling to the last row; it measured mid-animation and the focus return scrolled the page back.
This commit is contained in:
1 parent
333dfe8746
commit
d8e4cf6000
27 files changed
+846
-276
No files matched your search
@@ -6,7 +6,7 @@
|
||||
* request), an empty filter set restores the bare pathname, and
|
||||
* `syncToUrl: false` detaches the state from the URL entirely.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
let mockSearch = "";
|
||||
@@ -19,6 +19,13 @@ vi.mock("next/navigation", () => ({
|
||||
}));
|
||||
|
||||
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;
|
||||
@@ -61,6 +68,11 @@ function mount(search: string, syncToUrl?: boolean): ProbeResult {
|
||||
}
|
||||
renderToStaticMarkup(<Probe />);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -78,32 +90,28 @@ describe("useUrlFilterSync", () => {
|
||||
it("writes a filter change to the URL via push by default", () => {
|
||||
const { applyFilters } = mount("");
|
||||
applyFilters({ type: "WEIGHT" });
|
||||
expect(pushMock).toHaveBeenCalledWith("/measurements?type=WEIGHT", {
|
||||
scroll: false,
|
||||
});
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
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(replaceMock).toHaveBeenCalledWith("/measurements?source=MANUAL", {
|
||||
scroll: false,
|
||||
});
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
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(pushMock).toHaveBeenCalledWith("/measurements", { scroll: false });
|
||||
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(pushMock).not.toHaveBeenCalled();
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
expect(browser.push).not.toHaveBeenCalled();
|
||||
expect(browser.replace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { setAllParams, useUrlSearch, writeUrlState } from "@/lib/url-state";
|
||||
|
||||
/**
|
||||
* URL-owned filter state — the documents-vault pattern
|
||||
* (`src/components/documents/documents-view.tsx`), extracted so the
|
||||
@@ -20,6 +21,13 @@ import { useCallback, useMemo, useState } from "react";
|
||||
* the vault's kind/year chips; high-frequency typed input passes `replace` so
|
||||
* the history never fills with keystrokes, like the vault's debounced search.
|
||||
*
|
||||
* Reads and writes go through `@/lib/url-state`: the filters parse from the
|
||||
* live address bar, and `applyFilters` takes either the next filters or an
|
||||
* updater that receives the live ones at the moment of the write. A change
|
||||
* computed from a rendered copy wrote the previous filters back when Next
|
||||
* adopted an earlier write a render late (clearing the vault's filters left
|
||||
* `?encounter=` behind).
|
||||
*
|
||||
* `syncToUrl: false` keeps the identical contract in component memory only —
|
||||
* for a list embedded under a URL that belongs to another surface (the
|
||||
* type-locked insights readings subpage).
|
||||
@@ -34,16 +42,16 @@ export function useUrlFilterSync<T>({
|
||||
syncToUrl?: boolean;
|
||||
}): {
|
||||
filters: T;
|
||||
applyFilters: (next: T, mode?: "push" | "replace") => void;
|
||||
applyFilters: (
|
||||
next: T | ((current: T) => T),
|
||||
mode?: "push" | "replace",
|
||||
) => void;
|
||||
/** Merge `patch` into the live filters (the common facet change). */
|
||||
patchFilters: (patch: Partial<T>, mode?: "push" | "replace") => void;
|
||||
} {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const params = useUrlSearch();
|
||||
|
||||
const urlFilters = useMemo(
|
||||
() => parse(new URLSearchParams(searchParams.toString())),
|
||||
[parse, searchParams],
|
||||
);
|
||||
const urlFilters = useMemo(() => parse(params), [parse, params]);
|
||||
|
||||
// Memory-only fallback for `syncToUrl: false`. The hook always mounts both
|
||||
// sources (hooks are unconditional); only one is read.
|
||||
@@ -52,21 +60,33 @@ export function useUrlFilterSync<T>({
|
||||
);
|
||||
|
||||
const applyFilters = useCallback(
|
||||
(next: T, mode: "push" | "replace" = "push") => {
|
||||
(next: T | ((current: T) => T), mode: "push" | "replace" = "push") => {
|
||||
const resolve = (current: T): T =>
|
||||
typeof next === "function" ? (next as (c: T) => T)(current) : next;
|
||||
if (!syncToUrl) {
|
||||
setLocalFilters(next);
|
||||
setLocalFilters(resolve);
|
||||
return;
|
||||
}
|
||||
const search = serialise(next);
|
||||
const href = search ? `${pathname}?${search}` : pathname;
|
||||
if (mode === "push") {
|
||||
router.push(href, { scroll: false });
|
||||
} else {
|
||||
router.replace(href, { scroll: false });
|
||||
}
|
||||
writeUrlState(
|
||||
(live) => {
|
||||
const search = serialise(resolve(parse(live)));
|
||||
setAllParams(search)(live);
|
||||
},
|
||||
{ mode },
|
||||
);
|
||||
},
|
||||
[syncToUrl, serialise, pathname, router],
|
||||
[syncToUrl, serialise, parse],
|
||||
);
|
||||
|
||||
return { filters: syncToUrl ? urlFilters : localFilters, applyFilters };
|
||||
const patchFilters = useCallback(
|
||||
(patch: Partial<T>, mode: "push" | "replace" = "push") =>
|
||||
applyFilters((current) => ({ ...current, ...patch }), mode),
|
||||
[applyFilters],
|
||||
);
|
||||
|
||||
return {
|
||||
filters: syncToUrl ? urlFilters : localFilters,
|
||||
applyFilters,
|
||||
patchFilters,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user