fix(hk): protect unsaved editors and await prefix save results
This commit is contained in:
1 parent
89af4eb1f6
commit
d5091d1a73
11 files changed
+602
-231
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user