From 22eda063fb3904355b1fa1f175ebe744293d7238 Mon Sep 17 00:00:00 2001 From: Matthieu MALVACHE Date: Fri, 17 Apr 2026 14:35:36 +0200 Subject: [PATCH] fix(favicon): stop tearing out Next.js's managed icon link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The badge hook was calling removeCurrentFavicons() — document.head .querySelectorAll("link[rel~='icon']").forEach(l => l.remove()) — which also removed the link that Next.js renders from app/icon.svg and manages via its metadata reconciler. On the next render, React's commit phase tried to remove the now-missing node and crashed with "can't access property 'removeChild', finishedRoot.parentNode is null". Leave Next.js's icon alone. The hook now upserts its own link tagged data-dynamic-favicon and cleans up only that one on unmount / count returning to 0. Chromium picks the last matching rel="icon" link, so the badge still appears there; other browsers fall back to the static icon, which is an acceptable degradation. --- hooks/use-favicon-badge.ts | 57 +++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/hooks/use-favicon-badge.ts b/hooks/use-favicon-badge.ts index a8a4c79..1b49bbe 100644 --- a/hooks/use-favicon-badge.ts +++ b/hooks/use-favicon-badge.ts @@ -3,26 +3,30 @@ 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 originalHref = useRef(null); const countRef = useRef(count); countRef.current = count; useEffect(() => { - const link = document.querySelector("link[rel~='icon']"); - if (!link?.href) return; - - originalHref.current = link.href; + // 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])"); + if (!baseLink?.href) return; const canvas = document.createElement("canvas"); canvas.width = SIZE; canvas.height = SIZE; canvasRef.current = canvas; - fetch(link.href) + fetch(baseLink.href) .then((r) => r.blob()) .then((blob) => { const reader = new FileReader(); @@ -41,13 +45,15 @@ export function useFaviconBadge(count: number) { .catch(() => { /* favicon not loadable — badge will draw without base icon */ }); + + return () => { + clearDynamicFavicon(); + }; }, []); useEffect(() => { if (count <= 0) { - if (originalHref.current) { - setFavicon(originalHref.current, "image/svg+xml"); - } + clearDynamicFavicon(); return; } @@ -102,19 +108,30 @@ function applyBadge( ctx.fillStyle = "#ffffff"; ctx.fillText(label, x + padding, y + padding / 2); - setFavicon(canvas.toDataURL("image/png")); + setDynamicFavicon(canvas.toDataURL("image/png")); } -function removeCurrentFavicons() { - const links = document.querySelectorAll("link[rel~='icon']"); - links.forEach((link) => link.remove()); +/** + * 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; + } } -export function setFavicon(href: string, type: string = "image/png") { - removeCurrentFavicons(); - const link = document.createElement("link"); - link.rel = "icon"; - link.type = type; - link.href = href; - document.head.appendChild(link); +function clearDynamicFavicon() { + document.querySelector(`link[${DYNAMIC_ATTR}]`)?.remove(); }