Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed app/favicon.ico
Binary file not shown.
113 changes: 44 additions & 69 deletions hooks/use-favicon-badge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -92,46 +92,21 @@ function applyBadge(
const y = SIZE - badgeH;

ctx.fillStyle = "#ef4444";
ctx.beginPath();
ctx.strokeStyle = "#b91c1c";
ctx.lineWidth = 1;
// 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.strokeStyle = "#b91c1c";
ctx.lineWidth = 1;
ctx.stroke();

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");
}