diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index c5d17530a..512931154 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -90,7 +90,7 @@ "components/documents/document-ai-section.tsx :: DocumentAiSection": 262, "components/documents/document-bulk-bar.tsx :: DocumentBulkBar": 204, "components/documents/document-card.tsx :: DocumentCard": 354, - "components/documents/document-detail-sheet.tsx :: DocumentDetailSheet": 325, + "components/documents/document-detail-sheet.tsx :: DocumentDetailSheet": 327, "components/documents/document-facts-review.tsx :: DocumentFactsSection": 242, "components/documents/document-facts-review.tsx :: FactRow": 261, "components/documents/document-filter-bar.tsx :: DocumentFilterBar": 348, @@ -229,7 +229,7 @@ "components/timeline/timeline-chart.tsx :: ChartBody": 236, "components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 246, "components/timeline/timeline-view.tsx :: TimelineView": 486, - "components/ui/responsive-sheet.tsx :: ResponsiveSheet": 253, + "components/ui/responsive-sheet.tsx :: ResponsiveSheet": 255, "components/vaccinations/catalog-picker.tsx :: CatalogPicker": 221, "components/vaccinations/custom-vaccine-sheet.tsx :: CustomVaccineSheet": 246, "components/vaccinations/vaccination-form.tsx :: VaccinationForm": 218, diff --git a/src/__tests__/url-state-writes-guard.test.ts b/src/__tests__/url-state-writes-guard.test.ts index bb4a087f9..29b7605fc 100644 --- a/src/__tests__/url-state-writes-guard.test.ts +++ b/src/__tests__/url-state-writes-guard.test.ts @@ -24,6 +24,8 @@ const ALLOWED: Record = { "lib/url-state.ts": "the helper itself", "components/clinician/share-unlock-gate.tsx": "scrubs a passphrase from the fragment synchronously, before the unlock request leaves; no query state", + "components/ui/sheet-history.ts": + "a sheet's own history entry: the page's URL unchanged, never query state", "components/documents/vault/selection-history.ts": "closeDocumentSelectionHistoryEntry takes a history-shaped argument for its tests; the page passes `urlStateHistory`", }; diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 25e3cd02a..a3fb0308d 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -131,6 +131,8 @@ export function DocumentDetailSheet({ void; + +/** + * A session history the way a browser keeps it: `back()` is queued and + * answered later with a popstate, capture listeners hear it before bubble + * ones, and the router is a bubble listener registered first. + */ +function fakeBrowser(start = "https://app.test/measurements") { + const entries: Array<{ state: unknown; href: string }> = [ + { state: { __NA: true }, href: start }, + ]; + let index = 0; + const queued: Array<() => void> = []; + const capture: Listener[] = []; + const bubble: Listener[] = []; + const routerHeard: string[] = []; + const timers = new Map void>(); + let timerId = 0; + + const resolve = (url: string | URL | null | undefined) => + url == null + ? entries[index].href + : new URL(String(url), entries[index].href).href; + + function dispatch() { + let stopped = false; + const event = { stopImmediatePropagation: () => (stopped = true) }; + for (const listener of [...capture, ...bubble]) { + if (stopped) break; + listener(event); + } + } + + function traverse(delta: number) { + const target = index + delta; + if (target < 0 || target >= entries.length) return; + index = target; + dispatch(); + } + + const host: SheetHistoryHost = { + history: { + get state() { + return entries[index].state; + }, + pushState(data, _unused, url) { + entries.splice(index + 1); + entries.push({ state: data, href: resolve(url) }); + index += 1; + }, + replaceState(data, _unused, url) { + entries[index] = { state: data, href: resolve(url) }; + }, + back() { + queued.push(() => traverse(-1)); + }, + }, + location: { + get href() { + return entries[index].href; + }, + }, + addEventListener(_type, listener, useCapture) { + (useCapture ? capture : bubble).push(listener); + }, + dispatchEvent: () => true, + setTimeout(callback) { + timerId += 1; + timers.set(timerId, callback); + return timerId; + }, + clearTimeout(handle) { + timers.delete(handle as number); + }, + }; + // The router listens from the start, in the bubble phase. + bubble.push(() => routerHeard.push(entries[index].href)); + + return { + host, + entries, + get index() { + return index; + }, + routerHeard, + /** The browser answers the Backs that were asked for. */ + settle() { + while (queued.length > 0) queued.shift()!(); + }, + /** The person presses Back. */ + pressBack() { + traverse(-1); + }, + /** The person picks an entry further back from the Back button's list. */ + go(delta: number) { + traverse(delta); + }, + runTimers() { + const pending = [...timers.values()]; + timers.clear(); + for (const run of pending) run(); + }, + }; +} + +function marked(state: unknown): boolean { + return ( + state !== null && + typeof state === "object" && + "__healthlogSheet" in (state as Record) + ); +} + +describe("sheet history", () => { + it("Back closes the sheet and the page under it stays where it was", () => { + const browser = fakeBrowser(); + const sheets = createSheetHistory(browser.host); + const onBack = vi.fn(); + sheets.open(onBack); + expect(browser.entries).toHaveLength(2); + + browser.pressBack(); + + expect(onBack).toHaveBeenCalledTimes(1); + expect(browser.index).toBe(0); + expect(browser.host.location.href).toBe("https://app.test/measurements"); + // Nothing changed for the page, so the router never hears of it. + expect(browser.routerHeard).toEqual([]); + }); + + it("a sheet closed by its own controls takes its entry off again", () => { + const browser = fakeBrowser(); + const sheets = createSheetHistory(browser.host); + const onBack = vi.fn(); + const entry = sheets.open(onBack); + + entry.release(); + browser.settle(); + + expect(browser.index).toBe(0); + expect(onBack).not.toHaveBeenCalled(); + expect(browser.routerHeard).toEqual([]); + expect(marked(browser.host.history.state)).toBe(false); + }); + + it("a page pushed right after the close is not undone by the close's Back", () => { + const browser = fakeBrowser("https://app.test/medications"); + const sheets = createSheetHistory(browser.host); + const entry = sheets.open(vi.fn()); + + // A save closes the wizard and opens the new medication at once. + entry.release(); + browser.host.history.pushState({ __NA: true }, "", "/medications/m1"); + browser.settle(); + + expect(browser.entries.map((e) => e.href)).toEqual([ + "https://app.test/medications", + "https://app.test/medications/m1", + ]); + expect(browser.index).toBe(1); + }); + + it("a link followed from inside the sheet replaces the sheet's entry", () => { + const browser = fakeBrowser("https://app.test/settings"); + const sheets = createSheetHistory(browser.host); + const entry = sheets.open(vi.fn()); + + browser.host.history.pushState({ __NA: true }, "", "/settings/security"); + entry.release(); + browser.settle(); + + expect(browser.entries.map((e) => e.href)).toEqual([ + "https://app.test/settings", + "https://app.test/settings/security", + ]); + expect(browser.index).toBe(1); + }); + + it("Back on a sheet that stays open (unsaved input) gives it its entry back", () => { + const browser = fakeBrowser(); + const sheets = createSheetHistory(browser.host); + let restore = () => {}; + const entry = sheets.open(() => restore()); + restore = entry.restore; + + browser.pressBack(); + expect(browser.index).toBe(1); + expect(marked(browser.host.history.state)).toBe(true); + + // Once it does close, its entry goes and one Back leaves the page. + entry.release(); + browser.settle(); + expect(browser.index).toBe(0); + }); + + it("Back closes only the top sheet of two", () => { + const browser = fakeBrowser(); + const sheets = createSheetHistory(browser.host); + const lower = vi.fn(); + const upper = vi.fn(); + sheets.open(lower); + sheets.open(upper); + + browser.pressBack(); + + expect(upper).toHaveBeenCalledTimes(1); + expect(lower).not.toHaveBeenCalled(); + expect(browser.index).toBe(1); + expect(marked(browser.host.history.state)).toBe(true); + }); + + it("two sheets closed lower first still leave no entry behind", () => { + const browser = fakeBrowser(); + const sheets = createSheetHistory(browser.host); + const lower = sheets.open(vi.fn()); + const upper = sheets.open(vi.fn()); + + lower.release(); + upper.release(); + browser.settle(); + + expect(browser.index).toBe(0); + expect(marked(browser.host.history.state)).toBe(false); + }); + + it("a URL the page replaced while the sheet was open is the one it keeps", () => { + // `?add=` opens the sheet on the first paint and is stripped after it. + const browser = fakeBrowser("https://app.test/measurements?add=WEIGHT"); + const sheets = createSheetHistory(browser.host); + const entry = sheets.open(vi.fn()); + browser.host.history.replaceState(null, "", "/measurements"); + // The router rewrites the current entry; the sheet keeps its mark. + expect(marked(browser.host.history.state)).toBe(true); + + entry.release(); + browser.settle(); + + expect(browser.index).toBe(0); + expect(browser.host.location.href).toBe("https://app.test/measurements"); + expect(browser.routerHeard).toEqual([]); + }); + + it("Back on a sheet opened by a one-shot URL does not bring the URL back", () => { + const browser = fakeBrowser("https://app.test/measurements?add=WEIGHT"); + const sheets = createSheetHistory(browser.host); + const onBack = vi.fn(); + sheets.open(onBack); + browser.host.history.replaceState(null, "", "/measurements"); + + browser.pressBack(); + + expect(onBack).toHaveBeenCalledTimes(1); + expect(browser.host.location.href).toBe("https://app.test/measurements"); + expect(browser.routerHeard).toEqual([]); + }); + + it("a Back that jumps past the page is the router's to handle", () => { + const browser = fakeBrowser("https://app.test/"); + browser.host.history.pushState({ __NA: true }, "", "/labs"); + const sheets = createSheetHistory(browser.host); + const onBack = vi.fn(); + sheets.open(onBack); + + browser.go(-2); + + expect(onBack).toHaveBeenCalledTimes(1); + expect(browser.routerHeard).toEqual(["https://app.test/"]); + }); + + it("writes stop waiting when the close's Back never arrives", () => { + const browser = fakeBrowser("https://app.test/medications"); + const sheets = createSheetHistory(browser.host); + const entry = sheets.open(vi.fn()); + + entry.release(); + browser.host.history.pushState({ __NA: true }, "", "/medications/m1"); + expect(browser.host.location.href).toBe("https://app.test/medications"); + + browser.runTimers(); + expect(browser.host.location.href).toBe("https://app.test/medications/m1"); + }); +}); diff --git a/src/components/ui/responsive-sheet.tsx b/src/components/ui/responsive-sheet.tsx index 41f9f5925..779bddc6d 100644 --- a/src/components/ui/responsive-sheet.tsx +++ b/src/components/ui/responsive-sheet.tsx @@ -5,6 +5,10 @@ import * as React from "react"; import { cn } from "@/lib/utils"; import { useIsMobile } from "@/hooks/use-is-mobile"; import { SeededFormDiscardDialog } from "@/components/forms/use-seeded-form-dismissal"; +import { + sheetHistory, + type SheetHistoryEntry, +} from "@/components/ui/sheet-history"; import { Dialog, DialogContent, @@ -98,12 +102,17 @@ export interface ResponsiveSheetProps { onCloseAutoFocus?: (event: Event) => void; /** * Whether the form holds input a dismissal would lose. A backdrop tap, - * Escape, a swipe down and the X then ask before closing; the form's + * Escape, a swipe down, the X and Back then ask before closing; the form's * own Cancel and Save close it as before. A function is read at the moment * of the dismissal and gets this sheet's body: pass * `sheetBodyHasUnsavedInput` for an add form that keeps its state inside. */ dirty?: boolean | ((body: HTMLElement | null) => boolean); + /** + * Back closes the sheet (`sheet-history.ts`). False for a sheet whose open + * state lives in the URL already, which has its own history entry. + */ + historyEntry?: boolean; children: React.ReactNode; } @@ -194,14 +203,23 @@ export function sheetBodyHasUnsavedInput(body: HTMLElement | null): boolean { return false; } -/** How a sheet closes: a dismissal with unsaved input asks first. */ +/** + * How a sheet closes: a dismissal with unsaved input asks first, and Back + * closes the sheet (asking the same way) instead of leaving the page. + */ function useSheetDismissal({ open, onOpenChange, dirty, -}: Pick) { + historyEntry, +}: Pick< + ResponsiveSheetProps, + "open" | "onOpenChange" | "dirty" | "historyEntry" +>) { const bodyRef = React.useRef(null); + const entryRef = React.useRef(null); const [asking, setAsking] = React.useState(false); + const [backs, countBack] = React.useReducer((n: number) => n + 1, 0); if (!open && asking) setAsking(false); const hasUnsavedInput = () => @@ -216,6 +234,33 @@ function useSheetDismissal({ onOpenChange(next); }; + const handleBack = React.useEffectEvent(() => { + if (hasUnsavedInput()) { + entryRef.current?.restore(); + setAsking(true); + return; + } + countBack(); + onOpenChange(false); + }); + + React.useEffect(() => { + if (!open || historyEntry === false) return; + const entry = sheetHistory()?.open(() => handleBack()); + if (!entry) return; + entryRef.current = entry; + return () => { + entry.release(); + if (entryRef.current === entry) entryRef.current = null; + }; + }, [open, historyEntry]); + + // Back was answered and the sheet is still open: its owner kept it (its + // own discard question, a save under way), so Back must keep working. + React.useEffect(() => { + if (backs > 0 && open) entryRef.current?.restore(); + }, [backs, open]); + // A sibling of the sheet, not a child: inside it, a touch on the question // would reach the sheet's drag through the React tree. const discard = @@ -247,6 +292,7 @@ export function ResponsiveSheet({ contentWidth = "md", onCloseAutoFocus, dirty, + historyEntry, children, }: ResponsiveSheetProps) { const isMobile = useIsMobile(); @@ -254,6 +300,7 @@ export function ResponsiveSheet({ open, onOpenChange, dirty, + historyEntry, }); if (isMobile) { diff --git a/src/components/ui/sheet-history.ts b/src/components/ui/sheet-history.ts new file mode 100644 index 000000000..e1c232679 --- /dev/null +++ b/src/components/ui/sheet-history.ts @@ -0,0 +1,316 @@ +"use client"; + +import { URL_STATE_EVENT } from "@/lib/url-state"; + +/** + * Back closes the open sheet instead of leaving the page under it. + * + * Opening a sheet pushes one history entry with the page's own URL, marked + * with the sheet's id. Back (the Android button, the edge swipe of an + * installed app, the browser's button) then lands on the entry below, and + * the sheet hears it as a close it may refuse: one with unsaved input asks + * first and puts its entry back. Closing the sheet any other way takes its + * entry off again, so one Back after a closed sheet still leaves the page. + * + * Four things keep that from fighting the router: + * + * - Landing on the entry right below a sheet (by Back or by the sheet's + * own close) is never shown to Next: nothing changed for the page, and + * the entry below may still hold a URL the page has since replaced (a + * stripped `?add=`), which Next would restore and so reopen the sheet. + * That entry is given the page's current URL and state instead. + * - A history write made while the sheet's own Back is under way waits for + * it. A close followed by `router.push` would otherwise push the new page + * and have the queued Back undo it. + * - A navigation pushed while a sheet's entry is current (a link in the + * sheet) replaces that entry, so Back from the new page returns to the + * page, not to a sheet entry nobody shows any more. + * - A replace of a sheet's entry keeps the sheet's mark: the router + * rewrites the current entry on every update. + * + * A sheet whose open state already lives in the URL (the vault's `?doc=`) + * has its own entry and opts out (`ResponsiveSheet historyEntry={false}`). + */ + +const SHEET_ENTRY = "__healthlogSheet"; + +/** How long a Back of our own may take before writes stop waiting for it. */ +const SHEET_BACK_GRACE_MS = 500; + +type HistoryWrite = ( + data: unknown, + unused: string, + url?: string | URL | null, +) => void; + +export interface SheetHistoryHost { + history: { + readonly state: unknown; + pushState: HistoryWrite; + replaceState: HistoryWrite; + back(): void; + }; + location: { readonly href: string }; + addEventListener( + type: "popstate", + listener: (event: { stopImmediatePropagation(): void }) => void, + capture: boolean, + ): void; + dispatchEvent(event: Event): boolean; + setTimeout(callback: () => void, ms: number): unknown; + clearTimeout(handle: unknown): void; +} + +export interface SheetHistoryEntry { + /** The sheet closed some other way than Back: take its entry off. */ + release(): void; + /** The sheet stayed open after Back (unsaved input): push its entry again. */ + restore(): void; +} + +export interface SheetHistory { + /** + * A sheet opened. `onBack` runs when Back lands below its entry; the + * entry is gone by then, and `restore` puts it back. + */ + open(onBack: () => void): SheetHistoryEntry; +} + +interface Layer { + id: string; + onBack: () => void; + /** The URL of the entry the sheet was opened over. */ + baseHref: string; + /** Its own entry as last written, which the page entry takes on close. */ + snapshot: { state: unknown; href: string } | null; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function markOf(state: unknown): string | undefined { + if (!isRecord(state)) return undefined; + const id = state[SHEET_ENTRY]; + return typeof id === "string" ? id : undefined; +} + +function withMark(state: unknown, id: string | undefined): unknown { + const rest = isRecord(state) ? { ...state } : {}; + delete rest[SHEET_ENTRY]; + if (id !== undefined) rest[SHEET_ENTRY] = id; + return rest; +} + +function samePage(a: string, b: string): boolean { + const left = new URL(a); + const right = new URL(b, a); + return left.pathname === right.pathname && left.search === right.search; +} + +let sequence = 0; + +export function createSheetHistory(host: SheetHistoryHost): SheetHistory { + const { history, location } = host; + const prefix = Math.random().toString(36).slice(2, 8); + const layers: Layer[] = []; + /** Backs we asked for and whose popstate has not arrived yet. */ + let ownBacks = 0; + /** What the entry we land on takes once our Backs have arrived. */ + let landing: { state: unknown; href: string } | null = null; + /** Writes held back while our Back is under way. */ + let held: Array<() => void> = []; + let grace: unknown = null; + /** Set around our own writes, which pass the wrappers untouched. */ + let ours = false; + let installed = false; + let push: HistoryWrite = () => undefined; + let replace: HistoryWrite = () => undefined; + + const layerOf = (id: string | undefined) => + id === undefined ? undefined : layers.find((layer) => layer.id === id); + + function write(kind: "push" | "replace", data: unknown, url?: string) { + ours = true; + try { + // Through whatever wraps `history` (Next copies its own state in). + if (kind === "push") history.pushState(data, "", url); + else history.replaceState(data, "", url); + } finally { + ours = false; + } + } + + function snapshot() { + const layer = layerOf(markOf(history.state)); + if (layer) layer.snapshot = { state: history.state, href: location.href }; + } + + function install() { + if (installed) return; + installed = true; + const below = { + push: history.pushState, + replace: history.replaceState, + }; + push = (data, unused, url) => { + if (ours) return below.push.call(history, data, unused, url); + if (ownBacks > 0) { + held.push(() => push.call(history, data, unused, url)); + return; + } + const current = layerOf(markOf(history.state)); + if (current && url != null && !samePage(location.href, String(url))) { + below.replace.call(history, data, unused, url); + return; + } + below.push.call(history, data, unused, url); + }; + replace = (data, unused, url) => { + if (ours) return below.replace.call(history, data, unused, url); + if (ownBacks > 0) { + held.push(() => replace.call(history, data, unused, url)); + return; + } + const id = markOf(history.state); + const keep = layerOf(id) && markOf(data) === undefined; + below.replace.call( + history, + keep ? withMark(data, id) : data, + unused, + url, + ); + snapshot(); + }; + history.pushState = push; + history.replaceState = replace; + host.addEventListener("popstate", onPopState, true); + } + + function armGrace() { + if (grace !== null) host.clearTimeout(grace); + grace = host.setTimeout(() => { + // The Back never arrived (a traversal can be dropped while another + // settles). Stop holding writes; where we stand is unknown, so the + // landing is left alone. + grace = null; + ownBacks = 0; + landing = null; + flush(); + }, SHEET_BACK_GRACE_MS); + } + + function goBack() { + ownBacks += 1; + history.back(); + armGrace(); + } + + function land(target: { state: unknown; href: string }) { + write( + "replace", + withMark(target.state, markOf(history.state)), + target.href, + ); + snapshot(); + } + + function flush() { + if (grace !== null) { + host.clearTimeout(grace); + grace = null; + } + const writes = held; + held = []; + for (const run of writes) run(); + // `useUrlSearch` readers were told about a write that only lands now. + if (writes.length > 0) host.dispatchEvent(new Event(URL_STATE_EVENT)); + } + + function onPopState(event: { stopImmediatePropagation(): void }) { + if (ownBacks > 0) { + ownBacks -= 1; + event.stopImmediatePropagation(); + if (ownBacks > 0) return; + const id = markOf(history.state); + if (id !== undefined && !layerOf(id)) { + // The entry of a sheet that closed while another stood over it. + goBack(); + return; + } + if (landing) land(landing); + landing = null; + flush(); + return; + } + + const id = markOf(history.state); + const kept = layerOf(id); + const closing = layers.splice(kept ? layers.indexOf(kept) + 1 : 0); + if (closing.length === 0) return; + const top = closing[closing.length - 1]; + if (location.href === closing[0].baseHref) { + // One step below the sheet, on the page it was opened over. + event.stopImmediatePropagation(); + if (top.snapshot) land(top.snapshot); + } + for (const layer of closing.reverse()) layer.onBack(); + } + + function pushEntry(layer: Layer) { + if (ownBacks > 0) { + held.push(() => pushEntry(layer)); + return; + } + if (!layers.includes(layer)) return; + layer.baseHref = location.href; + write("push", withMark(null, layer.id)); + snapshot(); + } + + return { + open(onBack) { + install(); + sequence += 1; + const layer: Layer = { + id: `${prefix}-${sequence}`, + onBack, + baseHref: location.href, + snapshot: null, + }; + layers.push(layer); + pushEntry(layer); + return { + release() { + const index = layers.indexOf(layer); + if (index === -1) return; + layers.splice(index, 1); + // Below another sheet's entry, or taken by a navigation: a later + // Back of ours steps over it, or there is nothing to take off. + if (markOf(history.state) !== layer.id) return; + if (ownBacks === 0) { + landing = layer.snapshot ?? { + state: history.state, + href: location.href, + }; + } + goBack(); + }, + restore() { + if (layers.includes(layer)) return; + layers.push(layer); + pushEntry(layer); + }, + }; + }, + }; +} + +let shared: SheetHistory | null = null; + +/** The one instance for the page, or null outside a browser. */ +export function sheetHistory(): SheetHistory | null { + if (typeof window === "undefined") return null; + shared ??= createSheetHistory(window as unknown as SheetHistoryHost); + return shared; +}