mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Merge branch 'fix/v1475-no-sheet-back' into release/v1.47.5
This commit is contained in:
commit
6e56c63234
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");
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user