From 4e62c9070051424dee1ec503cee17ca86aac5d8f Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sat, 9 May 2026 23:46:33 +0800 Subject: [PATCH] feat: enhance website icon loading logic; implement error handling and timeout management --- webapp/src/App.tsx | 6 ++ webapp/src/components/AuthViews.tsx | 4 +- webapp/src/components/vault/WebsiteIcon.tsx | 11 +-- webapp/src/lib/website-icon-cache.ts | 93 ++++++++++++++++++++- 4 files changed, 99 insertions(+), 15 deletions(-) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 6c01acc..04bccb3 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -231,6 +231,8 @@ export default function App() { const pendingVaultCoreRefreshRef = useRef | null>(null); const notificationRefreshTimerRef = useRef(null); const domainRulesSaveSeqRef = useRef(0); + const loginEmailRef = useRef(loginValues.email); + const loginHintRequestSeqRef = useRef(0); const { toasts, pushToast, removeToast } = useToastManager(); useEffect(() => { @@ -263,6 +265,7 @@ export default function App() { }, [inviteCodeFromUrl]); useEffect(() => { + loginEmailRef.current = loginValues.email; const normalizedEmail = loginValues.email.trim().toLowerCase(); setLoginHintState((prev) => ( prev.email && prev.email !== normalizedEmail @@ -634,6 +637,7 @@ export default function App() { return; } + const requestSeq = ++loginHintRequestSeqRef.current; setLoginHintState({ email, loading: true, @@ -642,6 +646,7 @@ export default function App() { try { const result = await getPasswordHint(email); + if (loginHintRequestSeqRef.current !== requestSeq || loginEmailRef.current.trim().toLowerCase() !== email) return; openPasswordHintDialog(result.masterPasswordHint); setLoginHintState({ email, @@ -649,6 +654,7 @@ export default function App() { hint: result.masterPasswordHint, }); } catch (error) { + if (loginHintRequestSeqRef.current !== requestSeq || loginEmailRef.current.trim().toLowerCase() !== email) return; setLoginHintState({ email: '', loading: false, diff --git a/webapp/src/components/AuthViews.tsx b/webapp/src/components/AuthViews.tsx index aac965a..bb521a7 100644 --- a/webapp/src/components/AuthViews.tsx +++ b/webapp/src/components/AuthViews.tsx @@ -227,6 +227,7 @@ export default function AuthViews(props: AuthViewsProps) { value={props.loginValues.email} autoComplete="username" placeholder={props.authPlaceholder} + autoFocus onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })} /> @@ -236,7 +237,6 @@ export default function AuthViews(props: AuthViewsProps) { autoComplete="current-password" placeholder={props.authPlaceholder} onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })} - autoFocus />
@@ -244,7 +244,7 @@ export default function AuthViews(props: AuthViewsProps) { type="button" className="auth-link-btn" onClick={props.onTogglePasswordHint} - disabled={loginBusy || !props.loginValues.email.trim()} + disabled={loginBusy || props.loginHintLoading || !props.loginValues.email.trim()} > {props.loginHintLoading ? t('txt_loading_password_hint') diff --git a/webapp/src/components/vault/WebsiteIcon.tsx b/webapp/src/components/vault/WebsiteIcon.tsx index 73a826d..094e800 100644 --- a/webapp/src/components/vault/WebsiteIcon.tsx +++ b/webapp/src/components/vault/WebsiteIcon.tsx @@ -6,8 +6,6 @@ import { beginWebsiteIconLoad, getWebsiteIconImageUrl, getWebsiteIconStatus, - markWebsiteIconErrored, - markWebsiteIconLoaded, subscribeWebsiteIconStatus, } from '@/lib/website-icon-cache'; import { demoBrandIconUrl } from '@/lib/demo-brand-icons'; @@ -28,7 +26,6 @@ export default function WebsiteIcon(props: WebsiteIconProps) { const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true)); const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle')); const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : '')); - const [isLoadOwner, setIsLoadOwner] = useState(false); const demoIconUrl = SHOULD_LOAD_DEMO_BRAND_ICONS && host ? demoBrandIconUrl(host) : ''; useEffect(() => { @@ -36,14 +33,12 @@ export default function WebsiteIcon(props: WebsiteIconProps) { setShouldLoad(true); setStatus('idle'); setImageUrl(''); - setIsLoadOwner(false); return; } const nextStatus = getWebsiteIconStatus(host); setShouldLoad(nextStatus === 'loaded'); setStatus(nextStatus); setImageUrl(getWebsiteIconImageUrl(host)); - setIsLoadOwner(false); return subscribeWebsiteIconStatus(host, (next) => { setStatus(next); setImageUrl(getWebsiteIconImageUrl(host)); @@ -83,7 +78,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) { if (SHOULD_LOAD_DEMO_BRAND_ICONS) return; if (demoIconUrl) return; if (!host || !src || !shouldLoad || status !== 'idle') return; - setIsLoadOwner(beginWebsiteIconLoad(host, src)); + beginWebsiteIconLoad(host, src); }, [demoIconUrl, host, src, shouldLoad, status]); if (demoIconUrl) { @@ -104,7 +99,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) { return {props.fallback ?? }; } - const shouldRenderIconImage = !!imageUrl && (status === 'loaded' || (status === 'loading' && isLoadOwner)); + const shouldRenderIconImage = !!imageUrl && status === 'loaded'; return ( @@ -116,8 +111,6 @@ export default function WebsiteIcon(props: WebsiteIconProps) { alt="" loading="lazy" decoding="async" - onLoad={() => markWebsiteIconLoaded(host, imageUrl)} - onError={() => markWebsiteIconErrored(host)} /> )} diff --git a/webapp/src/lib/website-icon-cache.ts b/webapp/src/lib/website-icon-cache.ts index 6201518..8c73d7a 100644 --- a/webapp/src/lib/website-icon-cache.ts +++ b/webapp/src/lib/website-icon-cache.ts @@ -3,9 +3,17 @@ type WebsiteIconStatus = 'idle' | 'loading' | 'loaded' | 'error'; interface WebsiteIconRecord { status: WebsiteIconStatus; imageUrl: string | null; + errorAt: number; + loadStartedAt: number; + loadToken: number; + loader: HTMLImageElement | null; + timeoutId: ReturnType | null; listeners: Set<(status: WebsiteIconStatus) => void>; } +const WEBSITE_ICON_ERROR_TTL_MS = 5 * 60 * 1000; +const WEBSITE_ICON_LOAD_TIMEOUT_MS = 15 * 1000; + const iconRecords = new Map(); function ensureRecord(host: string): WebsiteIconRecord { @@ -14,6 +22,11 @@ function ensureRecord(host: string): WebsiteIconRecord { record = { status: 'idle', imageUrl: null, + errorAt: 0, + loadStartedAt: 0, + loadToken: 0, + loader: null, + timeoutId: null, listeners: new Set(), }; iconRecords.set(host, record); @@ -21,6 +34,29 @@ function ensureRecord(host: string): WebsiteIconRecord { return record; } +function clearLoadTimer(record: WebsiteIconRecord): void { + if (record.timeoutId) { + clearTimeout(record.timeoutId); + record.timeoutId = null; + } +} + +function expireRecordIfNeeded(record: WebsiteIconRecord): void { + const now = Date.now(); + if (record.status === 'error' && record.errorAt && now - record.errorAt >= WEBSITE_ICON_ERROR_TTL_MS) { + record.status = 'idle'; + record.errorAt = 0; + record.imageUrl = null; + } + if (record.status === 'loading' && record.loadStartedAt && now - record.loadStartedAt >= WEBSITE_ICON_LOAD_TIMEOUT_MS) { + clearLoadTimer(record); + record.status = 'error'; + record.errorAt = now; + record.imageUrl = null; + record.loader = null; + } +} + function notifyRecord(host: string, status: WebsiteIconStatus): void { const record = ensureRecord(host); record.status = status; @@ -31,12 +67,16 @@ function notifyRecord(host: string, status: WebsiteIconStatus): void { export function getWebsiteIconStatus(host: string): WebsiteIconStatus { if (!host) return 'idle'; - return ensureRecord(host).status; + const record = ensureRecord(host); + expireRecordIfNeeded(record); + return record.status; } export function getWebsiteIconImageUrl(host: string): string { if (!host) return ''; - return ensureRecord(host).imageUrl || ''; + const record = ensureRecord(host); + expireRecordIfNeeded(record); + return record.imageUrl || ''; } export function subscribeWebsiteIconStatus(host: string, listener: (status: WebsiteIconStatus) => void): () => void { @@ -48,27 +88,72 @@ export function subscribeWebsiteIconStatus(host: string, listener: (status: Webs }; } -export function markWebsiteIconLoaded(host: string, imageUrl?: string): void { +function markWebsiteIconLoaded(host: string, imageUrl?: string): void { if (!host) return; const record = ensureRecord(host); + clearLoadTimer(record); if (imageUrl) { record.imageUrl = imageUrl; } + record.errorAt = 0; + record.loadStartedAt = 0; + record.loader = null; notifyRecord(host, 'loaded'); } -export function markWebsiteIconErrored(host: string): void { +function markWebsiteIconErrored(host: string): void { if (!host) return; const record = ensureRecord(host); + clearLoadTimer(record); record.imageUrl = null; + record.errorAt = Date.now(); + record.loadStartedAt = 0; + record.loader = null; notifyRecord(host, 'error'); } export function beginWebsiteIconLoad(host: string, src: string): boolean { if (!host || !src) return false; const record = ensureRecord(host); + expireRecordIfNeeded(record); if (record.status !== 'idle') return false; + + if (typeof Image !== 'function') { + markWebsiteIconErrored(host); + return false; + } + + const token = record.loadToken + 1; + const loader = new Image(); + record.loadToken = token; + record.loader = loader; record.imageUrl = src; + record.errorAt = 0; + record.loadStartedAt = Date.now(); notifyRecord(host, 'loading'); + + record.timeoutId = setTimeout(() => { + const current = ensureRecord(host); + if (current.loadToken !== token || current.status !== 'loading') return; + current.imageUrl = null; + current.errorAt = Date.now(); + current.loadStartedAt = 0; + current.loader = null; + current.timeoutId = null; + notifyRecord(host, 'error'); + }, WEBSITE_ICON_LOAD_TIMEOUT_MS); + + loader.onload = () => { + const current = ensureRecord(host); + if (current.loadToken !== token) return; + markWebsiteIconLoaded(host, src); + }; + loader.onerror = () => { + const current = ensureRecord(host); + if (current.loadToken !== token) return; + markWebsiteIconErrored(host); + }; + loader.src = src; + return true; }