fix modal backdrop-click guard, re-entrancy, and monotonic clock

- Track window focus via performance.now() instead of suppressing all backdrop
  clicks for 300ms after the modal appears. This fixes over-suppression (Ctrl+W
  close-sheet now dismisses on immediate click) while keeping protection for
  genuine stray refocus-clicks (native dialog closing, tray window refocus).
- Serialize concurrent askModal calls via a promise chain so a second call
  queues instead of clobbering the first's handlers (was: hang on concurrent
  modal show).
- Use monotonic performance.now() throughout to avoid clock-jump edge cases
  with Date.now().
This commit is contained in:
Horizontal42 2026-07-16 02:42:14 +03:00
parent 087ee44f12
commit 68d7492dba

View file

@ -37,6 +37,7 @@ let lastResults: LineResult[] = [];
let selectedRange: [number, number] | null = null; let selectedRange: [number, number] | null = null;
let ratesFetchedAt = 0; let ratesFetchedAt = 0;
let liveRates: Record<string, number> = {}; let liveRates: Record<string, number> = {};
let lastWindowFocusAt = 0;
function uid(): string { function uid(): string {
return Math.random().toString(36).slice(2, 10); return Math.random().toString(36).slice(2, 10);
@ -663,8 +664,18 @@ function renderRatesInfo(): void {
// ---------- modal // ---------- modal
/** two-button modal; resolves "a" | "b" | null (click outside = cancel) */ let modalChain: Promise<unknown> = Promise.resolve();
/** two-button modal; resolves "a" | "b" | null (click outside = cancel).
calls serialize on the shared #modal singleton — a second call waits for
the first to resolve rather than clobbering its handlers */
function askModal(msg: string, aLabel: string, bLabel: string): Promise<"a" | "b" | null> { function askModal(msg: string, aLabel: string, bLabel: string): Promise<"a" | "b" | null> {
const run = modalChain.then(() => showModal(msg, aLabel, bLabel));
modalChain = run.catch(() => {});
return run;
}
function showModal(msg: string, aLabel: string, bLabel: string): Promise<"a" | "b" | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
const modal = $("#modal"); const modal = $("#modal");
const a = $("#modal-a"); const a = $("#modal-a");
@ -674,7 +685,6 @@ function askModal(msg: string, aLabel: string, bLabel: string): Promise<"a" | "b
b.textContent = bLabel; b.textContent = bLabel;
a.classList.add("primary"); a.classList.add("primary");
modal.classList.remove("hidden"); modal.classList.remove("hidden");
const shownAt = Date.now();
const done = (res: "a" | "b" | null) => { const done = (res: "a" | "b" | null) => {
modal.classList.add("hidden"); modal.classList.add("hidden");
a.onclick = b.onclick = modal.onclick = null; a.onclick = b.onclick = modal.onclick = null;
@ -683,10 +693,10 @@ function askModal(msg: string, aLabel: string, bLabel: string): Promise<"a" | "b
a.onclick = () => done("a"); a.onclick = () => done("a");
b.onclick = () => done("b"); b.onclick = () => done("b");
modal.onclick = (e) => { modal.onclick = (e) => {
// a click that focuses the window (e.g. after an unattended auto-check // a click that just refocused the window (native dialog closing, or
// shows this modal) lands on the backdrop too — ignore it right after // bringing a backgrounded tray window to front) lands on the backdrop
// showing so it can't dismiss a prompt the user hasn't actually seen yet // too — ignore a backdrop dismiss that arrives right after refocus
if (e.target === modal && Date.now() - shownAt > 300) done(null); if (e.target === modal && performance.now() - lastWindowFocusAt > 300) done(null);
}; };
}); });
} }
@ -884,6 +894,8 @@ async function initUI(): Promise<void> {
window.addEventListener("mouseup", onUp); window.addEventListener("mouseup", onUp);
}); });
window.addEventListener("focus", () => { lastWindowFocusAt = performance.now(); });
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "n") { if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "n") {
e.preventDefault(); e.preventDefault();