From 3190855dca1f35eb9cfb4ec560b7375ba2161bf3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 13:07:04 -0400 Subject: [PATCH 1/5] feat(slidingSync): prefetch recently-visited rooms on sync complete (P3) Tracks room visits in localStorage (max 10 per user) and subscribes to top 5 most recent rooms immediately after initial sync completes. Reduces perceived navigation latency by warming the cache for likely next-room-to-be-opened scenarios. New utilities: - getRecentRoomIds() - retrieve recent rooms for user - addRecentRoom() - track room visit (LRU order) - clearRecentRooms() - cleanup on logout Implementation: - SlidingSyncManager.prefetchRecentRooms() called after initialSyncCompleted - useSlidingSyncActiveRoom() tracks visits automatically - Only subscribes if room exists and not already subscribed Performance impact: - Top 5 recent rooms load faster on navigation - No-op for rooms already subscribed - Minimal overhead (localStorage read once per sync cycle) --- src/app/hooks/useSlidingSyncActiveRoom.ts | 10 +++ src/app/utils/recentRooms.ts | 76 +++++++++++++++++++++++ src/client/slidingSync.ts | 66 +++++++++++++++++++- 3 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 src/app/utils/recentRooms.ts 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..51b8c06e8 --- /dev/null +++ b/src/app/utils/recentRooms.ts @@ -0,0 +1,76 @@ +/** + * 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: RecentRoomsStore = JSON.parse(stored); + return data[userId] ?? []; + } 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..186d9a209 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'); @@ -375,7 +376,34 @@ export class SlidingSyncManager { 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 +>>>>>>> 5406e00b2 (feat(slidingSync): prefetch recently-visited rooms on sync complete (P3)) timelineSet.resetLiveTimeline(); }); } @@ -439,6 +467,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 +1035,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, From 0c77953e2f0fdf37bda521c785422ca5466155f9 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 14:25:11 -0400 Subject: [PATCH 2/5] chore: add changeset --- .changeset/sliding-sync-prefetch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sliding-sync-prefetch.md 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. From 2d6395d8451bd41877bd98004c15bbf8ded07e69 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 15:39:44 -0400 Subject: [PATCH 3/5] fix(sliding-sync): remove stray merge conflict marker in slidingSync.ts The >>>>>>> end-marker from commit 5406e00b2 was left without its corresponding <<<<<<< / ======= markers, causing TS1185, lint, build, and test failures. --- src/client/slidingSync.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 186d9a209..6c35e4309 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -403,7 +403,6 @@ export class SlidingSyncManager { } // No overlap: local events are stale, reset needed ->>>>>>> 5406e00b2 (feat(slidingSync): prefetch recently-visited rooms on sync complete (P3)) timelineSet.resetLiveTimeline(); }); } From 899170670be6a48feab0fdf6bfa10d7c05f6d104 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 18:19:03 -0400 Subject: [PATCH 4/5] fix(sliding-sync): pass roomData into forEach to fix implicit-any typecheck errors --- src/client/slidingSync.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 6c35e4309..85204368c 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -372,7 +372,7 @@ 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(); From 1eadeab1aad8054bced596a3f19701f3b000c978 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 20:06:38 -0400 Subject: [PATCH 5/5] fix(sliding-sync-prefetch): validate localStorage payload in getRecentRoomIds Copilot review: add Array.isArray + string entry validation so malformed or tampered localStorage data cannot cause type errors or pass non-string values downstream. --- src/app/utils/recentRooms.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/utils/recentRooms.ts b/src/app/utils/recentRooms.ts index 51b8c06e8..8be6da4d6 100644 --- a/src/app/utils/recentRooms.ts +++ b/src/app/utils/recentRooms.ts @@ -17,8 +17,11 @@ export function getRecentRoomIds(userId: string): string[] { const stored = localStorage.getItem(RECENT_ROOMS_KEY); if (!stored) return []; - const data: RecentRoomsStore = JSON.parse(stored); - return data[userId] ?? []; + 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 []; }