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:
@@ -0,0 +1,102 @@
|
||||
// Blob cache for decrypted attachments. Stores the Blob body in OPFS
|
||||
// (Origin Private File System — always sandbox-scoped to the origin, no
|
||||
// user prompt) keyed by attachment id. Avoids re-downloading + re-decrypting
|
||||
// the same blob on every scroll-into-view.
|
||||
//
|
||||
// Tauri WebKit + WebView2 both support OPFS as of the versions targeted by
|
||||
// tauri 2. On unsupported hosts the cache degrades to a no-op and callers
|
||||
// fall back to the fetch path.
|
||||
|
||||
const DIR_NAME = 'attachments';
|
||||
const DEFAULT_TTL_MS = 7 * 24 * 3600 * 1000;
|
||||
|
||||
interface OpfsRoot {
|
||||
getDirectoryHandle: (
|
||||
name: string,
|
||||
opts?: { create?: boolean },
|
||||
) => Promise<OpfsDir>;
|
||||
}
|
||||
|
||||
interface OpfsDir {
|
||||
getFileHandle: (
|
||||
name: string,
|
||||
opts?: { create?: boolean },
|
||||
) => Promise<OpfsFile>;
|
||||
removeEntry: (name: string, opts?: { recursive?: boolean }) => Promise<void>;
|
||||
entries?: () => AsyncIterableIterator<[string, OpfsFile]>;
|
||||
}
|
||||
|
||||
interface OpfsFile {
|
||||
getFile: () => Promise<File>;
|
||||
createWritable: () => Promise<{
|
||||
write: (data: ArrayBuffer | Blob) => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
}>;
|
||||
}
|
||||
|
||||
let dirPromise: Promise<OpfsDir | null> | null = null;
|
||||
|
||||
async function getDir(): Promise<OpfsDir | null> {
|
||||
if (!dirPromise) {
|
||||
dirPromise = (async () => {
|
||||
const storage = (navigator as unknown as { storage?: { getDirectory?: () => Promise<OpfsRoot> } }).storage;
|
||||
const getDirectory = storage?.getDirectory;
|
||||
if (!storage || !getDirectory) return null;
|
||||
try {
|
||||
const root = await getDirectory.call(storage);
|
||||
return await root.getDirectoryHandle(DIR_NAME, { create: true });
|
||||
} catch (err: unknown) {
|
||||
console.warn('opfs attachment cache init failed', err);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return dirPromise;
|
||||
}
|
||||
|
||||
function safeName(id: string): string {
|
||||
// OPFS file names can't contain slashes; attachment ids are UUIDs so this
|
||||
// is mostly defensive.
|
||||
return id.replace(/[^A-Za-z0-9_.-]/g, '_') + '.bin';
|
||||
}
|
||||
|
||||
export async function getCachedAttachment(id: string): Promise<Blob | null> {
|
||||
const dir = await getDir();
|
||||
if (!dir) return null;
|
||||
try {
|
||||
const handle = await dir.getFileHandle(safeName(id), { create: false });
|
||||
const file = await handle.getFile();
|
||||
// Evict stale entries lazily — if older than TTL, drop and miss.
|
||||
if (Date.now() - file.lastModified > DEFAULT_TTL_MS) {
|
||||
await dir.removeEntry(safeName(id)).catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
return file;
|
||||
} catch {
|
||||
// File doesn't exist → cache miss.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function putCachedAttachment(id: string, blob: Blob): Promise<void> {
|
||||
const dir = await getDir();
|
||||
if (!dir) return;
|
||||
try {
|
||||
const handle = await dir.getFileHandle(safeName(id), { create: true });
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(blob);
|
||||
await writable.close();
|
||||
} catch (err: unknown) {
|
||||
console.warn('putCachedAttachment failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function evictCachedAttachment(id: string): Promise<void> {
|
||||
const dir = await getDir();
|
||||
if (!dir) return;
|
||||
try {
|
||||
await dir.removeEntry(safeName(id));
|
||||
} catch {
|
||||
/* ignore — probably never existed */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Session-level avatar cache. Avatars are small (<100KB), referenced from
|
||||
// many places (chat list, bubbles, dialogs), and rarely change — so keeping
|
||||
// one blob-URL per remote URL avoids repeated decodes + network 304s across
|
||||
// remounts. OPFS is optional; the in-memory map is enough for daily use.
|
||||
|
||||
const blobUrls = new Map<string, string>(); // remote URL → blob URL
|
||||
const inflight = new Map<string, Promise<string | null>>();
|
||||
|
||||
async function fetchAsBlobUrl(url: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(url, { cache: 'force-cache' });
|
||||
if (!res.ok) return null;
|
||||
const blob = await res.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a cached blob URL for the avatar. Falls back to the original URL
|
||||
// if caching fails so rendering never breaks.
|
||||
export function useCachedAvatarUrl(url: string | null | undefined): string | null | undefined {
|
||||
if (!url) return url;
|
||||
return blobUrls.get(url) ?? url;
|
||||
}
|
||||
|
||||
// Eagerly populate the cache for a list of URLs (e.g. conversation members)
|
||||
// so subsequent renders hit the Map directly.
|
||||
export function warmAvatarCache(urls: Array<string | null | undefined>): void {
|
||||
for (const url of urls) {
|
||||
if (!url) continue;
|
||||
if (blobUrls.has(url)) continue;
|
||||
if (inflight.has(url)) continue;
|
||||
const p = fetchAsBlobUrl(url).then((blobUrl) => {
|
||||
if (blobUrl) blobUrls.set(url, blobUrl);
|
||||
inflight.delete(url);
|
||||
return blobUrl;
|
||||
});
|
||||
inflight.set(url, p);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user