mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: enhance website icon loading logic; implement error handling and timeout management
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user