fix(favicon): show dedicated icon on 404 pages

Add favicon-404.ico and switch tab favicon on not-found routes while
keeping static app pages and larger crawler icons unchanged.
This commit is contained in:
Tommaso Casaburi
2026-06-27 17:38:55 +07:00
parent eacb422f40
commit f213d41296
7 changed files with 61 additions and 14 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+6 -6
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { useAccountComment } from '@bitsocial/bitsocial-react-hooks';
import { isAllView, isModView, isSubscriptionsView } from '../lib/utils/view-utils';
import { isAllView, isModView, isNotFoundView, isSubscriptionsView } from '../lib/utils/view-utils';
import useThemeStore from '../stores/use-theme-store';
import { useDirectories } from './use-directories';
import { useResolvedCommunityAddress } from './use-resolved-community-address';
@@ -22,9 +22,8 @@ const updateThemeClass = (newTheme: string) => {
const useTheme = (): [string, (theme: string) => void] => {
const location = useLocation();
const params = useParams<{ boardIdentifier?: string }>();
const pendingPostParams = useParams<{ accountCommentIndex?: string }>();
const pendingPost = useAccountComment({ commentIndex: normalizeAccountCommentIndex(pendingPostParams?.accountCommentIndex) });
const params = useParams<{ accountCommentIndex?: string; boardIdentifier?: string; commentCid?: string }>();
const pendingPost = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) });
const pendingPostCommunityAddress = getCommentCommunityAddress(pendingPost);
const { isEnabled, setIsEnabled } = useSpecialThemeStore();
@@ -36,6 +35,7 @@ const useTheme = (): [string, (theme: string) => void] => {
const isInAllView = isAllView(location.pathname);
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
const isInModView = isModView(location.pathname);
const isInNotFoundView = isNotFoundView(location.pathname, params);
const routeIdentifier = params.boardIdentifier;
const resolvedAddress = useResolvedCommunityAddress();
const communityAddress = resolvedAddress || pendingPostCommunityAddress || routeIdentifier;
@@ -92,8 +92,8 @@ const useTheme = (): [string, (theme: string) => void] => {
}, [currentTheme]);
useEffect(() => {
updateFavicon(sfw);
}, [sfw]);
updateFavicon(isInNotFoundView ? 'not-found' : sfw);
}, [isInNotFoundView, sfw]);
const setCommunityTheme = useCallback(
async (newTheme: string) => {
+17
View File
@@ -31,6 +31,23 @@ describe('update-favicon', () => {
expect(document.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href')).toBe('/apple-touch-icon.png');
});
it('can switch to the not-found ico favicon without removing larger crawler icons', async () => {
const { updateFavicon } = await import('../update-favicon');
document.head.innerHTML =
'<link rel="icon" sizes="16x16" href="/favicon.ico"><link rel="shortcut icon" sizes="16x16" href="/favicon.ico"><link rel="icon" sizes="192x192" href="/manifest-icon-192x192.png"><link rel="apple-touch-icon" sizes="256x256" href="/apple-touch-icon.png">';
updateFavicon('not-found');
expect(document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]')).toHaveLength(4);
expect(document.querySelector('link[rel="icon"][sizes="16x16"]')?.getAttribute('href')).toBe('/favicon-404.ico?variant=404');
expect(document.querySelector('link[rel="shortcut icon"][sizes="16x16"]')?.getAttribute('href')).toBe('/favicon-404.ico?variant=404');
expect(document.querySelector('link[rel="icon"][sizes="16x16"]')?.getAttribute('type')).toBe('image/x-icon');
expect(document.querySelector('link[rel="shortcut icon"][sizes="16x16"]')?.getAttribute('type')).toBe('image/x-icon');
expect(document.querySelector('link[rel="icon"][sizes="192x192"]')?.getAttribute('href')).toBe('/manifest-icon-192x192.png');
expect(document.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href')).toBe('/apple-touch-icon.png');
});
it('marks only non-special, non-routing aggregate sfw boards as sfw', async () => {
const { isSfwBoard } = await import('../update-favicon');
+22 -7
View File
@@ -1,35 +1,50 @@
const DEFAULT_FAVICON = '/favicon.ico?variant=nsfw';
const SFW_FAVICON = '/favicon2.ico?variant=sfw';
const NOT_FOUND_FAVICON = '/favicon-404.ico?variant=404';
const FAVICON_RELS = ['icon', 'shortcut icon'] as const;
const FAVICON_SELECTOR = ['link[data-fivechan-tab-favicon="true"]', ...FAVICON_RELS.map((rel) => `link[rel="${rel}"][sizes="16x16"]`)].join(', ');
type FaviconVariant = 'default' | 'sfw' | 'not-found';
const FAVICONS: Record<FaviconVariant, { href: string; type: string }> = {
default: { href: DEFAULT_FAVICON, type: 'image/png' },
sfw: { href: SFW_FAVICON, type: 'image/png' },
'not-found': { href: NOT_FOUND_FAVICON, type: 'image/x-icon' },
};
let currentHref: string | null = null;
const hasExpectedFaviconLinks = (href: string): boolean =>
FAVICON_RELS.every((rel) => document.querySelector(`link[rel="${rel}"][href="${href}"][data-fivechan-tab-favicon="true"]`));
const createFaviconLink = (rel: (typeof FAVICON_RELS)[number], href: string): HTMLLinkElement => {
const createFaviconLink = (rel: (typeof FAVICON_RELS)[number], favicon: (typeof FAVICONS)[FaviconVariant]): HTMLLinkElement => {
const link = document.createElement('link');
link.rel = rel;
link.type = 'image/png';
link.type = favicon.type;
link.setAttribute('sizes', '16x16');
link.href = href;
link.href = favicon.href;
link.dataset.fivechanTabFavicon = 'true';
return link;
};
const getFaviconVariant = (variant: boolean | FaviconVariant): FaviconVariant => {
if (typeof variant === 'boolean') return variant ? 'sfw' : 'default';
return variant;
};
/**
* Swap the tab favicon between the default (NSFW/home) and SFW variants.
* Swap the tab favicon between the default (NSFW/home), SFW, and 404 variants.
* 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;
export const updateFavicon = (variant: boolean | FaviconVariant): void => {
const favicon = FAVICONS[getFaviconVariant(variant)];
const { href } = favicon;
if (href === currentHref && hasExpectedFaviconLinks(href)) return;
currentHref = href;
document.querySelectorAll<HTMLLinkElement>(FAVICON_SELECTOR).forEach((link) => link.remove());
FAVICON_RELS.forEach((rel) => {
document.head.appendChild(createFaviconLink(rel, href));
document.head.appendChild(createFaviconLink(rel, favicon));
});
};
@@ -22,6 +22,14 @@ describe('view-utils', () => {
expect(isModQueueView('/music.eth/mod/queue')).toBe(true);
expect(isSubscriptionsView('/subs/catalog/settings', {})).toBe(true);
expect(isPendingPostView('/pending/42/settings', { accountCommentIndex: '42' })).toBe(true);
expect(isNotFoundView('/faq', {})).toBe(false);
expect(isNotFoundView('/pass', {})).toBe(false);
expect(isNotFoundView('/rules', {})).toBe(false);
expect(isNotFoundView('/blotter', {})).toBe(false);
expect(isNotFoundView('/settings/account-data', {})).toBe(false);
expect(isNotFoundView('/not-allowed', {})).toBe(false);
expect(isNotFoundView('/not-found', {})).toBe(true);
expect(isNotFoundView('/faq/missing', {})).toBe(true);
});
it('detects board, catalog, post, and settings routes using board params', () => {
+5
View File
@@ -6,6 +6,10 @@ type ParamsType = {
commentCid?: string;
};
const STATIC_APP_ROUTES = new Set(['/faq', '/pass', '/rules', '/blotter', '/settings/account-data', '/not-allowed']);
const isStaticAppRoute = (pathname: string): boolean => STATIC_APP_ROUTES.has(pathname);
export const isAllView = (pathname: string): boolean => {
return pathname.startsWith('/all');
};
@@ -97,6 +101,7 @@ export const isNotFoundView = (pathname: string, params: ParamsType): boolean =>
!isArchiveView(pathname, params) &&
!isCatalogView(pathname, params) &&
!isHomeView(pathname) &&
!isStaticAppRoute(pathname) &&
!isPendingPostView(pathname, params) &&
!isPostPageView(pathname, params) &&
!isSettingsView(pathname, params) &&
+3 -1
View File
@@ -39,6 +39,7 @@ const neverPrecacheUrls = new Set(['index.html', 'version.json']);
const vitePwaManagedAssetUrls = new Set([
'manifest.webmanifest',
'favicon.ico',
'favicon-404.ico',
'favicon2.ico',
'robots.txt',
'apple-touch-icon.png',
@@ -50,6 +51,7 @@ const baselineAppShellUrls = new Set([
'manifest.json',
'manifest.webmanifest',
'favicon.ico',
'favicon-404.ico',
'favicon2.ico',
'robots.txt',
'apple-touch-icon.png',
@@ -393,7 +395,7 @@ export default defineConfig({
enabled: true,
type: 'module',
},
includeAssets: ['favicon.ico', 'favicon2.ico', 'robots.txt', 'apple-touch-icon.png'],
includeAssets: ['favicon.ico', 'favicon-404.ico', 'favicon2.ico', 'robots.txt', 'apple-touch-icon.png'],
manifest: {
name: '5chan',
short_name: '5chan',