perf(room): prioritize IndexedDB cache for instant loads

- 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
This commit is contained in:
ZhenYi 2026-04-17 21:36:55 +08:00
parent bab675cf60
commit 309bc50e86
2 changed files with 81 additions and 31 deletions

View File

@ -32,8 +32,8 @@ import {
saveMessage, saveMessage,
saveMessages, saveMessages,
loadMessages as loadMessagesFromIdb, loadMessages as loadMessagesFromIdb,
loadOlderMessagesFromIdb,
deleteMessage as deleteMessageFromIdb, deleteMessage as deleteMessageFromIdb,
clearRoomMessages,
} from '@/lib/storage/indexed-db'; } from '@/lib/storage/indexed-db';
export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client'; export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client';
@ -231,10 +231,8 @@ export function RoomProvider({
setMessages([]); setMessages([]);
setIsHistoryLoaded(false); setIsHistoryLoaded(false);
setNextCursor(null); setNextCursor(null);
// Clear old room's IDB cache asynchronously (fire and forget) // NOTE: intentionally NOT clearing IndexedDB — keeping it enables instant
if (oldRoomId) { // load when the user returns to this room without waiting for API.
clearRoomMessages(oldRoomId).catch(() => {});
}
} }
}, [activeRoomId]); }, [activeRoomId]);
@ -274,31 +272,56 @@ export function RoomProvider({
setIsLoadingMore(true); setIsLoadingMore(true);
try { try {
// Initial load: check IndexedDB first for fast render const isInitial = cursor === null || cursor === undefined;
if (cursor === null || cursor === undefined) { const limit = isInitial ? 200 : 50;
// --- Initial load: try IndexedDB first for instant render ---
if (isInitial) {
const cached = await loadMessagesFromIdb(activeRoomId); const cached = await loadMessagesFromIdb(activeRoomId);
if (cached.length > 0) { if (cached.length > 0) {
setMessages(cached); setMessages(cached);
setIsTransitioningRoom(false); setIsTransitioningRoom(false);
// Derive cursor from IDB data (oldest message's seq = cursor)
const minSeq = cached[0].seq; const minSeq = cached[0].seq;
setNextCursor(minSeq > 0 ? minSeq - 1 : null); setNextCursor(minSeq > 0 ? minSeq - 1 : null);
// If IDB has data, skip API call — WS will push live updates setIsLoadingMore(false);
// Still set isLoadingMore to false and return // No API call needed — WS will push any new messages that arrived while away
return;
}
}
// --- 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); setIsLoadingMore(false);
return; return;
} }
// IDB empty for this range — fall through to API
} }
// Call API (IDB was empty on initial load, or user is loading older history) // --- API fetch ---
const resp = await client.messageList(activeRoomId, { const resp = await client.messageList(activeRoomId, {
beforeSeq: cursor ?? undefined, beforeSeq: cursor ?? undefined,
limit: 50, limit,
}); });
if (abortController.signal.aborted) { if (abortController.signal.aborted) return;
return;
}
const newMessages = resp.messages.map((m) => ({ const newMessages = resp.messages.map((m) => ({
...m, ...m,
@ -308,17 +331,11 @@ export function RoomProvider({
})); }));
setMessages((prev) => { setMessages((prev) => {
// Double-check room hasn't changed if (abortController.signal.aborted) return prev;
if (abortController.signal.aborted) { if (isInitial) {
return prev;
}
// If initial load (cursor=null), replace instead of merge (room switching)
if (cursor === null || cursor === undefined) {
// Clear transitioning state
setIsTransitioningRoom(false); setIsTransitioningRoom(false);
return newMessages; return newMessages;
} }
// loadMore: prepend older messages before existing
const existingIds = new Set(prev.map((m) => m.id)); const existingIds = new Set(prev.map((m) => m.id));
const filtered = newMessages.filter((m) => !existingIds.has(m.id)); const filtered = newMessages.filter((m) => !existingIds.has(m.id));
let merged = [...filtered, ...prev]; let merged = [...filtered, ...prev];
@ -329,18 +346,16 @@ export function RoomProvider({
return merged; return merged;
}); });
// Persist new messages to IndexedDB
if (newMessages.length > 0) { if (newMessages.length > 0) {
saveMessages(activeRoomId, newMessages).catch(() => {}); saveMessages(activeRoomId, newMessages).catch(() => {});
} }
if (resp.messages.length < 50) { if (resp.messages.length < limit) {
setIsHistoryLoaded(true); 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); 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); const msgIds = newMessages.map((m) => m.id);
if (msgIds.length > 0) { if (msgIds.length > 0) {
try { try {
@ -363,10 +378,7 @@ export function RoomProvider({
} }
} }
} catch (error) { } catch (error) {
// Ignore abort errors if (abortController.signal.aborted) return;
if (abortController.signal.aborted) {
return;
}
handleRoomError('Load messages', error); handleRoomError('Load messages', error);
} finally { } finally {
setIsLoadingMore(false); setIsLoadingMore(false);

View File

@ -183,6 +183,44 @@ export async function clearRoomMessages(roomId: string): Promise<void> {
} }
} }
/** Load older messages from IDB (seq < beforeSeq), sorted ascending, up to `limit` */
export async function loadOlderMessagesFromIdb(
roomId: string,
beforeSeq: number,
limit = 50,
): Promise<MessageWithMeta[]> {
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) */ /** Get the highest seq number for a room (for dedup) */
export async function getMaxSeq(roomId: string): Promise<number> { export async function getMaxSeq(roomId: string): Promise<number> {
try { try {