Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
46 changes: 45 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,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();
});
});
});
37 changes: 36 additions & 1 deletion packages/ui/src/primitives/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 One-shot focus check misses later blur

When a toast is hover-paused while focused at the fallback deadline and the window loses focus afterward, this callback has already exited permanently and base-ui pauses its remaining timer, causing the finite toast to remain visible until focus returns.

Knowledge Base Used: @posthog/ui shared UI package

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/ui/src/primitives/toast.tsx
Line: 67-71

Comment:
**One-shot focus check misses later blur**

When a toast is hover-paused while focused at the fallback deadline and the window loses focus afterward, this callback has already exited permanently and base-ui pauses its remaining timer, causing the finite toast to remain visible until focus returns.

**Knowledge Base Used:** [`@posthog/ui` shared UI package](https://app.greptile.com/posthog-org-19734/-/custom-context/knowledge-base/posthog/code/-/docs/ui-package.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}

function emit(
level: Level,
title: string,
Expand Down Expand Up @@ -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 = {
Expand Down
Loading