mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
fix(sheets): let Back close the open sheet
Back on a phone (the Android button, the edge swipe of the installed app) with a sheet open went back a page under it: the sheet and what was typed in it were gone, in the edit sheets too. Only the account form guarded Back, behind an option. Opening a ResponsiveSheet now pushes one history entry with the page's URL. Back lands below it and closes the sheet, asking first when it holds unsaved input or when its owner keeps it open; a sheet closed any other way takes its entry off again, so the next Back still leaves the page. A history write made while that Back is under way waits for it, a link followed from a sheet replaces the sheet's entry, and the page entry keeps the URL the page has now, so a stripped one-shot `?add=` does not come back. The vault's document sheet already owns `?doc=` and opts out.
This commit is contained in:
1 parent
ffde57869b
commit
6f9b42c35e
6 files changed
+658
-5
No files matched your search
@@ -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,
|
||||
|
||||
@@ -24,6 +24,8 @@ 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,6 +131,8 @@ 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"
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<ResponsiveSheetProps, "open" | "onOpenChange" | "dirty">) {
|
||||
historyEntry,
|
||||
}: Pick<
|
||||
ResponsiveSheetProps,
|
||||
"open" | "onOpenChange" | "dirty" | "historyEntry"
|
||||
>) {
|
||||
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 = () =>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user