"use client" /* eslint-disable @next/next/no-img-element -- favicon 来源由数据动态决定,静态 Image 配置无法预枚举域名。 */ import { useEffect, useRef, useState } from "react" import { getIconCandidates, type IconCandidate, } from "@/components/shared/icon-candidates" import { SIMPLE_ICON_MAP } from "@/components/shared/simple-icon-map" import { cn } from "@/lib/utils" import type { NavLink } from "@/types/navigation" type SiteIconProps = { link: Pick size?: number className?: string } type SiteIconContentProps = SiteIconProps & { candidates: IconCandidate[] } function SiteIconContent({ link, candidates, size = 36, className, }: SiteIconContentProps) { const [candidateIndex, setCandidateIndex] = useState(0) const [loadedImageSrc, setLoadedImageSrc] = useState(null) const imageRef = useRef(null) const candidate = candidates[candidateIndex] const initialCandidate = candidates.at(-1) const initialValue = initialCandidate?.kind === "initial" ? initialCandidate.value : "?" const wrapperClassName = cn("site-icon", className) const style = { width: size, height: size } // 缓存命中的图片可能在 React 附加 onLoad/onError(如水合)之前就完成或失败, // 之后不再派发任何事件;挂载后按 complete/naturalWidth 补偿判定, // 避免透明图片永久停留在首字母占位,也避免坏图卡住候选链。 // 候选推进有末位钳制,末位为首字母分支,不会形成无限循环。 useEffect(() => { if (candidate.kind !== "image") return const image = imageRef.current if (!image?.complete) return if (image.naturalWidth > 0) { setLoadedImageSrc(candidate.src) } else { setCandidateIndex((index) => Math.min(index + 1, candidates.length - 1)) } }, [candidate, candidates.length]) if (candidate.kind === "simple") { const icon = SIMPLE_ICON_MAP[candidate.slug] return ( ) } if (candidate.kind === "image") { const imageLoaded = loadedImageSrc === candidate.src return ( ) } return ( ) } export function SiteIcon({ link, size = 36, className }: SiteIconProps) { const candidates = getIconCandidates(link) const candidateKey = JSON.stringify([link.icon ?? "", link.title, link.url]) // 链接变化时通过 key 重建候选状态,避免 effect 中同步 setState 和旧链接的失败记录泄漏。 return ( ) }