fix(hk): protect unsaved editors and await prefix save results
CI / check (push) Successful in 2m34s
CI / deploy (push) Successful in 19s
CI / publish-container (push) Successful in 1m24s

This commit is contained in:
Simo committed 2026-09-11 10:48:34 +02:00
1 parent 89af4eb1f6
commit d5091d1a73
11 files changed
+602 -231

No files matched your search

+57
View File
@@ -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<typeof vi.fn>;
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);
});
+90 -42
View File
@@ -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 };
}