feat: enhance website icon loading logic; implement error handling and timeout management

This commit is contained in:
shuaiplus
2026-05-09 23:46:33 +08:00
parent 7afb496eb0
commit 4e62c90700
4 changed files with 99 additions and 15 deletions
+6
View File
@@ -231,6 +231,8 @@ export default function App() {
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null); const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
const notificationRefreshTimerRef = useRef<number | null>(null); const notificationRefreshTimerRef = useRef<number | null>(null);
const domainRulesSaveSeqRef = useRef(0); const domainRulesSaveSeqRef = useRef(0);
const loginEmailRef = useRef(loginValues.email);
const loginHintRequestSeqRef = useRef(0);
const { toasts, pushToast, removeToast } = useToastManager(); const { toasts, pushToast, removeToast } = useToastManager();
useEffect(() => { useEffect(() => {
@@ -263,6 +265,7 @@ export default function App() {
}, [inviteCodeFromUrl]); }, [inviteCodeFromUrl]);
useEffect(() => { useEffect(() => {
loginEmailRef.current = loginValues.email;
const normalizedEmail = loginValues.email.trim().toLowerCase(); const normalizedEmail = loginValues.email.trim().toLowerCase();
setLoginHintState((prev) => ( setLoginHintState((prev) => (
prev.email && prev.email !== normalizedEmail prev.email && prev.email !== normalizedEmail
@@ -634,6 +637,7 @@ export default function App() {
return; return;
} }
const requestSeq = ++loginHintRequestSeqRef.current;
setLoginHintState({ setLoginHintState({
email, email,
loading: true, loading: true,
@@ -642,6 +646,7 @@ export default function App() {
try { try {
const result = await getPasswordHint(email); const result = await getPasswordHint(email);
if (loginHintRequestSeqRef.current !== requestSeq || loginEmailRef.current.trim().toLowerCase() !== email) return;
openPasswordHintDialog(result.masterPasswordHint); openPasswordHintDialog(result.masterPasswordHint);
setLoginHintState({ setLoginHintState({
email, email,
@@ -649,6 +654,7 @@ export default function App() {
hint: result.masterPasswordHint, hint: result.masterPasswordHint,
}); });
} catch (error) { } catch (error) {
if (loginHintRequestSeqRef.current !== requestSeq || loginEmailRef.current.trim().toLowerCase() !== email) return;
setLoginHintState({ setLoginHintState({
email: '', email: '',
loading: false, loading: false,
+2 -2
View File
@@ -227,6 +227,7 @@ export default function AuthViews(props: AuthViewsProps) {
value={props.loginValues.email} value={props.loginValues.email}
autoComplete="username" autoComplete="username"
placeholder={props.authPlaceholder} placeholder={props.authPlaceholder}
autoFocus
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })} onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
/> />
</label> </label>
@@ -236,7 +237,6 @@ export default function AuthViews(props: AuthViewsProps) {
autoComplete="current-password" autoComplete="current-password"
placeholder={props.authPlaceholder} placeholder={props.authPlaceholder}
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })} onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
autoFocus
/> />
<div className="auth-support-row"> <div className="auth-support-row">
<span /> <span />
@@ -244,7 +244,7 @@ export default function AuthViews(props: AuthViewsProps) {
type="button" type="button"
className="auth-link-btn" className="auth-link-btn"
onClick={props.onTogglePasswordHint} onClick={props.onTogglePasswordHint}
disabled={loginBusy || !props.loginValues.email.trim()} disabled={loginBusy || props.loginHintLoading || !props.loginValues.email.trim()}
> >
{props.loginHintLoading {props.loginHintLoading
? t('txt_loading_password_hint') ? t('txt_loading_password_hint')
+2 -9
View File
@@ -6,8 +6,6 @@ import {
beginWebsiteIconLoad, beginWebsiteIconLoad,
getWebsiteIconImageUrl, getWebsiteIconImageUrl,
getWebsiteIconStatus, getWebsiteIconStatus,
markWebsiteIconErrored,
markWebsiteIconLoaded,
subscribeWebsiteIconStatus, subscribeWebsiteIconStatus,
} from '@/lib/website-icon-cache'; } from '@/lib/website-icon-cache';
import { demoBrandIconUrl } from '@/lib/demo-brand-icons'; 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 [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle')); const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : '')); const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : ''));
const [isLoadOwner, setIsLoadOwner] = useState(false);
const demoIconUrl = SHOULD_LOAD_DEMO_BRAND_ICONS && host ? demoBrandIconUrl(host) : ''; const demoIconUrl = SHOULD_LOAD_DEMO_BRAND_ICONS && host ? demoBrandIconUrl(host) : '';
useEffect(() => { useEffect(() => {
@@ -36,14 +33,12 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
setShouldLoad(true); setShouldLoad(true);
setStatus('idle'); setStatus('idle');
setImageUrl(''); setImageUrl('');
setIsLoadOwner(false);
return; return;
} }
const nextStatus = getWebsiteIconStatus(host); const nextStatus = getWebsiteIconStatus(host);
setShouldLoad(nextStatus === 'loaded'); setShouldLoad(nextStatus === 'loaded');
setStatus(nextStatus); setStatus(nextStatus);
setImageUrl(getWebsiteIconImageUrl(host)); setImageUrl(getWebsiteIconImageUrl(host));
setIsLoadOwner(false);
return subscribeWebsiteIconStatus(host, (next) => { return subscribeWebsiteIconStatus(host, (next) => {
setStatus(next); setStatus(next);
setImageUrl(getWebsiteIconImageUrl(host)); setImageUrl(getWebsiteIconImageUrl(host));
@@ -83,7 +78,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return; if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
if (demoIconUrl) return; if (demoIconUrl) return;
if (!host || !src || !shouldLoad || status !== 'idle') return; if (!host || !src || !shouldLoad || status !== 'idle') return;
setIsLoadOwner(beginWebsiteIconLoad(host, src)); beginWebsiteIconLoad(host, src);
}, [demoIconUrl, host, src, shouldLoad, status]); }, [demoIconUrl, host, src, shouldLoad, status]);
if (demoIconUrl) { if (demoIconUrl) {
@@ -104,7 +99,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>; return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
} }
const shouldRenderIconImage = !!imageUrl && (status === 'loaded' || (status === 'loading' && isLoadOwner)); const shouldRenderIconImage = !!imageUrl && status === 'loaded';
return ( return (
<span className="list-icon-stack" ref={nodeRef}> <span className="list-icon-stack" ref={nodeRef}>
@@ -116,8 +111,6 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
alt="" alt=""
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onLoad={() => markWebsiteIconLoaded(host, imageUrl)}
onError={() => markWebsiteIconErrored(host)}
/> />
)} )}
</span> </span>
+89 -4
View File
@@ -3,9 +3,17 @@ type WebsiteIconStatus = 'idle' | 'loading' | 'loaded' | 'error';
interface WebsiteIconRecord { interface WebsiteIconRecord {
status: WebsiteIconStatus; status: WebsiteIconStatus;
imageUrl: string | null; imageUrl: string | null;
errorAt: number;
loadStartedAt: number;
loadToken: number;
loader: HTMLImageElement | null;
timeoutId: ReturnType<typeof setTimeout> | null;
listeners: Set<(status: WebsiteIconStatus) => void>; 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<string, WebsiteIconRecord>(); const iconRecords = new Map<string, WebsiteIconRecord>();
function ensureRecord(host: string): WebsiteIconRecord { function ensureRecord(host: string): WebsiteIconRecord {
@@ -14,6 +22,11 @@ function ensureRecord(host: string): WebsiteIconRecord {
record = { record = {
status: 'idle', status: 'idle',
imageUrl: null, imageUrl: null,
errorAt: 0,
loadStartedAt: 0,
loadToken: 0,
loader: null,
timeoutId: null,
listeners: new Set(), listeners: new Set(),
}; };
iconRecords.set(host, record); iconRecords.set(host, record);
@@ -21,6 +34,29 @@ function ensureRecord(host: string): WebsiteIconRecord {
return record; 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 { function notifyRecord(host: string, status: WebsiteIconStatus): void {
const record = ensureRecord(host); const record = ensureRecord(host);
record.status = status; record.status = status;
@@ -31,12 +67,16 @@ function notifyRecord(host: string, status: WebsiteIconStatus): void {
export function getWebsiteIconStatus(host: string): WebsiteIconStatus { export function getWebsiteIconStatus(host: string): WebsiteIconStatus {
if (!host) return 'idle'; if (!host) return 'idle';
return ensureRecord(host).status; const record = ensureRecord(host);
expireRecordIfNeeded(record);
return record.status;
} }
export function getWebsiteIconImageUrl(host: string): string { export function getWebsiteIconImageUrl(host: string): string {
if (!host) return ''; 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 { 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; if (!host) return;
const record = ensureRecord(host); const record = ensureRecord(host);
clearLoadTimer(record);
if (imageUrl) { if (imageUrl) {
record.imageUrl = imageUrl; record.imageUrl = imageUrl;
} }
record.errorAt = 0;
record.loadStartedAt = 0;
record.loader = null;
notifyRecord(host, 'loaded'); notifyRecord(host, 'loaded');
} }
export function markWebsiteIconErrored(host: string): void { function markWebsiteIconErrored(host: string): void {
if (!host) return; if (!host) return;
const record = ensureRecord(host); const record = ensureRecord(host);
clearLoadTimer(record);
record.imageUrl = null; record.imageUrl = null;
record.errorAt = Date.now();
record.loadStartedAt = 0;
record.loader = null;
notifyRecord(host, 'error'); notifyRecord(host, 'error');
} }
export function beginWebsiteIconLoad(host: string, src: string): boolean { export function beginWebsiteIconLoad(host: string, src: string): boolean {
if (!host || !src) return false; if (!host || !src) return false;
const record = ensureRecord(host); const record = ensureRecord(host);
expireRecordIfNeeded(record);
if (record.status !== 'idle') return false; 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.imageUrl = src;
record.errorAt = 0;
record.loadStartedAt = Date.now();
notifyRecord(host, 'loading'); 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; return true;
} }