diff --git a/src/components/Favicon/index.tsx b/src/components/Favicon/index.tsx index b7a4684e..fc003506 100644 --- a/src/components/Favicon/index.tsx +++ b/src/components/Favicon/index.tsx @@ -1,3 +1,4 @@ +import { isFaviconLoadFailed, markFaviconLoadFailed, normalizeFaviconDomain } from '@/lib/favicon-fail-cache' import { cn } from '@/lib/utils' import { useState } from 'react' @@ -10,19 +11,23 @@ export function Favicon({ className?: string fallback?: React.ReactNode }) { - const [loading, setLoading] = useState(true) - const [error, setError] = useState(false) - const trimmed = domain?.trim() ?? '' - if (error || !trimmed) return fallback + const host = normalizeFaviconDomain(domain) + const knownFailed = host ? isFaviconLoadFailed(host) : true + const [loading, setLoading] = useState(!knownFailed) + const [error, setError] = useState(knownFailed) + if (error || !host) return fallback return (
{loading &&
{fallback}
} {trimmed} setError(true)} + onError={() => { + markFaviconLoadFailed(host) + setError(true) + }} onLoad={() => setLoading(false)} />
diff --git a/src/lib/favicon-fail-cache.test.ts b/src/lib/favicon-fail-cache.test.ts new file mode 100644 index 00000000..f63da435 --- /dev/null +++ b/src/lib/favicon-fail-cache.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { + isFaviconLoadFailed, + markFaviconLoadFailed, + normalizeFaviconDomain +} from '@/lib/favicon-fail-cache' + +describe('favicon-fail-cache', () => { + it('normalizes domain casing and trailing dot', () => { + expect(normalizeFaviconDomain(' Example.COM. ')).toBe('example.com') + }) + + it('remembers failed domains for the session', () => { + const host = `fail-cache-test-${Date.now()}.example` + expect(isFaviconLoadFailed(host)).toBe(false) + markFaviconLoadFailed(host) + expect(isFaviconLoadFailed(host)).toBe(true) + expect(isFaviconLoadFailed(host.toUpperCase())).toBe(true) + }) +}) diff --git a/src/lib/favicon-fail-cache.ts b/src/lib/favicon-fail-cache.ts new file mode 100644 index 00000000..b0c323bb --- /dev/null +++ b/src/lib/favicon-fail-cache.ts @@ -0,0 +1,20 @@ +import { LRUCache } from 'lru-cache' + +/** Domains whose `https://{host}/favicon.ico` already failed — skip repeat network requests. */ +const FAILED_FAVICON_DOMAINS = new LRUCache({ max: 512 }) + +export function normalizeFaviconDomain(domain: string): string { + return domain.trim().toLowerCase().replace(/\.$/, '') +} + +export function isFaviconLoadFailed(domain: string): boolean { + const key = normalizeFaviconDomain(domain) + if (!key) return true + return FAILED_FAVICON_DOMAINS.has(key) +} + +export function markFaviconLoadFailed(domain: string): void { + const key = normalizeFaviconDomain(domain) + if (!key) return + FAILED_FAVICON_DOMAINS.set(key, true) +}