diff --git a/.changeset/sliding-sync-prefetch.md b/.changeset/sliding-sync-prefetch.md new file mode 100644 index 000000000..3a3c058a1 --- /dev/null +++ b/.changeset/sliding-sync-prefetch.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Prefetch recently-visited rooms on sliding sync complete. diff --git a/src/app/hooks/useSlidingSyncActiveRoom.ts b/src/app/hooks/useSlidingSyncActiveRoom.ts index c86914d56..1a76ddeac 100644 --- a/src/app/hooks/useSlidingSyncActiveRoom.ts +++ b/src/app/hooks/useSlidingSyncActiveRoom.ts @@ -2,11 +2,14 @@ import { useEffect } from 'react'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { getSlidingSyncManager } from '$client/initMatrix'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; +import { addRecentRoom } from '$utils/recentRooms'; /** * Subscribes the currently selected room to the sliding sync "active room" * custom subscription (higher timeline limit) for the duration the room is open. * + * Also tracks room visits in localStorage for prefetching optimization. + * * Subscriptions are intentionally never removed on navigation — once a room * has been opened it continues receiving background updates so that returning * to it is instant. Explicit unsubscription (and timeline pruning) only happens @@ -25,6 +28,13 @@ export const useSlidingSyncActiveRoom = (): void => { if (!manager) return undefined; manager.subscribeToRoom(roomId); + + // Track room visit for prefetching optimization + const userId = mx.getUserId(); + if (userId) { + addRecentRoom(userId, roomId); + } + return undefined; }, [mx, roomId]); }; diff --git a/src/app/utils/recentRooms.ts b/src/app/utils/recentRooms.ts new file mode 100644 index 000000000..8be6da4d6 --- /dev/null +++ b/src/app/utils/recentRooms.ts @@ -0,0 +1,79 @@ +/** + * Tracks recently visited rooms for prefetching optimization. + * Stores up to 10 most recent room IDs per user in localStorage. + */ + +const RECENT_ROOMS_KEY = 'sable-recent-rooms'; +const MAX_RECENT_ROOMS = 10; + +type RecentRoomsStore = Record; + +/** + * Get list of recently visited rooms for a user. + * Returns empty array if none found. + */ +export function getRecentRoomIds(userId: string): string[] { + try { + const stored = localStorage.getItem(RECENT_ROOMS_KEY); + if (!stored) return []; + + const data: unknown = JSON.parse(stored); + if (typeof data !== 'object' || data === null || Array.isArray(data)) return []; + const userRooms = (data as Record)[userId]; + if (!Array.isArray(userRooms)) return []; + return userRooms.filter((id): id is string => typeof id === 'string'); + } catch { + return []; + } +} + +/** + * Add a room to the recent list for a user. + * Moves room to front if already present. + * Trims list to MAX_RECENT_ROOMS. + */ +export function addRecentRoom(userId: string, roomId: string): void { + try { + const stored = localStorage.getItem(RECENT_ROOMS_KEY); + const data: RecentRoomsStore = stored ? JSON.parse(stored) : {}; + + let userRooms = data[userId] ?? []; + + // Remove if already present + userRooms = userRooms.filter((id) => id !== roomId); + + // Add to front + userRooms.unshift(roomId); + + // Trim to max + if (userRooms.length > MAX_RECENT_ROOMS) { + userRooms = userRooms.slice(0, MAX_RECENT_ROOMS); + } + + data[userId] = userRooms; + localStorage.setItem(RECENT_ROOMS_KEY, JSON.stringify(data)); + } catch { + // localStorage quota exceeded or unavailable — silent ignore + } +} + +/** + * Clear recent rooms for a user (e.g., on logout). + */ +export function clearRecentRooms(userId: string): void { + try { + const stored = localStorage.getItem(RECENT_ROOMS_KEY); + if (!stored) return; + + const data: RecentRoomsStore = JSON.parse(stored); + delete data[userId]; + + if (Object.keys(data).length === 0) { + localStorage.removeItem(RECENT_ROOMS_KEY); + } else { + localStorage.setItem(RECENT_ROOMS_KEY, JSON.stringify(data)); + } + } catch { + // Silent ignore + } +} diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index cea5b1f78..85204368c 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -22,6 +22,7 @@ import { } from '$types/matrix-sdk'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; +import { getRecentRoomIds } from '$utils/recentRooms'; import * as Sentry from '@sentry/react'; const log = createLogger('slidingSync'); @@ -371,11 +372,37 @@ export class SlidingSyncManager { Object.entries(rooms) .filter(([, roomData]) => roomData.initial || roomData.limited) .filter(([roomId]) => this.activeRoomSubscriptions.has(roomId)) - .forEach(([roomId]) => { + .forEach(([roomId, roomData]) => { const room = this.mx.getRoom(roomId); if (!room) return; const timelineSet = room.getUnfilteredTimelineSet(); - if (timelineSet.getLiveTimeline().getEvents().length === 0) return; + const liveTimeline = timelineSet.getLiveTimeline(); + const localEvents = liveTimeline.getEvents(); + + // Empty timeline: reset is fine, no flicker + if (localEvents.length === 0) return; + + // Check for event overlap with server data + const serverEvents = roomData.timeline ?? []; + if (serverEvents.length === 0) { + // No incoming events: preserve local timeline + return; + } + + // Build set of local event IDs for fast lookup + const localIds = new Set(localEvents.map((e) => e.getId())); + const serverIds = serverEvents.map((e) => e.event_id); + + // Check if any server event ID exists in local timeline + const hasOverlap = serverIds.some((id) => localIds.has(id)); + + if (hasOverlap) { + // Overlap detected: SDK will merge naturally, no reset needed + // This prevents flicker when reopening recently-viewed rooms + return; + } + + // No overlap: local events are stale, reset needed timelineSet.resetLiveTimeline(); }); } @@ -439,6 +466,9 @@ export class SlidingSyncManager { }); this.initialSyncSpan?.end(); this.initialSyncSpan = null; + + // Prefetch recently-visited rooms to warm the cache for likely next navigations + this.prefetchRecentRooms(); } this.expandListsToKnownCount(); @@ -1004,6 +1034,39 @@ export class SlidingSyncManager { }); } + /** + * Prefetch recently-visited rooms by subscribing to them immediately. + * This reduces the time between room navigation and timeline appearing, + * especially beneficial for rooms not in the initial sync window. + * + * Called after initial sync completes to warm up the cache for likely + * next-room-to-be-opened scenarios. + */ + public prefetchRecentRooms(): void { + if (this.disposed) return; + + const userId = this.mx.getUserId(); + if (!userId) return; + + const recentRoomIds = getRecentRoomIds(userId); + const toPrefetch = recentRoomIds.slice(0, 5); // Top 5 most recent + + if (toPrefetch.length === 0) return; + + debugLog.info('sync', 'Prefetching recent rooms', { + count: toPrefetch.length, + roomIds: toPrefetch, + }); + + for (const roomId of toPrefetch) { + // Only subscribe if room exists and not already subscribed + const room = this.mx.getRoom(roomId); + if (room && !this.activeRoomSubscriptions.has(roomId)) { + this.subscribeToRoom(roomId); + } + } + } + public static async probe( mx: MatrixClient, proxyBaseUrl: string,