From 919c508d5937c8deab89f82d8862687796788c15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 16:27:10 +0100 Subject: [PATCH] revert(sheets): take out Back closing the open sheet Opening a sheet pushed a history entry, and closing it took that entry off again with a history back. The browser answers that back later, so when the close started a navigation it landed after the router's push and undid it: the medication wizard stayed on the list instead of opening the new medication, a section picked from the settings list on a phone stayed on the account page, and a link followed from a sheet could put the page under it back. The sheet history goes, with its wiring in ResponsiveSheet, the document sheet's opt-out, the account form's deference to it, Back answering the discard question, and their tests. Back behaves as before: it leaves the page and the sheet goes with it, and the account form's own Back guard answers every Back again. A tap outside, Escape, the X and a swipe still ask before dropping typed input. The two size baselines return to their earlier numbers. --- .../baselines/source-function-lines.json | 6 +- src/__tests__/url-state-writes-guard.test.ts | 2 - .../documents/document-detail-sheet.tsx | 2 - .../seeded-form-guard-sheet.browser.test.tsx | 78 ---- .../forms/use-seeded-form-dismissal.tsx | 4 - .../__tests__/sheet-history.browser.test.tsx | 139 ------- .../ui/__tests__/sheet-history.test.ts | 286 --------------- src/components/ui/responsive-sheet.tsx | 54 +-- src/components/ui/sheet-history.ts | 344 ------------------ 9 files changed, 6 insertions(+), 909 deletions(-) delete mode 100644 src/components/forms/__tests__/seeded-form-guard-sheet.browser.test.tsx delete mode 100644 src/components/ui/__tests__/sheet-history.browser.test.tsx delete mode 100644 src/components/ui/__tests__/sheet-history.test.ts delete mode 100644 src/components/ui/sheet-history.ts diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 1add8f02f..cfb0e5ea0 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 335, - "total": 109080, + "total": 109077, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -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": 327, + "components/documents/document-detail-sheet.tsx :: DocumentDetailSheet": 325, "components/documents/document-facts-review.tsx :: DocumentFactsSection": 242, "components/documents/document-facts-review.tsx :: FactRow": 261, "components/documents/document-filter-bar.tsx :: DocumentFilterBar": 348, @@ -225,7 +225,7 @@ "components/timeline/timeline-chart.tsx :: ChartBody": 236, "components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 246, "components/timeline/timeline-view.tsx :: TimelineView": 478, - "components/ui/responsive-sheet.tsx :: ResponsiveSheet": 255, + "components/ui/responsive-sheet.tsx :: ResponsiveSheet": 253, "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 29b7605fc..bb4a087f9 100644 --- a/src/__tests__/url-state-writes-guard.test.ts +++ b/src/__tests__/url-state-writes-guard.test.ts @@ -24,8 +24,6 @@ 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 a3fb0308d..25e3cd02a 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -131,8 +131,6 @@ export function DocumentDetailSheet({ - (window as unknown as { navigation: Navigation }).navigation.currentEntry - .index; - -let popstates = 0; -window.addEventListener("popstate", () => (popstates += 1), true); - -async function settle(count: number): Promise { - const target = popstates + count; - await vi.waitFor(() => expect(popstates).toBeGreaterThanOrEqual(target), { - timeout: 2_000, - }); - for (;;) { - const seen = popstates; - await new Promise((resolve) => setTimeout(resolve, 150)); - if (popstates === seen) return; - } -} - -function EditedForm() { - const { discardDialogOpen } = useSeededFormDismissal({ - seed: "Ada", - value: "Ada L.", - guardNavigation: true, - navigate: () => {}, - }); - return

{discardDialogOpen ? "asking" : "quiet"}

; -} - -describe("a guarded form under a sheet", () => { - // One case: the listener order is set once per page, by the first open. - it("leaves the sheet's Back to the sheet and guards the page's", async () => { - window.history.pushState(null, "", "?section=account"); - const page = index(); - const screen = await render(); - const asking = screen.getByTestId("asking"); - - // Back closes the sheet. - const onBack = vi.fn(); - sheetHistory()!.open(onBack); - expect(index()).toBe(page + 1); - window.history.back(); - await settle(1); - expect(onBack).toHaveBeenCalledTimes(1); - expect(index()).toBe(page); - await expect.element(asking).toHaveTextContent("quiet"); - - // The sheet's own close takes its entry off. - sheetHistory()! - .open(() => {}) - .release(); - await settle(1); - expect(index()).toBe(page); - await expect.element(asking).toHaveTextContent("quiet"); - - // With no sheet open, Back is the form's. - window.history.back(); - await settle(1); - await expect.element(asking).toHaveTextContent("asking"); - }); -}); diff --git a/src/components/forms/use-seeded-form-dismissal.tsx b/src/components/forms/use-seeded-form-dismissal.tsx index 361476a5b..58a3bec84 100644 --- a/src/components/forms/use-seeded-form-dismissal.tsx +++ b/src/components/forms/use-seeded-form-dismissal.tsx @@ -12,7 +12,6 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { sheetHistoryOwnsPopstate } from "@/components/ui/sheet-history"; import { useTranslations } from "@/lib/i18n/context"; type CloseRequest = () => void; @@ -348,9 +347,6 @@ export function useSeededFormDismissal({ }); historyTraversalGuardRef.current = historyTraversalGuard; const handlePopState = (event: PopStateEvent) => { - // A Back that closes a sheet over the form is the sheet's to answer; - // whichever capture listener was added first hears it first. - if (sheetHistoryOwnsPopstate()) return; historyTraversalGuard.handlePopState(event); }; diff --git a/src/components/ui/__tests__/sheet-history.browser.test.tsx b/src/components/ui/__tests__/sheet-history.browser.test.tsx deleted file mode 100644 index bd57bbdea..000000000 --- a/src/components/ui/__tests__/sheet-history.browser.test.tsx +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Sheet history against Chromium's own session history: `back()` answers - * with a real popstate a task or more later, and the entries keep their - * state the way the browser keeps it. - * - * The router's half is played by hand where it matters: Next answers a URL - * write one scheduler task later by rewriting the current entry with that - * entry's own state copied in (`HistoryUpdater`), and that write can land - * after a sheet's close has asked for Back but before Back arrives. - */ -import { useState } from "react"; -import { describe, expect, it, vi } from "vitest"; - -import { DAY_HISTORY_KEY } from "@/components/day/day-url"; - -import { renderWithApp } from "../../../../tests/browser/render"; -import { ResponsiveSheet } from "../responsive-sheet"; -import { sheetHistory } from "../sheet-history"; - -type Navigation = { currentEntry: { index: number } }; -const index = () => - (window as unknown as { navigation: Navigation }).navigation.currentEntry - .index; -const state = () => (window.history.state ?? {}) as Record; -const MARK = "__healthlogSheet"; - -// Registered before the sheet history installs its own listener at the -// first open, so it hears every popstate, including the ones it swallows. -let popstates = 0; -window.addEventListener("popstate", () => (popstates += 1), true); - -/** Wait for `count` more popstates, then until none follows. */ -async function settle(count: number): Promise { - const target = popstates + count; - await vi.waitFor(() => expect(popstates).toBeGreaterThanOrEqual(target), { - timeout: 2_000, - }); - for (;;) { - const seen = popstates; - await new Promise((resolve) => setTimeout(resolve, 150)); - if (popstates === seen) return; - } -} - -describe("sheet history in the browser", () => { - it("leaves the page entry unmarked when the close also writes the URL", async () => { - const sheets = sheetHistory()!; - window.history.pushState(null, "", "?step=before"); - window.history.pushState(null, "", "?step=page"); - const page = index(); - - // The day's date picker: its handler writes the day into the URL while - // the sheet's entry is current, then the sheet closes. - const picker = sheets.open(() => {}); - expect(index()).toBe(page + 1); - window.history.replaceState(null, "", "?step=page&day=2026-11-10"); - picker.release(); - // The router's answer to that write, copying the entry it finds. - window.history.replaceState( - { ...state(), __NA: true }, - "", - window.location.href, - ); - await settle(1); - - expect(index()).toBe(page); - expect(window.location.search).toBe("?step=page&day=2026-11-10"); - expect(state()[MARK]).toBeUndefined(); - - // The next sheet closes onto the page, not onto the entry before it. - const again = sheets.open(() => {}); - again.release(); - await settle(1); - expect(index()).toBe(page); - expect(window.location.search).toBe("?step=page&day=2026-11-10"); - }); - - it("keeps the page entry's own state through a sheet", async () => { - const sheets = sheetHistory()!; - window.history.pushState( - { [DAY_HISTORY_KEY]: "2026-11-10" }, - "", - "?day=2026-11-10", - ); - const page = index(); - - // Closed by its own button. - sheets.open(() => {}).release(); - await settle(1); - expect(index()).toBe(page); - expect(state()[DAY_HISTORY_KEY]).toBe("2026-11-10"); - - // Closed by Back. - const onBack = vi.fn(); - sheets.open(onBack); - window.history.back(); - await settle(1); - expect(onBack).toHaveBeenCalledTimes(1); - expect(index()).toBe(page); - expect(state()[DAY_HISTORY_KEY]).toBe("2026-11-10"); - expect(state()[MARK]).toBeUndefined(); - }); - - it("answers Back during the discard question by keeping the sheet open", async () => { - window.history.pushState(null, "", "?step=form"); - const page = index(); - function EditedSheet() { - const [open, setOpen] = useState(true); - return ( - -

Typed but not saved

-
- ); - } - const { screen } = await renderWithApp(); - await expect.element(screen.getByText("Typed but not saved")).toBeVisible(); - await vi.waitFor(() => expect(index()).toBe(page + 1)); - - // Back with unsaved input asks first and puts the sheet's entry back. - window.history.back(); - await settle(1); - await expect.element(screen.getByRole("alertdialog")).toBeVisible(); - expect(index()).toBe(page + 1); - - // Back again means "not now": the question goes, the sheet stays. - window.history.back(); - await settle(1); - await expect - .element(screen.getByRole("alertdialog")) - .not.toBeInTheDocument(); - await expect.element(screen.getByText("Typed but not saved")).toBeVisible(); - expect(index()).toBe(page + 1); - }); -}); diff --git a/src/components/ui/__tests__/sheet-history.test.ts b/src/components/ui/__tests__/sheet-history.test.ts deleted file mode 100644 index e198d3fb6..000000000 --- a/src/components/ui/__tests__/sheet-history.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { createSheetHistory, type SheetHistoryHost } from "../sheet-history"; - -type Listener = (event: { stopImmediatePropagation(): void }) => 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 2733ae7a6..482820d8e 100644 --- a/src/components/ui/responsive-sheet.tsx +++ b/src/components/ui/responsive-sheet.tsx @@ -5,10 +5,6 @@ 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, @@ -102,17 +98,12 @@ export interface ResponsiveSheetProps { onCloseAutoFocus?: (event: Event) => void; /** * Whether the form holds input a dismissal would lose. A backdrop tap, - * Escape, a swipe down, the X and Back then ask before closing; the form's + * Escape, a swipe down and the X 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; } @@ -215,23 +206,14 @@ export function sheetBodyHasUnsavedInput(body: HTMLElement | null): boolean { return false; } -/** - * 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. - */ +/** How a sheet closes: a dismissal with unsaved input asks first. */ function useSheetDismissal({ open, onOpenChange, dirty, - historyEntry, -}: Pick< - ResponsiveSheetProps, - "open" | "onOpenChange" | "dirty" | "historyEntry" ->) { +}: Pick) { 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 = () => @@ -246,34 +228,6 @@ function useSheetDismissal({ onOpenChange(next); }; - const handleBack = React.useEffectEvent(() => { - // Back while the question is open answers it with "keep editing". - if (asking || hasUnsavedInput()) { - entryRef.current?.restore(); - setAsking(!asking); - 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 = @@ -305,7 +259,6 @@ export function ResponsiveSheet({ contentWidth = "md", onCloseAutoFocus, dirty, - historyEntry, children, }: ResponsiveSheetProps) { const isMobile = useIsMobile(); @@ -313,7 +266,6 @@ 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 deleted file mode 100644 index b7059fd14..000000000 --- a/src/components/ui/sheet-history.ts +++ /dev/null @@ -1,344 +0,0 @@ -"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 write that carries the - * mark of a sheet that has closed (the router copying an entry it read - * before our Back landed) loses it, or the entry would read as a sheet's - * and the next close would step one Back too far. - * - * A sheet's entry starts as a copy of the page entry's state, so landing - * on the page entry gives it back its own keys (an open day's, the vault's). - * - * 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; - /** - * Whether the popstate being dispatched is ours: a Back we asked for, or - * one that lands on a sheet's entry or right below the first sheet. Asked - * by other capture listeners, which may hear it before we do. - */ - ownsPopstate(): boolean; -} - -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; -} - -/** `data` without the mark of a sheet that is no longer open. */ -function withoutDeadMark(data: unknown, open: readonly Layer[]): unknown { - const id = markOf(data); - return id === undefined || open.some((layer) => layer.id === id) - ? data - : withMark(data, undefined); -} - -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; - } - data = withoutDeadMark(data, layers); - 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; - } - data = withoutDeadMark(data, layers); - 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(history.state, layer.id)); - snapshot(); - } - - return { - ownsPopstate: () => - ownBacks > 0 || - layerOf(markOf(history.state)) !== undefined || - (layers.length > 0 && location.href === layers[0].baseHref), - 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; -} - -/** Whether the popstate under way belongs to an open or closing sheet. */ -export function sheetHistoryOwnsPopstate(): boolean { - return shared?.ownsPopstate() ?? false; -}