diff --git a/packages/ui/src/primitives/toast.test.ts b/packages/ui/src/primitives/toast.test.ts index 7f81820d58..48b439f9e7 100644 --- a/packages/ui/src/primitives/toast.test.ts +++ b/packages/ui/src/primitives/toast.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const quill = vi.hoisted(() => { let n = 0; @@ -32,6 +32,7 @@ import { toast } from "./toast"; beforeEach(() => { vi.clearAllMocks(); + vi.useFakeTimers(); quill._reset(); settings.toastNotifications = true; // clearAllMocks resets the level fns to undefined returns; restore ids. @@ -47,6 +48,12 @@ beforeEach(() => { } }); +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + describe("toast wrapper", () => { it("creates without an id and forwards title/description/timeout", () => { toast.success("Saved", { description: "All good", duration: 1000 }); @@ -128,4 +135,55 @@ describe("toast wrapper", () => { expect(quill.success).toHaveBeenCalledTimes(2); expect(quill.update).not.toHaveBeenCalled(); }); + + // base-ui pauses auto-dismiss while the window is unfocused, so on the desktop + // app a toast can hang until closed by hand. The wrapper backs it with a + // blur-only fallback. + describe("blur fallback", () => { + it("dismisses a duration toast once its time is up while the window is unfocused", () => { + vi.spyOn(document, "hasFocus").mockReturnValue(false); + toast.success("Task archived", { id: "archive-x", duration: 8000 }); + expect(quill.dismiss).not.toHaveBeenCalled(); + vi.advanceTimersByTime(8000); + expect(quill.dismiss).toHaveBeenCalledWith("q1"); + }); + + it("falls back on the provider default when no duration is set", () => { + vi.spyOn(document, "hasFocus").mockReturnValue(false); + toast.success("Task deleted"); + vi.advanceTimersByTime(4999); + expect(quill.dismiss).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(quill.dismiss).toHaveBeenCalledWith("q1"); + }); + + it("leaves base-ui's own timer in charge while the window is focused", () => { + vi.spyOn(document, "hasFocus").mockReturnValue(true); + toast.success("Task archived", { duration: 8000 }); + vi.advanceTimersByTime(8000); + expect(quill.dismiss).not.toHaveBeenCalled(); + // Simulate the toast closing so its pending blur listener is cleaned up. + quill.success.mock.calls[0]?.[0]?.onClose?.(); + }); + + it("dismisses on a later blur when the window was focused at the deadline", () => { + const hasFocus = vi.spyOn(document, "hasFocus").mockReturnValue(true); + toast.success("Task archived", { id: "archive-y", duration: 8000 }); + vi.advanceTimersByTime(8000); + expect(quill.dismiss).not.toHaveBeenCalled(); + // Window loses focus while the toast is still up — base-ui re-pauses, so + // the fallback must still clear it rather than having given up already. + hasFocus.mockReturnValue(false); + window.dispatchEvent(new Event("blur")); + expect(quill.dismiss).toHaveBeenCalledWith("q1"); + }); + + it("never force-dismisses loading or never-expiring toasts", () => { + vi.spyOn(document, "hasFocus").mockReturnValue(false); + toast.loading("Working…"); + toast.error("Offline", { duration: Number.POSITIVE_INFINITY }); + vi.advanceTimersByTime(60_000); + expect(quill.dismiss).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/ui/src/primitives/toast.tsx b/packages/ui/src/primitives/toast.tsx index e752a213ea..f949cd79bb 100644 --- a/packages/ui/src/primitives/toast.tsx +++ b/packages/ui/src/primitives/toast.tsx @@ -35,10 +35,58 @@ type Level = "success" | "error" | "info" | "warning" | "loading"; // stacking; `dismiss(id)` resolves through here. Entries self-clean on close. const idRegistry = new Map(); +// Mirrors quill's default; used to time the blur fallback for +// toasts that don't set their own duration. Keep in sync with the provider in +// App.tsx, which mounts without a timeout override. +const PROVIDER_DEFAULT_TIMEOUT_MS = 5000; + function normalize(detail?: Detail): ToastOptions { return typeof detail === "string" ? { description: detail } : (detail ?? {}); } +// base-ui pauses a toast's auto-dismiss timer whenever the app window isn't +// OS-focused — not only while it's hovered. On the Electron app the window is +// often not frontmost, so a toast can hang on screen until it's closed by hand. +// Back base-ui's timer with one that still clears the toast once its time is up +// while the window is unfocused; when the window is focused we leave base-ui's +// own timer (and its hover-to-pause) in charge. Returns a cleanup to wire into +// the toast's onClose so nothing is left pending once it goes away. +function armBlurDismiss( + level: Level, + timeout: number | undefined, + quillId: string, +): (() => void) | undefined { + if (level === "loading" || typeof document === "undefined") { + return undefined; + } + const dismissAfter = + typeof timeout === "number" ? timeout : PROVIDER_DEFAULT_TIMEOUT_MS; + // timeout 0 is the "never auto-dismiss" contract (e.g. the offline toast). + if (dismissAfter <= 0) { + return undefined; + } + let onBlur: (() => void) | undefined; + const timer = setTimeout(() => { + if (!document.hasFocus()) { + quillToast.dismiss(quillId); + return; + } + // Focused at the deadline (base-ui is holding it, e.g. hover-paused). Once + // it's no longer hovered while focused base-ui dismisses it — but if the + // window loses focus first, base-ui re-pauses and it would hang, so dismiss + // on the next blur instead of giving up after this single check. + onBlur = () => quillToast.dismiss(quillId); + window.addEventListener("blur", onBlur, { once: true }); + }, dismissAfter); + return () => { + clearTimeout(timer); + if (onBlur) { + window.removeEventListener("blur", onBlur); + onBlur = undefined; + } + }; +} + function emit( level: Level, title: string, @@ -70,17 +118,26 @@ function emit( quillToast.update(existing, { type: level, ...fields }); return stableId; } + let cleanupBlurDismiss: (() => void) | undefined; const quillId = quillToast[level]({ ...fields, onClose: () => { + cleanupBlurDismiss?.(); if (idRegistry.get(stableId) === quillId) idRegistry.delete(stableId); }, }); idRegistry.set(stableId, quillId); + cleanupBlurDismiss = armBlurDismiss(level, timeout, quillId); return stableId; } - return quillToast[level](fields); + let cleanupBlurDismiss: (() => void) | undefined; + const quillId = quillToast[level]({ + ...fields, + onClose: () => cleanupBlurDismiss?.(), + }); + cleanupBlurDismiss = armBlurDismiss(level, timeout, quillId); + return quillId; } export const toast = {