Harden OAuth provider icon loading

This commit is contained in:
dysf888
2026-07-03 11:20:21 +08:00
parent 6bd7866688
commit 64e6249e2d
+50 -31
View File
@@ -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<string, string | null>()
const iconMarkupCache = new Map<string, IconMarkupState>()
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>([^<]*)<\/title>/i)?.[1]
const path = markup.match(/<path d="([^"]+)"/i)?.[1]
if (!path) {
return null
}
function getSvgInnerMarkup(markup: string) {
return markup
.replace(/^<svg[^>]*>/i, "")
.replace(/<\/svg>\s*$/i, "")
.trim()
return { title, viewBox, path }
}
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}</title> : null}
<g dangerouslySetInnerHTML={{ __html: getSvgInnerMarkup(iconMarkup) }} />
{iconTitle ? <title>{iconTitle}</title> : null}
<path d={iconState.icon.path} />
</svg>
)
}