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" import { useEffect, useState } from "react"
type SVGProps = React.ComponentPropsWithoutRef<"svg"> 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", { const simpleIconModules = import.meta.glob("/node_modules/simple-icons/icons/*.svg", {
query: "?raw", 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) { function toProviderSlug(provider: string) {
return provider.trim().replace(/[^a-z0-9]+/gi, "").toLowerCase() return provider.trim().replace(/[^a-z0-9]+/gi, "").toLowerCase()
@@ -25,15 +34,30 @@ function loadProviderIconMarkup(provider: string) {
return loader ? loader() : null return loader ? loader() : null
} }
function getSvgViewBox(markup: string) { function parseIconMarkup(markup: string): ParsedIconMarkup | null {
return markup.match(/viewBox="([^"]+)"/i)?.[1] ?? "0 0 24 24" 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
}
return { title, viewBox, path }
} }
function getSvgInnerMarkup(markup: string) { async function loadProviderIcon(provider: string): Promise<IconMarkupState> {
return markup const markup = await loadProviderIconMarkup(provider)
.replace(/^<svg[^>]*>/i, "") if (!markup) {
.replace(/<\/svg>\s*$/i, "") return { status: "missing" }
.trim() }
const parsed = parseIconMarkup(markup)
if (!parsed) {
return { status: "missing" }
}
return { status: "ready", icon: parsed }
} }
export function OAuthProviderIcon({ export function OAuthProviderIcon({
@@ -42,35 +66,28 @@ export function OAuthProviderIcon({
...props ...props
}: SVGProps & { provider: string; title?: string }) { }: SVGProps & { provider: string; title?: string }) {
const providerSlug = toProviderSlug(provider) const providerSlug = toProviderSlug(provider)
const [iconMarkup, setIconMarkup] = useState<string | null>(() => { const [iconState, setIconState] = useState<IconMarkupState | null>(() => {
return iconMarkupCache.get(providerSlug) ?? null return iconMarkupCache.get(providerSlug) ?? null
}) })
useEffect(() => { useEffect(() => {
const cached = iconMarkupCache.get(providerSlug) if (iconMarkupCache.has(providerSlug)) {
if (cached !== undefined) { setIconState(iconMarkupCache.get(providerSlug) ?? null)
setIconMarkup(cached)
return
}
const loadPromise = loadProviderIconMarkup(provider)
if (!loadPromise) {
iconMarkupCache.set(providerSlug, null)
setIconMarkup(null)
return return
} }
let cancelled = false let cancelled = false
loadPromise loadProviderIcon(provider)
.then((markup) => { .then((result) => {
if (cancelled) return if (cancelled) return
iconMarkupCache.set(providerSlug, markup) iconMarkupCache.set(providerSlug, result)
setIconMarkup(markup) setIconState(result)
}) })
.catch(() => { .catch(() => {
if (cancelled) return if (cancelled) return
iconMarkupCache.set(providerSlug, null) const missingState = { status: "missing" } as const
setIconMarkup(null) iconMarkupCache.set(providerSlug, missingState)
setIconState(missingState)
}) })
return () => { return () => {
@@ -78,20 +95,22 @@ export function OAuthProviderIcon({
} }
}, [provider, providerSlug]) }, [provider, providerSlug])
if (!iconMarkup) { if (!iconState || iconState.status !== "ready") {
return null return null
} }
const iconTitle = title ?? iconState.icon.title
return ( return (
<svg <svg
viewBox={getSvgViewBox(iconMarkup)} viewBox={iconState.icon.viewBox}
fill="currentColor" fill="currentColor"
aria-hidden={title ? undefined : "true"} aria-hidden={iconTitle ? undefined : "true"}
role={title ? "img" : undefined} role={iconTitle ? "img" : undefined}
{...props} {...props}
> >
{title ? <title>{title}</title> : null} {iconTitle ? <title>{iconTitle}</title> : null}
<g dangerouslySetInnerHTML={{ __html: getSvgInnerMarkup(iconMarkup) }} /> <path d={iconState.icon.path} />
</svg> </svg>
) )
} }