From 334fdbfb86443a75d1adc2abcc758a3960d2df9e Mon Sep 17 00:00:00 2001
From: honzup <5564623+honzup@users.noreply.github.com>
Date: Sat, 11 Jul 2026 11:06:14 +0200
Subject: [PATCH] feat: show unread count badge on favicon
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes #560.
Composes the active inbox's unread count over the base favicon as an SVG
badge, served as a percent-encoded data: URL, so new mail is visible on a
tab that is not focused — including when the browser collapses tabs to
icon-only, where a title-based count disappears entirely.
The base icon is read from the rendered rather than from
config, so admin and per-domain branding overrides are inherited for free:
the count is drawn on whatever logo the deployment actually serves. Keeping
the badge in SVG rather than rasterising to a canvas also means the browser
can rasterise it at whatever size it asks for, so a HiDPI tab is not served a
16px bitmap.
Notes on the approach:
- The badge link is an *additional* icon link that we append and mark as
ours; we never remove or mutate a link we did not create. Next's metadata
icons are rendered by React, which keeps a fiber pointing at that DOM node,
so removing it would leave React holding a detached node and throw
"Cannot read properties of null (reading 'removeChild')" on the next
commit that deletes the fiber. Appending instead means the last-declared
icon wins, and non-SVG fallback links survive with their type/sizes intact.
(The usual recipe for this feature — assign canvas.toDataURL() to the
existing link's href — does both of the things that break here.)
- Every change of state is an *insertion* of a fresh link of ours, never a
mutation or a removal, because that is the only signal a browser reliably
re-reads the favicon on. Firefox ignores an in-place href change, and it
equally ignores a removal — so clearing the badge by deleting our link left
a stale count painted on the tab until a hard reload. Clearing it instead
inserts a new link of ours carrying the original base href.
- Holding last place has to be defended: on a client-side navigation React
re-hoists its metadata icon link into
, landing after ours, and the
base icon silently wins again. A MutationObserver on moves our own
link back to the end whenever a foreign icon link appears — moving only our
node, never anyone else's. It no-ops once ours is last again, so a move
cannot feed itself.
- The badge is a full-width band across the foot of the icon, drawn to the
metrics measured from Gmail's own 16px favicon: band height 0.625 of the
icon, digit cap height 0.44, flush to the edges, corners rounded by about a
pixel. Full width is what keeps a three-glyph label legible — rounded ends
waste exactly the horizontal space it needs. Neutral white with black digits
rather than the conventional red: faviconUrl is admin-overridable and
Bulwark's own icon is rgb(219,45,84), so a red badge sat red-on-red.
- The base SVG may be admin-uploaded, and the branding route deliberately
serves it under a sandboxing CSP because SVG can carry script. Re-emitting
it as a same-origin data: URL would un-fence that, so script, foreignObject
and every on* handler are stripped before serialising.
- Mounted in the root layout, not on the mail route: the badge belongs to the
tab, so mounting it on the page would clear it on every hop to settings,
calendar or contacts.
---
app/(main)/layout.tsx | 2 +
components/__tests__/favicon-badge.test.tsx | 98 +++
components/favicon-badge.tsx | 33 ++
components/settings/layout-settings.tsx | 9 +-
hooks/__tests__/use-favicon-badge.test.tsx | 559 ++++++++++++++++++
hooks/use-favicon-badge.ts | 237 ++++++++
lib/__tests__/favicon-badge.test.ts | 342 +++++++++++
lib/favicon-badge.ts | 206 +++++++
locales/cs/common.json | 4 +
locales/da/common.json | 4 +
locales/de/common.json | 4 +
locales/en/common.json | 4 +
locales/es/common.json | 4 +
locales/fa/common.json | 4 +
locales/fr/common.json | 4 +
locales/he/common.json | 4 +
locales/hu/common.json | 4 +
locales/it/common.json | 4 +
locales/ja/common.json | 4 +
locales/ko/common.json | 4 +
locales/lv/common.json | 4 +
locales/nl/common.json | 4 +
locales/pl/common.json | 4 +
locales/pt/common.json | 4 +
locales/ro/common.json | 4 +
locales/ru/common.json | 4 +
locales/sk/common.json | 4 +
locales/tr/common.json | 4 +
locales/uk/common.json | 4 +
locales/zh/common.json | 4 +
.../settings-store-favicon-badge.test.ts | 22 +
stores/settings-store.ts | 3 +
32 files changed, 1598 insertions(+), 1 deletion(-)
create mode 100644 components/__tests__/favicon-badge.test.tsx
create mode 100644 components/favicon-badge.tsx
create mode 100644 hooks/__tests__/use-favicon-badge.test.tsx
create mode 100644 hooks/use-favicon-badge.ts
create mode 100644 lib/__tests__/favicon-badge.test.ts
create mode 100644 lib/favicon-badge.ts
create mode 100644 stores/__tests__/settings-store-favicon-badge.test.ts
diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx
index a8af157f..34d9f94a 100644
--- a/app/(main)/layout.tsx
+++ b/app/(main)/layout.tsx
@@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import { getLocale, getTranslations } from "next-intl/server";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
+import { FaviconBadge } from "@/components/favicon-badge";
import { configManager } from "@/lib/admin/config-manager";
import {
matchDomainBranding,
@@ -132,6 +133,7 @@ export default async function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
+
{children}
diff --git a/components/__tests__/favicon-badge.test.tsx b/components/__tests__/favicon-badge.test.tsx
new file mode 100644
index 00000000..5524c01c
--- /dev/null
+++ b/components/__tests__/favicon-badge.test.tsx
@@ -0,0 +1,98 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { render } from '@testing-library/react';
+import { FaviconBadge } from '@/components/favicon-badge';
+import { useFaviconBadge } from '@/hooks/use-favicon-badge';
+import { useEmailStore } from '@/stores/email-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import type { Mailbox } from '@/lib/jmap/types';
+
+vi.mock('@/hooks/use-favicon-badge', () => ({
+ useFaviconBadge: vi.fn(),
+}));
+
+const useFaviconBadgeMock = vi.mocked(useFaviconBadge);
+
+function mailbox(patch: Partial & { id: string }): Mailbox {
+ return {
+ name: patch.id,
+ sortOrder: 0,
+ totalEmails: 0,
+ unreadEmails: 0,
+ totalThreads: 0,
+ unreadThreads: 0,
+ isSubscribed: true,
+ myRights: {
+ mayReadItems: true,
+ mayAddItems: true,
+ mayRemoveItems: true,
+ maySetSeen: true,
+ maySetKeywords: true,
+ mayCreateChild: true,
+ mayRename: true,
+ mayDelete: true,
+ maySubmit: true,
+ },
+ ...patch,
+ } as Mailbox;
+}
+
+const initialMailboxes = useEmailStore.getState().mailboxes;
+
+beforeEach(() => {
+ useEmailStore.setState({ mailboxes: initialMailboxes });
+ useSettingsStore.setState({ faviconUnreadBadge: true });
+});
+
+afterEach(() => {
+ useEmailStore.setState({ mailboxes: initialMailboxes });
+ useSettingsStore.setState({ faviconUnreadBadge: true });
+ vi.clearAllMocks();
+});
+
+describe('FaviconBadge', () => {
+ it('badges the unread count of the primary inbox', () => {
+ useEmailStore.setState({
+ mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
+ });
+
+ const { container } = render();
+
+ expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, true);
+ expect(container.firstChild).toBeNull(); // renders no markup
+ });
+
+ it('disables the badge when the setting is off', () => {
+ useSettingsStore.setState({ faviconUnreadBadge: false });
+ useEmailStore.setState({
+ mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
+ });
+
+ render();
+
+ expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, false);
+ });
+
+ it('ignores a shared inbox, even when it sorts first', () => {
+ // Shared and group inboxes ship in the same `mailboxes` array. A plain
+ // `role === 'inbox'` lookup would badge somebody else's inbox on a
+ // delegated setup, so the store's canonical `!isShared` filter is required.
+ useEmailStore.setState({
+ mailboxes: [
+ mailbox({ id: 'shared', role: 'inbox', isShared: true, unreadEmails: 99 }),
+ mailbox({ id: 'mine', role: 'inbox', unreadEmails: 4 }),
+ ],
+ });
+
+ render();
+
+ expect(useFaviconBadgeMock).toHaveBeenCalledWith(4, true);
+ });
+
+ it('badges zero when there is no inbox yet', () => {
+ useEmailStore.setState({ mailboxes: [] });
+
+ render();
+
+ expect(useFaviconBadgeMock).toHaveBeenCalledWith(0, true);
+ });
+});
diff --git a/components/favicon-badge.tsx b/components/favicon-badge.tsx
new file mode 100644
index 00000000..0e3a4295
--- /dev/null
+++ b/components/favicon-badge.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { useEmailStore } from "@/stores/email-store";
+import { useSettingsStore } from "@/stores/settings-store";
+import { useFaviconBadge } from "@/hooks/use-favicon-badge";
+
+/**
+ * Badges the browser-tab favicon with the inbox unread count, so new mail is
+ * visible without focusing the tab. See issue #560.
+ *
+ * Opt-out via the `faviconUnreadBadge` setting (Settings -> Appearance); on by
+ * default.
+ *
+ * Mounted in the root layout rather than on the mail route: the badge belongs
+ * to the tab, not to a page. Mounting it on the mail page unmounted it — and so
+ * cleared the badge, and flickered the icon — on every hop to /settings,
+ * /calendar or /contacts.
+ *
+ * Renders nothing.
+ */
+export function FaviconBadge() {
+ // The store's canonical inbox selector. `role === 'inbox'` alone is not
+ // enough: shared and group inboxes ship in the same `mailboxes` array, so on
+ // a delegated setup the first match can be somebody else's inbox.
+ const inboxUnread = useEmailStore(
+ (s) => s.mailboxes.find((m) => m.role === "inbox" && !m.isShared)?.unreadEmails ?? 0,
+ );
+ const enabled = useSettingsStore((s) => s.faviconUnreadBadge);
+
+ useFaviconBadge(inboxUnread, enabled);
+
+ return null;
+}
diff --git a/components/settings/layout-settings.tsx b/components/settings/layout-settings.tsx
index bbab221f..8e8e15a8 100644
--- a/components/settings/layout-settings.tsx
+++ b/components/settings/layout-settings.tsx
@@ -118,7 +118,7 @@ function MailLayoutPreview({
export function LayoutSettings() {
const t = useTranslations('settings.appearance');
const tEmail = useTranslations('settings.email_behavior');
- const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, mailLayout, proInterface, updateSetting } = useSettingsStore();
+ const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, faviconUnreadBadge, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
const activeAccountId = useAccountStore(s => s.activeAccountId);
@@ -231,6 +231,13 @@ export function LayoutSettings() {
/>
+
+ updateSetting('faviconUnreadBadge', checked)}
+ />
+
+
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
({
+ renderBadgedFavicon: vi.fn((_source: string, count: number) =>
+ count > 0 ? `data:image/svg+xml,BADGED-${count}` : null,
+ ),
+}));
+
+const renderBadgedFaviconMock = vi.mocked(renderBadgedFavicon);
+
+const ORIGINAL_HREF = '/branding/Bulwark_Favicon.svg';
+
+/** The link the hook owns: the only one it may ever touch. */
+function badgeLink(): HTMLLinkElement | null {
+ return document.querySelector('link[data-favicon-badge]');
+}
+
+/** The base icon link the page (or React) rendered: must survive untouched. */
+function baseLinks(): HTMLLinkElement[] {
+ return Array.from(
+ document.querySelectorAll('link[rel~="icon"]:not([data-favicon-badge])'),
+ );
+}
+
+/** Every icon link in , in document order. The browser honours the last. */
+function iconLinks(): HTMLLinkElement[] {
+ return Array.from(document.head.querySelectorAll('link[rel~="icon"]'));
+}
+
+function lastIconLink(): HTMLLinkElement | null {
+ return iconLinks().at(-1) ?? null;
+}
+
+/**
+ * What Next/React does on a client-side navigation: it re-hoists its metadata
+ * icon link into , appending a *fresh* node after everything already
+ * there — including our badge link.
+ */
+function rehoistBaseIcon(href = ORIGINAL_HREF): HTMLLinkElement {
+ const link = document.createElement('link');
+ link.rel = 'icon';
+ link.href = href;
+ document.head.appendChild(link);
+ return link;
+}
+
+/** Drains microtasks (MutationObserver callbacks) and one macrotask. */
+async function settle(): Promise {
+ for (let i = 0; i < 20; i++) await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+function svgResponse(body = '') {
+ return new Response(body, { status: 200, headers: { 'content-type': 'image/svg+xml' } });
+}
+
+function Badger({ count }: { count: number }) {
+ useFaviconBadge(count);
+ return null;
+}
+
+beforeEach(() => {
+ document.head.innerHTML = ``;
+ vi.stubGlobal('fetch', vi.fn(async () => svgResponse()));
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+ // document.head outlives every test, so a spy on it that a failing assertion
+ // never got to restore would leak into the next test's counts.
+ vi.restoreAllMocks();
+});
+
+describe('useFaviconBadge', () => {
+ it('appends its own badged icon link when the count is positive', async () => {
+ renderHook(() => useFaviconBadge(3));
+ await waitFor(() => {
+ expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
+ });
+ expect(badgeLink()!.getAttribute('type')).toBe('image/svg+xml');
+ // Last-declared icon wins, so ours must be last in .
+ expect(document.head.lastElementChild).toBe(badgeLink());
+ });
+
+ it('never removes or mutates an icon link it did not create', async () => {
+ const before = baseLinks()[0];
+ renderHook(() => useFaviconBadge(3));
+ await waitFor(() => expect(badgeLink()).not.toBeNull());
+
+ expect(before.isConnected).toBe(true);
+ expect(before.getAttribute('href')).toBe(ORIGINAL_HREF);
+ expect(baseLinks()).toHaveLength(1);
+ });
+
+ it('leaves every icon link it did not create intact, including non-SVG fallbacks', async () => {
+ document.head.innerHTML =
+ `` +
+ ``;
+
+ renderHook(() => useFaviconBadge(3));
+ await waitFor(() => expect(badgeLink()).not.toBeNull());
+
+ const survivors = baseLinks();
+ expect(survivors).toHaveLength(2);
+ expect(survivors[0].getAttribute('href')).toBe('/a.svg');
+ expect(survivors[0].getAttribute('type')).toBe('image/svg+xml');
+ expect(survivors[1].getAttribute('href')).toBe('/a.png');
+ expect(survivors[1].getAttribute('type')).toBe('image/png');
+ expect(survivors[1].getAttribute('sizes')).toBe('32x32');
+ });
+
+ it('does not throw when React owns the icon link and its subtree is deleted', async () => {
+ // React 19 hoists into and keeps a fiber pointing at
+ // that DOM node. Removing it out from under React makes the commit phase throw
+ // "Cannot read properties of null (reading 'removeChild')" when the fiber is
+ // later deleted. The hook must therefore never touch a node it did not create.
+ document.head.innerHTML = '';
+
+ const { unmount } = render(
+ <>
+
+
+ >,
+ );
+
+ await waitFor(() => expect(badgeLink()).not.toBeNull());
+
+ // The React-owned node is still there, untouched.
+ const reactOwned = baseLinks();
+ expect(reactOwned).toHaveLength(1);
+ expect(reactOwned[0].getAttribute('href')).toBe(ORIGINAL_HREF);
+
+ expect(() => unmount()).not.toThrow();
+ expect(badgeLink()).toBeNull();
+ });
+
+ it('replaces its own link rather than mutating its href', async () => {
+ const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
+ await waitFor(() => {
+ expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
+ });
+ const first = badgeLink();
+
+ rerender({ n: 4 });
+ await waitFor(() => {
+ expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-4');
+ });
+
+ // Firefox ignores an in-place href change on the favicon link.
+ expect(badgeLink()).not.toBe(first);
+ expect(first!.isConnected).toBe(false);
+ });
+
+ it('clears the badge by inserting a fresh link carrying the base href, not by removing its own', async () => {
+ // The field bug: Firefox does not re-evaluate the favicon when an icon link
+ // is *removed* — a removal is not an insertion, so it keeps painting the
+ // last icon it was handed and the stale "99+" badge sticks until a hard
+ // reload. Clearing must therefore be an insertion: our own link is replaced
+ // by a brand-new node carrying the original base href.
+ const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
+ await waitFor(() => {
+ expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
+ });
+ const badged = badgeLink()!;
+
+ rerender({ n: 0 });
+ await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
+
+ const restored = badgeLink()!;
+ expect(restored).not.toBe(badged); // a NEW node: an insertion, not an href swap
+ expect(badged.isConnected).toBe(false);
+ expect(restored.getAttribute('type')).toBe('image/svg+xml');
+ expect(lastIconLink()).toBe(restored);
+
+ // And the base link the page rendered is still untouched.
+ const survivors = baseLinks();
+ expect(survivors).toHaveLength(1);
+ expect(survivors[0].getAttribute('href')).toBe(ORIGINAL_HREF);
+ });
+
+ it('inserts the base-href link exactly once while the count stays at zero', async () => {
+ const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
+ await waitFor(() => expect(badgeLink()).not.toBeNull());
+
+ const appendSpy = vi.spyOn(document.head, 'appendChild');
+ const ownAppends = () =>
+ appendSpy.mock.calls.filter(
+ ([node]) => node instanceof Element && node.matches('link[data-favicon-badge]'),
+ ).length;
+
+ rerender({ n: 0 });
+ await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
+ const restored = badgeLink()!;
+ expect(ownAppends()).toBe(1);
+
+ // Neither further renders at the same count nor unrelated churn (an
+ // observer tick) may re-insert it: no remove/append thrash on every tick.
+ rerender({ n: 0 });
+ rerender({ n: 0 });
+ document.head.appendChild(document.createElement('meta'));
+ await settle();
+
+ expect(ownAppends()).toBe(1);
+ expect(badgeLink()).toBe(restored);
+ expect(document.querySelectorAll('link[data-favicon-badge]')).toHaveLength(1);
+ appendSpy.mockRestore();
+ });
+
+ it('keeps its own link last even while it is only carrying the base href', async () => {
+ const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
+ await waitFor(() => expect(badgeLink()).not.toBeNull());
+
+ rerender({ n: 0 });
+ await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
+ const own = badgeLink()!;
+
+ rehoistBaseIcon();
+ await waitFor(() => expect(lastIconLink()).toBe(own));
+ expect(badgeLink()).toBe(own); // moved, not recreated
+ });
+
+ it('badges again with a fresh insertion when the count leaves zero', async () => {
+ const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
+ await waitFor(() => expect(badgeLink()).not.toBeNull());
+
+ rerender({ n: 0 });
+ await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
+ const cleared = badgeLink()!;
+
+ rerender({ n: 7 });
+ await waitFor(() => {
+ expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-7');
+ });
+ expect(badgeLink()).not.toBe(cleared);
+ expect(cleared.isConnected).toBe(false);
+ expect(lastIconLink()).toBe(badgeLink());
+ expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
+ expect(baseLinks()).toHaveLength(1);
+ });
+
+ it('removes only its own link on unmount', async () => {
+ const { unmount } = renderHook(() => useFaviconBadge(3));
+ await waitFor(() => expect(badgeLink()).not.toBeNull());
+
+ unmount();
+ expect(badgeLink()).toBeNull();
+ expect(baseLinks()).toHaveLength(1);
+ expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
+ });
+
+ it('does not fetch, and adds no link, while the count is zero', async () => {
+ renderHook(() => useFaviconBadge(0));
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(fetch).not.toHaveBeenCalled();
+ expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
+ expect(badgeLink()).toBeNull();
+ expect(baseLinks()).toHaveLength(1);
+ });
+
+ it('leaves the icon alone when the base is not SVG', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response('binary', { headers: { 'content-type': 'image/png' } })),
+ );
+ renderHook(() => useFaviconBadge(3));
+ await waitFor(() => expect(fetch).toHaveBeenCalled());
+
+ // Assert on the observable end state, not merely on the href being
+ // unchanged: the href is also unchanged *before* the catch block runs,
+ // so a href-only assertion would pass even if the code went on to swap
+ // the icon a tick later. The renderer must never be reached.
+ await waitFor(() => expect(renderBadgedFaviconMock).not.toHaveBeenCalled());
+ expect(badgeLink()).toBeNull();
+ expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
+ });
+
+ it('leaves the icon alone when the base fetch fails', async () => {
+ vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 404 })));
+ renderHook(() => useFaviconBadge(3));
+ await waitFor(() => expect(fetch).toHaveBeenCalled());
+ expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
+ expect(badgeLink()).toBeNull();
+ expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
+ });
+
+ it('does nothing when there is no icon link to read', async () => {
+ document.head.innerHTML = '';
+ renderHook(() => useFaviconBadge(3));
+ await Promise.resolve(); // let any deferred async work start
+ expect(fetch).not.toHaveBeenCalled();
+ expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
+ expect(badgeLink()).toBeNull();
+ });
+
+ it('fetches the base icon exactly once under StrictMode and rapid count changes', async () => {
+ // StrictMode double-invokes effects, and a count change while the fetch is in
+ // flight re-runs the effect: neither may issue a second request.
+ let resolveFetch: (response: Response) => void = () => {};
+ const inFlight = new Promise((resolve) => {
+ resolveFetch = resolve;
+ });
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() => inFlight),
+ );
+
+ const { rerender } = renderHook(({ n }) => useFaviconBadge(n), {
+ initialProps: { n: 1 },
+ wrapper: StrictMode,
+ });
+
+ rerender({ n: 2 });
+ rerender({ n: 5 });
+ expect(fetch).toHaveBeenCalledTimes(1);
+
+ resolveFetch(svgResponse());
+ await waitFor(() => {
+ // The badge lands on the latest count, not the one in flight at fetch time.
+ expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-5');
+ });
+
+ rerender({ n: 6 });
+ await waitFor(() => {
+ expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-6');
+ });
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not re-render the badge when the count is unchanged', async () => {
+ const { rerender } = renderHook(({ n }) => useFaviconBadge(n), {
+ initialProps: { n: 3 },
+ });
+ await waitFor(() => {
+ expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
+ });
+
+ const settled = badgeLink();
+ renderBadgedFaviconMock.mockClear();
+ rerender({ n: 3 });
+
+ // The element identity alone is not evidence: `count` is the effect's only
+ // dependency, so React would skip the effect regardless. Assert the
+ // renderer was not invoked again — that is the behaviour under test.
+ expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
+ expect(badgeLink()).toBe(settled);
+ });
+
+ describe('when the setting is off', () => {
+ it('adds no link and does not fetch, however high the count', async () => {
+ renderHook(() => useFaviconBadge(3, false));
+ await settle();
+
+ expect(fetch).not.toHaveBeenCalled();
+ expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
+ expect(badgeLink()).toBeNull();
+ expect(baseLinks()).toHaveLength(1);
+ expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
+ });
+
+ it('clears a showing badge by inserting a fresh link carrying the base href', async () => {
+ // Same guarantee as the count-back-to-zero clear, and for the same reason:
+ // Firefox re-evaluates the favicon on an *insertion* and on nothing else.
+ // Turning the setting off by *removing* our link would leave the stale
+ // badge painted on the tab until a hard reload.
+ const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
+ initialProps: { on: true },
+ });
+ await waitFor(() => {
+ expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
+ });
+ const badged = badgeLink()!;
+
+ rerender({ on: false });
+ await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
+
+ const restored = badgeLink()!;
+ expect(restored).not.toBe(badged); // a NEW node: an insertion, not an href swap
+ expect(badged.isConnected).toBe(false);
+ expect(restored.getAttribute('type')).toBe('image/svg+xml');
+ expect(lastIconLink()).toBe(restored);
+
+ // And the base link the page rendered is still untouched.
+ const survivors = baseLinks();
+ expect(survivors).toHaveLength(1);
+ expect(survivors[0].getAttribute('href')).toBe(ORIGINAL_HREF);
+ });
+
+ it('re-badges with a fresh insertion when the setting is turned back on', async () => {
+ const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
+ initialProps: { on: true },
+ });
+ await waitFor(() => expect(badgeLink()).not.toBeNull());
+
+ rerender({ on: false });
+ await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
+ const cleared = badgeLink()!;
+
+ rerender({ on: true });
+ await waitFor(() => {
+ expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
+ });
+ expect(badgeLink()).not.toBe(cleared);
+ expect(cleared.isConnected).toBe(false);
+ expect(lastIconLink()).toBe(badgeLink());
+ expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
+ expect(baseLinks()).toHaveLength(1);
+ });
+
+ it('keeps its base-href link last when React re-hoists its icon', async () => {
+ const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
+ initialProps: { on: true },
+ });
+ await waitFor(() => expect(badgeLink()).not.toBeNull());
+
+ rerender({ on: false });
+ await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
+ const own = badgeLink()!;
+
+ rehoistBaseIcon();
+ await waitFor(() => expect(lastIconLink()).toBe(own));
+ expect(badgeLink()).toBe(own); // moved, not recreated
+ });
+ });
+
+ describe('when React re-hoists its icon link on a client-side navigation', () => {
+ it('moves its own link back to the end so the badge keeps winning', async () => {
+ // The field bug: Inbox badges the tab, a hop to /calendar makes React
+ // re-insert its metadata *after* ours, the base icon
+ // wins again and the badge vanishes.
+ renderHook(() => useFaviconBadge(3));
+ await waitFor(() => expect(badgeLink()).not.toBeNull());
+ expect(lastIconLink()).toBe(badgeLink());
+
+ const own = badgeLink()!;
+ const rehoisted = rehoistBaseIcon();
+ expect(lastIconLink()).toBe(rehoisted); // the badge is now outranked
+
+ await waitFor(() => expect(lastIconLink()).toBe(own));
+ expect(document.head.lastElementChild).toBe(own);
+ expect(badgeLink()).toBe(own); // moved, not recreated
+ expect(rehoisted.isConnected).toBe(true); // and React's node is untouched
+ });
+
+ it('restores the badge without the count changing', async () => {
+ // The "never comes back" half of the bug. Navigating back to the inbox
+ // does not change the unread count, so nothing re-runs the count effect:
+ // the observer alone must put the badge back on top.
+ const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
+ await waitFor(() => {
+ expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
+ });
+
+ renderBadgedFaviconMock.mockClear();
+ rehoistBaseIcon();
+ await waitFor(() => expect(lastIconLink()).toBe(badgeLink()));
+
+ rerender({ n: 3 }); // same count: no effect re-run to lean on
+ await settle();
+
+ expect(lastIconLink()).toBe(badgeLink());
+ expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
+ expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('re-applies the badge if its own link is removed entirely', async () => {
+ renderHook(() => useFaviconBadge(3));
+ await waitFor(() => expect(badgeLink()).not.toBeNull());
+
+ badgeLink()!.remove(); // React blows away part of
+
+ await waitFor(() => {
+ expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
+ });
+ expect(lastIconLink()).toBe(badgeLink());
+ expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
+ });
+
+ it('settles: re-appending its own link does not feed the observer a loop', async () => {
+ // Moving our link fires the observer again. If the move is not guarded by
+ // "am I already last?", that second run moves it again, for ever. Count
+ // the appends of *our* node: exactly one, and it must stop growing.
+ renderHook(() => useFaviconBadge(3));
+ await waitFor(() => expect(badgeLink()).not.toBeNull());
+ const own = badgeLink()!;
+
+ const appendSpy = vi.spyOn(document.head, 'appendChild');
+ const ownAppends = () => appendSpy.mock.calls.filter(([node]) => node === own).length;
+
+ rehoistBaseIcon();
+ await waitFor(() => expect(lastIconLink()).toBe(own));
+ expect(ownAppends()).toBe(1);
+
+ await settle();
+ expect(ownAppends()).toBe(1); // the observer's own mutation is a no-op
+ expect(lastIconLink()).toBe(own);
+ // Base + re-hoisted base + exactly one badge: nothing was duplicated.
+ expect(iconLinks()).toHaveLength(3);
+ expect(document.querySelectorAll('link[data-favicon-badge]')).toHaveLength(1);
+ appendSpy.mockRestore();
+ });
+
+ it('still never removes or mutates a link it did not create', async () => {
+ document.head.innerHTML =
+ `` +
+ ``;
+
+ const { unmount } = render(
+ <>
+
+
+ >,
+ );
+ await waitFor(() => expect(badgeLink()).not.toBeNull());
+
+ const rehoisted = rehoistBaseIcon();
+ await waitFor(() => expect(lastIconLink()).toBe(badgeLink()));
+
+ const survivors = baseLinks();
+ expect(survivors).toHaveLength(4);
+ expect(survivors.map((l) => l.getAttribute('href'))).toEqual([
+ '/a.svg',
+ '/a.png',
+ ORIGINAL_HREF,
+ ORIGINAL_HREF,
+ ]);
+ expect(survivors[1].getAttribute('type')).toBe('image/png');
+ expect(survivors[1].getAttribute('sizes')).toBe('32x32');
+ expect(rehoisted.isConnected).toBe(true);
+
+ // The React-owned node is still React's to delete.
+ expect(() => unmount()).not.toThrow();
+ });
+
+ it('disconnects the observer on unmount and leaves nothing of its own behind', async () => {
+ const disconnect = vi.spyOn(MutationObserver.prototype, 'disconnect');
+ const { unmount } = renderHook(() => useFaviconBadge(3));
+ await waitFor(() => expect(badgeLink()).not.toBeNull());
+
+ unmount();
+ expect(disconnect).toHaveBeenCalled();
+ expect(badgeLink()).toBeNull();
+
+ // A post-unmount re-hoist must not resurrect the badge.
+ rehoistBaseIcon();
+ await settle();
+ expect(badgeLink()).toBeNull();
+ expect(baseLinks()).toHaveLength(2);
+ disconnect.mockRestore();
+ });
+ });
+});
diff --git a/hooks/use-favicon-badge.ts b/hooks/use-favicon-badge.ts
new file mode 100644
index 00000000..1053f0e8
--- /dev/null
+++ b/hooks/use-favicon-badge.ts
@@ -0,0 +1,237 @@
+"use client";
+
+import { useCallback, useEffect, useRef } from 'react';
+import { renderBadgedFavicon } from '@/lib/favicon-badge';
+import { debug } from '@/lib/debug';
+
+// Our own link, and only ever our own. Next's metadata `icons` (app/(main)/
+// layout.tsx) renders through React, which hoists it into
+// and keeps a fiber pointing at that DOM node. Removing it out from
+// under React leaves the fiber holding a detached node, and the next commit
+// that deletes that fiber throws "Cannot read properties of null (reading
+// 'removeChild')". So we never remove or mutate a node we did not create:
+// instead we append an *extra* icon link, marked as ours. The last-declared
+// icon wins in browsers, so ours overrides the base without deleting it.
+//
+// Ours is never removed to clear the badge, though — only on unmount. Firefox
+// re-evaluates the favicon on an *insertion* and on nothing else: a removal
+// leaves it painting the last icon it was handed, which is how a read inbox
+// kept a stale "99+" in the tab. Clearing therefore re-inserts our link with
+// the original base href in place of the badge (see `apply`).
+const MARKER = 'data-favicon-badge';
+const OWN_SELECTOR = `link[${MARKER}]`;
+const ICON_SELECTOR = 'link[rel~="icon"]';
+const BASE_SELECTOR = `${ICON_SELECTOR}:not([${MARKER}])`;
+
+// Both the badged icon and the untouched base we fall back to are SVG: the hook
+// disables itself unless the fetched base is served as image/svg+xml, so by the
+// time either link exists that content type is a proven fact, not a guess.
+const ICON_TYPE = 'image/svg+xml';
+
+function removeOwnLink(): void {
+ document.querySelectorAll(OWN_SELECTOR).forEach((el) => el.remove());
+}
+
+function ownLink(): HTMLLinkElement | null {
+ return document.head.querySelector(OWN_SELECTOR);
+}
+
+/** True when ours is the last icon link in , i.e. the one the browser uses. */
+function isLastIconLink(link: HTMLLinkElement): boolean {
+ const icons = document.head.querySelectorAll(ICON_SELECTOR);
+ return icons[icons.length - 1] === link;
+}
+
+/**
+ * Appends a fresh icon link of ours, replacing any previous one of ours.
+ *
+ * Always a remove-then-append of a *new* node, never an href mutation: Firefox
+ * only re-evaluates the favicon when an icon link is inserted. It ignores an
+ * in-place href change, and — the count-back-to-zero bug — it equally ignores a
+ * removal, happily painting the last icon it was handed. So even *clearing* the
+ * badge is done by inserting: see `apply`, which re-inserts our link carrying
+ * the original base href rather than deleting it.
+ */
+function setOwnLink(href: string): void {
+ removeOwnLink();
+ const link = document.createElement('link');
+ link.rel = 'icon';
+ link.type = ICON_TYPE;
+ link.href = href;
+ link.setAttribute(MARKER, '');
+ document.head.appendChild(link);
+}
+
+/**
+ * Draws `count` as a badge on the browser-tab favicon, unless `enabled` is
+ * false (the `faviconUnreadBadge` setting).
+ *
+ * The base icon is read from the rendered , so admin and
+ * per-domain branding overrides (configManager `faviconUrl`) are respected
+ * without plumbing config to the client.
+ *
+ * Every failure — no icon link, a fetch error, a non-SVG base, unparseable
+ * source — leaves the existing favicon untouched.
+ */
+export function useFaviconBadge(count: number, enabled = true): void {
+ // Disabled is just "nothing to show", i.e. exactly a count of zero, so it
+ // rides the same paths: no fetch while we have never badged, and — the part
+ // that matters — clearing an *existing* badge by inserting a fresh link
+ // carrying the base href rather than removing ours, which Firefox would
+ // ignore (see `apply`). Switching the setting off therefore restores the
+ // plain icon immediately, with no reload.
+ const effectiveCount = enabled ? count : 0;
+ const baseSource = useRef(null);
+ const baseHref = useRef(null);
+ // The href our own link currently carries, and the hook's whole state machine:
+ // null -> nothing of ours is in (we have never badged)
+ // baseHref -> ours is in , showing the unbadged base icon
+ // a data: URL -> ours is in , showing the badge
+ const appliedHref = useRef(null);
+ const disabled = useRef(false);
+ const fetchStarted = useRef(false);
+ const unmounted = useRef(false);
+ const latestCount = useRef(effectiveCount);
+ latestCount.current = effectiveCount;
+
+ // Declared before the badge effect so that on a StrictMode remount it runs
+ // first and clears `unmounted` before the badge effect reads it.
+ useEffect(() => {
+ unmounted.current = false;
+ return () => {
+ unmounted.current = true;
+ // Restore the server-rendered favicon by removing our override. Nothing
+ // else in is ours to touch.
+ removeOwnLink();
+ appliedHref.current = null;
+ };
+ }, []);
+
+ // Reads the refs rather than a closure over `count`, so that the reply to an
+ // in-flight fetch — and the MutationObserver below, which outlives any single
+ // render — lands on the newest count, not the one that started it.
+ const apply = useCallback(() => {
+ if (unmounted.current || disabled.current) return;
+
+ const current = latestCount.current;
+ if (current <= 0) {
+ // Clearing the badge is an *insertion*, not a removal.
+ //
+ // The field bug: with 133 unread the tab showed "99+", the user read
+ // everything, the store went to 0 — and Firefox kept painting "99+" until
+ // a hard reload. Removing our link is not an insertion, and Firefox only
+ // re-evaluates the favicon on an insertion; a removal leaves it painting
+ // the last icon it was handed. So instead of deleting our link we replace
+ // it with a fresh one carrying the *original* base href: same pixels as
+ // the untouched base link below it, but handed to the browser as a new
+ // icon, which it does repaint.
+ //
+ // Never badged (`appliedHref` still null)? Then nothing of ours is in
+ // and nothing should be: a fully-read inbox adds no link at all.
+ const base = baseHref.current;
+ if (appliedHref.current === null || base === null) return;
+ if (appliedHref.current === base && ownLink()) return; // already showing the base: no thrash
+
+ setOwnLink(base);
+ appliedHref.current = base;
+ return;
+ }
+
+ const source = baseSource.current;
+ if (source === null) return; // still fetching; the fetch will call back
+
+ const next = renderBadgedFavicon(source, current);
+ if (!next) return;
+ if (next === appliedHref.current && ownLink()) return;
+
+ setOwnLink(next);
+ appliedHref.current = next;
+ }, []);
+
+ useEffect(() => {
+ if (disabled.current) return;
+
+ // Nothing to show and nothing applied: do not even fetch. A fully-read
+ // inbox — or the setting switched off before we ever badged — should cost
+ // no request.
+ if (effectiveCount <= 0 && baseSource.current === null && !fetchStarted.current) return;
+
+ // The base is fetched at most once, ever. Without this guard a StrictMode
+ // double-invoke issues two requests, and any count change while the fetch
+ // is in flight issues another.
+ if (baseSource.current !== null || fetchStarted.current) {
+ apply();
+ return;
+ }
+
+ // The one and only read of the base link. Its href is both what we fetch the
+ // source from and what we hand back to the browser when the badge clears.
+ const link = document.querySelector(BASE_SELECTOR);
+ const href = link?.getAttribute('href');
+ if (!href) {
+ disabled.current = true;
+ return;
+ }
+ baseHref.current = href;
+ fetchStarted.current = true;
+
+ void (async () => {
+ try {
+ const response = await fetch(href);
+ if (!response.ok) throw new Error(`favicon fetch failed: ${response.status}`);
+
+ const contentType = response.headers.get('content-type') ?? '';
+ if (!contentType.includes('image/svg+xml')) {
+ throw new Error(`favicon is not SVG: ${contentType || 'unknown'}`);
+ }
+
+ baseSource.current = await response.text();
+ apply();
+ } catch (error) {
+ disabled.current = true;
+ debug.log('[favicon-badge] disabled:', error);
+ }
+ })();
+ }, [effectiveCount, apply]);
+
+ // Keep ours the last icon link in .
+ //
+ // On a client-side navigation (Inbox -> Calendar) Next re-hoists the metadata
+ // from app/(main)/layout.tsx into . The re-inserted
+ // node lands *after* our badge link, the last-declared icon wins, and the
+ // badge vanishes. Coming back to the inbox did not bring it back either: the
+ // count is unchanged, so the effect above never re-ran and our link just sat
+ // there outranked. Watching fixes both halves at once.
+ //
+ // Termination: moving our own link is itself a mutation, so it feeds
+ // the observer a fresh record. The guard is `isLastIconLink` — on that second
+ // run ours *is* last, so we do nothing and the cascade stops. One move per
+ // foreign insertion, never two.
+ const keepOwnLinkLast = useCallback(() => {
+ if (unmounted.current || disabled.current) return;
+ // Ours must stay last in *both* states — badged, and showing the base href
+ // after a clear (`appliedHref` is only null when we have never badged, and
+ // then nothing of ours is in to keep last). Gating this on the count
+ // instead would strand our base-href link behind a re-hoisted React icon,
+ // and the next badge would have to fight its way back on top.
+ if (appliedHref.current === null) return;
+
+ const own = ownLink();
+ if (!own) {
+ // React blew our link away with the rest of the head: re-apply from scratch.
+ apply();
+ return;
+ }
+ if (isLastIconLink(own)) return;
+
+ // Re-appending *our own* element is the only mutation we ever make; a node
+ // we did not create is never removed, moved or touched (see above).
+ document.head.appendChild(own);
+ }, [apply]);
+
+ useEffect(() => {
+ const observer = new MutationObserver(keepOwnLinkLast);
+ observer.observe(document.head, { childList: true });
+ return () => observer.disconnect();
+ }, [keepOwnLinkLast]);
+}
diff --git a/lib/__tests__/favicon-badge.test.ts b/lib/__tests__/favicon-badge.test.ts
new file mode 100644
index 00000000..699f4b36
--- /dev/null
+++ b/lib/__tests__/favicon-badge.test.ts
@@ -0,0 +1,342 @@
+import { describe, it, expect } from 'vitest';
+import { formatBadgeCount, renderBadgedFavicon } from '@/lib/favicon-badge';
+
+const BASE_SVG = ``;
+
+function decode(dataUrl: string): string {
+ return decodeURIComponent(dataUrl.replace('data:image/svg+xml,', ''));
+}
+
+/** The badge band: the last the renderer appends, identified by its fill. */
+function band(svg: string): { x: number; y: number; w: number; h: number; rx: number } {
+ const match =
+ /]*\bx="(-?[\d.]+)"[^>]*\by="(-?[\d.]+)"[^>]*\bwidth="([\d.]+)"[^>]*\bheight="([\d.]+)"[^>]*\brx="([\d.]+)"[^>]*fill="#ffffff"/.exec(
+ svg,
+ );
+ expect(match).not.toBeNull();
+ const [, x, y, w, h, rx] = match!.map(Number);
+ return { x, y, w, h, rx };
+}
+
+function fontSize(svg: string): number {
+ return Number(/]*font-size="([\d.]+)"/.exec(svg)![1]);
+}
+
+function viewBoxOf(svg: string): { minX: number; minY: number; width: number; height: number } {
+ const [minX, minY, width, height] = /viewBox="([^"]+)"/
+ .exec(svg)![1]
+ .trim()
+ .split(/[\s,]+/)
+ .map(Number);
+ return { minX, minY, width, height };
+}
+
+describe('formatBadgeCount', () => {
+ it('returns an empty string for zero and below', () => {
+ expect(formatBadgeCount(0)).toBe('');
+ expect(formatBadgeCount(-3)).toBe('');
+ });
+
+ it('returns the count verbatim from 1 to 99', () => {
+ expect(formatBadgeCount(1)).toBe('1');
+ expect(formatBadgeCount(9)).toBe('9');
+ expect(formatBadgeCount(47)).toBe('47');
+ expect(formatBadgeCount(99)).toBe('99');
+ });
+
+ it('caps at 99+ above 99', () => {
+ // Gmail caps at 20; matching it was tried and reverted. A lower cap means a
+ // typical inbox needs three glyphs almost always, and three glyphs do not
+ // fit at the full font size — so "99+" rendered permanently smaller than a
+ // real two-digit count would have.
+ expect(formatBadgeCount(100)).toBe('99+');
+ expect(formatBadgeCount(133)).toBe('99+');
+ expect(formatBadgeCount(1000)).toBe('99+');
+ });
+
+ it('returns an empty string for non-finite input', () => {
+ expect(formatBadgeCount(Number.NaN)).toBe('');
+ expect(formatBadgeCount(Number.POSITIVE_INFINITY)).toBe('');
+ });
+});
+
+describe('renderBadgedFavicon', () => {
+ it('returns null when the count is zero', () => {
+ expect(renderBadgedFavicon(BASE_SVG, 0)).toBeNull();
+ });
+
+ it('returns null when the source is not SVG', () => {
+ expect(renderBadgedFavicon('this is not svg', 3)).toBeNull();
+ });
+
+ it('returns null when the root element is not ', 3)).toBeNull();
+ });
+
+ it('returns null when the root has no viewBox', () => {
+ const noViewBox = ``;
+ expect(renderBadgedFavicon(noViewBox, 3)).toBeNull();
+ });
+
+ it('returns a percent-encoded svg data URL', () => {
+ const url = renderBadgedFavicon(BASE_SVG, 3);
+ expect(url).not.toBeNull();
+ expect(url!.startsWith('data:image/svg+xml,')).toBe(true);
+ });
+
+ it('draws a badge band and the count text', () => {
+ const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
+ expect(svg).toContain('3<');
+ });
+
+ it('renders 99+ for large counts', () => {
+ const svg = decode(renderBadgedFavicon(BASE_SVG, 250)!);
+ expect(svg).toContain('>99+<');
+ });
+
+ it('preserves the base artwork and its clipPath id', () => {
+ const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
+ expect(svg).toContain('id="_clip1"');
+ expect(svg).toContain('#123456');
+ });
+
+ it('overrides pt-unit width and height with unitless 16 and keeps the viewBox', () => {
+ const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
+ expect(svg).toContain('width="16"');
+ expect(svg).toContain('height="16"');
+ expect(svg).toContain('viewBox="0 0 1000 1000"');
+ expect(svg).not.toContain('1000pt');
+ });
+
+ it('draws a white band with black digits, so the count stays legible over any base icon', () => {
+ const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
+ expect(svg).toMatch(/]*fill="#ffffff"/);
+ expect(svg).toMatch(/]*fill="#000000"/);
+ });
+
+ it('shrinks the font as the label grows so three glyphs still fit', () => {
+ const one = decode(renderBadgedFavicon(BASE_SVG, 3)!);
+ const three = decode(renderBadgedFavicon(BASE_SVG, 250)!);
+ expect(fontSize(three)).toBeLessThan(fontSize(one));
+ });
+
+ it('returns null rather than throwing when the source contains a lone surrogate', () => {
+ const bad = ``;
+ expect(() => renderBadgedFavicon(bad, 3)).not.toThrow();
+ expect(renderBadgedFavicon(bad, 3)).toBeNull();
+ });
+
+ it('sizes the band to the label, and only "99+" fills the full icon width', () => {
+ // The box is only as wide as its digits need — "5" must not squat on as much
+ // white as "99+". It is never wider than the icon, and three glyphs, whose
+ // font is budgeted against the full span, grow to exactly fill it.
+ const w = (count: number) => band(decode(renderBadgedFavicon(BASE_SVG, count)!)).w;
+ expect(w(7)).toBeLessThan(w(47));
+ expect(w(47)).toBeLessThan(w(250));
+ expect(w(250)).toBeCloseTo(1000, 5);
+ });
+
+ it('matches the geometry measured from Gmail\'s 16px favicon', () => {
+ // Ground truth, measured pixel-by-pixel off Gmail's tab icon and scaled to a
+ // 0 0 1000 1000 viewBox: band 10/16 of the icon (0.625), flush to the bottom
+ // edge, corners rounded by 0.1h, width fitted to the label, anchored right.
+ // Gmail's own single-digit badge sits hard right in a box about a third of
+ // the icon wide, so the box grows leftwards from the corner.
+ // w = label.length * 0.6 * font + 2 * 0.04 * 1000, x = 1000 - w.
+ const expected: Record = {
+ '5': { x: 554, w: 446, font: 610 }, // textW = 1 * 0.6 * 610 = 366
+ '15': { x: 188, w: 812, font: 610 }, // textW = 2 * 0.6 * 610 = 732
+ '250': { x: 0, w: 1000, font: 920 / 1.8 }, // "99+": font = (1000 - 80) / (3 * 0.6)
+ };
+ for (const [count, want] of Object.entries(expected)) {
+ const svg = decode(renderBadgedFavicon(BASE_SVG, Number(count))!);
+ const { x, y, w, h, rx } = band(svg);
+ expect(x).toBeCloseTo(want.x, 5);
+ expect(w).toBeCloseTo(want.w, 5);
+ expect(fontSize(svg)).toBeCloseTo(want.font, 5);
+ expect(y).toBeCloseTo(375, 5);
+ expect(h).toBeCloseTo(625, 5);
+ expect(rx).toBeCloseTo(62.5, 5);
+ }
+ });
+
+ it('anchors the band to the right edge, including on a negative-origin viewBox', () => {
+ // Corner-anchored, not centred: the box grows leftwards from the bottom-right
+ // corner, so its right edge sits on minX + span whatever the label. Centring
+ // was rejected — at a single digit it lands under the middle of the mark.
+ const cases: [string, number, number][] = [
+ // [base svg, minX, span]
+ [BASE_SVG, 0, 1000],
+ [``, -4, 24],
+ ];
+ for (const [svgSource, minX, span] of cases) {
+ for (const count of [7, 47, 250]) {
+ const { x, w } = band(decode(renderBadgedFavicon(svgSource, count)!));
+ expect(x + w).toBeCloseTo(minX + span, 5);
+ expect(x).toBeGreaterThanOrEqual(minX);
+ }
+ }
+ });
+
+ it('renders 1- and 2-digit labels at the max font size, and shrinks only for "99+"', () => {
+ // The font is budgeted against the full icon span, not against the fitted
+ // box, so one or two glyphs always land at FONT_MAX; only three force a
+ // shrink — and their box then grows to fill the icon.
+ const FONT_MAX = 0.61 * 1000;
+ const one = decode(renderBadgedFavicon(BASE_SVG, 7)!);
+ const two = decode(renderBadgedFavicon(BASE_SVG, 47)!);
+ const three = decode(renderBadgedFavicon(BASE_SVG, 250)!);
+ expect(fontSize(one)).toBeCloseTo(FONT_MAX, 5);
+ expect(fontSize(two)).toBeCloseTo(FONT_MAX, 5);
+ expect(fontSize(three)).toBeLessThan(FONT_MAX);
+ });
+
+ it('rounds the band corners slightly — neither an oval nor a hard square', () => {
+ // rx = h / 2 was the pill: at one digit it read as a circle, at two an oval,
+ // and "99+" was a smudge. rx = 0 is the other failure: Gmail's corners carry
+ // a visible ~1px round at 16px. Guard against a silent revert to either.
+ for (const count of [7, 47, 250]) {
+ const { h, rx } = band(decode(renderBadgedFavicon(BASE_SVG, count)!));
+ expect(rx).toBeCloseTo(0.1 * h, 5);
+ expect(rx).toBeGreaterThan(0);
+ expect(rx).toBeLessThan(h / 2);
+ }
+ });
+
+ it('draws the digits at font-weight 500, in both the attribute and the style', () => {
+ // 700 read visibly heavier than Gmail's equivalent badge.
+ const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
+ expect(svg).toMatch(/]*font-weight="500"/);
+ expect(svg).toMatch(/]*style="[^"]*font-weight:\s*500/);
+ });
+
+ it('keeps the badge band entirely inside the viewBox for 1, 2, and 3-glyph labels', () => {
+ // The band is flush to the bottom and, at three glyphs, to the left and
+ // right edges too — but it must never overflow any of them.
+ for (const count of [7, 47, 250]) {
+ const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
+ const { x, y, w, h } = band(svg);
+ expect(x).toBeGreaterThanOrEqual(0);
+ expect(y).toBeGreaterThanOrEqual(0);
+ expect(x + w).toBeLessThanOrEqual(1000);
+ expect(y + h).toBeLessThanOrEqual(1000);
+ }
+ });
+
+ // A previous version of this test used /\bx="([\d.]+)"/, which cannot match a
+ // negative number: dropping `minX +` from the anchoring passed it. Anchor
+ // against a viewBox whose origin is negative, where the band's own x is
+ // legitimately negative, so the offset is genuinely pinned.
+ it('anchors the band to the viewBox origin, including a negative origin', () => {
+ const NEGATIVE_ORIGIN = ``;
+ for (const count of [7, 47, 250]) {
+ const svg = decode(renderBadgedFavicon(NEGATIVE_ORIGIN, count)!);
+ const { x, y, w, h } = band(svg);
+ expect(x).toBeGreaterThanOrEqual(-40);
+ expect(y).toBeGreaterThanOrEqual(-40);
+ expect(x + w).toBeLessThanOrEqual(200);
+ expect(y + h).toBeLessThanOrEqual(200);
+ // Anchored to the bottom-right: in a viewBox running from -40 to 200, the
+ // band's bottom edge and its right edge both sit well past the midpoint.
+ expect(x + w).toBeGreaterThan(80);
+ expect(y + h).toBeGreaterThan(80);
+ }
+ });
+
+ it('fits the label inside the band, with padding, for every label length', () => {
+ // The core band invariant: textW + 2 * pad <= w, where the glyph advance and
+ // padding are the renderer's own published constants. PAD_FACTOR is a
+ // fraction of the icon span, not of the fitted box, so the padding is the
+ // same at every label length.
+ const GLYPH_ADV = 0.6;
+ const PAD_FACTOR = 0.04;
+ for (const count of [7, 47, 250]) {
+ const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
+ const { w } = band(svg);
+ const label = count > 99 ? '99+' : String(count);
+ const textW = label.length * GLYPH_ADV * fontSize(svg);
+ const pad = PAD_FACTOR * 1000;
+ expect(textW + 2 * pad).toBeLessThanOrEqual(w + 1e-6);
+ }
+ });
+
+ it('percent-encodes the payload, so a "#" in a fill cannot truncate the data URL', () => {
+ const url = renderBadgedFavicon(BASE_SVG, 3)!;
+ // encodeURI leaves "#" bare, which the browser reads as a fragment
+ // delimiter: everything after the first colour would be silently dropped.
+ expect(url).toContain('%23');
+ expect(url).not.toContain('#');
+ });
+
+ it('returns an empty label, and no badge, for a fractional count below one', () => {
+ expect(formatBadgeCount(0.5)).toBe('');
+ expect(renderBadgedFavicon(BASE_SVG, 0.5)).toBeNull();
+ });
+
+ it('returns null when the root svg has no SVG namespace', () => {
+ // Non-null but unrenderable: a data URL built from this would show nothing.
+ const noNs = ``;
+ expect(renderBadgedFavicon(noNs, 3)).toBeNull();
+ });
+
+ it('beats a stylesheet in the base SVG, keeping the badge white-on-black', () => {
+ // Presentation attributes lose to any CSS rule in the document. A branded
+ // base carrying `rect { fill: #db2d54 }` would otherwise paint the band red
+ // and the digits red — exactly what the white band exists to prevent.
+ const STYLED = ``;
+ const svg = decode(renderBadgedFavicon(STYLED, 3)!);
+ expect(svg).toMatch(/]*style="[^"]*fill:\s*#ffffff/);
+ expect(svg).toMatch(/]*style="[^"]*fill:\s*#000000/);
+ });
+
+ it('strips scripts, foreignObject and event handlers from the base SVG', () => {
+ // The base may be an admin-uploaded file, which upstream serves under a
+ // sandboxing CSP precisely because SVG can carry script. Re-emitting it as a
+ // same-origin data: URL would un-fence it, so sanitise before serialising.
+ const HOSTILE = ``;
+ const url = renderBadgedFavicon(HOSTILE, 3)!;
+ expect(url).not.toBeNull();
+ const svg = decode(url);
+ expect(svg).not.toContain('