From 64e6249e2d896fbfd891b11f5e6365c1a3f8336f Mon Sep 17 00:00:00 2001 From: dysf888 <47450409+dysf888@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:20:21 +0800 Subject: [PATCH] Harden OAuth provider icon loading --- src/components/ui/icon.tsx | 81 +++++++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/src/components/ui/icon.tsx b/src/components/ui/icon.tsx index 4b299dd..1ef9e0b 100644 --- a/src/components/ui/icon.tsx +++ b/src/components/ui/icon.tsx @@ -1,6 +1,15 @@ import { useEffect, useState } from "react" type SVGProps = React.ComponentPropsWithoutRef<"svg"> +type IconMarkupState = + | { status: "ready"; icon: ParsedIconMarkup } + | { status: "missing" } + +type ParsedIconMarkup = { + title?: string + viewBox: string + path: string +} const simpleIconModules = import.meta.glob("/node_modules/simple-icons/icons/*.svg", { query: "?raw", @@ -14,7 +23,7 @@ const iconLoadersBySlug = Object.fromEntries( ]), ) -const iconMarkupCache = new Map() +const iconMarkupCache = new Map() function toProviderSlug(provider: string) { return provider.trim().replace(/[^a-z0-9]+/gi, "").toLowerCase() @@ -25,15 +34,30 @@ function loadProviderIconMarkup(provider: string) { return loader ? loader() : null } -function getSvgViewBox(markup: string) { - return markup.match(/viewBox="([^"]+)"/i)?.[1] ?? "0 0 24 24" +function parseIconMarkup(markup: string): ParsedIconMarkup | null { + const viewBox = markup.match(/viewBox="([^"]+)"/i)?.[1] ?? "0 0 24 24" + const title = markup.match(/([^<]*)<\/title>/i)?.[1] + const path = markup.match(/<path d="([^"]+)"/i)?.[1] + + if (!path) { + return null + } + + return { title, viewBox, path } } -function getSvgInnerMarkup(markup: string) { - return markup - .replace(/^<svg[^>]*>/i, "") - .replace(/<\/svg>\s*$/i, "") - .trim() +async function loadProviderIcon(provider: string): Promise<IconMarkupState> { + const markup = await loadProviderIconMarkup(provider) + if (!markup) { + return { status: "missing" } + } + + const parsed = parseIconMarkup(markup) + if (!parsed) { + return { status: "missing" } + } + + return { status: "ready", icon: parsed } } export function OAuthProviderIcon({ @@ -42,35 +66,28 @@ export function OAuthProviderIcon({ ...props }: SVGProps & { provider: string; title?: string }) { const providerSlug = toProviderSlug(provider) - const [iconMarkup, setIconMarkup] = useState<string | null>(() => { + const [iconState, setIconState] = useState<IconMarkupState | null>(() => { return iconMarkupCache.get(providerSlug) ?? null }) useEffect(() => { - const cached = iconMarkupCache.get(providerSlug) - if (cached !== undefined) { - setIconMarkup(cached) - return - } - - const loadPromise = loadProviderIconMarkup(provider) - if (!loadPromise) { - iconMarkupCache.set(providerSlug, null) - setIconMarkup(null) + if (iconMarkupCache.has(providerSlug)) { + setIconState(iconMarkupCache.get(providerSlug) ?? null) return } let cancelled = false - loadPromise - .then((markup) => { + loadProviderIcon(provider) + .then((result) => { if (cancelled) return - iconMarkupCache.set(providerSlug, markup) - setIconMarkup(markup) + iconMarkupCache.set(providerSlug, result) + setIconState(result) }) .catch(() => { if (cancelled) return - iconMarkupCache.set(providerSlug, null) - setIconMarkup(null) + const missingState = { status: "missing" } as const + iconMarkupCache.set(providerSlug, missingState) + setIconState(missingState) }) return () => { @@ -78,20 +95,22 @@ export function OAuthProviderIcon({ } }, [provider, providerSlug]) - if (!iconMarkup) { + if (!iconState || iconState.status !== "ready") { return null } + const iconTitle = title ?? iconState.icon.title + return ( <svg - viewBox={getSvgViewBox(iconMarkup)} + viewBox={iconState.icon.viewBox} fill="currentColor" - aria-hidden={title ? undefined : "true"} - role={title ? "img" : undefined} + aria-hidden={iconTitle ? undefined : "true"} + role={iconTitle ? "img" : undefined} {...props} > - {title ? <title>{title} : null} - + {iconTitle ? {iconTitle} : null} + ) }