diff --git a/.changeset/fix-sidebar-cache-prune-stale-rooms.md b/.changeset/fix-sidebar-cache-prune-stale-rooms.md new file mode 100644 index 0000000000..78b6165474 --- /dev/null +++ b/.changeset/fix-sidebar-cache-prune-stale-rooms.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix sidebar briefly showing rooms left on another device when loading with sliding sync. diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index eba55eea16..ff297de929 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -37,6 +37,7 @@ export const LIST_SPACE = 'space'; const LIST_TIMELINE_LIMIT = 1; const LIST_PAGE_SIZE = 30; const STEADY_STATE_DETAILED_ROOMS = 3; +const JOINED_ROOMS_HYDRATION_TIMEOUT_MS = 2000; const DEFAULT_POLL_TIMEOUT_MS = 45000; const LIST_SORT_ORDER = ['by_recency', 'by_name']; @@ -268,6 +269,8 @@ export class SlidingSyncManager { private sidebarCacheReconciliationQueued = false; + private joinedRoomIdsPromise: Promise | undefined> | undefined; + private readonly serverMembershipRoomIds = new Set(); private initialSyncCompleted = false; @@ -611,14 +614,9 @@ export class SlidingSyncManager { void this.cacheHydrationPromise.then(async () => { if (this.disposed || this.sidebarCacheReconciled) return; - let joinedRoomIds: string[]; - try { - const response = await this.mx.getJoinedRooms(); - joinedRoomIds = response.joined_rooms; - } catch (error) { - debugLog.warn('sync', 'Skipped sidebar cache reconciliation: joined rooms unavailable', { - error: error instanceof Error ? error.message : String(error), - }); + const joinedRoomIds = await this.getJoinedRoomIds(); + if (!joinedRoomIds) { + debugLog.warn('sync', 'Skipped sidebar cache reconciliation: joined rooms unavailable'); return; } @@ -636,7 +634,7 @@ export class SlidingSyncManager { if (removedRoomIds.length > 0) { debugLog.info('sync', 'Removed stale rooms from the sliding sync sidebar cache', { removedRoomCount: removedRoomIds.length, - joinedRoomCount: joinedRoomIds.length, + joinedRoomCount: joinedRoomIds.size, observedRoomCount: this.serverMembershipRoomIds.size, }); } @@ -663,6 +661,9 @@ export class SlidingSyncManager { public prepareSidebarCacheHydration(): void { if (this.disposed || this.cacheHydrationNewListener) return; + // Start the joined-rooms fetch now so it's ready before hydration prunes. + if (this.sidebarCache.size > 0) void this.getJoinedRoomIds(); + this.cacheHydrationPromise = new Promise((resolve) => { this.cacheHydrationResolve = resolve; }); @@ -677,8 +678,8 @@ export class SlidingSyncManager { globalThis.queueMicrotask(() => { this.hydratingSidebarCache = true; - void this.sidebarCache - .hydrate(this.mx, this.slidingSync) + void this.pruneStaleCachedRoomsBeforeHydration() + .then(() => this.sidebarCache.hydrate(this.mx, this.slidingSync)) .then((hydrated) => this.cacheHydrationResolve?.(hydrated)) .catch(() => this.cacheHydrationResolve?.(false)) .finally(() => { @@ -691,6 +692,35 @@ export class SlidingSyncManager { this.slidingSync.on(EventEmitterEvents.NewListener, this.cacheHydrationNewListener as never); } + private getJoinedRoomIds(): Promise | undefined> { + if (!this.joinedRoomIdsPromise) { + this.joinedRoomIdsPromise = this.mx + .getJoinedRooms() + .then((response) => new Set(response.joined_rooms)) + .catch((error) => { + debugLog.warn('sync', 'Failed to fetch joined rooms for sidebar cache', { + error: error instanceof Error ? error.message : String(error), + }); + return undefined; + }); + } + return this.joinedRoomIdsPromise; + } + + // Prune left-elsewhere rooms before hydration so stale spaces never render. + // No-op if joined rooms are slow/unavailable; reconciliation cleans up later. + private async pruneStaleCachedRoomsBeforeHydration(): Promise { + if (this.sidebarCache.size === 0) return; + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = globalThis.setTimeout(() => resolve(undefined), JOINED_ROOMS_HYDRATION_TIMEOUT_MS); + }); + const joinedRoomIds = await Promise.race([this.getJoinedRoomIds(), timeout]); + globalThis.clearTimeout(timer); + if (!joinedRoomIds || this.disposed) return; + this.sidebarCache.pruneToJoined(joinedRoomIds); + } + public waitForSidebarCacheHydration(): Promise { return this.cacheHydrationPromise; } diff --git a/src/client/slidingSyncSidebarCache.test.ts b/src/client/slidingSyncSidebarCache.test.ts index f288a87f04..93a88c2f66 100644 --- a/src/client/slidingSyncSidebarCache.test.ts +++ b/src/client/slidingSyncSidebarCache.test.ts @@ -95,6 +95,43 @@ describe('SlidingSyncSidebarCache', () => { ); }); + it('prunes joined rooms absent from the joined set but keeps pending invites', async () => { + const cache = new SlidingSyncSidebarCache(userId); + cache.cacheRoom('!joined:example.com', { + name: 'Still Joined', + required_state: [stateEvent(EventType.RoomMember, userId, { membership: 'join' })], + timeline: [], + } as MSC3575RoomData); + cache.cacheRoom('!left:example.com', { + name: 'Left Elsewhere', + required_state: [stateEvent(EventType.RoomMember, userId, { membership: 'join' })], + timeline: [], + } as MSC3575RoomData); + cache.cacheRoom('!invited:example.com', { + name: 'Pending Invite', + required_state: [], + invite_state: [stateEvent(EventType.RoomMember, userId, { membership: 'invite' })], + timeline: [], + } as MSC3575RoomData); + + cache.pruneToJoined(new Set(['!joined:example.com'])); + + const emitPromised = vi + .fn<(event: SlidingSyncEvent, roomId: string, data: MSC3575RoomData) => Promise>() + .mockResolvedValue(true); + await cache.hydrate( + { + store: { storeAccountDataEvents: vi.fn<(events: MatrixEvent[]) => void>() }, + } as unknown as MatrixClient, + { emitPromised } as unknown as SlidingSync + ); + + const hydratedRoomIds = emitPromised.mock.calls.map((call) => call[1]); + expect(hydratedRoomIds).toContain('!joined:example.com'); + expect(hydratedRoomIds).toContain('!invited:example.com'); + expect(hydratedRoomIds).not.toContain('!left:example.com'); + }); + it('merges later state into the existing cached room snapshot', async () => { const cache = new SlidingSyncSidebarCache(userId); cache.cacheRoom('!room:example.com', { diff --git a/src/client/slidingSyncSidebarCache.ts b/src/client/slidingSyncSidebarCache.ts index 4aad4638d4..5c95fc73ad 100644 --- a/src/client/slidingSyncSidebarCache.ts +++ b/src/client/slidingSyncSidebarCache.ts @@ -89,6 +89,8 @@ const mergeRoomData = ( prev_batch: undefined, }); +const isInviteRoomData = (data: MSC3575RoomData): boolean => (data.invite_state?.length ?? 0) > 0; + const parseCache = (value: string | null): SidebarCacheData => { if (!value) return emptyCache(); try { @@ -144,6 +146,20 @@ export class SlidingSyncSidebarCache { this.data = parseCache(stored); } + public get size(): number { + return Object.keys(this.data.rooms).length; + } + + // Drops rooms left on another device while offline; keeps pending invites. + public pruneToJoined(joinedRoomIds: ReadonlySet): void { + const stale = Object.entries(this.data.rooms) + .filter(([roomId, data]) => !joinedRoomIds.has(roomId) && !isInviteRoomData(data)) + .map(([roomId]) => roomId); + if (stale.length === 0) return; + stale.forEach((roomId) => delete this.data.rooms[roomId]); + this.scheduleWrite(); + } + public cacheRoom(roomId: string, roomData: MSC3575RoomData): void { this.data.rooms[roomId] = mergeRoomData(this.data.rooms[roomId], roomData, this.userId); this.scheduleWrite();