From 309bc50e866ec6bf4680453d8bb18a532bb98146 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Fri, 17 Apr 2026 21:36:55 +0800 Subject: [PATCH] perf(room): prioritize IndexedDB cache for instant loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove clearRoomMessages on room switch: IDB cache now persists across visits, enabling instant re-entry without API round-trip - Increase initial API limit from 50 → 200: more messages cached upfront - Add loadOlderMessagesFromIdb: uses 'by_room_seq' compound index to serve scroll-back history from IDB without API call - loadMore now tries IDB first before falling back to API --- src/contexts/room-context.tsx | 74 ++++++++++++++++++++--------------- src/lib/storage/indexed-db.ts | 38 ++++++++++++++++++ 2 files changed, 81 insertions(+), 31 deletions(-) diff --git a/src/contexts/room-context.tsx b/src/contexts/room-context.tsx index a318c77..cf82794 100644 --- a/src/contexts/room-context.tsx +++ b/src/contexts/room-context.tsx @@ -32,8 +32,8 @@ import { saveMessage, saveMessages, loadMessages as loadMessagesFromIdb, + loadOlderMessagesFromIdb, deleteMessage as deleteMessageFromIdb, - clearRoomMessages, } from '@/lib/storage/indexed-db'; export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client'; @@ -231,10 +231,8 @@ export function RoomProvider({ setMessages([]); setIsHistoryLoaded(false); setNextCursor(null); - // Clear old room's IDB cache asynchronously (fire and forget) - if (oldRoomId) { - clearRoomMessages(oldRoomId).catch(() => {}); - } + // NOTE: intentionally NOT clearing IndexedDB — keeping it enables instant + // load when the user returns to this room without waiting for API. } }, [activeRoomId]); @@ -274,31 +272,56 @@ export function RoomProvider({ setIsLoadingMore(true); try { - // Initial load: check IndexedDB first for fast render - if (cursor === null || cursor === undefined) { + const isInitial = cursor === null || cursor === undefined; + const limit = isInitial ? 200 : 50; + + // --- Initial load: try IndexedDB first for instant render --- + if (isInitial) { const cached = await loadMessagesFromIdb(activeRoomId); if (cached.length > 0) { setMessages(cached); setIsTransitioningRoom(false); - // Derive cursor from IDB data (oldest message's seq = cursor) const minSeq = cached[0].seq; setNextCursor(minSeq > 0 ? minSeq - 1 : null); - // If IDB has data, skip API call — WS will push live updates - // Still set isLoadingMore to false and return setIsLoadingMore(false); + // No API call needed — WS will push any new messages that arrived while away return; } } - // Call API (IDB was empty on initial load, or user is loading older history) + // --- Load older history: try IDB first, then fall back to API --- + if (!isInitial && cursor != null) { + const idbMessages = await loadOlderMessagesFromIdb(activeRoomId, cursor, limit); + if (idbMessages.length > 0) { + setMessages((prev) => { + if (abortController.signal.aborted) return prev; + const existingIds = new Set(prev.map((m) => m.id)); + const filtered = idbMessages.filter((m) => !existingIds.has(m.id)); + let merged = [...filtered, ...prev]; + merged.sort((a, b) => a.seq - b.seq); + if (merged.length > MAX_MESSAGES_IN_MEMORY) { + merged = merged.slice(-MAX_MESSAGES_IN_MEMORY); + } + return merged; + }); + const oldest = idbMessages[0]; + setNextCursor(oldest.seq > 0 ? oldest.seq - 1 : null); + if (idbMessages.length < limit) { + setIsHistoryLoaded(true); + } + setIsLoadingMore(false); + return; + } + // IDB empty for this range — fall through to API + } + + // --- API fetch --- const resp = await client.messageList(activeRoomId, { beforeSeq: cursor ?? undefined, - limit: 50, + limit, }); - if (abortController.signal.aborted) { - return; - } + if (abortController.signal.aborted) return; const newMessages = resp.messages.map((m) => ({ ...m, @@ -308,17 +331,11 @@ export function RoomProvider({ })); setMessages((prev) => { - // Double-check room hasn't changed - if (abortController.signal.aborted) { - return prev; - } - // If initial load (cursor=null), replace instead of merge (room switching) - if (cursor === null || cursor === undefined) { - // Clear transitioning state + if (abortController.signal.aborted) return prev; + if (isInitial) { setIsTransitioningRoom(false); return newMessages; } - // loadMore: prepend older messages before existing const existingIds = new Set(prev.map((m) => m.id)); const filtered = newMessages.filter((m) => !existingIds.has(m.id)); let merged = [...filtered, ...prev]; @@ -329,18 +346,16 @@ export function RoomProvider({ return merged; }); - // Persist new messages to IndexedDB if (newMessages.length > 0) { saveMessages(activeRoomId, newMessages).catch(() => {}); } - if (resp.messages.length < 50) { + if (resp.messages.length < limit) { setIsHistoryLoaded(true); } - // messages are in ascending order (oldest first), so [length-1] is newest setNextCursor(resp.messages.length > 0 ? resp.messages[resp.messages.length - 1].seq : null); - // Fetch reactions for all loaded messages (backend may not support this yet) + // Fetch reactions for all loaded messages const msgIds = newMessages.map((m) => m.id); if (msgIds.length > 0) { try { @@ -363,10 +378,7 @@ export function RoomProvider({ } } } catch (error) { - // Ignore abort errors - if (abortController.signal.aborted) { - return; - } + if (abortController.signal.aborted) return; handleRoomError('Load messages', error); } finally { setIsLoadingMore(false); diff --git a/src/lib/storage/indexed-db.ts b/src/lib/storage/indexed-db.ts index 9020e9b..bea8ddd 100644 --- a/src/lib/storage/indexed-db.ts +++ b/src/lib/storage/indexed-db.ts @@ -183,6 +183,44 @@ export async function clearRoomMessages(roomId: string): Promise { } } +/** Load older messages from IDB (seq < beforeSeq), sorted ascending, up to `limit` */ +export async function loadOlderMessagesFromIdb( + roomId: string, + beforeSeq: number, + limit = 50, +): Promise { + try { + const db = await openDB(); + const tx = db.transaction(STORE_MESSAGES, 'readonly'); + const index = tx.objectStore(STORE_MESSAGES).index('by_room_seq'); + + // Compound key range: roomId + any seq less than beforeSeq + const range = IDBKeyRange.bound([roomId, 0], [roomId, beforeSeq - 1]); + const request = index.openCursor(range, 'prev'); // 'prev' = descending seq (newest first) + // We want oldest before `beforeSeq`, so after getting `limit` items in 'prev' order, + // reverse to ascending seq. + const collected: StoredMessage[] = []; + + return new Promise((resolve, reject) => { + request.onsuccess = () => { + const cursor = request.result; + if (cursor && collected.length < limit) { + collected.push(cursor.value); + cursor.continue(); + } else { + // Reverse back to ascending order (oldest first) + const msgs = collected.reverse().map(storedToMsg); + resolve(msgs); + } + }; + request.onerror = () => reject(request.error); + }); + } catch (err) { + console.warn('[IDB] loadOlderMessagesFromIdb failed:', err); + return []; + } +} + /** Get the highest seq number for a room (for dedup) */ export async function getMaxSeq(roomId: string): Promise { try {