Files
SRCmail/hooks/use-favicon-badge.ts
T
honzupandLinus Rath 334fdbfb86 feat: show unread count badge on favicon
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 <link rel="icon"> 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 <head>, landing after ours, and the
  base icon silently wins again. A MutationObserver on <head> 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.
2026-07-16 18:00:41 +02:00

238 lines
10 KiB
TypeScript

"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 <link rel="icon"> through React, which hoists it into
// <head> 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<HTMLLinkElement>(OWN_SELECTOR);
}
/** True when ours is the last icon link in <head>, i.e. the one the browser uses. */
function isLastIconLink(link: HTMLLinkElement): boolean {
const icons = document.head.querySelectorAll<HTMLLinkElement>(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 <link rel="icon">, 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<string | null>(null);
const baseHref = useRef<string | null>(null);
// The href our own link currently carries, and the hook's whole state machine:
// null -> nothing of ours is in <head> (we have never badged)
// baseHref -> ours is in <head>, showing the unbadged base icon
// a data: URL -> ours is in <head>, showing the badge
const appliedHref = useRef<string | null>(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 <head> 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
// <head> 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<HTMLLinkElement>(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 <head>.
//
// On a client-side navigation (Inbox -> Calendar) Next re-hoists the metadata
// <link rel="icon"> from app/(main)/layout.tsx into <head>. 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 <head> fixes both halves at once.
//
// Termination: moving our own link is itself a <head> 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 <head> 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]);
}