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
@@ -343,6 +343,36 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
if (!conversationId || !userId || !deviceId) return;
void refresh();
// Batch INSERT bursts so a paste / backfill doesn't fire N parallel
// refetches + decrypts. If more than BATCH_BURST_THRESHOLD ids arrive
// within BATCH_WINDOW_MS, collapse to a single refresh() which pulls
// the last 100 in one query — cheaper and keeps order stable. For
// lone inserts the per-id path stays so latency is unchanged.
const BATCH_WINDOW_MS = 250;
const BATCH_BURST_THRESHOLD = 3;
let burstBuffer: Array<Record<string, unknown>> = [];
let burstTimer: number | null = null;
const flushBurst = () => {
const buf = burstBuffer;
burstBuffer = [];
if (burstTimer !== null) {
window.clearTimeout(burstTimer);
burstTimer = null;
}
if (buf.length === 0) return;
if (buf.length > BATCH_BURST_THRESHOLD) {
void refresh();
} else {
for (const row of buf) void handleInsert(row);
}
};
const queueInsert = (row: Record<string, unknown>) => {
burstBuffer.push(row);
if (burstTimer === null) {
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
}
};
const channel = supabase
.channel('conv:' + conversationId)
.on(
@@ -355,7 +385,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
},
(payload: MessageChangePayload) => {
if (payload.eventType === 'INSERT') {
void handleInsert(payload.new);
queueInsert(payload.new);
} else if (payload.eventType === 'UPDATE') {
void handleUpdate(payload.new);
} else if (payload.eventType === 'DELETE') {
@@ -404,6 +434,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
window.addEventListener('online', onAwake);
return () => {
if (burstTimer !== null) window.clearTimeout(burstTimer);
document.removeEventListener('visibilitychange', onAwake);
window.removeEventListener('online', onAwake);
void supabase.removeChannel(channel);