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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user