feat: implement caching for cryptographic keys to improve performance and reduce overhead

This commit is contained in:
shuaiplus
2026-04-27 22:49:52 +08:00
parent 4b69f71ddb
commit fdb4cb91bf
7 changed files with 414 additions and 192 deletions
+96 -31
View File
@@ -33,6 +33,8 @@ const TOTP_PERIOD_SECONDS = 30;
const TOTP_RING_RADIUS = 14;
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
const TOTP_REFRESH_BATCH_SIZE = 16;
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
const failedIconHosts = new Set<string>();
function getTotpTimeState(): { windowId: number; remain: number } {
@@ -71,41 +73,80 @@ function hostFromUri(uri: string): string {
function TotpListIcon({ cipher }: { cipher: Cipher }) {
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
const iconStackRef = useRef<HTMLSpanElement | null>(null);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
const [loaded, setLoaded] = useState(false);
const [shouldLoad, setShouldLoad] = useState(() => !host);
const markIconError = () => {
if (host) failedIconHosts.add(host);
setErrored(true);
};
const syncCachedIconState = (img: HTMLImageElement | null) => {
if (!img || !img.complete) return;
if (img.naturalWidth > 0) {
setLoaded(true);
return;
const hideFallback = () => {
const stack = iconStackRef.current;
if (stack) {
const fallback = stack.querySelector('.list-icon-fallback') as HTMLElement | null;
if (fallback) fallback.style.display = 'none';
}
markIconError();
};
const handleImgRef = (img: HTMLImageElement | null) => {
if (!img || !img.complete) return;
if (img.naturalWidth > 0) hideFallback();
};
useEffect(() => {
setErrored(host ? failedIconHosts.has(host) : false);
setLoaded(false);
setShouldLoad(!host);
const fallback = iconStackRef.current?.querySelector('.list-icon-fallback') as HTMLElement | null;
if (fallback) fallback.style.display = '';
}, [host]);
useEffect(() => {
if (!host || errored || shouldLoad) return;
const node = iconStackRef.current;
if (!node) return;
if (typeof IntersectionObserver !== 'function') {
setShouldLoad(true);
return;
}
let cancelled = false;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting && entry.intersectionRatio <= 0) continue;
if (!cancelled) setShouldLoad(true);
observer.disconnect();
break;
}
},
{ rootMargin: ICON_LOAD_ROOT_MARGIN }
);
observer.observe(node);
return () => {
cancelled = true;
observer.disconnect();
};
}, [host, errored, shouldLoad]);
if (host && !errored) {
return (
<span className="list-icon-stack">
<span className={`list-icon-fallback ${loaded ? 'hidden' : ''}`}>
<span className="list-icon-stack" ref={iconStackRef}>
<span className="list-icon-fallback">
<Globe size={18} />
</span>
<img
className={`list-icon ${loaded ? 'loaded' : ''}`}
src={websiteIconUrl(host)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
ref={syncCachedIconState}
onLoad={() => setLoaded(true)}
onError={markIconError}
/>
{shouldLoad && (
<img
className="list-icon loaded"
src={websiteIconUrl(host)}
alt=""
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
ref={handleImgRef}
onLoad={hideFallback}
onError={markIconError}
/>
)}
</span>
);
}
@@ -294,17 +335,41 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
const refreshCodes = async () => {
const runId = ++activeRun;
const entries = await Promise.all(
totpItems.map(async (cipher) => {
try {
const next = await calcTotpNow(cipher.login?.decTotp || '');
return [cipher.id, next?.code || null] as const;
} catch {
return [cipher.id, null] as const;
}
})
);
if (!stopped && runId === activeRun) setTotpCodes(Object.fromEntries(entries));
const nextCodes: Record<string, string | null> = {};
for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) {
if (stopped || runId !== activeRun) return;
const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
const entries = await Promise.all(
batch.map(async (cipher) => {
try {
const next = await calcTotpNow(cipher.login?.decTotp || '');
return [cipher.id, next?.code || null] as const;
} catch {
return [cipher.id, null] as const;
}
})
);
for (const [id, code] of entries) nextCodes[id] = code;
if (start + TOTP_REFRESH_BATCH_SIZE < totpItems.length) {
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
}
}
if (stopped || runId !== activeRun) return;
setTotpCodes((prev) => {
let changed = false;
const next: Record<string, string | null> = { ...prev };
for (const id of Object.keys(next)) {
if (id in nextCodes) continue;
delete next[id];
changed = true;
}
for (const [id, code] of Object.entries(nextCodes)) {
if (next[id] === code) continue;
next[id] = code;
changed = true;
}
return changed ? next : prev;
});
};
const tick = () => {
+116 -91
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
import VaultDialogs from '@/components/vault/VaultDialogs';
import VaultDetailView from '@/components/vault/VaultDetailView';
import VaultEditor from '@/components/vault/VaultEditor';
@@ -474,7 +474,7 @@ export default function VaultPage(props: VaultPageProps) {
!props.loading &&
!busy;
function handleReorderVaultCipher(activeId: string, overId: string): void {
const handleReorderVaultCipher = useCallback((activeId: string, overId: string): void => {
if (!canReorderVaultList || activeId === overId) return;
const currentIds = filteredCiphers.map((cipher) => cipher.id);
const fromIndex = currentIds.indexOf(activeId);
@@ -498,7 +498,7 @@ export default function VaultPage(props: VaultPageProps) {
suppressNextSortScrollRef.current = true;
setSortMode('manual');
}
}
}, [canReorderVaultList, filteredCiphers, props.ciphers, sortMode]);
useEffect(() => {
if (isCreating) return;
@@ -575,27 +575,27 @@ export default function VaultPage(props: VaultPageProps) {
);
const totalCipherCount = filteredCiphers.length;
function folderName(id: string | null | undefined): string {
const folderName = useCallback((id: string | null | undefined): string => {
if (!id) return t('txt_no_folder');
const folder = folderById.get(id);
return folder?.decName || folder?.name || id;
}
}, [folderById]);
function listSubtitle(cipher: Cipher): string {
const listSubtitle = useCallback((cipher: Cipher): string => {
if (Number(cipher.type || 1) === 1) {
return cipher.login?.decUsername || cipherMetaById.get(cipher.id)?.firstUri || '';
}
return cipherTypeLabel(Number(cipher.type || 1));
}
}, [cipherMetaById]);
function handleListScroll(top: number): void {
const handleListScroll = useCallback((top: number): void => {
const bucket = Math.floor(Math.max(0, top) / VAULT_LIST_ROW_HEIGHT);
if (bucket === listScrollBucketRef.current) return;
listScrollBucketRef.current = bucket;
setListScrollTop(top);
}
}, []);
function startCreate(type: number): void {
const startCreate = useCallback((type: number): void => {
setDraft(createEmptyDraft(type));
setIsCreating(true);
setIsEditing(true);
@@ -608,9 +608,9 @@ function folderName(id: string | null | undefined): string {
if (isMobileLayout) setMobilePanel('edit');
setMobileSidebarOpen(false);
if (type === 5) void seedSshDefaults();
}
}, [isMobileLayout]);
function startEdit(): void {
const startEdit = useCallback((): void => {
if (!selectedCipher) return;
setDraft(draftFromCipher(selectedCipher));
setIsCreating(false);
@@ -621,9 +621,9 @@ function folderName(id: string | null | undefined): string {
setRemovedAttachmentIds({});
if (isMobileLayout) setMobilePanel('edit');
setMobileSidebarOpen(false);
}
}, [selectedCipher, isMobileLayout]);
function cancelEdit(): void {
const cancelEdit = useCallback((): void => {
const returnToDetail = isMobileLayout && !isCreating && !!selectedCipher;
setDraft(null);
setIsEditing(false);
@@ -633,11 +633,11 @@ function folderName(id: string | null | undefined): string {
setRemovedAttachmentIds({});
setPendingDeletePasskeyIndex(null);
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
}
}, [isMobileLayout, isCreating, selectedCipher]);
function updateDraft(patch: Partial<VaultDraft>): void {
const updateDraft = useCallback((patch: Partial<VaultDraft>): void => {
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
}
}, []);
function confirmDeleteLoginPasskey(): void {
if (pendingDeletePasskeyIndex == null) return;
@@ -1002,16 +1002,88 @@ function folderName(id: string | null | undefined): string {
}
}
const handleClearSearch = useCallback(() => setSearchInput(''), []);
const handleSearchCompositionStart = useCallback(() => setSearchComposing(true), []);
const handleSearchCompositionEnd = useCallback((value: string) => {
setSearchComposing(false);
setSearchInput(value);
}, []);
const handleToggleSortMenu = useCallback(() => setSortMenuOpen((open) => !open), []);
const handleSelectSortMode = useCallback((value: VaultSortMode) => {
setSortMode(value);
setSortMenuOpen(false);
}, []);
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
const handleSelectDuplicates = useCallback(() => {
const map: Record<string, boolean> = {};
const seen = new Set<string>();
for (const cipher of filteredCiphers) {
const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher);
if (seen.has(signature)) {
map[cipher.id] = true;
continue;
}
seen.add(signature);
}
setSelectedMap(map);
}, [filteredCiphers, duplicateSignatureInfo]);
const handleSelectAll = useCallback(() => {
const map: Record<string, boolean> = {};
for (const cipher of filteredCiphers) map[cipher.id] = true;
setSelectedMap(map);
}, [filteredCiphers]);
const handleToggleCreateMenu = useCallback(() => setCreateMenuOpen((open) => !open), []);
const handleBulkRestore = useCallback(() => { void confirmBulkRestore(); }, [selectedMap, props.onBulkRestore]);
const handleBulkArchive = useCallback(() => setBulkArchiveOpen(true), []);
const handleBulkUnarchive = useCallback(() => { void confirmBulkUnarchive(); }, [selectedMap, props.onBulkUnarchive]);
const handleOpenMove = useCallback(() => {
setMoveFolderId('__none__');
setMoveOpen(true);
}, []);
const handleClearSelection = useCallback(() => setSelectedMap({}), []);
const handleToggleSelected = useCallback((cipherId: string, checked: boolean) =>
setSelectedMap((prev) => {
if (checked) return { ...prev, [cipherId]: true };
if (!prev[cipherId]) return prev;
const next = { ...prev };
delete next[cipherId];
return next;
})
, []);
const handleSelectCipher = useCallback((cipherId: string) => {
if (isEditing || isCreating) {
cancelEdit();
}
setSelectedCipherId(cipherId);
setRepromptApprovedCipherId(null);
if (isMobileLayout) setMobilePanel('detail');
setMobileSidebarOpen(false);
}, [isEditing, isCreating, cancelEdit, isMobileLayout]);
const handleCloseMobileSidebar = useCallback(() => setMobileSidebarOpen(false), []);
const handleOpenDeleteAllFolders = useCallback(() => setDeleteAllFoldersOpen(true), []);
const handleOpenCreateFolder = useCallback(() => setCreateFolderOpen(true), []);
const handleOpenRenameFolder = useCallback((folder: Folder) => {
setPendingRenameFolder(folder);
setRenameFolderName(folder.decName || folder.name || '');
}, []);
const handleToggleFolderSortMenu = useCallback(() => setFolderSortMenuOpen((open) => !open), []);
const handleSelectFolderSortMode = useCallback((value: VaultSortMode) => {
setFolderSortMode(value);
setFolderSortMenuOpen(false);
}, []);
const handleMobileSidebarMaskClick = useCallback(() => {
if (!mobileSidebarOpen) return;
setMobileSidebarOpen(false);
}, [mobileSidebarOpen]);
return (
<>
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
{isMobileLayout && (
<div
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
onClick={() => {
if (!mobileSidebarOpen) return;
setMobileSidebarOpen(false);
}}
onClick={handleMobileSidebarMaskClick}
/>
)}
<VaultSidebar
@@ -1023,20 +1095,14 @@ function folderName(id: string | null | undefined): string {
folderSortMode={folderSortMode}
folderSortMenuOpen={folderSortMenuOpen}
folderSortMenuRef={folderSortMenuRef}
onCloseMobileSidebar={() => setMobileSidebarOpen(false)}
onCloseMobileSidebar={handleCloseMobileSidebar}
onChangeFilter={setSidebarFilter}
onOpenDeleteAllFolders={() => setDeleteAllFoldersOpen(true)}
onOpenCreateFolder={() => setCreateFolderOpen(true)}
onOpenRenameFolder={(folder) => {
setPendingRenameFolder(folder);
setRenameFolderName(folder.decName || folder.name || '');
}}
onOpenDeleteAllFolders={handleOpenDeleteAllFolders}
onOpenCreateFolder={handleOpenCreateFolder}
onOpenRenameFolder={handleOpenRenameFolder}
onOpenDeleteFolder={setPendingDeleteFolder}
onToggleFolderSortMenu={() => setFolderSortMenuOpen((open) => !open)}
onSelectFolderSortMode={(value) => {
setFolderSortMode(value);
setFolderSortMenuOpen(false);
}}
onToggleFolderSortMenu={handleToggleFolderSortMenu}
onSelectFolderSortMode={handleSelectFolderSortMode}
/>
<VaultListPanel
@@ -1061,67 +1127,26 @@ function folderName(id: string | null | undefined): string {
sortMenuRef={sortMenuRef}
listPanelRef={listPanelRef}
onSearchInput={setSearchInput}
onClearSearch={() => setSearchInput('')}
onSearchCompositionStart={() => setSearchComposing(true)}
onSearchCompositionEnd={(value) => {
setSearchComposing(false);
setSearchInput(value);
}}
onToggleSortMenu={() => setSortMenuOpen((open) => !open)}
onSelectSortMode={(value) => {
setSortMode(value);
setSortMenuOpen(false);
}}
onSyncVault={() => void syncVault()}
onOpenBulkDelete={() => setBulkDeleteOpen(true)}
onSelectDuplicates={() => {
const map: Record<string, boolean> = {};
const seen = new Set<string>();
for (const cipher of filteredCiphers) {
const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher);
if (seen.has(signature)) {
map[cipher.id] = true;
continue;
}
seen.add(signature);
}
setSelectedMap(map);
}}
onSelectAll={() => {
const map: Record<string, boolean> = {};
for (const cipher of filteredCiphers) map[cipher.id] = true;
setSelectedMap(map);
}}
onToggleCreateMenu={() => setCreateMenuOpen((open) => !open)}
onClearSearch={handleClearSearch}
onSearchCompositionStart={handleSearchCompositionStart}
onSearchCompositionEnd={handleSearchCompositionEnd}
onToggleSortMenu={handleToggleSortMenu}
onSelectSortMode={handleSelectSortMode}
onSyncVault={handleSyncVault}
onOpenBulkDelete={handleOpenBulkDelete}
onSelectDuplicates={handleSelectDuplicates}
onSelectAll={handleSelectAll}
onToggleCreateMenu={handleToggleCreateMenu}
onStartCreate={startCreate}
onBulkRestore={() => void confirmBulkRestore()}
onBulkArchive={() => setBulkArchiveOpen(true)}
onBulkUnarchive={() => void confirmBulkUnarchive()}
onOpenMove={() => {
setMoveFolderId('__none__');
setMoveOpen(true);
}}
onClearSelection={() => setSelectedMap({})}
onBulkRestore={handleBulkRestore}
onBulkArchive={handleBulkArchive}
onBulkUnarchive={handleBulkUnarchive}
onOpenMove={handleOpenMove}
onClearSelection={handleClearSelection}
onReorderCipher={handleReorderVaultCipher}
onScroll={handleListScroll}
onToggleSelected={(cipherId, checked) =>
setSelectedMap((prev) => {
if (checked) return { ...prev, [cipherId]: true };
if (!prev[cipherId]) return prev;
const next = { ...prev };
delete next[cipherId];
return next;
})
}
onSelectCipher={(cipherId) => {
if (isEditing || isCreating) {
cancelEdit();
}
setSelectedCipherId(cipherId);
setRepromptApprovedCipherId(null);
if (isMobileLayout) setMobilePanel('detail');
setMobileSidebarOpen(false);
}}
onToggleSelected={handleToggleSelected}
onSelectCipher={handleSelectCipher}
listSubtitle={listSubtitle}
/>
@@ -1,4 +1,5 @@
import type { JSX, RefObject } from 'preact';
import { memo } from 'preact/compat';
import { createPortal } from 'preact/compat';
import { useMemo, useState } from 'preact/hooks';
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, GripVertical, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
@@ -106,7 +107,7 @@ interface CipherListItemBodyProps {
onSelectCipher?: (cipherId: string) => void;
}
function CipherListItemBody(props: CipherListItemBodyProps) {
const CipherListItemBody = memo(function CipherListItemBody(props: CipherListItemBodyProps) {
return (
<>
<input
@@ -143,12 +144,12 @@ function CipherListItemBody(props: CipherListItemBodyProps) {
</button>
</>
);
}
});
const animateLayoutChanges: AnimateLayoutChanges = (args) =>
args.isSorting || args.wasDragging ? defaultAnimateLayoutChanges(args) : false;
function SortableCipherListItem(props: SortableCipherListItemProps) {
const SortableCipherListItem = memo(function SortableCipherListItem(props: SortableCipherListItemProps) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.cipher.id,
disabled: !props.canReorder,
@@ -184,9 +185,9 @@ function SortableCipherListItem(props: SortableCipherListItemProps) {
/>
</div>
);
}
});
function PlainCipherListItem(props: SortableCipherListItemProps) {
const PlainCipherListItem = memo(function PlainCipherListItem(props: SortableCipherListItemProps) {
return (
<div
className={`list-item ${props.selected ? 'active' : ''}`}
@@ -206,7 +207,7 @@ function PlainCipherListItem(props: SortableCipherListItemProps) {
/>
</div>
);
}
});
export default function VaultListPanel(props: VaultListPanelProps) {
const [activeDragId, setActiveDragId] = useState('');
+7 -3
View File
@@ -43,6 +43,10 @@ interface VaultSidebarProps {
}
export default function VaultSidebar(props: VaultSidebarProps) {
const nameCollator = useMemo(
() => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
[]
);
const sortedFolders = useMemo(() => {
const sorted = [...props.folders];
sorted.sort((a, b) => {
@@ -67,14 +71,14 @@ export default function VaultSidebar(props: VaultSidebarProps) {
}
if (aValid !== bValid) return aValid ? -1 : 1;
}
const nameDiff = String(a.decName || a.name || '').localeCompare(
String(b.decName || b.name || ''), undefined, { sensitivity: 'base', numeric: true }
const nameDiff = nameCollator.compare(
String(a.decName || a.name || ''), String(b.decName || b.name || '')
);
if (nameDiff !== 0) return nameDiff;
return String(a.id || '').localeCompare(String(b.id || ''));
});
return sorted;
}, [props.folders, props.folderSortMode]);
}, [props.folders, props.folderSortMode, nameCollator]);
return (
<aside className={`sidebar ${props.isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${props.isMobileLayout && props.mobileSidebarOpen ? 'open' : ''}`}>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import {
CreditCard,
FileKey2,
@@ -436,44 +436,85 @@ export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
}
const failedIconHosts = new Set<string>();
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
const iconStackRef = useRef<HTMLSpanElement | null>(null);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
const [loaded, setLoaded] = useState(false);
const [shouldLoad, setShouldLoad] = useState(() => !host);
const markIconError = () => {
if (host) failedIconHosts.add(host);
setErrored(true);
};
const syncCachedIconState = (img: HTMLImageElement | null) => {
if (!img || !img.complete) return;
if (img.naturalWidth > 0) {
setLoaded(true);
return;
const hideFallback = () => {
const stack = iconStackRef.current;
if (stack) {
const fallback = stack.querySelector('.list-icon-fallback') as HTMLElement | null;
if (fallback) fallback.style.display = 'none';
}
markIconError();
};
const handleImgRef = (img: HTMLImageElement | null) => {
if (!img || !img.complete) return;
if (img.naturalWidth > 0) hideFallback();
};
useEffect(() => {
setErrored(host ? failedIconHosts.has(host) : false);
setLoaded(false);
setShouldLoad(!host);
// Reset fallback visibility so it shows while loading the new icon
const fallback = iconStackRef.current?.querySelector('.list-icon-fallback') as HTMLElement | null;
if (fallback) fallback.style.display = '';
}, [host]);
useEffect(() => {
if (!host || errored || shouldLoad) return;
const node = iconStackRef.current;
if (!node) return;
if (typeof IntersectionObserver !== 'function') {
setShouldLoad(true);
return;
}
let cancelled = false;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting && entry.intersectionRatio <= 0) continue;
if (!cancelled) setShouldLoad(true);
observer.disconnect();
break;
}
},
{ rootMargin: ICON_LOAD_ROOT_MARGIN }
);
observer.observe(node);
return () => {
cancelled = true;
observer.disconnect();
};
}, [host, errored, shouldLoad]);
if (host && !errored) {
return (
<span className="list-icon-stack">
<span className={`list-icon-fallback ${loaded ? 'hidden' : ''}`}>
<span className="list-icon-stack" ref={iconStackRef}>
<span className="list-icon-fallback">
<Globe size={18} />
</span>
<img
className={`list-icon ${loaded ? 'loaded' : ''}`}
src={websiteIconUrl(host)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
ref={syncCachedIconState}
onLoad={() => setLoaded(true)}
onError={markIconError}
/>
{shouldLoad && (
<img
className="list-icon loaded"
src={websiteIconUrl(host)}
alt=""
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
ref={handleImgRef}
onLoad={hideFallback}
onError={markIconError}
/>
)}
</span>
);
}