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 { useCallback, useEffect, useMemo } from 'react';
import { useLocation, useParams } from 'react-router-dom'; import { useLocation, useParams } from 'react-router-dom';
import { useAccountComment } from '@bitsocial/bitsocial-react-hooks'; 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 useThemeStore from '../stores/use-theme-store';
import { useDirectories } from './use-directories'; import { useDirectories } from './use-directories';
import { useResolvedCommunityAddress } from './use-resolved-community-address'; import { useResolvedCommunityAddress } from './use-resolved-community-address';
@@ -22,9 +22,8 @@ const updateThemeClass = (newTheme: string) => {
const useTheme = (): [string, (theme: string) => void] => { const useTheme = (): [string, (theme: string) => void] => {
const location = useLocation(); const location = useLocation();
const params = useParams<{ boardIdentifier?: string }>(); const params = useParams<{ accountCommentIndex?: string; boardIdentifier?: string; commentCid?: string }>();
const pendingPostParams = useParams<{ accountCommentIndex?: string }>(); const pendingPost = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) });
const pendingPost = useAccountComment({ commentIndex: normalizeAccountCommentIndex(pendingPostParams?.accountCommentIndex) });
const pendingPostCommunityAddress = getCommentCommunityAddress(pendingPost); const pendingPostCommunityAddress = getCommentCommunityAddress(pendingPost);
const { isEnabled, setIsEnabled } = useSpecialThemeStore(); const { isEnabled, setIsEnabled } = useSpecialThemeStore();
@@ -36,6 +35,7 @@ const useTheme = (): [string, (theme: string) => void] => {
const isInAllView = isAllView(location.pathname); const isInAllView = isAllView(location.pathname);
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
const isInModView = isModView(location.pathname); const isInModView = isModView(location.pathname);
const isInNotFoundView = isNotFoundView(location.pathname, params);
const routeIdentifier = params.boardIdentifier; const routeIdentifier = params.boardIdentifier;
const resolvedAddress = useResolvedCommunityAddress(); const resolvedAddress = useResolvedCommunityAddress();
const communityAddress = resolvedAddress || pendingPostCommunityAddress || routeIdentifier; const communityAddress = resolvedAddress || pendingPostCommunityAddress || routeIdentifier;
@@ -92,8 +92,8 @@ const useTheme = (): [string, (theme: string) => void] => {
}, [currentTheme]); }, [currentTheme]);
useEffect(() => { useEffect(() => {
updateFavicon(sfw); updateFavicon(isInNotFoundView ? 'not-found' : sfw);
}, [sfw]); }, [isInNotFoundView, sfw]);
const setCommunityTheme = useCallback( const setCommunityTheme = useCallback(
async (newTheme: string) => { 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'); 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 () => { it('marks only non-special, non-routing aggregate sfw boards as sfw', async () => {
const { isSfwBoard } = await import('../update-favicon'); const { isSfwBoard } = await import('../update-favicon');
+22 -7
View File
@@ -1,35 +1,50 @@
const DEFAULT_FAVICON = '/favicon.ico?variant=nsfw'; const DEFAULT_FAVICON = '/favicon.ico?variant=nsfw';
const SFW_FAVICON = '/favicon2.ico?variant=sfw'; 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_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(', '); 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; let currentHref: string | null = null;
const hasExpectedFaviconLinks = (href: string): boolean => const hasExpectedFaviconLinks = (href: string): boolean =>
FAVICON_RELS.every((rel) => document.querySelector(`link[rel="${rel}"][href="${href}"][data-fivechan-tab-favicon="true"]`)); 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'); const link = document.createElement('link');
link.rel = rel; link.rel = rel;
link.type = 'image/png'; link.type = favicon.type;
link.setAttribute('sizes', '16x16'); link.setAttribute('sizes', '16x16');
link.href = href; link.href = favicon.href;
link.dataset.fivechanTabFavicon = 'true'; link.dataset.fivechanTabFavicon = 'true';
return link; 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. * Uses remove-and-recreate plus cache-busted URLs to bypass sticky favicon caching.
*/ */
export const updateFavicon = (isSfw: boolean): void => { export const updateFavicon = (variant: boolean | FaviconVariant): void => {
const href = isSfw ? SFW_FAVICON : DEFAULT_FAVICON; const favicon = FAVICONS[getFaviconVariant(variant)];
const { href } = favicon;
if (href === currentHref && hasExpectedFaviconLinks(href)) return; if (href === currentHref && hasExpectedFaviconLinks(href)) return;
currentHref = href; currentHref = href;
document.querySelectorAll<HTMLLinkElement>(FAVICON_SELECTOR).forEach((link) => link.remove()); document.querySelectorAll<HTMLLinkElement>(FAVICON_SELECTOR).forEach((link) => link.remove());
FAVICON_RELS.forEach((rel) => { 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(isModQueueView('/music.eth/mod/queue')).toBe(true);
expect(isSubscriptionsView('/subs/catalog/settings', {})).toBe(true); expect(isSubscriptionsView('/subs/catalog/settings', {})).toBe(true);
expect(isPendingPostView('/pending/42/settings', { accountCommentIndex: '42' })).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', () => { it('detects board, catalog, post, and settings routes using board params', () => {
+5
View File
@@ -6,6 +6,10 @@ type ParamsType = {
commentCid?: string; 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 => { export const isAllView = (pathname: string): boolean => {
return pathname.startsWith('/all'); return pathname.startsWith('/all');
}; };
@@ -97,6 +101,7 @@ export const isNotFoundView = (pathname: string, params: ParamsType): boolean =>
!isArchiveView(pathname, params) && !isArchiveView(pathname, params) &&
!isCatalogView(pathname, params) && !isCatalogView(pathname, params) &&
!isHomeView(pathname) && !isHomeView(pathname) &&
!isStaticAppRoute(pathname) &&
!isPendingPostView(pathname, params) && !isPendingPostView(pathname, params) &&
!isPostPageView(pathname, params) && !isPostPageView(pathname, params) &&
!isSettingsView(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([ const vitePwaManagedAssetUrls = new Set([
'manifest.webmanifest', 'manifest.webmanifest',
'favicon.ico', 'favicon.ico',
'favicon-404.ico',
'favicon2.ico', 'favicon2.ico',
'robots.txt', 'robots.txt',
'apple-touch-icon.png', 'apple-touch-icon.png',
@@ -50,6 +51,7 @@ const baselineAppShellUrls = new Set([
'manifest.json', 'manifest.json',
'manifest.webmanifest', 'manifest.webmanifest',
'favicon.ico', 'favicon.ico',
'favicon-404.ico',
'favicon2.ico', 'favicon2.ico',
'robots.txt', 'robots.txt',
'apple-touch-icon.png', 'apple-touch-icon.png',
@@ -393,7 +395,7 @@ export default defineConfig({
enabled: true, enabled: true,
type: 'module', 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: { manifest: {
name: '5chan', name: '5chan',
short_name: '5chan', short_name: '5chan',