From fd0c79306c205a16af61226114ae36daf97c5a62 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Sat, 1 Aug 2026 07:33:25 +0100 Subject: [PATCH 1/2] fix(toast): auto-dismiss toasts when the app window is unfocused base-ui pauses a toast's auto-dismiss timer whenever the window isn't OS-focused, not only while it's hovered. On the desktop app the window is frequently not frontmost, so a toast (e.g. "Task archived") would hang on screen until closed by hand. Back base-ui's timer with a blur-only fallback in the shared toast wrapper: once a toast's time is up, if the window is unfocused, dismiss it. When the window is focused we do nothing and leave base-ui's own timer and its hover-to-pause behavior in charge. Generated-By: PostHog Code Task-Id: 48d0d198-f58f-46a2-be36-36aeb6517a93 --- packages/ui/src/primitives/toast.test.ts | 46 +++++++++++++++++++++++- packages/ui/src/primitives/toast.tsx | 37 ++++++++++++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/primitives/toast.test.ts b/packages/ui/src/primitives/toast.test.ts index 7f81820d58..2a8a18936c 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,41 @@ 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(); + }); + + 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..3f93eccdc7 100644 --- a/packages/ui/src/primitives/toast.tsx +++ b/packages/ui/src/primitives/toast.tsx @@ -35,10 +35,42 @@ 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 do nothing and +// leave base-ui's own timer (and its hover-to-pause) in charge. +function armBlurDismiss( + level: Level, + timeout: number | undefined, + quillId: string, +): void { + if (level === "loading" || typeof document === "undefined") { + return; + } + 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; + } + setTimeout(() => { + if (!document.hasFocus()) { + quillToast.dismiss(quillId); + } + }, dismissAfter); +} + function emit( level: Level, title: string, @@ -77,10 +109,13 @@ function emit( }, }); idRegistry.set(stableId, quillId); + armBlurDismiss(level, timeout, quillId); return stableId; } - return quillToast[level](fields); + const quillId = quillToast[level](fields); + armBlurDismiss(level, timeout, quillId); + return quillId; } export const toast = { From 32e693ef88128832969199052934384edd039325 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Sat, 1 Aug 2026 09:21:19 +0100 Subject: [PATCH 2/2] fix(toast): keep blur fallback armed after the deadline while focused The one-shot focus check gave up permanently if the window was focused at the fallback deadline (e.g. hover-paused). If the window then lost focus while the toast was still up, base-ui re-paused its timer and the toast hung until focus returned. Now, when focused at the deadline, arm a one-time window blur listener that dismisses on the next blur, cleaned up via the toast's onClose so nothing is left pending once it closes normally. Generated-By: PostHog Code Task-Id: 48d0d198-f58f-46a2-be36-36aeb6517a93 --- packages/ui/src/primitives/toast.test.ts | 14 +++++++++ packages/ui/src/primitives/toast.tsx | 40 ++++++++++++++++++------ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/primitives/toast.test.ts b/packages/ui/src/primitives/toast.test.ts index 2a8a18936c..48b439f9e7 100644 --- a/packages/ui/src/primitives/toast.test.ts +++ b/packages/ui/src/primitives/toast.test.ts @@ -162,6 +162,20 @@ describe("toast wrapper", () => { 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", () => { diff --git a/packages/ui/src/primitives/toast.tsx b/packages/ui/src/primitives/toast.tsx index 3f93eccdc7..f949cd79bb 100644 --- a/packages/ui/src/primitives/toast.tsx +++ b/packages/ui/src/primitives/toast.tsx @@ -48,27 +48,43 @@ function normalize(detail?: Detail): ToastOptions { // 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 do nothing and -// leave base-ui's own timer (and its hover-to-pause) in charge. +// 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 { +): (() => void) | undefined { if (level === "loading" || typeof document === "undefined") { - return; + 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; + return undefined; } - setTimeout(() => { + 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( @@ -102,19 +118,25 @@ 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); - armBlurDismiss(level, timeout, quillId); + cleanupBlurDismiss = armBlurDismiss(level, timeout, quillId); return stableId; } - const quillId = quillToast[level](fields); - armBlurDismiss(level, timeout, quillId); + let cleanupBlurDismiss: (() => void) | undefined; + const quillId = quillToast[level]({ + ...fields, + onClose: () => cleanupBlurDismiss?.(), + }); + cleanupBlurDismiss = armBlurDismiss(level, timeout, quillId); return quillId; }