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 = () => {
useTheme();
useTheme({ applyDocumentEffects: true });
useSuspendOffscreenMediaPlayback();
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 testState = vi.hoisted(() => ({
boardIdentifier: 'trash',
boardIdentifier: 'trash' as string | undefined,
directories: [] as Array<{ address: string; nsfw?: boolean }>,
isSpecialThemeEnabled: false as boolean | null,
locationPathname: '/trash',
resolvedAddress: 'off-topic.bso' as string | undefined,
setIsEnabledMock: vi.fn(),
setThemeMock: vi.fn().mockResolvedValue(undefined),
updateFaviconMock: vi.fn(),
themes: {
nsfw: 'tomorrow',
sfw: 'yotsuba-b',
@@ -60,7 +61,7 @@ vi.mock('../../stores/use-theme-store', () => ({
vi.mock('../../lib/update-favicon', () => ({
isSfwBoard: () => false,
updateFavicon: vi.fn(),
updateFavicon: testState.updateFaviconMock,
}));
vi.mock('../../lib/utils/time-utils', () => ({
@@ -73,7 +74,7 @@ let container: HTMLDivElement;
let root: Root;
const HookHarness = () => {
latestValue = useTheme();
latestValue = useTheme({ applyDocumentEffects: true });
return null;
};
@@ -119,4 +120,18 @@ describe('useTheme', () => {
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'];
type UseThemeOptions = {
applyDocumentEffects?: boolean;
};
const updateThemeClass = (newTheme: string) => {
document.body.classList.remove(...themeClasses);
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 params = useParams<{ accountCommentIndex?: string; boardIdentifier?: string; commentCid?: string }>();
const pendingPost = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) });
@@ -90,12 +94,15 @@ const useTheme = (): [string, (theme: string) => void] => {
});
useEffect(() => {
if (!applyDocumentEffects) return;
updateThemeClass(currentTheme);
}, [currentTheme]);
}, [applyDocumentEffects, currentTheme]);
useEffect(() => {
updateFavicon(isInNotFoundView ? 'not-found' : sfw);
}, [isInNotFoundView, sfw]);
if (!applyDocumentEffects) return;
const faviconVariant = isInNotFoundView ? 'not-found' : sfw ? 'sfw' : 'default';
updateFavicon(faviconVariant);
}, [applyDocumentEffects, isInNotFoundView, sfw]);
const setCommunityTheme = useCallback(
async (newTheme: string) => {
+8 -3
View File
@@ -13,17 +13,17 @@ describe('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(false);
updateFavicon('default');
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="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="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);
updateFavicon(true);
updateFavicon('sfw');
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="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="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');
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 () => {
+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_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 }> = {
default: { href: DEFAULT_FAVICON, type: 'image/png' },
@@ -29,17 +29,12 @@ const createFaviconLink = (rel: (typeof FAVICON_RELS)[number], favicon: (typeof
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.
* Uses remove-and-recreate plus cache-busted URLs to bypass sticky favicon caching.
*/
export const updateFavicon = (variant: boolean | FaviconVariant): void => {
const favicon = FAVICONS[getFaviconVariant(variant)];
export const updateFavicon = (variant: FaviconVariant): void => {
const favicon = FAVICONS[variant];
const { href } = favicon;
if (href === currentHref && hasExpectedFaviconLinks(href)) return;
currentHref = href;
@@ -32,6 +32,18 @@ describe('view-utils', () => {
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', () => {
const params = {
boardIdentifier: 'music.eth',
@@ -58,7 +70,16 @@ describe('view-utils', () => {
expect(isNotFoundView('/definitely-not-a-route', params)).toBe(true);
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/settings', { boardIdentifier: 'music.eth' })).toBe(true);
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/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 = {
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 normalizeViewPathname = (pathname: string): string => pathname.replace(/\/+$/, '') || '/';
export const isAllView = (pathname: string): boolean => {
return pathname.startsWith('/all');
};
@@ -89,23 +91,27 @@ export const isSubscriptionsView = (pathname: string, _params: ParamsType): bool
export const isArchiveView = (pathname: string, params: ParamsType): boolean => {
const { boardIdentifier } = params;
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 => {
const normalizedPathname = normalizeViewPathname(pathname);
return (
!isAllView(pathname) &&
!isBoardView(pathname, params) &&
!isArchiveView(pathname, params) &&
!isCatalogView(pathname, params) &&
!isHomeView(pathname) &&
!isStaticAppRoute(pathname) &&
!isPendingPostView(pathname, params) &&
!isPostPageView(pathname, params) &&
!isSettingsView(pathname, params) &&
!isSubscriptionsView(pathname, params) &&
!isModQueueView(pathname)
!isAllView(normalizedPathname) &&
!isBoardView(normalizedPathname, params) &&
!isArchiveView(normalizedPathname, params) &&
!isCatalogView(normalizedPathname, params) &&
!isHomeView(normalizedPathname) &&
!isStaticAppRoute(normalizedPathname) &&
!isPendingPostView(normalizedPathname, params) &&
!isPostPageView(normalizedPathname, params) &&
!isSettingsView(normalizedPathname, params) &&
!isSubscriptionsView(normalizedPathname, params) &&
!isValidModRoute(normalizedPathname) &&
!isModQueueView(normalizedPathname)
);
};