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:
Marc-André Bombeck committed 2026-10-23 02:04:21 +02:00
1 parent 333dfe8746
commit d8e4cf6000
27 files changed
+846 -276

No files matched your search

@@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { reportRouterSearch, resetUrlStateForTests } from "@/lib/url-state";
/**
* The history contract of the day layer, against a small stand-in for the
* browser's session history:
@@ -27,6 +29,10 @@ function installFakeBrowser(initialUrl: string) {
get search() {
return new URL(entries[index]!.url, "http://x").search;
},
hash: "",
get href() {
return new URL(entries[index]!.url, "http://x").href;
},
};
const history = {
get state() {
@@ -48,11 +54,31 @@ function installFakeBrowser(initialUrl: string) {
},
};
const g = globalThis as unknown as Record<string, unknown>;
g.window = { location, history };
g.window = {
location,
history,
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return true;
},
};
g.document = { activeElement: null, body: {} };
// Next adopts each write a render later; the URL-state queue holds the
// next write until it has. Reading the address bar stands for the router
// catching up with every write made so far.
const catchUp = () => {
for (let i = 0; i < 10; i++) reportRouterSearch(location.search);
};
return {
current: () => entries[index]!.url,
length: () => entries.length,
current: () => {
catchUp();
return entries[index]!.url;
},
length: () => {
catchUp();
return entries.length;
},
};
}
@@ -61,6 +87,7 @@ let browser: ReturnType<typeof installFakeBrowser>;
const TODAY = "2026-01-10";
beforeEach(() => {
resetUrlStateForTests();
browser = installFakeBrowser("/insights/blood-pressure?range=90");
});
+11 -21
View File
@@ -3,13 +3,13 @@
import { useSyncExternalStore } from "react";
import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract";
import { currentUrlSearch, writeUrlState } from "@/lib/url-state";
import {
dayHistoryState,
historyEntryOwnedByLayer,
parseDayParam,
shiftDateKey,
withDayHref,
} from "./day-url";
/**
@@ -134,10 +134,6 @@ export interface OpenDayOptions {
trigger?: HTMLElement | null;
}
function currentLocation() {
return { pathname: window.location.pathname, search: window.location.search };
}
/** Open `date` over the current page. */
export function openDay(date: DateKey, options: OpenDayOptions = {}): void {
if (typeof window === "undefined") return;
@@ -154,13 +150,10 @@ export function openDay(date: DateKey, options: OpenDayOptions = {}): void {
? memory.trigger.getBoundingClientRect().top
: null;
emit();
const { pathname, search } = currentLocation();
const href = withDayHref(pathname, search, date);
if (historyEntryOwnedByLayer(window.history.state)) {
window.history.replaceState(dayHistoryState(date), "", href);
} else {
window.history.pushState(dayHistoryState(date), "", href);
}
writeUrlState((params) => params.set(DAY_QUERY_PARAM, date), {
mode: historyEntryOwnedByLayer(window.history.state) ? "replace" : "push",
state: dayHistoryState(date),
});
}
/**
@@ -191,7 +184,8 @@ let pendingStep: { delta: number; today: DateKey } | null = null;
export function stepDay(delta: number, today: DateKey): DateKey | null {
if (typeof window === "undefined") return null;
const from = parseDayParam(
new URLSearchParams(window.location.search).get(DAY_QUERY_PARAM),
// The live address, a queued write included (`@/lib/url-state`).
new URLSearchParams(currentUrlSearch()).get(DAY_QUERY_PARAM),
today,
);
if (from === null) {
@@ -218,15 +212,12 @@ export function stepDay(delta: number, today: DateKey): DateKey | null {
export function jumpDay(next: DateKey): void {
memory.focus = null;
emit();
const { pathname, search } = currentLocation();
const owned = historyEntryOwnedByLayer(window.history.state);
window.history.replaceState(
writeUrlState((params) => params.set(DAY_QUERY_PARAM, next), {
// A deep link stays a deep link: marking it as pushed would make a later
// close go Back off the page the person arrived on.
owned ? dayHistoryState(next) : null,
"",
withDayHref(pathname, search, next),
);
state: owned ? dayHistoryState(next) : null,
});
}
/**
@@ -283,6 +274,5 @@ export function closeDay(): void {
window.history.back();
return;
}
const { pathname, search } = currentLocation();
window.history.replaceState(null, "", withDayHref(pathname, search, null));
writeUrlState((params) => params.delete(DAY_QUERY_PARAM));
}
+11 -16
View File
@@ -2,6 +2,8 @@
import dynamic from "next/dynamic";
import { usePathname, useSearchParams } from "next/navigation";
import { reportRouterSearch, writeUrlState } from "@/lib/url-state";
import {
Suspense,
useCallback,
@@ -56,12 +58,7 @@ import {
takeDayYield,
useDayFocus,
} from "./day-layer-controller";
import {
parseDayParam,
shiftDateKey,
stripDayOf,
withDayHref,
} from "./day-url";
import { parseDayParam, shiftDateKey, stripDayOf } from "./day-url";
import { useLongDayLabel } from "./day-label";
import type { DayViewProps } from "./day-view";
import { usePrefetchDay } from "./use-day";
@@ -167,12 +164,14 @@ function DayLayer() {
// A parameter the layer cannot open leaves the URL quietly.
useEffect(() => {
if (raw === null || date !== null) return;
window.history.replaceState(
null,
"",
withDayHref(window.location.pathname, window.location.search, null),
);
writeUrlState((params) => params.delete(DAY_QUERY_PARAM));
}, [raw, date]);
// The layer reads the router's copy of the URL; it tells the URL-state
// queue when that copy has caught up with a write.
const routerSearch = searchParams.toString();
useEffect(() => {
reportRouterSearch(routerSearch);
}, [routerSearch]);
useEffect(() => {
publishOpenDay(date);
@@ -282,11 +281,7 @@ function DayLayer() {
// Read again, not from the render: a Back on this page has just closed it.
if (!readDayOpen(owner)) return;
restoring.current = true;
window.history.replaceState(
null,
"",
withDayHref(window.location.pathname, window.location.search, railDay),
);
writeUrlState((params) => params.set(DAY_QUERY_PARAM, railDay));
}, [shell, raw, leftOpen, railDay, owner]);
// What the polite region says: the day that just opened or was stepped to.
+49 -70
View File
@@ -30,15 +30,8 @@ import {
Wrench,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { usePathname, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
@@ -53,6 +46,12 @@ import { useAuth } from "@/hooks/use-auth";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { useUrlFilterSync } from "@/hooks/use-url-filter-sync";
import {
currentUrlSearch,
urlStateHistory,
useUrlSearch,
writeUrlState,
} from "@/lib/url-state";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import {
@@ -117,26 +116,6 @@ const BULK_VISIT_OPTIONS = 12;
const DOCUMENT_QUERY_ID_PATTERN = /^[a-zA-Z0-9_-]{1,40}(?![\s\S])/;
const DOCUMENT_SELECTION_HISTORY_KEY = "__healthlogPushedDocumentSelection";
/**
* The query string in the address bar, read at render time.
*
* The open sheet follows `?doc=`, and `useSearchParams` is not a faithful
* copy of it: Next 16.4 applies the copy of a `pushState` later than the write,
* and a Back pressed in between moves the address bar from `?doc=` to the
* page without it while `useSearchParams` can read "no document" on both sides.
* The sheet then saw no change and stayed open. `window.location` is never
* behind; `popstate` re-reads it on every traversal, and every other render
* (including the ones Next's own navigations cause) reads it afresh.
*/
function subscribeToTraversal(onChange: () => void): () => void {
window.addEventListener("popstate", onChange);
return () => window.removeEventListener("popstate", onChange);
}
function readLocationSearch(): string {
return window.location.search;
}
export function documentSelectionHistoryState(
documentId: string,
): Record<string, unknown> {
@@ -287,7 +266,6 @@ export function DocumentsView() {
const canManageDocuments = canManageDomain("documents");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const coachLaunch = useCoachLaunch();
@@ -326,8 +304,12 @@ export function DocumentsView() {
const handle = setTimeout(() => {
const trimmed = searchDraft.trim();
if (trimmed === (filters.q ?? "")) return;
// Live filters, not the rendered ones a "Clear filters" just removed.
applyFilters(
{ ...filters, q: trimmed === "" ? undefined : trimmed },
(current) =>
trimmed === (current.q ?? "")
? current
: { ...current, q: trimmed === "" ? undefined : trimmed },
"replace",
);
}, 200);
@@ -356,46 +338,46 @@ export function DocumentsView() {
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
// Every facet toggles against the live filters (`applyFilters` updater).
const toggleKind = (kind: InboundDocumentKindValue) => {
const current = new Set(filters.kinds ?? []);
if (current.has(kind)) {
current.delete(kind);
} else {
current.add(kind);
}
applyFilters(
{
...filters,
kinds: current.size > 0 ? [...current].sort() : undefined,
},
"push",
);
applyFilters((current) => {
const kinds = new Set(current.kinds ?? []);
if (kinds.has(kind)) kinds.delete(kind);
else kinds.add(kind);
return {
...current,
kinds: kinds.size > 0 ? [...kinds].sort() : undefined,
};
}, "push");
};
const toggleEpisode = (episodeId: string) => {
applyFilters(
{
...filters,
episodeId: filters.episodeId === episodeId ? undefined : episodeId,
},
(current) => ({
...current,
episodeId: current.episodeId === episodeId ? undefined : episodeId,
}),
"push",
);
};
const toggleEncounter = (encounterId: string) => {
applyFilters(
{
...filters,
(current) => ({
...current,
encounterId:
filters.encounterId === encounterId ? undefined : encounterId,
},
current.encounterId === encounterId ? undefined : encounterId,
}),
"push",
);
};
const toggleYear = (year: number) => {
applyFilters(
{ ...filters, year: filters.year === year ? undefined : year },
(current) => ({
...current,
year: current.year === year ? undefined : year,
}),
"push",
);
};
@@ -845,13 +827,11 @@ export function DocumentsView() {
const [detailOpen, setDetailOpen] = useState(false);
const handedOffDocumentRef = useRef<string | null>(null);
// Before hydration the server's view of the query stands in for the URL.
const locationSearch = useSyncExternalStore(
subscribeToTraversal,
readLocationSearch,
() => searchParams.toString(),
);
const rawDocParam = new URLSearchParams(locationSearch).get("doc");
// `?doc=` read live (`@/lib/url-state`): Next 16.4 adopts a pushState a
// render after the write, and a Back pressed in between left the router's
// copy reading "no document" on both sides, so the sheet stayed open.
const liveParams = useUrlSearch();
const rawDocParam = liveParams.get("doc");
const docParam =
rawDocParam !== null && DOCUMENT_QUERY_ID_PATTERN.test(rawDocParam)
? rawDocParam
@@ -870,12 +850,15 @@ export function DocumentsView() {
const openDetail = useCallback(
(id: string) => {
const href = documentSelectionHref(pathname, window.location.search, id);
const href = documentSelectionHref(pathname, currentUrlSearch(), id);
if (href === null) return;
handedOffDocumentRef.current = null;
setDetailId(id);
setDetailOpen(true);
window.history.pushState(documentSelectionHistoryState(id), "", href);
writeUrlState((params) => params.set("doc", id), {
mode: "push",
state: documentSelectionHistoryState(id),
});
},
[pathname],
);
@@ -912,9 +895,9 @@ export function DocumentsView() {
);
handedOffDocumentRef.current = closeDocumentSelectionAfterCoachHandoff(
window.history,
urlStateHistory,
pathname,
window.location.search,
currentUrlSearch(),
docParam,
handedOffDocumentRef.current,
coachLaunch?.closeIntent,
@@ -940,14 +923,10 @@ export function DocumentsView() {
// the moment the sheet re-opens or the parameter changes — which is what
// makes re-opening within the window safe.
const verify = window.setTimeout(() => {
if (!documentSelectionSurvivedClose(window.location.search, docParam)) {
if (!documentSelectionSurvivedClose(currentUrlSearch(), docParam)) {
return;
}
window.history.replaceState(
null,
"",
withoutDocumentSelectionHref(pathname, window.location.search),
);
writeUrlState((params) => params.delete("doc"));
}, CLOSE_TRAVERSAL_GRACE_MS);
return () => window.clearTimeout(verify);
}, [
@@ -68,6 +68,7 @@ import {
useSendCoachMessage,
} from "./use-coach";
import { translateOrNull } from "@/lib/i18n/translate-code";
import { currentUrlSearch, writeUrlHref } from "@/lib/url-state";
/**
* v1.12.0 (Coach v2 #6) — shared chat surface for both the Coach drawer
@@ -371,9 +372,8 @@ export function CoachConversation({
// a shared tab) lands on it. The drawer has no URL of its own.
function syncPageUrl(conversationId: string | null) {
if (surface !== "page" || typeof window === "undefined") return;
const href = coachPageHref(conversationId, window.location.search);
if (window.location.pathname + window.location.search === href) return;
window.history.replaceState(window.history.state, "", href);
const href = coachPageHref(conversationId, currentUrlSearch());
writeUrlHref(href, { state: "keep" });
}
const send = useSendCoachMessage({
@@ -15,7 +15,7 @@
* user's click (post-render setState is a no-op under the server renderer;
* the router spies record the navigation the click causes).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const baseMeasurements = [
@@ -113,6 +113,13 @@ vi.mock("@/components/ui/filter-bar", () => ({
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { MeasurementList } from "../measurement-list";
import {
installUrlStateBrowser,
type UrlStateBrowser,
} from "@/__tests__/helpers/url-state-browser";
let browser: UrlStateBrowser = installUrlStateBrowser("/");
afterEach(() => browser.uninstall());
function render(search: string) {
mockSearch = search;
@@ -121,11 +128,17 @@ function render(search: string) {
dateRange = null;
valueRange = null;
recordedQueryKeys.length = 0;
return renderToStaticMarkup(
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementList />
</I18nProvider>,
);
// The live address the writes start from (`@/lib/url-state`).
browser.uninstall();
browser = installUrlStateBrowser(
search ? `/measurements?${search}` : "/measurements",
);
return html;
}
const typeSelect = () => selects[0]!;
@@ -140,40 +153,37 @@ describe("MeasurementList — filter changes write the URL", () => {
it("pushes the type facet", () => {
render("");
typeSelect().onValueChange("WEIGHT");
expect(pushMock).toHaveBeenCalledWith("/measurements?type=WEIGHT", {
scroll: false,
});
expect(browser.push).toHaveBeenCalledWith("/measurements?type=WEIGHT");
});
it("pushes the source facet on top of the active type", () => {
render("type=WEIGHT");
sourceSelect().onValueChange("WITHINGS");
expect(pushMock).toHaveBeenCalledWith(
expect(browser.push).toHaveBeenCalledWith(
"/measurements?type=WEIGHT&source=WITHINGS",
{ scroll: false },
);
});
it("replaces (no history entry) for a committed date bound", () => {
render("");
dateRange!.onFromChange!("2026-05-01");
expect(replaceMock).toHaveBeenCalledWith("/measurements?from=2026-05-01", {
scroll: false,
});
expect(pushMock).not.toHaveBeenCalled();
expect(browser.replace).toHaveBeenCalledWith(
"/measurements?from=2026-05-01",
);
expect(browser.push).not.toHaveBeenCalled();
});
it("clearing the last facet restores the bare pathname", () => {
render("type=WEIGHT");
typeSelect().onValueChange("ALL");
expect(pushMock).toHaveBeenCalledWith("/measurements", { scroll: false });
expect(browser.push).toHaveBeenCalledWith("/measurements");
});
it("reset clears every facet in one push", () => {
render("type=WEIGHT&source=WITHINGS&from=2026-05-01&min=60");
expect(barProps!.isFiltered).toBe(true);
barProps!.onReset();
expect(pushMock).toHaveBeenCalledWith("/measurements", { scroll: false });
expect(browser.push).toHaveBeenCalledWith("/measurements");
});
});
@@ -411,7 +411,7 @@ export function MeasurementList({
// v1.8.5 — when `lockedType` is set the list is pinned to that metric, the
// type selector is hidden, and the URL belongs to the insights subpage —
// the remaining filters stay in component memory (`syncToUrl: false`).
const { filters, applyFilters } = useUrlFilterSync({
const { filters, applyFilters, patchFilters } = useUrlFilterSync({
parse: parseMeasurementListSearchParams,
serialise: measurementListFiltersToSearch,
syncToUrl: lockedType === undefined,
@@ -505,25 +505,25 @@ export function MeasurementList({
// vault's chips); the committed date bounds replace — a from/to pair set in
// one sitting should not cost two Back presses.
const setTypeFilter = (value: string) => {
applyFilters({ ...filters, type: value === "ALL" ? undefined : value });
patchFilters({ type: value === "ALL" ? undefined : value });
setPage(1);
clearSelection();
};
const setSourceFilter = (value: string) => {
applyFilters({ ...filters, source: value === "ALL" ? undefined : value });
patchFilters({ source: value === "ALL" ? undefined : value });
setPage(1);
clearSelection();
};
const setFromDay = (value: string) => {
applyFilters({ ...filters, fromDay: value || undefined }, "replace");
patchFilters({ fromDay: value || undefined }, "replace");
setPage(1);
clearSelection();
};
const setToDay = (value: string) => {
applyFilters({ ...filters, toDay: value || undefined }, "replace");
patchFilters({ toDay: value || undefined }, "replace");
setPage(1);
clearSelection();
};
@@ -553,10 +553,10 @@ export function MeasurementList({
) {
return;
}
applyFilters({ ...filters, valueMin: min, valueMax: max }, "replace");
patchFilters({ valueMin: min, valueMax: max }, "replace");
}, 200);
return () => clearTimeout(handle);
}, [valueDraft, filters, applyFilters]);
}, [valueDraft, filters, patchFilters]);
const goToPage = (updater: (p: number) => number) => {
setPage(updater);
@@ -50,7 +50,7 @@ import {
} from "react";
import Link from "next/link";
import dynamic from "next/dynamic";
import { useRouter, useSearchParams } from "next/navigation";
import { useUrlSearch, writeUrlState } from "@/lib/url-state";
import { ArrowLeft, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -309,8 +309,8 @@ export function MedicationDetailTabs({
const fmt = useFormatters();
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
const canManageMedications = canManageDomain("medications");
const router = useRouter();
const searchParams = useSearchParams();
// Read live (`@/lib/url-state`): the tab is written here.
const searchParams = useUrlSearch();
// v1.15.20 — the medications-list kebab's "Bearbeiten" arrives as
// `?edit=1` and opens the wizard straight away (the same editOpen the
@@ -407,33 +407,17 @@ export function MedicationDetailTabs({
useEffect(() => {
if (shouldOpenEditFromUrl) {
// Drop `?edit=1` (keep any `tab` param) so a refresh after closing
// the wizard does not keep reopening it.
const params = new URLSearchParams(
Array.from(searchParams?.entries() ?? []),
);
params.delete("edit");
const qs = params.toString();
router.replace(`/medications/${id}${qs ? `?${qs}` : ""}`, {
scroll: false,
});
// the wizard does not keep reopening it. Made while mounting, so it
// waits for the router's history integration (`afterPaint`).
writeUrlState((params) => params.delete("edit"), { afterPaint: true });
}
}, [shouldOpenEditFromUrl, id, router, searchParams]);
}, [shouldOpenEditFromUrl]);
const onTabChange = useCallback(
(next: string) => {
const params = new URLSearchParams(
Array.from(searchParams?.entries() ?? []),
);
params.set("tab", next);
// Shallow URL swap so the tab is deep-linkable + survives reload
// without a full navigation. `scroll: false` keeps the viewport
// pinned to the tab strip on switch.
router.replace(`/medications/${id}?${params.toString()}`, {
scroll: false,
});
},
[id, router, searchParams],
);
const onTabChange = useCallback((next: string) => {
// The tab is deep-linkable and survives a reload; a shallow in-place
// write keeps the viewport pinned to the tab strip on switch.
writeUrlState((params) => params.set("tab", next));
}, []);
const { data: compliance } = useQuery({
queryKey: queryKeys.medicationCompliance(id),
@@ -10,7 +10,7 @@
* user's click (post-render setState is a no-op under the server renderer;
* the router spies record the navigation the click causes).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const baseEntries = [
@@ -101,6 +101,13 @@ vi.mock("@/components/ui/filter-bar", () => ({
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { MoodList } from "../mood-list";
import {
installUrlStateBrowser,
type UrlStateBrowser,
} from "@/__tests__/helpers/url-state-browser";
let browser: UrlStateBrowser = installUrlStateBrowser("/");
afterEach(() => browser.uninstall());
function render(search: string) {
mockSearch = search;
@@ -108,11 +115,15 @@ function render(search: string) {
barProps = null;
dateRange = null;
recordedQueryKeys.length = 0;
return renderToStaticMarkup(
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MoodList />
</I18nProvider>,
);
// The live address the writes start from (`@/lib/url-state`).
browser.uninstall();
browser = installUrlStateBrowser(search ? `/mood?${search}` : "/mood");
return html;
}
const moodSelect = () => selects[0]!;
@@ -127,37 +138,33 @@ describe("MoodList — filter changes write the URL", () => {
it("pushes the mood facet", () => {
render("");
moodSelect().onValueChange("GUT");
expect(pushMock).toHaveBeenCalledWith("/mood?mood=GUT", { scroll: false });
expect(browser.push).toHaveBeenCalledWith("/mood?mood=GUT");
});
it("pushes the source facet on top of the active mood", () => {
render("mood=GUT");
sourceSelect().onValueChange("TELEGRAM");
expect(pushMock).toHaveBeenCalledWith("/mood?mood=GUT&source=TELEGRAM", {
scroll: false,
});
expect(browser.push).toHaveBeenCalledWith("/mood?mood=GUT&source=TELEGRAM");
});
it("replaces (no history entry) for a committed date bound", () => {
render("");
dateRange!.onToChange("2026-05-31");
expect(replaceMock).toHaveBeenCalledWith("/mood?to=2026-05-31", {
scroll: false,
});
expect(pushMock).not.toHaveBeenCalled();
expect(browser.replace).toHaveBeenCalledWith("/mood?to=2026-05-31");
expect(browser.push).not.toHaveBeenCalled();
});
it("clearing the last facet restores the bare pathname", () => {
render("mood=GUT");
moodSelect().onValueChange("ALL");
expect(pushMock).toHaveBeenCalledWith("/mood", { scroll: false });
expect(browser.push).toHaveBeenCalledWith("/mood");
});
it("reset clears every facet in one push", () => {
render("mood=GUT&source=TELEGRAM&from=2026-05-01");
expect(barProps!.isFiltered).toBe(true);
barProps!.onReset();
expect(pushMock).toHaveBeenCalledWith("/mood", { scroll: false });
expect(browser.push).toHaveBeenCalledWith("/mood");
});
});
+5 -5
View File
@@ -197,7 +197,7 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) {
// through the router, so a filtered view survives navigation, reload and
// back/forward.
// v1.15.13 — management-list source filter + optional date range.
const { filters, applyFilters } = useUrlFilterSync({
const { filters, applyFilters, patchFilters } = useUrlFilterSync({
parse: parseMoodListSearchParams,
serialise: moodListFiltersToSearch,
});
@@ -298,25 +298,25 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) {
// vault's chips); the committed date bounds replace — a from/to pair set in
// one sitting should not cost two Back presses.
const setMoodFilter = (value: string) => {
applyFilters({ ...filters, mood: value === "ALL" ? undefined : value });
patchFilters({ mood: value === "ALL" ? undefined : value });
setPage(1);
clearSelection();
};
const setSourceFilter = (value: string) => {
applyFilters({ ...filters, source: value === "ALL" ? undefined : value });
patchFilters({ source: value === "ALL" ? undefined : value });
setPage(1);
clearSelection();
};
const setFromDay = (value: string) => {
applyFilters({ ...filters, fromDay: value || undefined }, "replace");
patchFilters({ fromDay: value || undefined }, "replace");
setPage(1);
clearSelection();
};
const setToDay = (value: string) => {
applyFilters({ ...filters, toDay: value || undefined }, "replace");
patchFilters({ toDay: value || undefined }, "replace");
setPage(1);
clearSelection();
};
@@ -14,7 +14,7 @@
*/
import { useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
@@ -51,6 +51,7 @@ import {
type ProviderType,
type UserAIProvider,
} from "./shared";
import { writeUrlState } from "@/lib/url-state";
export function AiInsightsCard({
isAuthenticated,
@@ -59,8 +60,6 @@ export function AiInsightsCard({
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// A read failure must surface, not be swallowed into a null that renders the
@@ -146,10 +145,7 @@ export function AiInsightsCard({
function pickProvider(next: ProviderType) {
setSelectedProvider(next);
if (typeof window === "undefined") return;
const params = new URLSearchParams(searchParams?.toString() ?? "");
params.set("provider", next);
router.replace(`${pathname}?${params.toString()}`);
writeUrlState((params) => params.set("provider", next));
}
// v1.42 — one card per concern. The page used to be a single "AI provider"
@@ -19,6 +19,7 @@ import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
import type { InsightsSettings } from "./shared";
import { InlineAction } from "@/components/ui/inline-action";
import { dropParams, writeUrlState } from "@/lib/url-state";
export function CodexProviderForm({
settings,
@@ -52,10 +53,9 @@ export function CodexProviderForm({
useEffect(() => {
if (!oauthOutcome) return;
const url = new URL(window.location.href);
url.searchParams.delete("codex_connected");
url.searchParams.delete("codex_error");
window.history.replaceState({}, "", url.toString());
writeUrlState(dropParams("codex_connected", "codex_error"), {
afterPaint: true,
});
if (oauthOutcome.kind === "connected") {
void invalidateKeys(queryClient, aiInputDependentKeys);
}
@@ -39,6 +39,7 @@ import { useAuth } from "@/hooks/use-auth";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { dropParams, writeUrlState } from "@/lib/url-state";
/**
* The Withings OAuth callback (`/api/withings/callback`) redirects back
@@ -246,10 +247,7 @@ export function ConnectionsPanel({
if (!withingsOauthOutcome) return;
// Scrub the one-shot params so a reload / bookmark doesn't replay
// the toast.
const url = new URL(window.location.href);
url.searchParams.delete("withings");
url.searchParams.delete("reason");
router.replace(`${url.pathname}${url.search}`, { scroll: false });
writeUrlState(dropParams("withings", "reason"), { afterPaint: true });
if (withingsOauthOutcome.kind === "connected") {
toast.success(t("settings.withingsOauthConnected"));
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
@@ -282,10 +280,7 @@ export function ConnectionsPanel({
useEffect(() => {
if (!oauthOutcome) return;
const { provider } = oauthOutcome;
const url = new URL(window.location.href);
url.searchParams.delete(provider);
url.searchParams.delete("reason");
router.replace(`${url.pathname}${url.search}`, { scroll: false });
writeUrlState(dropParams(provider, "reason"), { afterPaint: true });
if (oauthOutcome.kind === "connected") {
toast.success(t(`settings.${provider}OauthConnected`));
// Polar/Oura own a per-card status query; WHOOP/Fitbit read off the
+12 -13
View File
@@ -19,7 +19,7 @@
* thin and the person has not closed it; empty lanes are never drawn.
*/
import { useCallback, useMemo, useState } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { setAllParams, useUrlSearch, writeUrlState } from "@/lib/url-state";
import { CalendarRange, Plus } from "lucide-react";
import { openDay as openDayLayer } from "@/components/day/day-layer-controller";
@@ -151,8 +151,9 @@ export function TimelineView() {
const { t } = useTranslations();
const { user } = useAuth();
const caps = useRecordCapabilities();
const pathname = usePathname();
const searchParams = useSearchParams();
// Read live (`@/lib/url-state`): the router's copy trails a write by a
// render, and the zoom, the range and `?day=` are all written here.
const searchParams = useUrlSearch();
const today = todayKeyIn(user?.timezone);
const dayParam = parseDayParam(searchParams.get(DAY_QUERY_PARAM), today);
// Life events are written in one's own record only (owner-only routes).
@@ -285,14 +286,7 @@ export function TimelineView() {
if (open) return;
setEditing(null);
if (searchParams.has("add")) {
const next = new URLSearchParams(searchParams.toString());
next.delete("add");
const search = next.toString();
window.history.replaceState(
null,
"",
search ? `${pathname}?${search}` : pathname,
);
writeUrlState((params) => params.delete("add"));
}
}
@@ -312,8 +306,13 @@ export function TimelineView() {
(range ? range.to : today);
function navigate(nextZoom: TimelineZoom, nextRange: TimelineRange | null) {
const search = timelineSearch(window.location.search, nextZoom, nextRange);
window.history.pushState(null, "", `${pathname}${search}`);
writeUrlState(
(params) => {
const next = timelineSearch(`?${params}`, nextZoom, nextRange);
setAllParams(next)(params);
},
{ mode: "push" },
);
}
function setZoom(next: Exclude<TimelineZoom, "range">) {