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,
|
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
|
|
||||||
// Still set isLoadingMore to false and return
|
|
||||||
setIsLoadingMore(false);
|
setIsLoadingMore(false);
|
||||||
|
// No API call needed — WS will push any new messages that arrived while away
|
||||||
return;
|
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, {
|
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);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user