Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion packages/ui/src/primitives/toast.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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 });
Expand Down Expand Up @@ -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();
});
});
});
59 changes: 58 additions & 1 deletion packages/ui/src/primitives/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();

// Mirrors quill's <ToastProvider> 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 <ToastProvider> 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,
Expand Down Expand Up @@ -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 = {
Expand Down
Loading