fix(favicon): harden sfw board icon swapping

This commit is contained in:
Tommaso Casaburi
2026-03-24 16:03:31 +07:00
parent fae4bcfe7f
commit 0b844e6060
3 changed files with 34 additions and 19 deletions
+11 -7
View File
@@ -7,19 +7,23 @@ describe('update-favicon', () => {
vi.resetModules();
});
it('creates a favicon link and replaces it only when the target icon changes', async () => {
it('replaces managed icon links and keeps both icon rel variants in sync', async () => {
const { updateFavicon } = await import('../update-favicon');
updateFavicon(false);
expect(document.querySelectorAll('link[rel="icon"]')).toHaveLength(1);
expect(document.querySelector('link[rel="icon"]')?.getAttribute('href')).toBe('/favicon.ico');
document.head.innerHTML = '<link rel="icon" href="/favicon.ico"><link rel="shortcut icon" href="/favicon.ico">';
updateFavicon(false);
expect(document.querySelectorAll('link[rel="icon"]')).toHaveLength(1);
expect(document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"]')).toHaveLength(2);
expect(document.querySelector('link[rel="icon"]')?.getAttribute('href')).toBe('/favicon.ico?variant=nsfw');
expect(document.querySelector('link[rel="shortcut icon"]')?.getAttribute('href')).toBe('/favicon.ico?variant=nsfw');
updateFavicon(false);
expect(document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"]')).toHaveLength(2);
updateFavicon(true);
expect(document.querySelectorAll('link[rel="icon"]')).toHaveLength(1);
expect(document.querySelector('link[rel="icon"]')?.getAttribute('href')).toBe('/favicon2.ico');
expect(document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"]')).toHaveLength(2);
expect(document.querySelector('link[rel="icon"]')?.getAttribute('href')).toBe('/favicon2.ico?variant=sfw');
expect(document.querySelector('link[rel="shortcut icon"]')?.getAttribute('href')).toBe('/favicon2.ico?variant=sfw');
});
it('marks only non-special, non-routing aggregate sfw boards as sfw', async () => {
+21 -11
View File
@@ -1,24 +1,34 @@
const DEFAULT_FAVICON = '/favicon.ico';
const SFW_FAVICON = '/favicon2.ico';
const DEFAULT_FAVICON = '/favicon.ico?variant=nsfw';
const SFW_FAVICON = '/favicon2.ico?variant=sfw';
const FAVICON_RELS = ['icon', 'shortcut icon'] as const;
const FAVICON_SELECTOR = FAVICON_RELS.map((rel) => `link[rel="${rel}"]`).join(', ');
let currentHref: string | null = null;
const hasExpectedFaviconLinks = (href: string): boolean => FAVICON_RELS.every((rel) => document.querySelector(`link[rel="${rel}"][href="${href}"]`));
const createFaviconLink = (rel: (typeof FAVICON_RELS)[number], href: string): HTMLLinkElement => {
const link = document.createElement('link');
link.rel = rel;
link.type = 'image/png';
link.sizes = '16x16';
link.href = href;
return link;
};
/**
* Swap the tab favicon between the default (NSFW/home) and SFW variants.
* Uses remove-and-recreate to bypass aggressive browser favicon caching.
* Uses remove-and-recreate plus cache-busted URLs to bypass sticky favicon caching.
*/
export const updateFavicon = (isSfw: boolean): void => {
const href = isSfw ? SFW_FAVICON : DEFAULT_FAVICON;
if (href === currentHref) return;
if (href === currentHref && hasExpectedFaviconLinks(href)) return;
currentHref = href;
const existing = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (existing) existing.remove();
const link = document.createElement('link');
link.rel = 'icon';
link.href = href;
document.head.appendChild(link);
document.querySelectorAll<HTMLLinkElement>(FAVICON_SELECTOR).forEach((link) => link.remove());
FAVICON_RELS.forEach((rel) => {
document.head.appendChild(createFaviconLink(rel, href));
});
};
/**