refactor: optimize TOTP and vault components with useMemo for performance improvements

This commit is contained in:
shuaiplus
2026-04-27 15:14:32 +08:00
parent 44020541e8
commit 4b69f71ddb
4 changed files with 167 additions and 76 deletions
+17 -11
View File
@@ -70,8 +70,7 @@ function hostFromUri(uri: string): string {
} }
function TotpListIcon({ cipher }: { cipher: Cipher }) { function TotpListIcon({ cipher }: { cipher: Cipher }) {
const uri = firstCipherUri(cipher); const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
const host = hostFromUri(uri);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false)); const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const markIconError = () => { const markIconError = () => {
@@ -226,16 +225,21 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') }); await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
} }
const nameCollator = useMemo(
() => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
[]
);
const baseTotpItems = useMemo( const baseTotpItems = useMemo(
() => () =>
props.ciphers props.ciphers
.filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp) .filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp)
.sort((a, b) => { .sort((a, b) => {
const nameA = (a.decName || a.name || '').trim().toLowerCase(); const nameA = (a.decName || a.name || '').trim();
const nameB = (b.decName || b.name || '').trim().toLowerCase(); const nameB = (b.decName || b.name || '').trim();
return nameA.localeCompare(nameB); return nameCollator.compare(nameA, nameB);
}), }),
[props.ciphers] [props.ciphers, nameCollator]
); );
const totpItems = useMemo(() => { const totpItems = useMemo(() => {
@@ -247,11 +251,13 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
if (orderA != null && orderB != null) return orderA - orderB; if (orderA != null && orderB != null) return orderA - orderB;
if (orderA != null) return -1; if (orderA != null) return -1;
if (orderB != null) return 1; if (orderB != null) return 1;
const nameA = (a.decName || a.name || '').trim().toLowerCase(); const nameA = (a.decName || a.name || '').trim();
const nameB = (b.decName || b.name || '').trim().toLowerCase(); const nameB = (b.decName || b.name || '').trim();
return nameA.localeCompare(nameB); return nameCollator.compare(nameA, nameB);
}); });
}, [baseTotpItems, orderedIds]); }, [baseTotpItems, orderedIds, nameCollator]);
const sortableTotpItems = useMemo(() => totpItems.map((cipher) => cipher.id), [totpItems]);
useEffect(() => { useEffect(() => {
if (!baseTotpItems.length) return; if (!baseTotpItems.length) return;
@@ -361,7 +367,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
> >
{!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>} {!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={totpItems.map((cipher) => cipher.id)} strategy={rectSortingStrategy}> <SortableContext items={sortableTotpItems} strategy={rectSortingStrategy}>
{totpItems.map((cipher) => ( {totpItems.map((cipher) => (
<SortableTotpRow <SortableTotpRow
key={cipher.id} key={cipher.id}
+89 -32
View File
@@ -131,6 +131,7 @@ export default function VaultPage(props: VaultPageProps) {
const suppressNextSortScrollRef = useRef(false); const suppressNextSortScrollRef = useRef(false);
const sshSeedTicketRef = useRef(0); const sshSeedTicketRef = useRef(0);
const sshFingerprintTicketRef = useRef(0); const sshFingerprintTicketRef = useRef(0);
const listScrollBucketRef = useRef(0);
const [listScrollTop, setListScrollTop] = useState(0); const [listScrollTop, setListScrollTop] = useState(0);
const [listViewportHeight, setListViewportHeight] = useState(0); const [listViewportHeight, setListViewportHeight] = useState(0);
@@ -326,29 +327,75 @@ export default function VaultPage(props: VaultPageProps) {
void recalculateSshFingerprint(draft.sshPublicKey); void recalculateSshFingerprint(draft.sshPublicKey);
}, [isEditing, draft?.id, draft?.type]); }, [isEditing, draft?.id, draft?.type]);
const duplicateSignatureCounts = useMemo(() => { const cipherMetaById = useMemo(() => {
const meta = new Map<string, {
name: string;
searchText: string;
firstUri: string;
typeKey: string;
sortTime: number;
creationTime: number;
}>();
for (const cipher of props.ciphers) {
const name = String(cipher.decName || cipher.name || '');
const username = String(cipher.login?.decUsername || '');
const uri = firstCipherUri(cipher);
meta.set(cipher.id, {
name,
searchText: `${name}\n${username}\n${uri}`.toLowerCase(),
firstUri: uri,
typeKey: cipherTypeKey(Number(cipher.type || 1)),
sortTime: sortTimeValue(cipher),
creationTime: creationTimeValue(cipher),
});
}
return meta;
}, [props.ciphers]);
const cipherById = useMemo(() => {
const map = new Map<string, Cipher>();
for (const cipher of props.ciphers) map.set(cipher.id, cipher);
return map;
}, [props.ciphers]);
const folderById = useMemo(() => {
const map = new Map<string, Folder>();
for (const folder of props.folders) map.set(folder.id, folder);
return map;
}, [props.folders]);
const nameCollator = useMemo(
() => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
[]
);
const duplicateSignatureInfo = useMemo(() => {
if (sidebarFilter.kind !== 'duplicates') return null;
const byId = new Map<string, string>();
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const cipher of props.ciphers) { for (const cipher of props.ciphers) {
if (!isCipherVisibleInNormalVault(cipher)) continue; if (!isCipherVisibleInNormalVault(cipher)) continue;
const signature = buildCipherDuplicateSignature(cipher); const signature = buildCipherDuplicateSignature(cipher);
byId.set(cipher.id, signature);
counts.set(signature, (counts.get(signature) || 0) + 1); counts.set(signature, (counts.get(signature) || 0) + 1);
} }
return counts; return { byId, counts };
}, [props.ciphers]); }, [props.ciphers, sidebarFilter.kind]);
const filteredCiphers = useMemo(() => { const filteredCiphers = useMemo(() => {
const next = props.ciphers.filter((cipher) => { const next = props.ciphers.filter((cipher) => {
const meta = cipherMetaById.get(cipher.id);
if (sidebarFilter.kind === 'trash') { if (sidebarFilter.kind === 'trash') {
if (!isCipherVisibleInTrash(cipher)) return false; if (!isCipherVisibleInTrash(cipher)) return false;
} else if (sidebarFilter.kind === 'archive') { } else if (sidebarFilter.kind === 'archive') {
if (!isCipherVisibleInArchive(cipher)) return false; if (!isCipherVisibleInArchive(cipher)) return false;
} else { } else {
if (!isCipherVisibleInNormalVault(cipher)) return false; if (!isCipherVisibleInNormalVault(cipher)) return false;
if (sidebarFilter.kind === 'duplicates' && (duplicateSignatureCounts.get(buildCipherDuplicateSignature(cipher)) || 0) < 2) { if (sidebarFilter.kind === 'duplicates' && ((duplicateSignatureInfo?.counts.get(duplicateSignatureInfo.byId.get(cipher.id) || '') || 0) < 2)) {
return false; return false;
} }
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false; if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
if (sidebarFilter.kind === 'type' && cipherTypeKey(Number(cipher.type || 1)) !== sidebarFilter.value) return false; if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false;
if (sidebarFilter.kind === 'folder') { if (sidebarFilter.kind === 'folder') {
if (sidebarFilter.folderId === null) { if (sidebarFilter.folderId === null) {
if (cipher.folderId) return false; if (cipher.folderId) return false;
@@ -358,15 +405,14 @@ export default function VaultPage(props: VaultPageProps) {
} }
} }
if (!searchQuery) return true; if (!searchQuery) return true;
const name = (cipher.decName || '').toLowerCase(); return !!meta?.searchText.includes(searchQuery);
const username = (cipher.login?.decUsername || '').toLowerCase();
const uri = firstCipherUri(cipher).toLowerCase();
return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery);
}); });
const orderMap = new Map(vaultOrderedIds.map((id, index) => [id, index])); const orderMap = sortMode === 'manual' ? new Map(vaultOrderedIds.map((id, index) => [id, index])) : null;
next.sort((a, b) => { next.sort((a, b) => {
if (sortMode === 'manual') { const metaA = cipherMetaById.get(a.id);
const metaB = cipherMetaById.get(b.id);
if (sortMode === 'manual' && orderMap) {
const orderA = orderMap.get(a.id); const orderA = orderMap.get(a.id);
const orderB = orderMap.get(b.id); const orderB = orderMap.get(b.id);
if (orderA != null && orderB != null) { if (orderA != null && orderB != null) {
@@ -376,16 +422,13 @@ export default function VaultPage(props: VaultPageProps) {
if (orderA != null) return -1; if (orderA != null) return -1;
if (orderB != null) return 1; if (orderB != null) return 1;
} else if (sortMode === 'edited') { } else if (sortMode === 'edited') {
const diff = sortTimeValue(b) - sortTimeValue(a); const diff = (metaB?.sortTime || 0) - (metaA?.sortTime || 0);
if (diff !== 0) return diff; if (diff !== 0) return diff;
} else if (sortMode === 'created') { } else if (sortMode === 'created') {
const diff = creationTimeValue(b) - creationTimeValue(a); const diff = (metaB?.creationTime || 0) - (metaA?.creationTime || 0);
if (diff !== 0) return diff; if (diff !== 0) return diff;
} else { } else {
const nameDiff = String(a.decName || a.name || '').localeCompare(String(b.decName || b.name || ''), undefined, { const nameDiff = nameCollator.compare(metaA?.name || '', metaB?.name || '');
sensitivity: 'base',
numeric: true,
});
if (nameDiff !== 0) return nameDiff; if (nameDiff !== 0) return nameDiff;
} }
@@ -393,7 +436,13 @@ export default function VaultPage(props: VaultPageProps) {
}); });
return next; return next;
}, [props.ciphers, sidebarFilter, searchQuery, sortMode, duplicateSignatureCounts, vaultOrderedIds]); }, [props.ciphers, cipherMetaById, sidebarFilter, searchQuery, sortMode, duplicateSignatureInfo, vaultOrderedIds, nameCollator]);
const filteredCipherIds = useMemo(() => {
const ids = new Set<string>();
for (const cipher of filteredCiphers) ids.add(cipher.id);
return ids;
}, [filteredCiphers]);
const sidebarFilterKey = useMemo(() => { const sidebarFilterKey = useMemo(() => {
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`; if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
@@ -407,6 +456,7 @@ export default function VaultPage(props: VaultPageProps) {
return; return;
} }
setListScrollTop(0); setListScrollTop(0);
listScrollBucketRef.current = 0;
listPanelRef.current?.scrollTo({ top: 0 }); listPanelRef.current?.scrollTo({ top: 0 });
}, [searchQuery, sortMode, sidebarFilterKey]); }, [searchQuery, sortMode, sidebarFilterKey]);
@@ -456,15 +506,12 @@ export default function VaultPage(props: VaultPageProps) {
if (selectedCipherId) setSelectedCipherId(''); if (selectedCipherId) setSelectedCipherId('');
return; return;
} }
if (!selectedCipherId || !filteredCiphers.some((x) => x.id === selectedCipherId)) { if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) {
setSelectedCipherId(filteredCiphers[0].id); setSelectedCipherId(filteredCiphers[0].id);
} }
}, [filteredCiphers, selectedCipherId, isCreating]); }, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating]);
const selectedCipher = useMemo( const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]);
() => props.ciphers.find((x) => x.id === selectedCipherId) || null,
[props.ciphers, selectedCipherId]
);
const virtualRange = useMemo(() => { const virtualRange = useMemo(() => {
if (!filteredCiphers.length) { if (!filteredCiphers.length) {
return { start: 0, end: 0, padTop: 0, padBottom: 0 }; return { start: 0, end: 0, padTop: 0, padBottom: 0 };
@@ -530,17 +577,24 @@ export default function VaultPage(props: VaultPageProps) {
function folderName(id: string | null | undefined): string { function folderName(id: string | null | undefined): string {
if (!id) return t('txt_no_folder'); if (!id) return t('txt_no_folder');
const folder = props.folders.find((x) => x.id === id); const folder = folderById.get(id);
return folder?.decName || folder?.name || id; return folder?.decName || folder?.name || id;
} }
function listSubtitle(cipher: Cipher): string { function listSubtitle(cipher: Cipher): string {
if (Number(cipher.type || 1) === 1) { if (Number(cipher.type || 1) === 1) {
return cipher.login?.decUsername || firstCipherUri(cipher) || ''; return cipher.login?.decUsername || cipherMetaById.get(cipher.id)?.firstUri || '';
} }
return cipherTypeLabel(Number(cipher.type || 1)); return cipherTypeLabel(Number(cipher.type || 1));
} }
function handleListScroll(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 { function startCreate(type: number): void {
setDraft(createEmptyDraft(type)); setDraft(createEmptyDraft(type));
setIsCreating(true); setIsCreating(true);
@@ -1024,7 +1078,7 @@ function folderName(id: string | null | undefined): string {
const map: Record<string, boolean> = {}; const map: Record<string, boolean> = {};
const seen = new Set<string>(); const seen = new Set<string>();
for (const cipher of filteredCiphers) { for (const cipher of filteredCiphers) {
const signature = buildCipherDuplicateSignature(cipher); const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher);
if (seen.has(signature)) { if (seen.has(signature)) {
map[cipher.id] = true; map[cipher.id] = true;
continue; continue;
@@ -1049,12 +1103,15 @@ function folderName(id: string | null | undefined): string {
}} }}
onClearSelection={() => setSelectedMap({})} onClearSelection={() => setSelectedMap({})}
onReorderCipher={handleReorderVaultCipher} onReorderCipher={handleReorderVaultCipher}
onScroll={setListScrollTop} onScroll={handleListScroll}
onToggleSelected={(cipherId, checked) => onToggleSelected={(cipherId, checked) =>
setSelectedMap((prev) => ({ setSelectedMap((prev) => {
...prev, if (checked) return { ...prev, [cipherId]: true };
[cipherId]: checked, if (!prev[cipherId]) return prev;
})) const next = { ...prev };
delete next[cipherId];
return next;
})
} }
onSelectCipher={(cipherId) => { onSelectCipher={(cipherId) => {
if (isEditing || isCreating) { if (isEditing || isCreating) {
+59 -30
View File
@@ -1,6 +1,6 @@
import type { JSX, RefObject } from 'preact'; import type { JSX, RefObject } from 'preact';
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { useState } from 'preact/hooks'; import { useMemo, useState } from 'preact/hooks';
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, GripVertical, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact'; import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, GripVertical, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
import { import {
closestCenter, closestCenter,
@@ -186,6 +186,28 @@ function SortableCipherListItem(props: SortableCipherListItemProps) {
); );
} }
function PlainCipherListItem(props: SortableCipherListItemProps) {
return (
<div
className={`list-item ${props.selected ? 'active' : ''}`}
onClick={(event) => {
const target = event.target as HTMLElement;
if (target.closest('.row-check') || target.closest('.cipher-drag-btn')) return;
props.onSelectCipher(props.cipher.id);
}}
>
<CipherListItemBody
cipher={props.cipher}
checked={props.checked}
canReorder={false}
subtitle={props.subtitle}
onToggleSelected={props.onToggleSelected}
onSelectCipher={props.onSelectCipher}
/>
</div>
);
}
export default function VaultListPanel(props: VaultListPanelProps) { export default function VaultListPanel(props: VaultListPanelProps) {
const [activeDragId, setActiveDragId] = useState(''); const [activeDragId, setActiveDragId] = useState('');
const [activeDragWidth, setActiveDragWidth] = useState<number | null>(null); const [activeDragWidth, setActiveDragWidth] = useState<number | null>(null);
@@ -203,7 +225,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
}) })
); );
const sortableItems = props.filteredCiphers.map((cipher) => cipher.id); const sortableItems = useMemo(() => props.visibleCiphers.map((cipher) => cipher.id), [props.visibleCiphers]);
const renderedCiphers = props.visibleCiphers; const renderedCiphers = props.visibleCiphers;
const activeDragCipher = activeDragId ? props.filteredCiphers.find((cipher) => cipher.id === activeDragId) || null : null; const activeDragCipher = activeDragId ? props.filteredCiphers.find((cipher) => cipher.id === activeDragId) || null : null;
@@ -250,6 +272,22 @@ export default function VaultListPanel(props: VaultListPanelProps) {
</div> </div>
); );
const listItems = renderedCiphers.map((cipher) => {
const ItemComponent = props.canReorder ? SortableCipherListItem : PlainCipherListItem;
return (
<ItemComponent
key={cipher.id}
cipher={cipher}
selected={props.selectedCipherId === cipher.id}
checked={!!props.selectedMap[cipher.id]}
canReorder={props.canReorder}
subtitle={props.listSubtitle(cipher)}
onToggleSelected={props.onToggleSelected}
onSelectCipher={props.onSelectCipher}
/>
);
});
return ( return (
<section className="list-col"> <section className="list-col">
<div className="list-head"> <div className="list-head">
@@ -357,34 +395,25 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}> <div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
{!!props.filteredCiphers.length && ( {!!props.filteredCiphers.length && (
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}> <div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd} onDragCancel={handleDragCancel}> {props.canReorder ? (
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd} onDragCancel={handleDragCancel}>
{renderedCiphers.map((cipher) => ( <SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
<SortableCipherListItem {listItems}
key={cipher.id} </SortableContext>
cipher={cipher} <DragOverlay adjustScale={false}>
selected={props.selectedCipherId === cipher.id} {activeDragCipher ? (
checked={!!props.selectedMap[cipher.id]} <div className="list-item cipher-drag-overlay" style={activeDragWidth ? { width: `${activeDragWidth}px` } : undefined}>
canReorder={props.canReorder} <CipherListItemBody
subtitle={props.listSubtitle(cipher)} cipher={activeDragCipher}
onToggleSelected={props.onToggleSelected} checked={!!props.selectedMap[activeDragCipher.id]}
onSelectCipher={props.onSelectCipher} canReorder={true}
/> subtitle={props.listSubtitle(activeDragCipher)}
))} />
</SortableContext> </div>
<DragOverlay adjustScale={false}> ) : null}
{activeDragCipher ? ( </DragOverlay>
<div className="list-item cipher-drag-overlay" style={activeDragWidth ? { width: `${activeDragWidth}px` } : undefined}> </DndContext>
<CipherListItemBody ) : listItems}
cipher={activeDragCipher}
checked={!!props.selectedMap[activeDragCipher.id]}
canReorder={true}
subtitle={props.listSubtitle(activeDragCipher)}
/>
</div>
) : null}
</DragOverlay>
</DndContext>
</div> </div>
)} )}
{!props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>} {!props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useMemo, useState } from 'preact/hooks';
import { import {
CreditCard, CreditCard,
FileKey2, FileKey2,
@@ -438,8 +438,7 @@ export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
const failedIconHosts = new Set<string>(); const failedIconHosts = new Set<string>();
export function VaultListIcon({ cipher }: { cipher: Cipher }) { export function VaultListIcon({ cipher }: { cipher: Cipher }) {
const uri = firstCipherUri(cipher); const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
const host = hostFromUri(uri);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false)); const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const markIconError = () => { const markIconError = () => {