Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sliding-sync-prefetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Prefetch recently-visited rooms on sliding sync complete.
10 changes: 10 additions & 0 deletions src/app/hooks/useSlidingSyncActiveRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]);
};
79 changes: 79 additions & 0 deletions src/app/utils/recentRooms.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>;

/**
* 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<string, unknown>)[userId];
if (!Array.isArray(userRooms)) return [];
return userRooms.filter((id): id is string => typeof id === 'string');
} catch {
Comment thread
Just-Insane marked this conversation as resolved.
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
}
}
67 changes: 65 additions & 2 deletions src/client/slidingSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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();
});
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
Loading