From aa208d2dd7230eba4e05f3c2d67fa4815348b493 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Fri, 28 Aug 2026 08:57:04 +0000 Subject: [PATCH] fix(web): give the favicon a single owner in root metadata (#41433) Co-authored-by: Claude Opus 5 (1M context) --- web/app/__tests__/layout.spec.tsx | 43 ++++++++++++++++++++++++++++++ web/app/layout.tsx | 8 +++++- web/hooks/use-document-title.ts | 23 ---------------- web/{app => public}/favicon.ico | Bin 4 files changed, 50 insertions(+), 24 deletions(-) rename web/{app => public}/favicon.ico (100%) diff --git a/web/app/__tests__/layout.spec.tsx b/web/app/__tests__/layout.spec.tsx index ef8c38103fb..ea56e750817 100644 --- a/web/app/__tests__/layout.spec.tsx +++ b/web/app/__tests__/layout.spec.tsx @@ -73,6 +73,49 @@ describe('Root layout System Features bootstrap', () => { }) }) + it('points the icons at the branding favicon when one is configured', async () => { + mocks.getSystemFeatures.mockResolvedValue({ + branding: { + application_title: 'Acme AI', + enabled: true, + favicon: 'https://cdn.example.com/brand.ico', + }, + deployment_edition: 'CLOUD', + }) + const { generateMetadata } = await import('../layout') + + await expect(generateMetadata()).resolves.toMatchObject({ + icons: { + icon: 'https://cdn.example.com/brand.ico', + apple: 'https://cdn.example.com/brand.ico', + }, + }) + }) + + it('falls back to the static favicon without branding', async () => { + mocks.getSystemFeatures.mockResolvedValue({ + branding: { enabled: false }, + deployment_edition: 'CLOUD', + }) + const { generateMetadata } = await import('../layout') + + await expect(generateMetadata()).resolves.toMatchObject({ + icons: { icon: '/favicon.ico' }, + }) + }) + + it('falls back to the static favicon when branding is enabled without one', async () => { + mocks.getSystemFeatures.mockResolvedValue({ + branding: { application_title: 'Acme AI', enabled: true, favicon: '' }, + deployment_edition: 'CLOUD', + }) + const { generateMetadata } = await import('../layout') + + await expect(generateMetadata()).resolves.toMatchObject({ + icons: { icon: '/favicon.ico' }, + }) + }) + it('renders the client recovery path when the server prefetch fails', async () => { mocks.getSystemFeatures.mockRejectedValue(new Error('system features unavailable')) const { default: RootLayout, generateMetadata } = await import('../layout') diff --git a/web/app/layout.tsx b/web/app/layout.tsx index f9452cc2777..4e50f33409e 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -16,6 +16,7 @@ import { import { getLocaleOnServer } from '@/i18n-config/server' import { headers } from '@/next/headers' import { getApplicationTitle } from '@/utils/document-title' +import { basePath } from '@/utils/var' import { CloudAnalytics } from './components/base/analytics-consent/cloud-analytics' import { PartnerStackCookieRecorder } from './components/billing/partner-stack/cookie-recorder' import { AgentationLoader } from './components/devtools/agentation-loader' @@ -33,13 +34,18 @@ export const viewport: Viewport = { export async function generateMetadata(): Promise { const systemFeatures = await prefetchSystemFeatures() - const applicationTitle = getApplicationTitle(systemFeatures?.branding) + const branding = systemFeatures?.branding + const applicationTitle = getApplicationTitle(branding) + const brandedFavicon = branding?.enabled ? branding.favicon : undefined return { title: { default: applicationTitle, template: `%s - ${applicationTitle}`, }, + icons: brandedFavicon + ? { icon: brandedFavicon, apple: brandedFavicon } + : { icon: `${basePath}/favicon.ico` }, } } diff --git a/web/hooks/use-document-title.ts b/web/hooks/use-document-title.ts index 1a6c67a08de..d82208ff7b5 100644 --- a/web/hooks/use-document-title.ts +++ b/web/hooks/use-document-title.ts @@ -1,37 +1,14 @@ 'use client' import { useSuspenseQuery } from '@tanstack/react-query' -import { useFavicon } from 'ahooks' import { useEffect } from 'react' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { formatDocumentTitle, getApplicationTitle } from '@/utils/document-title' -import { basePath } from '@/utils/var' export default function useDocumentTitle(title: string | null) { const { data } = useSuspenseQuery(systemFeaturesQueryOptions()) const branding = data.branding const titleStr = title === null ? null : formatDocumentTitle(title, getApplicationTitle(branding)) - const favicon = branding.enabled ? branding.favicon : `${basePath}/favicon.ico` useEffect(() => { if (titleStr !== null) document.title = titleStr }, [titleStr]) - useEffect(() => { - let apple: HTMLLinkElement | null = null - if (branding.favicon) { - document - .querySelectorAll( - "link[rel='icon'], link[rel='shortcut icon'], link[rel='apple-touch-icon'], link[rel='mask-icon']", - ) - .forEach((n) => n.parentNode?.removeChild(n)) - - apple = document.createElement('link') - apple.rel = 'apple-touch-icon' - apple.href = branding.favicon - document.head.appendChild(apple) - } - - return () => { - apple?.remove() - } - }, [branding.favicon]) - useFavicon(favicon) } diff --git a/web/app/favicon.ico b/web/public/favicon.ico similarity index 100% rename from web/app/favicon.ico rename to web/public/favicon.ico