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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 16:27:10 +01:00
1 parent 6994cb5ce8
commit 919c508d59
9 files changed
+6 -909

No files matched your search

@@ -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,
@@ -24,8 +24,6 @@ const ALLOWED: Record<string, string> = {
"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`",
};
@@ -131,8 +131,6 @@ export function DocumentDetailSheet({
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
// `?doc=` is this sheet's history entry already (`use-vault-detail`).
historyEntry={false}
title={title}
description={t("documents.detail.title")}
contentWidth="3xl"
@@ -1,78 +0,0 @@
/**
* A form that guards Back (`useSeededFormDismissal({ guardNavigation })`)
* and a sheet opened over it both listen for popstate in the capture phase,
* so the one registered first hears Back first. Here the guard is: the page
* mounts the form, and the sheet history installs at the first sheet open,
* which is the order on `/settings/account` when "All sections" is the first
* sheet of the visit. A Back that closes the sheet is the sheet's, and the
* form must not answer it with its discard question.
*/
import { describe, expect, it, vi } from "vitest";
import { render } from "vitest-browser-react";
import { sheetHistory } from "@/components/ui/sheet-history";
import { useSeededFormDismissal } from "../use-seeded-form-dismissal";
type Navigation = { currentEntry: { index: number } };
const index = () =>
(window as unknown as { navigation: Navigation }).navigation.currentEntry
.index;
let popstates = 0;
window.addEventListener("popstate", () => (popstates += 1), true);
async function settle(count: number): Promise<void> {
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 <p data-testid="asking">{discardDialogOpen ? "asking" : "quiet"}</p>;
}
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(<EditedForm />);
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");
});
});
@@ -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<T>({
});
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);
};
@@ -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<string, unknown>;
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<void> {
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 (
<ResponsiveSheet
open={open}
onOpenChange={setOpen}
title="Add a reading"
dirty
>
<p>Typed but not saved</p>
</ResponsiveSheet>
);
}
const { screen } = await renderWithApp(<EditedSheet />);
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);
});
});
@@ -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<number, () => 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<string, unknown>)
);
}
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");
});
});
+3 -51
View File
@@ -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<ResponsiveSheetProps, "open" | "onOpenChange" | "dirty">) {
const bodyRef = React.useRef<HTMLDivElement | null>(null);
const entryRef = React.useRef<SheetHistoryEntry | null>(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) {
-344
View File
@@ -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<string, unknown> {
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;
}