Merge branch 'codex/fix/favicon-route-ownership'

This commit is contained in:
Tommaso Casaburi
2026-07-10 15:22:19 +07:00
7 changed files with 82 additions and 33 deletions
+1 -1
View File
@@ -200,7 +200,7 @@ const BoardLayout = () => {
}; };
const GlobalLayout = () => { const GlobalLayout = () => {
useTheme(); useTheme({ applyDocumentEffects: true });
useSuspendOffscreenMediaPlayback(); useSuspendOffscreenMediaPlayback();
const { const {
+18 -3
View File
@@ -9,13 +9,14 @@ import { TRASH_BOARD_ADDRESS, TRASH_BOARD_CODE } from '../../lib/special-boards'
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>; const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({ const testState = vi.hoisted(() => ({
boardIdentifier: 'trash', boardIdentifier: 'trash' as string | undefined,
directories: [] as Array<{ address: string; nsfw?: boolean }>, directories: [] as Array<{ address: string; nsfw?: boolean }>,
isSpecialThemeEnabled: false as boolean | null, isSpecialThemeEnabled: false as boolean | null,
locationPathname: '/trash', locationPathname: '/trash',
resolvedAddress: 'off-topic.bso' as string | undefined, resolvedAddress: 'off-topic.bso' as string | undefined,
setIsEnabledMock: vi.fn(), setIsEnabledMock: vi.fn(),
setThemeMock: vi.fn().mockResolvedValue(undefined), setThemeMock: vi.fn().mockResolvedValue(undefined),
updateFaviconMock: vi.fn(),
themes: { themes: {
nsfw: 'tomorrow', nsfw: 'tomorrow',
sfw: 'yotsuba-b', sfw: 'yotsuba-b',
@@ -60,7 +61,7 @@ vi.mock('../../stores/use-theme-store', () => ({
vi.mock('../../lib/update-favicon', () => ({ vi.mock('../../lib/update-favicon', () => ({
isSfwBoard: () => false, isSfwBoard: () => false,
updateFavicon: vi.fn(), updateFavicon: testState.updateFaviconMock,
})); }));
vi.mock('../../lib/utils/time-utils', () => ({ vi.mock('../../lib/utils/time-utils', () => ({
@@ -73,7 +74,7 @@ let container: HTMLDivElement;
let root: Root; let root: Root;
const HookHarness = () => { const HookHarness = () => {
latestValue = useTheme(); latestValue = useTheme({ applyDocumentEffects: true });
return null; return null;
}; };
@@ -119,4 +120,18 @@ describe('useTheme', () => {
expect(testState.setThemeMock).toHaveBeenCalledWith('nsfw', 'photon'); expect(testState.setThemeMock).toHaveBeenCalledWith('nsfw', 'photon');
}); });
it('restores the default favicon when navigating from not-found to mod', async () => {
testState.boardIdentifier = undefined;
testState.resolvedAddress = undefined;
testState.locationPathname = '/not-found';
await renderHook();
expect(testState.updateFaviconMock).toHaveBeenLastCalledWith('not-found');
testState.locationPathname = '/mod';
await renderHook();
expect(testState.updateFaviconMock).toHaveBeenLastCalledWith('default');
});
}); });
+11 -4
View File
@@ -14,6 +14,10 @@ import { normalizeAccountCommentIndex } from '../lib/utils/account-comment-index
const themeClasses = ['yotsuba', 'yotsuba-b', 'futaba', 'burichan', 'tomorrow', 'photon', 'spooky']; const themeClasses = ['yotsuba', 'yotsuba-b', 'futaba', 'burichan', 'tomorrow', 'photon', 'spooky'];
type UseThemeOptions = {
applyDocumentEffects?: boolean;
};
const updateThemeClass = (newTheme: string) => { const updateThemeClass = (newTheme: string) => {
document.body.classList.remove(...themeClasses); document.body.classList.remove(...themeClasses);
if (newTheme) { if (newTheme) {
@@ -21,7 +25,7 @@ const updateThemeClass = (newTheme: string) => {
} }
}; };
const useTheme = (): [string, (theme: string) => void] => { const useTheme = ({ applyDocumentEffects = false }: UseThemeOptions = {}): [string, (theme: string) => void] => {
const location = useLocation(); const location = useLocation();
const params = useParams<{ accountCommentIndex?: string; boardIdentifier?: string; commentCid?: string }>(); const params = useParams<{ accountCommentIndex?: string; boardIdentifier?: string; commentCid?: string }>();
const pendingPost = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) }); const pendingPost = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) });
@@ -90,12 +94,15 @@ const useTheme = (): [string, (theme: string) => void] => {
}); });
useEffect(() => { useEffect(() => {
if (!applyDocumentEffects) return;
updateThemeClass(currentTheme); updateThemeClass(currentTheme);
}, [currentTheme]); }, [applyDocumentEffects, currentTheme]);
useEffect(() => { useEffect(() => {
updateFavicon(isInNotFoundView ? 'not-found' : sfw); if (!applyDocumentEffects) return;
}, [isInNotFoundView, sfw]); const faviconVariant = isInNotFoundView ? 'not-found' : sfw ? 'sfw' : 'default';
updateFavicon(faviconVariant);
}, [applyDocumentEffects, isInNotFoundView, sfw]);
const setCommunityTheme = useCallback( const setCommunityTheme = useCallback(
async (newTheme: string) => { async (newTheme: string) => {
+8 -3
View File
@@ -13,17 +13,17 @@ describe('update-favicon', () => {
document.head.innerHTML = 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">'; '<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(false); updateFavicon('default');
expect(document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]')).toHaveLength(4); 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.ico?variant=nsfw'); expect(document.querySelector('link[rel="icon"][sizes="16x16"]')?.getAttribute('href')).toBe('/favicon.ico?variant=nsfw');
expect(document.querySelector('link[rel="shortcut icon"][sizes="16x16"]')?.getAttribute('href')).toBe('/favicon.ico?variant=nsfw'); expect(document.querySelector('link[rel="shortcut icon"][sizes="16x16"]')?.getAttribute('href')).toBe('/favicon.ico?variant=nsfw');
expect(document.querySelector('link[rel="icon"][sizes="192x192"]')?.getAttribute('href')).toBe('/manifest-icon-192x192.png'); 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'); expect(document.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href')).toBe('/apple-touch-icon.png');
updateFavicon(false); updateFavicon('default');
expect(document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]')).toHaveLength(4); expect(document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]')).toHaveLength(4);
updateFavicon(true); updateFavicon('sfw');
expect(document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]')).toHaveLength(4); 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('/favicon2.ico?variant=sfw'); expect(document.querySelector('link[rel="icon"][sizes="16x16"]')?.getAttribute('href')).toBe('/favicon2.ico?variant=sfw');
expect(document.querySelector('link[rel="shortcut icon"][sizes="16x16"]')?.getAttribute('href')).toBe('/favicon2.ico?variant=sfw'); expect(document.querySelector('link[rel="shortcut icon"][sizes="16x16"]')?.getAttribute('href')).toBe('/favicon2.ico?variant=sfw');
@@ -46,6 +46,11 @@ describe('update-favicon', () => {
expect(document.querySelector('link[rel="shortcut 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="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'); expect(document.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href')).toBe('/apple-touch-icon.png');
updateFavicon('default');
expect(document.querySelector('link[rel="icon"][sizes="16x16"]')?.getAttribute('href')).toBe('/favicon.ico?variant=nsfw');
expect(document.querySelector('link[rel="shortcut icon"][sizes="16x16"]')?.getAttribute('href')).toBe('/favicon.ico?variant=nsfw');
expect(document.querySelector('link[rel="icon"][sizes="16x16"]')?.getAttribute('type')).toBe('image/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 () => {
+3 -8
View File
@@ -6,7 +6,7 @@ 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'; export type FaviconVariant = 'default' | 'sfw' | 'not-found';
const FAVICONS: Record<FaviconVariant, { href: string; type: string }> = { const FAVICONS: Record<FaviconVariant, { href: string; type: string }> = {
default: { href: DEFAULT_FAVICON, type: 'image/png' }, default: { href: DEFAULT_FAVICON, type: 'image/png' },
@@ -29,17 +29,12 @@ const createFaviconLink = (rel: (typeof FAVICON_RELS)[number], favicon: (typeof
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), SFW, and 404 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 = (variant: boolean | FaviconVariant): void => { export const updateFavicon = (variant: FaviconVariant): void => {
const favicon = FAVICONS[getFaviconVariant(variant)]; const favicon = FAVICONS[variant];
const { href } = favicon; const { href } = favicon;
if (href === currentHref && hasExpectedFaviconLinks(href)) return; if (href === currentHref && hasExpectedFaviconLinks(href)) return;
currentHref = href; currentHref = href;
@@ -32,6 +32,18 @@ describe('view-utils', () => {
expect(isNotFoundView('/faq/missing', {})).toBe(true); expect(isNotFoundView('/faq/missing', {})).toBe(true);
}); });
it.each(['/mod', '/mod/settings', '/mod/catalog', '/mod/catalog/settings', '/mod/queue', '/mod/queue/settings', '/mod/'])(
'keeps canonical mod route %s out of the not-found view',
(pathname) => {
expect(isNotFoundView(pathname, {})).toBe(false);
},
);
it('keeps invalid mod routes in the not-found view', () => {
expect(isNotFoundView('/mod/asdf', {})).toBe(true);
expect(isNotFoundView('/mod/modqueue', {})).toBe(true);
});
it('detects board, catalog, post, and settings routes using board params', () => { it('detects board, catalog, post, and settings routes using board params', () => {
const params = { const params = {
boardIdentifier: 'music.eth', boardIdentifier: 'music.eth',
@@ -58,7 +70,16 @@ describe('view-utils', () => {
expect(isNotFoundView('/definitely-not-a-route', params)).toBe(true); expect(isNotFoundView('/definitely-not-a-route', params)).toBe(true);
expect(isNotFoundView('/emoji-%F0%9F%8E%B5.eth/thread/cid-123', params)).toBe(false); expect(isNotFoundView('/emoji-%F0%9F%8E%B5.eth/thread/cid-123', params)).toBe(false);
expect(isArchiveView('/music.eth/archive', { boardIdentifier: 'music.eth' })).toBe(true); expect(isArchiveView('/music.eth/archive', { boardIdentifier: 'music.eth' })).toBe(true);
expect(isArchiveView('/music.eth/archive/settings', { boardIdentifier: 'music.eth' })).toBe(true);
expect(isBoardView('/music.eth/archive', { boardIdentifier: 'music.eth' })).toBe(false); expect(isBoardView('/music.eth/archive', { boardIdentifier: 'music.eth' })).toBe(false);
expect(isNotFoundView('/music.eth/archive', { boardIdentifier: 'music.eth' })).toBe(false); expect(isNotFoundView('/music.eth/archive', { boardIdentifier: 'music.eth' })).toBe(false);
expect(isNotFoundView('/music.eth/archive/settings/', { boardIdentifier: 'music.eth' })).toBe(false);
}); });
it.each(['/faq/', '/pass/', '/rules/', '/blotter/', '/settings/account-data/', '/not-allowed/', '/subs/', '/subs/catalog/settings/'])(
'normalizes the valid trailing-slash route %s',
(pathname) => {
expect(isNotFoundView(pathname, {})).toBe(false);
},
);
}); });
+20 -14
View File
@@ -1,4 +1,4 @@
import { isArchiveRoute, isBoardModRoute, isModQueueRoute } from './route-utils'; import { isArchiveRoute, isBoardModRoute, isModQueueRoute, isValidModRoute } from './route-utils';
type ParamsType = { type ParamsType = {
accountCommentIndex?: string; accountCommentIndex?: string;
@@ -10,6 +10,8 @@ const STATIC_APP_ROUTES = new Set(['/faq', '/pass', '/rules', '/blotter', '/sett
const isStaticAppRoute = (pathname: string): boolean => STATIC_APP_ROUTES.has(pathname); const isStaticAppRoute = (pathname: string): boolean => STATIC_APP_ROUTES.has(pathname);
const normalizeViewPathname = (pathname: string): string => pathname.replace(/\/+$/, '') || '/';
export const isAllView = (pathname: string): boolean => { export const isAllView = (pathname: string): boolean => {
return pathname.startsWith('/all'); return pathname.startsWith('/all');
}; };
@@ -89,23 +91,27 @@ export const isSubscriptionsView = (pathname: string, _params: ParamsType): bool
export const isArchiveView = (pathname: string, params: ParamsType): boolean => { export const isArchiveView = (pathname: string, params: ParamsType): boolean => {
const { boardIdentifier } = params; const { boardIdentifier } = params;
const identifier = boardIdentifier; const identifier = boardIdentifier;
const decodedPathname = decodeURIComponent(pathname); const decodedPathname = decodeURIComponent(normalizeViewPathname(pathname));
const archivePathname = decodedPathname.replace(/\/settings$/, '');
return Boolean(identifier && isArchiveRoute(decodedPathname) && decodedPathname === `/${identifier}/archive`); return Boolean(identifier && isArchiveRoute(decodedPathname) && archivePathname === `/${identifier}/archive`);
}; };
export const isNotFoundView = (pathname: string, params: ParamsType): boolean => { export const isNotFoundView = (pathname: string, params: ParamsType): boolean => {
const normalizedPathname = normalizeViewPathname(pathname);
return ( return (
!isAllView(pathname) && !isAllView(normalizedPathname) &&
!isBoardView(pathname, params) && !isBoardView(normalizedPathname, params) &&
!isArchiveView(pathname, params) && !isArchiveView(normalizedPathname, params) &&
!isCatalogView(pathname, params) && !isCatalogView(normalizedPathname, params) &&
!isHomeView(pathname) && !isHomeView(normalizedPathname) &&
!isStaticAppRoute(pathname) && !isStaticAppRoute(normalizedPathname) &&
!isPendingPostView(pathname, params) && !isPendingPostView(normalizedPathname, params) &&
!isPostPageView(pathname, params) && !isPostPageView(normalizedPathname, params) &&
!isSettingsView(pathname, params) && !isSettingsView(normalizedPathname, params) &&
!isSubscriptionsView(pathname, params) && !isSubscriptionsView(normalizedPathname, params) &&
!isModQueueView(pathname) !isValidModRoute(normalizedPathname) &&
!isModQueueView(normalizedPathname)
); );
}; };