diff --git a/e2e/ui/fixture-server.mjs b/e2e/ui/fixture-server.mjs index 52f44062..547d9f6c 100644 --- a/e2e/ui/fixture-server.mjs +++ b/e2e/ui/fixture-server.mjs @@ -197,6 +197,7 @@ const server = createServer(async (req, res) => { } if ( [ + "/admin/unsaved-harness", "/admin/attachment-harness", "/admin/studio-harness", "/admin/jobs-harness", diff --git a/e2e/ui/fixtures/entry.tsx b/e2e/ui/fixtures/entry.tsx index 1dcac4a1..17d0f77a 100644 --- a/e2e/ui/fixtures/entry.tsx +++ b/e2e/ui/fixtures/entry.tsx @@ -16,6 +16,7 @@ import messages from "@/messages/en.json"; import { AttachmentHarness } from "./attachment-harness"; import { FurnitureJobsHarness } from "./furniture-jobs-harness"; import { fixtureAction } from "./server-actions"; +import { UnsavedHarness } from "./unsaved-harness"; const theme = document.createElement("style"); theme.textContent = themePaletteCss( @@ -105,7 +106,9 @@ createRoot(root).render( ? "News editor" : "Furniture synchronization"} - {route.includes("studio-harness") ? ( + {route.includes("unsaved-harness") ? ( + + ) : route.includes("studio-harness") ? ( + + Working value + setValue(event.target.value)} + /> + + Leave editor + Same page + Email + { + if (confirmUnsavedNavigation()) setNavigated(true); + }} + > + Programmatic navigation + + { + guard.markSaved(); + setValue(""); + setSaved(true); + }} + > + Save working value + + {saved && Working value saved} + {navigated && Navigation allowed} + setOpen(true)}> + Open prefix + + + setFailSave(event.target.checked)} + /> + Fail prefix save + + setOpen(false)} + editPrefix={null} + onSave={async () => { + if (failSave) return false; + setSaved(true); + return true; + }} + /> + > + ); +} diff --git a/e2e/ui/unsaved-changes.spec.ts b/e2e/ui/unsaved-changes.spec.ts new file mode 100644 index 00000000..020feede --- /dev/null +++ b/e2e/ui/unsaved-changes.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/admin/unsaved-harness"); +}); +test("cancelled programmatic navigation preserves changes and save clears protection", async ({ + page, +}) => { + await page.getByLabel("Working value").fill("Keep this"); + page.once("dialog", (dialog) => dialog.dismiss()); + await page.getByRole("button", { name: "Programmatic navigation" }).click(); + await expect(page.getByText("Navigation allowed")).toHaveCount(0); + await expect(page.getByLabel("Working value")).toHaveValue("Keep this"); + await page.getByRole("button", { name: "Save working value" }).click(); + let prompts = 0; + page.on("dialog", async (dialog) => { + prompts++; + await dialog.dismiss(); + }); + await page.getByRole("button", { name: "Programmatic navigation" }).click(); + await expect(page.getByText("Navigation allowed")).toBeVisible(); + expect(prompts).toBe(0); +}); +test("cancelled link navigation and same-page anchors preserve the editor", async ({ + page, +}) => { + await page.getByLabel("Working value").fill("Keep this"); + let prompts = 0; + page.on("dialog", async (dialog) => { + prompts++; + await dialog.dismiss(); + }); + await page.getByRole("link", { name: "Leave editor" }).click(); + await expect(page).toHaveURL(/unsaved-harness$/); + await page.getByRole("link", { name: "Same page" }).click(); + await expect(page).toHaveURL(/#same-page$/); + expect(prompts).toBe(1); +}); +test("prefix cancel and Escape preserve edits; accepting discard resets reopening", async ({ + page, +}) => { + await page.getByRole("button", { name: "Open prefix" }).click(); + await page + .getByRole("textbox", { name: "Prefix text", exact: true }) + .fill("VIP"); + page.once("dialog", (dialog) => dialog.dismiss()); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect( + page.getByRole("textbox", { name: "Prefix text", exact: true }), + ).toHaveValue("VIP"); + page.once("dialog", (dialog) => dialog.dismiss()); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toBeVisible(); + page.once("dialog", (dialog) => dialog.accept()); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await page.getByRole("button", { name: "Open prefix" }).click(); + await expect( + page.getByRole("textbox", { name: "Prefix text", exact: true }), + ).toHaveValue(""); +}); +test("successful prefix save closes without a discard prompt", async ({ + page, +}) => { + await page.getByRole("button", { name: "Open prefix" }).click(); + await page + .getByRole("textbox", { name: "Username", exact: true }) + .fill("Alice"); + await page + .getByRole("textbox", { name: "Prefix text", exact: true }) + .fill("VIP"); + let prompts = 0; + page.on("dialog", async (dialog) => { + prompts++; + await dialog.dismiss(); + }); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + expect(prompts).toBe(0); +}); +test("supported browser back can be cancelled without changing the address or draft", async ({ + page, +}) => { + test.skip( + !(await page.evaluate(() => "navigation" in window)), + "Navigation API unavailable", + ); + await page.evaluate(() => + history.pushState({}, "", "/admin/unsaved-harness?step=2"), + ); + await page.getByLabel("Working value").fill("Keep this"); + const prompt = page.waitForEvent("dialog"); + await page.evaluate(() => history.back()); + await (await prompt).dismiss(); + await expect(page).toHaveURL(/step=2$/); + await expect(page.getByLabel("Working value")).toHaveValue("Keep this"); +}); + +test("failed prefix save keeps the editor and dirty protection", async ({ + page, +}) => { + await page.getByLabel("Fail prefix save").check(); + await page.getByRole("button", { name: "Open prefix" }).click(); + await page + .getByRole("textbox", { name: "Username", exact: true }) + .fill("Alice"); + await page + .getByRole("textbox", { name: "Prefix text", exact: true }) + .fill("VIP"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); + page.once("dialog", (dialog) => dialog.dismiss()); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect( + page.getByRole("textbox", { name: "Prefix text", exact: true }), + ).toHaveValue("VIP"); +}); diff --git a/src/app/admin/import/badges/badge-editor-dialog.tsx b/src/app/admin/import/badges/badge-editor-dialog.tsx index dfdc7dcb..3f8bedc6 100644 --- a/src/app/admin/import/badges/badge-editor-dialog.tsx +++ b/src/app/admin/import/badges/badge-editor-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { getBadgeData, updateBadge } from "@/actions/badges"; @@ -15,6 +16,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useServerAction } from "@/hooks/use-server-action"; +import { useUnsavedChanges } from "@/hooks/use-unsaved-changes"; interface BadgeEditorDialogProps { code: string; @@ -34,23 +36,39 @@ export function BadgeEditorDialog({ const [desc, setDesc] = useState(""); const [loading, setLoading] = useState(false); const { run, isPending: saving } = useServerAction(); + const [saved, setSaved] = useState({ name: "", desc: "" }); + const tAction = useTranslations("pages.admin.actions"); + const { confirmLeave, markSaved } = useUnsavedChanges( + open && !loading && (name !== saved.name || desc !== saved.desc), + tAction("leaveUnsaved"), + ); + function requestOpenChange(next: boolean) { + if (next || (!saving && confirmLeave())) onOpenChange(next); + } // Fetch current values when dialog opens useEffect(() => { if (!open || !code) return; + let active = true; setLoading(true); getBadgeData({ code }) .then((result) => { - if (result.ok) { + if (active && result.ok) { const data = result.data as { name: string; desc: string }; setName(data.name ?? ""); setDesc(data.desc ?? ""); + setSaved({ name: data.name ?? "", desc: data.desc ?? "" }); } }) .catch(() => { - toast.error("Failed to load badge data"); + if (active) toast.error("Failed to load badge data"); }) - .finally(() => setLoading(false)); + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; }, [open, code]); function handleSave() { @@ -58,6 +76,8 @@ export function BadgeEditorDialog({ successMessage: `Badge ${code} updated`, errorMessage: "Save failed", onSuccess: () => { + markSaved(); + setSaved({ name, desc }); onSaved?.(); onOpenChange(false); }, @@ -65,7 +85,7 @@ export function BadgeEditorDialog({ } return ( - + @@ -78,7 +98,7 @@ export function BadgeEditorDialog({ ) : ( - + Name - + )} onOpenChange(false)} + onClick={() => requestOpenChange(false)} disabled={saving} > Cancel diff --git a/src/app/admin/prefixes/prefix-dialog.tsx b/src/app/admin/prefixes/prefix-dialog.tsx index 8efc2f9c..686db963 100644 --- a/src/app/admin/prefixes/prefix-dialog.tsx +++ b/src/app/admin/prefixes/prefix-dialog.tsx @@ -1,9 +1,11 @@ "use client"; import { Save, X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { useUnsavedChanges } from "@/hooks/use-unsaved-changes"; import { PRESET_EFFECTS, PRESET_ICONS, @@ -18,7 +20,7 @@ export function PrefixDialog({ }: { isOpen: boolean; onClose: () => void; - onSave: (data: PrefixFormData, editId?: number) => Promise; + onSave: (data: PrefixFormData, editId?: number) => Promise; editPrefix: UserPrefix | null; }) { const [form, setForm] = useState({ @@ -30,9 +32,33 @@ export function PrefixDialog({ active: false, }); const [saving, setSaving] = useState(false); + const savingRef = useRef(false); + const [saveError, setSaveError] = useState(false); + const tAction = useTranslations("pages.admin.actions"); + const initial: PrefixFormData = { + username: editPrefix?.username || "", + text: editPrefix?.text || "", + color: editPrefix?.color || "#FFFFFF", + icon: editPrefix?.icon || "", + effect: editPrefix?.effect || "", + active: editPrefix?.active ?? false, + }; + const dirty = Object.keys(initial).some( + (key) => + form[key as keyof PrefixFormData] !== + initial[key as keyof PrefixFormData], + ); + const { confirmLeave, markSaved } = useUnsavedChanges( + isOpen && dirty, + tAction("leaveUnsaved"), + ); + function requestClose() { + if (!saving && confirmLeave()) onClose(); + } // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code useEffect(() => { + setSaveError(false); if (editPrefix) { setForm({ username: editPrefix.username || "", @@ -57,11 +83,21 @@ export function PrefixDialog({ if (!isOpen) return null; const handleSave = async () => { + if (savingRef.current) return; + savingRef.current = true; + setSaveError(false); setSaving(true); try { - await onSave(form, editPrefix?.id); + if (!(await onSave(form, editPrefix?.id))) { + setSaveError(true); + return; + } + markSaved(); onClose(); + } catch { + setSaveError(true); } finally { + savingRef.current = false; setSaving(false); } }; @@ -74,7 +110,7 @@ export function PrefixDialog({ { - if (!open && !saving) onClose(); + if (!open) requestClose(); }} disablePointerDismissal > @@ -82,157 +118,162 @@ export function PrefixDialog({ showCloseButton={false} className="block max-h-[90dvh] overflow-y-auto sm:max-w-md space-y-4" > - - - {editPrefix ? "Edit Prefix" : "Add Prefix"} - - - Close prefix editor - - - + + {saveError && {tAction("saveError")}} + + + {editPrefix ? "Edit Prefix" : "Add Prefix"} + + + Close prefix editor + + + - {/* Preview */} - - - {form.icon && {form.icon}} - {hasMultiColor ? ( - - {"{"} - {[...(form.text || "...")] - .map((char, i) => ({ char, n: i })) - .map(({ char, n }) => ( - - {char} - - ))} - {"}"} - - ) : ( - - {"{"} - {form.text || "..."} - {"}"} - - )} - - - {form.username || "Username"} - - + {/* Preview */} + + + {form.icon && {form.icon}} + {hasMultiColor ? ( + + {"{"} + {[...(form.text || "...")] + .map((char, i) => ({ char, n: i })) + .map(({ char, n }) => ( + + {char} + + ))} + {"}"} + + ) : ( + + {"{"} + {form.text || "..."} + {"}"} + + )} + + + {form.username || "Username"} + + + + {!editPrefix && ( + + Username + setForm({ ...form, username: e.target.value })} + /> + + )} - {!editPrefix && ( - Username + Text setForm({ ...form, username: e.target.value })} + maxLength={15} + placeholder="Prefix text..." + aria-label="Prefix text" + value={form.text} + onChange={(e) => setForm({ ...form, text: e.target.value })} /> + + {form.text.length}/15 + - )} - - Text - setForm({ ...form, text: e.target.value })} - /> - - {form.text.length}/15 - - + + + Color (HEX or multi: #FF0000,#00FF00) + + + setForm({ ...form, color: e.target.value })} + /> + + + + + + Icon + setForm({ ...form, icon: e.target.value })} + > + {PRESET_ICONS.map((icon) => ( + + {icon.emoji ? `${icon.emoji} ${icon.label}` : icon.label} + + ))} + + + + + Effect + setForm({ ...form, effect: e.target.value })} + > + {PRESET_EFFECTS.map((fx) => ( + + {fx.label} + + ))} + + - - - Color (HEX or multi: #FF0000,#00FF00) - - setForm({ ...form, color: e.target.value })} - /> - setForm({ ...form, active: e.target.checked })} /> + + Active + - - - Icon - setForm({ ...form, icon: e.target.value })} - > - {PRESET_ICONS.map((icon) => ( - - {icon.emoji ? `${icon.emoji} ${icon.label}` : icon.label} - - ))} - - - - - Effect - setForm({ ...form, effect: e.target.value })} - > - {PRESET_EFFECTS.map((fx) => ( - - {fx.label} - - ))} - - - - - setForm({ ...form, active: e.target.checked })} - /> - - Active - - - - - - Cancel - - - - {saving ? "Saving..." : "Save"} - - + + + Cancel + + + + {saving ? "Saving..." : "Save"} + + + ); diff --git a/src/app/admin/prefixes/prefixes-client.tsx b/src/app/admin/prefixes/prefixes-client.tsx index 684684ae..c9703eda 100644 --- a/src/app/admin/prefixes/prefixes-client.tsx +++ b/src/app/admin/prefixes/prefixes-client.tsx @@ -107,44 +107,34 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) { }); }; - const handleSavePrefix = async (data: PrefixFormData, editId?: number) => { - if (editId) { + const handleSavePrefix = ( + data: PrefixFormData, + editId?: number, + ): Promise => { + if (isPending) return Promise.resolve(false); + return new Promise((resolve) => { + const fields = { + text: data.text, + color: data.color, + icon: data.icon, + effect: data.effect, + active: data.active ? 1 : 0, + }; run( () => - updatePrefix({ - id: editId, - text: data.text, - color: data.color, - icon: data.icon, - effect: data.effect, - active: data.active ? 1 : 0, - }), + editId + ? updatePrefix({ id: editId, ...fields }) + : createPrefix({ username: data.username, ...fields }), { - successMessage: "Prefix updated!", + successMessage: editId ? "Prefix updated!" : "Prefix created!", onSuccess: () => { - fetchPrefixes(); + void fetchPrefixes(); + resolve(true); }, + onError: () => resolve(false), }, ); - } else { - run( - () => - createPrefix({ - username: data.username, - text: data.text, - color: data.color, - icon: data.icon, - effect: data.effect, - active: data.active ? 1 : 0, - }), - { - successMessage: "Prefix created!", - onSuccess: () => { - fetchPrefixes(); - }, - }, - ); - } + }); }; const openAddDialog = () => { diff --git a/src/app/admin/rooms/[id]/furni/edit-item-dialog.tsx b/src/app/admin/rooms/[id]/furni/edit-item-dialog.tsx index 79fb5464..acff4553 100644 --- a/src/app/admin/rooms/[id]/furni/edit-item-dialog.tsx +++ b/src/app/admin/rooms/[id]/furni/edit-item-dialog.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { updateRoomItem } from "@/actions/rooms"; import { Button } from "@/components/ui/button"; @@ -15,6 +16,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { useServerAction } from "@/hooks/use-server-action"; +import { useUnsavedChanges } from "@/hooks/use-unsaved-changes"; interface EditItem { id: number; @@ -45,9 +47,26 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) { const [extraData, setExtraData] = useState(""); const isWall = item?.type === "i"; + const tAction = useTranslations("pages.admin.actions"); + const dirty = + !!item && + (extraData !== item.extraData || + (isWall + ? wallPos !== item.wallPos + : x !== String(item.x) || + y !== String(item.y) || + z !== String(item.z) || + rot !== String(item.rot))); + const { confirmLeave, markSaved } = useUnsavedChanges( + open && dirty, + tAction("leaveUnsaved"), + ); + function requestOpenChange(next: boolean) { + if (next || (!isPending && confirmLeave())) onOpenChange(next); + } useEffect(() => { - if (item) { + if (item && open) { setX(String(item.x)); setY(String(item.y)); setZ(String(item.z)); @@ -55,7 +74,7 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) { setWallPos(item.wallPos); setExtraData(item.extraData); } - }, [item]); + }, [item, open]); function handleSave() { if (!item) return; @@ -72,12 +91,15 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) { }; run(() => updateRoomItem(payload), { successMessage: "Item updated and room reloaded", - onSuccess: () => onOpenChange(false), + onSuccess: () => { + markSaved(); + onOpenChange(false); + }, }); } return ( - + Edit furniture @@ -86,7 +108,7 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) { - + {isWall ? ( Wall position @@ -165,12 +187,12 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) { Item state/data (e.g. dice value, sticky note text, color) - + onOpenChange(false)} + onClick={() => requestOpenChange(false)} disabled={isPending} > Cancel diff --git a/src/components/admin/search-dialog.tsx b/src/components/admin/search-dialog.tsx index 4ba7db9f..24a41947 100644 --- a/src/components/admin/search-dialog.tsx +++ b/src/components/admin/search-dialog.tsx @@ -21,6 +21,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { useDebounce } from "@/hooks/use-debounce"; +import { confirmUnsavedNavigation } from "@/hooks/use-unsaved-changes"; import { type AdminNavigationOptions, visibleAdminNavigation, @@ -152,6 +153,7 @@ export function SearchDialog({ } function handleSelect(url: string) { + if (!confirmUnsavedNavigation()) return; handleOpenChange(false); setQuery(""); setResults([]); diff --git a/src/hooks/use-unsaved-changes.test.ts b/src/hooks/use-unsaved-changes.test.ts new file mode 100644 index 00000000..d130e381 --- /dev/null +++ b/src/hooks/use-unsaved-changes.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const cleanup = vi.hoisted(() => [] as Array<() => void>); +vi.mock("react", () => ({ + useRef: (value: unknown) => ({ current: value }), + useCallback: (callback: unknown) => callback, + useEffect: (effect: () => (() => void) | undefined) => { + const dispose = effect(); + if (dispose) cleanup.push(dispose); + }, +})); + +import { + confirmUnsavedNavigation, + useUnsavedChanges, +} from "./use-unsaved-changes"; + +let confirm: ReturnType; +beforeEach(() => { + confirm = vi.fn().mockReturnValue(false); + vi.stubGlobal("window", Object.assign(new EventTarget(), { confirm })); + vi.stubGlobal("document", new EventTarget()); + vi.stubGlobal("location", new URL("https://hotel.test/admin/editor")); +}); +afterEach(() => { + for (const dispose of cleanup.splice(0)) dispose(); + vi.unstubAllGlobals(); +}); +it("asks once for several dirty forms and allows clean navigation", () => { + useUnsavedChanges(true, "Discard changes?"); + useUnsavedChanges(true, "Other changes?"); + expect(confirmUnsavedNavigation()).toBe(false); + expect(confirm).toHaveBeenCalledTimes(1); + confirm.mockReturnValue(true); + expect(confirmUnsavedNavigation()).toBe(true); +}); +it("removes unmounted forms from navigation protection", () => { + useUnsavedChanges(true, "Discard changes?"); + for (const dispose of cleanup.splice(0)) dispose(); + expect(confirmUnsavedNavigation()).toBe(true); + expect(confirm).not.toHaveBeenCalled(); +}); +it("protects explicit close but clears synchronously after successful save", () => { + const guard = useUnsavedChanges(true, "Discard changes?"); + expect(guard.confirmLeave()).toBe(false); + guard.markSaved(); + expect(guard.confirmLeave()).toBe(true); + expect(confirmUnsavedNavigation()).toBe(true); + expect(confirm).toHaveBeenCalledTimes(1); +}); +it("keeps reload protected after cancelled navigation", () => { + useUnsavedChanges(true, "Discard changes?"); + confirmUnsavedNavigation(); + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); +}); diff --git a/src/hooks/use-unsaved-changes.ts b/src/hooks/use-unsaved-changes.ts index 58025acd..43296200 100644 --- a/src/hooks/use-unsaved-changes.ts +++ b/src/hooks/use-unsaved-changes.ts @@ -1,50 +1,98 @@ "use client"; -import { useEffect } from "react"; -/** Protect browser close/reload and same-tab link navigation while editing. */ +import { useCallback, useEffect, useRef } from "react"; + +interface Guard { + dirty: boolean; + message: string; +} +const guards = new Set<{ current: Guard }>(); +/** Call before programmatic navigation that would replace an unsaved form. */ +export function confirmUnsavedNavigation(): boolean { + const guard = [...guards].find((entry) => entry.current.dirty)?.current; + return !guard || window.confirm(guard.message); +} +function leavesCurrentPage(href: string) { + const target = new URL(href, location.href); + return ( + (target.protocol === "http:" || target.protocol === "https:") && + (target.origin !== location.origin || + target.pathname !== location.pathname || + target.search !== location.search) + ); +} +function beforeUnload(event: BeforeUnloadEvent) { + if (![...guards].some((entry) => entry.current.dirty)) return; + event.preventDefault(); + event.returnValue = ""; +} +function click(event: MouseEvent) { + if ( + event.defaultPrevented || + event.button !== 0 || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.altKey + ) + return; + const link = + event.target instanceof Element ? event.target.closest("a[href]") : null; + if ( + !(link instanceof HTMLAnchorElement) || + link.hasAttribute("download") || + (link.target && link.target !== "_self") || + !leavesCurrentPage(link.href) + ) + return; + if (!confirmUnsavedNavigation()) { + event.preventDefault(); + event.stopImmediatePropagation(); + } +} +interface HistoryNavigationEvent extends Event { + navigationType: string; + destination: { url: string }; +} +function traverse(event: Event) { + const navigation = event as HistoryNavigationEvent; + if ( + navigation.navigationType !== "traverse" || + !event.cancelable || + !leavesCurrentPage(navigation.destination.url) + ) + return; + if (!confirmUnsavedNavigation()) event.preventDefault(); +} +/** Protect reload, links and supported browser history traversals; expose explicit close protection. */ export function useUnsavedChanges(dirty: boolean, message: string) { + const guard = useRef({ dirty, message }); + guard.current = { dirty, message }; useEffect(() => { if (!dirty) return; - function beforeUnload(event: BeforeUnloadEvent) { - event.preventDefault(); - event.returnValue = ""; + const entry = guard; + const navigation = (window as Window & { navigation?: EventTarget }) + .navigation; + if (guards.size === 0) { + window.addEventListener("beforeunload", beforeUnload); + document.addEventListener("click", click, true); + navigation?.addEventListener("navigate", traverse); } - function click(event: MouseEvent) { - if ( - event.defaultPrevented || - event.button !== 0 || - event.ctrlKey || - event.metaKey || - event.shiftKey || - event.altKey - ) - return; - const link = - event.target instanceof Element - ? event.target.closest("a[href]") - : null; - if ( - !(link instanceof HTMLAnchorElement) || - link.hasAttribute("download") || - (link.target && link.target !== "_self") - ) - return; - const target = new URL(link.href, location.href); - if ( - target.origin === location.origin && - target.pathname === location.pathname && - target.search === location.search - ) - return; - if (!window.confirm(message)) { - event.preventDefault(); - event.stopPropagation(); - } - } - window.addEventListener("beforeunload", beforeUnload); - document.addEventListener("click", click, true); + guards.add(entry); return () => { - window.removeEventListener("beforeunload", beforeUnload); - document.removeEventListener("click", click, true); + guards.delete(entry); + if (guards.size === 0) { + window.removeEventListener("beforeunload", beforeUnload); + document.removeEventListener("click", click, true); + navigation?.removeEventListener("navigate", traverse); + } }; - }, [dirty, message]); + }, [dirty]); + const confirmLeave = useCallback( + () => !guard.current.dirty || window.confirm(guard.current.message), + [], + ); + const markSaved = useCallback(() => { + guard.current.dirty = false; + }, []); + return { confirmLeave, markSaved }; }
Working value saved
Navigation allowed
{tAction("saveError")}