fix: favicon badge — remove favicon.ico, fix Firefox compat

- Delete app/favicon.ico so Next.js renders a single <link rel="icon">
  for icon.svg. With two icon links in the DOM, browsers disagreed on
  which to use: Chrome and Firefox on Linux both picked the *first* one,
  making the dynamically-appended badge link invisible.
- Mutate href on the existing <link> instead of injecting a second one.
  Firefox only updates the tab icon when the href of the element it
  already tracks changes — adding new <link> nodes dynamically is ignored.
- Simplify image loading: replace fetch → blob → FileReader → Image
  chain with new Image() + crossOrigin="anonymous".
- Fix badge border in Safari 15 fallback: use strokeRect instead of
  stroke() after fillRect (fillRect does not add to the canvas path).
This commit is contained in:
Javier Infante
2026-04-19 21:21:18 +02:00
parent f3e84a9549
commit 54a2f8569c
2 changed files with 49 additions and 74 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+49 -74
View File
@@ -3,81 +3,81 @@
import { useEffect, useRef } from "react";
const SIZE = 32;
const DYNAMIC_ATTR = "data-dynamic-favicon";
export function useFaviconBadge(count: number) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const baseLinkRef = useRef<HTMLLinkElement | null>(null);
const originalHrefRef = useRef<string>("");
const imageLoaded = useRef(false);
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
// Grab the base favicon href from whatever the document currently has
// (Next.js renders one from app/icon.svg). We NEVER remove or edit
// that link — Next.js's metadata reconciler owns it and pulling it
// out from under React crashes reconciliation with
// "parentNode is null". Our badge lives on its own link tagged with
// data-dynamic-favicon.
const baseLink = document.querySelector<HTMLLinkElement>("link[rel~='icon']:not([data-dynamic-favicon])");
// We mutate href on the existing Next.js-rendered <link> directly.
// Firefox only updates the tab favicon when the href of the element it
// already tracks changes — it ignores new <link> nodes added dynamically.
// Changing href is safe: the React reconciler crash ("parentNode is null")
// only happens when a tracked node is *removed* from the DOM, not when
// its attributes change. On cleanup we restore the original href.
const baseLink = document.querySelector<HTMLLinkElement>("link[rel~='icon']");
if (!baseLink?.href) return;
baseLinkRef.current = baseLink;
originalHrefRef.current = baseLink.href;
const canvas = document.createElement("canvas");
canvas.width = SIZE;
canvas.height = SIZE;
canvasRef.current = canvas;
fetch(baseLink.href)
.then((r) => r.blob())
.then((blob) => {
const reader = new FileReader();
reader.onloadend = () => {
const img = new Image();
img.onload = () => {
imageRef.current = img;
if (countRef.current > 0) {
applyBadge(canvas, img, countRef.current);
}
};
img.src = reader.result as string;
};
reader.readAsDataURL(blob);
})
.catch(() => {
/* favicon not loadable — badge will draw without base icon */
});
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
imageLoaded.current = true;
imageRef.current = img;
if (countRef.current > 0) {
drawBadge(canvas, img, countRef.current, baseLink);
}
};
img.onerror = () => {
imageLoaded.current = true;
};
img.src = originalHrefRef.current;
return () => {
clearDynamicFavicon();
if (baseLinkRef.current) {
baseLinkRef.current.href = originalHrefRef.current;
}
};
}, []);
useEffect(() => {
const baseLink = baseLinkRef.current;
if (!baseLink || !imageLoaded.current) return;
if (count <= 0) {
clearDynamicFavicon();
baseLink.href = originalHrefRef.current;
return;
}
if (canvasRef.current) {
applyBadge(canvasRef.current, imageRef.current, count);
drawBadge(canvasRef.current, imageRef.current, count, baseLink);
}
}, [count]);
}
function applyBadge(
function drawBadge(
canvas: HTMLCanvasElement,
image: HTMLImageElement | null,
count: number,
link: HTMLLinkElement,
) {
const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}
if (!ctx) return;
ctx.clearRect(0, 0, SIZE, SIZE);
if (image) {
ctx.drawImage(image, 0, 0, SIZE, SIZE);
}
if (image) ctx.drawImage(image, 0, 0, SIZE, SIZE);
const label = count > 9 ? "+" : String(count);
const fontSize = SIZE * 0.55;
@@ -92,46 +92,21 @@ function applyBadge(
const y = SIZE - badgeH;
ctx.fillStyle = "#ef4444";
ctx.beginPath();
// roundRect is Baseline 2023; fall back to fillRect on Safari 15 and below.
if (typeof ctx.roundRect === "function") {
ctx.roundRect(x, y, badgeW, badgeH, 3);
ctx.fill();
} else {
ctx.fillRect(x, y, badgeW, badgeH);
}
ctx.strokeStyle = "#b91c1c";
ctx.lineWidth = 1;
ctx.stroke();
// roundRect is Baseline 2023; fall back to fillRect on Safari 15 and below.
if (typeof ctx.roundRect === "function") {
ctx.beginPath();
ctx.roundRect(x, y, badgeW, badgeH, 3);
ctx.fill();
ctx.stroke();
} else {
ctx.fillRect(x, y, badgeW, badgeH);
ctx.strokeRect(x, y, badgeW, badgeH);
}
ctx.fillStyle = "#ffffff";
ctx.fillText(label, x + padding, y + padding / 2);
setDynamicFavicon(canvas.toDataURL("image/png"));
}
/**
* Upsert a dynamic favicon link marked with data-dynamic-favicon. The
* app/icon.svg managed by Next.js is left alone. Browsers pick the
* last matching link for rel="icon", so the dynamic link takes
* precedence on Chromium; Firefox/Safari fall back to the static icon,
* which is fine — the count-based badge remains a Chromium nicety.
*/
function setDynamicFavicon(href: string) {
let link = document.querySelector<HTMLLinkElement>(`link[${DYNAMIC_ATTR}]`);
if (!link) {
link = document.createElement("link");
link.setAttribute(DYNAMIC_ATTR, "");
link.rel = "icon";
link.type = "image/png";
document.head.appendChild(link);
}
if (link.href !== href) {
link.href = href;
}
}
function clearDynamicFavicon() {
document.querySelector(`link[${DYNAMIC_ATTR}]`)?.remove();
link.href = canvas.toDataURL("image/png");
}