diff --git a/app/favicon.ico b/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/app/favicon.ico and /dev/null differ diff --git a/hooks/use-favicon-badge.ts b/hooks/use-favicon-badge.ts index 1b49bbe..2d6daae 100644 --- a/hooks/use-favicon-badge.ts +++ b/hooks/use-favicon-badge.ts @@ -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(null); const imageRef = useRef(null); + const baseLinkRef = useRef(null); + const originalHrefRef = useRef(""); + 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("link[rel~='icon']:not([data-dynamic-favicon])"); + // We mutate href on the existing Next.js-rendered directly. + // Firefox only updates the tab favicon when the href of the element it + // already tracks changes — it ignores new 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("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(`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"); }