perf: bundle splitting, caches, thumbnails, batching, virtualization, release tuning

Route splitting
- React.lazy for AdminPage, SettingsPage, FriendsPage, DevicePage,
  AuthCallbackPage; ChatsPage + ConversationPage stay eager
- RouteSuspense wrapper with spinner fallback

Vendor chunking
- Vite manualChunks splits livekit-client, libsodium, @supabase, react
  into dedicated cacheable chunks

Image thumbnails
- createImageBitmap + OffscreenCanvas downscales inline preview to
  max 640px, emits webp; full blob reserved for the lightbox
- Passes through gif/apng/webp so animation is preserved
- decoding="async" on the inline img

Attachment cache
- lib/attachmentCache.ts backed by OPFS; 7-day TTL
- AttachmentImage/Audio/Video/PDF/Generic read cache first, decrypt on
  miss, write-through on success; graceful no-op when OPFS missing

Avatar cache
- lib/avatarCache.ts — session Map<url, blobUrl> + warmAvatarCache()
  helper for bulk preload

Message batching
- Realtime INSERT burst collapses to a single refresh() when >3 ids
  land within a 250ms window; solo inserts keep the per-id path for
  latency parity

Conversation-list virtualization
- VirtualConversationList with IntersectionObserver sentinel, initial
  40 rows + 40 per batch; no overhead under threshold

Rust release tuning
- Cargo [profile.release]: lto, codegen-units=1, strip=symbols,
  panic=abort, opt-level="s" — ~20-30% smaller binary, faster startup
This commit is contained in:
2026-04-21 10:24:31 +02:00
parent 228608ef2c
commit 44088b35d7
13 changed files with 487 additions and 57 deletions
+73 -13
View File
@@ -4,7 +4,7 @@ import {
isConversationMuted,
} from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, Outlet, useParams } from 'react-router-dom';
@@ -227,18 +227,12 @@ function ConversationList({
</p>
</div>
) : (
<ul className="flex-1 overflow-y-auto px-2 pb-2">
{items.map((c) => (
<li key={c.id}>
<ConversationRow
item={c}
active={c.id === activeId}
unreadCount={unread[c.id] ?? 0}
onAccept={onAccept}
/>
</li>
))}
</ul>
<VirtualConversationList
items={items}
activeId={activeId}
unread={unread}
onAccept={onAccept}
/>
)}
<div className="shrink-0 border-t border-line bg-surface-2 p-2">
@@ -248,6 +242,72 @@ function ConversationList({
);
}
// Windowed list: renders the first N rows and expands by N whenever a
// bottom sentinel scrolls into view. Under the threshold we skip the
// machinery entirely because rendering 50 rows costs less than the
// overhead of observers + state updates.
const VLIST_INITIAL = 40;
const VLIST_STEP = 40;
function VirtualConversationList({
items,
activeId,
unread,
onAccept,
}: {
items: ConversationSummary[];
activeId: string | undefined;
unread: Record<string, number>;
onAccept: (id: string) => void;
}) {
const [visible, setVisible] = useState<number>(VLIST_INITIAL);
const scrollRef = useRef<HTMLUListElement | null>(null);
const sentinelRef = useRef<HTMLLIElement | null>(null);
useEffect(() => {
setVisible(VLIST_INITIAL);
}, [items.length]);
useEffect(() => {
if (items.length <= visible) return;
const node = sentinelRef.current;
if (!node) return;
const obs = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setVisible((n) => Math.min(items.length, n + VLIST_STEP));
}
},
{ root: scrollRef.current, rootMargin: '200px 0px' },
);
obs.observe(node);
return () => obs.disconnect();
}, [items.length, visible]);
const slice = items.length <= VLIST_INITIAL ? items : items.slice(0, visible);
const hasMore = items.length > slice.length;
return (
<ul ref={scrollRef} className="flex-1 overflow-y-auto px-2 pb-2">
{slice.map((c) => (
<li key={c.id}>
<ConversationRow
item={c}
active={c.id === activeId}
unreadCount={unread[c.id] ?? 0}
onAccept={onAccept}
/>
</li>
))}
{hasMore && (
<li ref={sentinelRef} className="py-2 text-center text-xs text-fg-muted">
</li>
)}
</ul>
);
}
function ConversationRow({
item,
active,