From 4b69f71ddbc32843d6c74376d97e8bd8fd986b22 Mon Sep 17 00:00:00 2001
From: shuaiplus <2327005759@qq.com>
Date: Mon, 27 Apr 2026 15:14:32 +0800
Subject: [PATCH] refactor: optimize TOTP and vault components with useMemo for
performance improvements
---
webapp/src/components/TotpCodesPage.tsx | 28 ++--
webapp/src/components/VaultPage.tsx | 121 +++++++++++++-----
.../src/components/vault/VaultListPanel.tsx | 89 ++++++++-----
.../components/vault/vault-page-helpers.tsx | 5 +-
4 files changed, 167 insertions(+), 76 deletions(-)
diff --git a/webapp/src/components/TotpCodesPage.tsx b/webapp/src/components/TotpCodesPage.tsx
index bd4e9ca..de3cd6d 100644
--- a/webapp/src/components/TotpCodesPage.tsx
+++ b/webapp/src/components/TotpCodesPage.tsx
@@ -70,8 +70,7 @@ function hostFromUri(uri: string): string {
}
function TotpListIcon({ cipher }: { cipher: Cipher }) {
- const uri = firstCipherUri(cipher);
- const host = hostFromUri(uri);
+ const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
const [loaded, setLoaded] = useState(false);
const markIconError = () => {
@@ -226,16 +225,21 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
}
+ const nameCollator = useMemo(
+ () => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
+ []
+ );
+
const baseTotpItems = useMemo(
() =>
props.ciphers
.filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp)
.sort((a, b) => {
- const nameA = (a.decName || a.name || '').trim().toLowerCase();
- const nameB = (b.decName || b.name || '').trim().toLowerCase();
- return nameA.localeCompare(nameB);
+ const nameA = (a.decName || a.name || '').trim();
+ const nameB = (b.decName || b.name || '').trim();
+ return nameCollator.compare(nameA, nameB);
}),
- [props.ciphers]
+ [props.ciphers, nameCollator]
);
const totpItems = useMemo(() => {
@@ -247,11 +251,13 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
if (orderA != null && orderB != null) return orderA - orderB;
if (orderA != null) return -1;
if (orderB != null) return 1;
- const nameA = (a.decName || a.name || '').trim().toLowerCase();
- const nameB = (b.decName || b.name || '').trim().toLowerCase();
- return nameA.localeCompare(nameB);
+ const nameA = (a.decName || a.name || '').trim();
+ const nameB = (b.decName || b.name || '').trim();
+ return nameCollator.compare(nameA, nameB);
});
- }, [baseTotpItems, orderedIds]);
+ }, [baseTotpItems, orderedIds, nameCollator]);
+
+ const sortableTotpItems = useMemo(() => totpItems.map((cipher) => cipher.id), [totpItems]);
useEffect(() => {
if (!baseTotpItems.length) return;
@@ -361,7 +367,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
>
{!totpItems.length && !props.loading &&
{t('txt_no_verification_codes')}
}
- cipher.id)} strategy={rectSortingStrategy}>
+
{totpItems.map((cipher) => (
{
+ const cipherMetaById = useMemo(() => {
+ const meta = new Map();
+ 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();
+ for (const cipher of props.ciphers) map.set(cipher.id, cipher);
+ return map;
+ }, [props.ciphers]);
+
+ const folderById = useMemo(() => {
+ const map = new Map();
+ 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();
const counts = new Map();
for (const cipher of props.ciphers) {
if (!isCipherVisibleInNormalVault(cipher)) continue;
const signature = buildCipherDuplicateSignature(cipher);
+ byId.set(cipher.id, signature);
counts.set(signature, (counts.get(signature) || 0) + 1);
}
- return counts;
- }, [props.ciphers]);
+ return { byId, counts };
+ }, [props.ciphers, sidebarFilter.kind]);
const filteredCiphers = useMemo(() => {
const next = props.ciphers.filter((cipher) => {
+ const meta = cipherMetaById.get(cipher.id);
if (sidebarFilter.kind === 'trash') {
if (!isCipherVisibleInTrash(cipher)) return false;
} else if (sidebarFilter.kind === 'archive') {
if (!isCipherVisibleInArchive(cipher)) return false;
} else {
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;
}
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.folderId === null) {
if (cipher.folderId) return false;
@@ -358,15 +405,14 @@ export default function VaultPage(props: VaultPageProps) {
}
}
if (!searchQuery) return true;
- const name = (cipher.decName || '').toLowerCase();
- const username = (cipher.login?.decUsername || '').toLowerCase();
- const uri = firstCipherUri(cipher).toLowerCase();
- return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery);
+ return !!meta?.searchText.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) => {
- 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 orderB = orderMap.get(b.id);
if (orderA != null && orderB != null) {
@@ -376,16 +422,13 @@ export default function VaultPage(props: VaultPageProps) {
if (orderA != null) return -1;
if (orderB != null) return 1;
} else if (sortMode === 'edited') {
- const diff = sortTimeValue(b) - sortTimeValue(a);
+ const diff = (metaB?.sortTime || 0) - (metaA?.sortTime || 0);
if (diff !== 0) return diff;
} else if (sortMode === 'created') {
- const diff = creationTimeValue(b) - creationTimeValue(a);
+ const diff = (metaB?.creationTime || 0) - (metaA?.creationTime || 0);
if (diff !== 0) return diff;
} else {
- const nameDiff = String(a.decName || a.name || '').localeCompare(String(b.decName || b.name || ''), undefined, {
- sensitivity: 'base',
- numeric: true,
- });
+ const nameDiff = nameCollator.compare(metaA?.name || '', metaB?.name || '');
if (nameDiff !== 0) return nameDiff;
}
@@ -393,7 +436,13 @@ export default function VaultPage(props: VaultPageProps) {
});
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();
+ for (const cipher of filteredCiphers) ids.add(cipher.id);
+ return ids;
+ }, [filteredCiphers]);
const sidebarFilterKey = useMemo(() => {
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
@@ -407,6 +456,7 @@ export default function VaultPage(props: VaultPageProps) {
return;
}
setListScrollTop(0);
+ listScrollBucketRef.current = 0;
listPanelRef.current?.scrollTo({ top: 0 });
}, [searchQuery, sortMode, sidebarFilterKey]);
@@ -456,15 +506,12 @@ export default function VaultPage(props: VaultPageProps) {
if (selectedCipherId) setSelectedCipherId('');
return;
}
- if (!selectedCipherId || !filteredCiphers.some((x) => x.id === selectedCipherId)) {
+ if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) {
setSelectedCipherId(filteredCiphers[0].id);
}
- }, [filteredCiphers, selectedCipherId, isCreating]);
+ }, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating]);
- const selectedCipher = useMemo(
- () => props.ciphers.find((x) => x.id === selectedCipherId) || null,
- [props.ciphers, selectedCipherId]
- );
+ const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]);
const virtualRange = useMemo(() => {
if (!filteredCiphers.length) {
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 {
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;
}
function listSubtitle(cipher: Cipher): string {
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));
}
+ 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 {
setDraft(createEmptyDraft(type));
setIsCreating(true);
@@ -1024,7 +1078,7 @@ function folderName(id: string | null | undefined): string {
const map: Record = {};
const seen = new Set();
for (const cipher of filteredCiphers) {
- const signature = buildCipherDuplicateSignature(cipher);
+ const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher);
if (seen.has(signature)) {
map[cipher.id] = true;
continue;
@@ -1049,12 +1103,15 @@ function folderName(id: string | null | undefined): string {
}}
onClearSelection={() => setSelectedMap({})}
onReorderCipher={handleReorderVaultCipher}
- onScroll={setListScrollTop}
+ onScroll={handleListScroll}
onToggleSelected={(cipherId, checked) =>
- setSelectedMap((prev) => ({
- ...prev,
- [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) {
diff --git a/webapp/src/components/vault/VaultListPanel.tsx b/webapp/src/components/vault/VaultListPanel.tsx
index cbd976c..02b2f34 100644
--- a/webapp/src/components/vault/VaultListPanel.tsx
+++ b/webapp/src/components/vault/VaultListPanel.tsx
@@ -1,6 +1,6 @@
import type { JSX, RefObject } from 'preact';
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 {
closestCenter,
@@ -186,6 +186,28 @@ function SortableCipherListItem(props: SortableCipherListItemProps) {
);
}
+function PlainCipherListItem(props: SortableCipherListItemProps) {
+ return (
+ {
+ const target = event.target as HTMLElement;
+ if (target.closest('.row-check') || target.closest('.cipher-drag-btn')) return;
+ props.onSelectCipher(props.cipher.id);
+ }}
+ >
+
+
+ );
+}
+
export default function VaultListPanel(props: VaultListPanelProps) {
const [activeDragId, setActiveDragId] = useState('');
const [activeDragWidth, setActiveDragWidth] = useState(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 activeDragCipher = activeDragId ? props.filteredCiphers.find((cipher) => cipher.id === activeDragId) || null : null;
@@ -250,6 +272,22 @@ export default function VaultListPanel(props: VaultListPanelProps) {
);
+ const listItems = renderedCiphers.map((cipher) => {
+ const ItemComponent = props.canReorder ? SortableCipherListItem : PlainCipherListItem;
+ return (
+
+ );
+ });
+
return (
@@ -357,34 +395,25 @@ export default function VaultListPanel(props: VaultListPanelProps) {
props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
{!!props.filteredCiphers.length && (
-
-
- {renderedCiphers.map((cipher) => (
-
- ))}
-
-
- {activeDragCipher ? (
-
-
-
- ) : null}
-
-
+ {props.canReorder ? (
+
+
+ {listItems}
+
+
+ {activeDragCipher ? (
+
+
+
+ ) : null}
+
+
+ ) : listItems}
)}
{!props.filteredCiphers.length &&
{t('txt_no_items')}
}
diff --git a/webapp/src/components/vault/vault-page-helpers.tsx b/webapp/src/components/vault/vault-page-helpers.tsx
index 23896b5..67ccf13 100644
--- a/webapp/src/components/vault/vault-page-helpers.tsx
+++ b/webapp/src/components/vault/vault-page-helpers.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'preact/hooks';
+import { useEffect, useMemo, useState } from 'preact/hooks';
import {
CreditCard,
FileKey2,
@@ -438,8 +438,7 @@ export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
const failedIconHosts = new Set
();
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
- const uri = firstCipherUri(cipher);
- const host = hostFromUri(uri);
+ const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
const [loaded, setLoaded] = useState(false);
const markIconError = () => {