feat: improve offline PWA resilience

This commit is contained in:
shuaiplus
2026-06-09 14:09:46 +08:00
parent 1a10df4a18
commit 615caf5946
23 changed files with 432 additions and 21 deletions
@@ -3,6 +3,7 @@ import type { ComponentChildren } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { Link } from 'wouter';
import AppMainRoutes from '@/components/AppMainRoutes';
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
import ThemeSwitch from '@/components/ThemeSwitch';
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
import { t } from '@/lib/i18n';
@@ -237,6 +238,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<span className="mobile-page-title">{props.currentPageTitle}</span>
</div>
<div className="topbar-actions">
<NetworkStatusBadge />
<div className="user-chip">
<ShieldUser size={16} />
<span>{props.profile?.email}</span>
+4 -3
View File
@@ -1,5 +1,6 @@
import { useState } from 'preact/hooks';
import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
@@ -83,7 +84,7 @@ export default function AuthViews(props: AuthViewsProps) {
if (props.mode === 'locked') {
return (
<div className="auth-page">
<StandalonePageFrame title={t('txt_unlock_vault')}>
<StandalonePageFrame title={t('txt_unlock_vault')} titleAccessory={<NetworkStatusBadge />}>
<form
onSubmit={(e) => {
e.preventDefault();
@@ -132,7 +133,7 @@ export default function AuthViews(props: AuthViewsProps) {
if (props.mode === 'register') {
return (
<div className="auth-page">
<StandalonePageFrame title={t('txt_create_account')}>
<StandalonePageFrame title={t('txt_create_account')} titleAccessory={<NetworkStatusBadge />}>
<form
onSubmit={(e) => {
e.preventDefault();
@@ -216,7 +217,7 @@ export default function AuthViews(props: AuthViewsProps) {
return (
<div className="auth-page">
<StandalonePageFrame title={t('txt_log_in')}>
<StandalonePageFrame title={t('txt_log_in')} titleAccessory={<NetworkStatusBadge />}>
<form
onSubmit={(e) => {
e.preventDefault();
@@ -0,0 +1,86 @@
import { Wifi, WifiOff } from 'lucide-preact';
import { useEffect, useState } from 'preact/hooks';
import { t } from '@/lib/i18n';
import {
browserReportsOffline,
getCurrentNetworkStatus,
probeNodeWardenService,
setCurrentNetworkStatus,
subscribeNetworkStatus,
type NetworkStatus,
} from '@/lib/network-status';
const STATUS_CHECK_INTERVAL_MS = 30_000;
function statusLabel(status: NetworkStatus): string {
if (status === 'online') return t('txt_online');
return t('txt_offline');
}
export default function NetworkStatusBadge() {
const [status, setStatus] = useState<NetworkStatus>(getCurrentNetworkStatus);
const label = statusLabel(status);
const Icon = status === 'online' ? Wifi : WifiOff;
useEffect(() => {
let cancelled = false;
let timer = 0;
const checkService = async () => {
if (browserReportsOffline()) {
setCurrentNetworkStatus('offline');
return;
}
const reachable = await probeNodeWardenService();
if (!cancelled) {
setCurrentNetworkStatus(reachable ? 'online' : 'offline');
}
};
const scheduleNextCheck = () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => {
void checkService().finally(scheduleNextCheck);
}, STATUS_CHECK_INTERVAL_MS);
};
const handleOnline = () => {
void checkService();
};
const handleOffline = () => {
setCurrentNetworkStatus('offline');
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') void checkService();
};
const unsubscribe = subscribeNetworkStatus(setStatus);
void checkService().finally(scheduleNextCheck);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
window.addEventListener('focus', handleOnline);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
cancelled = true;
unsubscribe();
window.clearTimeout(timer);
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
window.removeEventListener('focus', handleOnline);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
return (
<span
className={`network-status-badge ${status}`}
title={label}
aria-label={label}
aria-live="polite"
>
<Icon size={14} aria-hidden="true" />
<span className="network-status-label">{label}</span>
</span>
);
}
@@ -4,6 +4,7 @@ import { APP_VERSION } from '@shared/app-version';
interface StandalonePageFrameProps {
title: string;
eyebrow?: ComponentChildren;
titleAccessory?: ComponentChildren;
children: ComponentChildren;
}
@@ -19,7 +20,10 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
<div className="auth-card">
{props.eyebrow && <div className="standalone-eyebrow">{props.eyebrow}</div>}
<h1 className="standalone-title">{props.title}</h1>
<div className="standalone-title-row">
<h1 className="standalone-title">{props.title}</h1>
{props.titleAccessory}
</div>
{props.children}
</div>
+30
View File
@@ -716,6 +716,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
setAttachmentQueue([]);
setRemovedAttachmentIds({});
if (isMobileLayout) setMobilePanel('detail');
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -729,6 +731,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
setPendingDelete(null);
cancelEdit();
if (isMobileLayout) setMobilePanel('list');
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -741,6 +745,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
if (isMobileLayout && selectedCipherId === cipher.id) {
setMobilePanel('list');
}
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -760,6 +766,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
}
setSelectedMap({});
setBulkDeleteOpen(false);
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -776,6 +784,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
await props.onBulkMove(ids, folderId);
setSelectedMap({});
setMoveOpen(false);
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -785,6 +795,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
setBusy(true);
try {
await props.onRefresh();
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -819,6 +831,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
await props.onCreateFolder(newFolderName);
setCreateFolderOpen(false);
setNewFolderName('');
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -833,6 +847,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
setSidebarFilter({ kind: 'all' });
}
setPendingDeleteFolder(null);
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -850,6 +866,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
await props.onRenameFolder(pendingRenameFolder.id, nextName);
setPendingRenameFolder(null);
setRenameFolderName('');
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -864,6 +882,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
try {
await props.onBulkRestore(ids);
setSelectedMap({});
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -878,6 +898,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
if (isMobileLayout && selectedCipherId === pendingArchive.id) {
setMobilePanel('list');
}
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -892,6 +914,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
delete next[cipher.id];
return next;
});
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -907,6 +931,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
await props.onBulkArchive(ids);
setSelectedMap({});
setBulkArchiveOpen(false);
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -921,6 +947,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
try {
await props.onBulkUnarchive(ids);
setSelectedMap({});
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -935,6 +963,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
setSidebarFilter({ kind: 'all' });
}
setDeleteAllFoldersOpen(false);
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
+6 -1
View File
@@ -9,6 +9,7 @@ import {
subscribeWebsiteIconStatus,
} from '@/lib/website-icon-cache';
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status';
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
@@ -26,8 +27,11 @@ 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 [networkStatus, setNetworkStatus] = useState(getCurrentNetworkStatus);
const demoIconUrl = SHOULD_LOAD_DEMO_BRAND_ICONS && host ? demoBrandIconUrl(host) : '';
useEffect(() => subscribeNetworkStatus(setNetworkStatus), []);
useEffect(() => {
if (!host) {
setShouldLoad(true);
@@ -77,9 +81,10 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
useEffect(() => {
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
if (demoIconUrl) return;
if (networkStatus !== 'online') return;
if (!host || !src || !shouldLoad || status !== 'idle') return;
beginWebsiteIconLoad(host, src);
}, [demoIconUrl, host, src, shouldLoad, status]);
}, [demoIconUrl, host, networkStatus, src, shouldLoad, status]);
if (demoIconUrl) {
return (