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:
parent
bab675cf60
commit
309bc50e86
@ -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);
|
||||
|
||||
@ -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) */
|
||||
export async function getMaxSeq(roomId: string): Promise<number> {
|
||||
try {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user