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/fix-sidebar-cache-prune-stale-rooms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix sidebar briefly showing rooms left on another device when loading with sliding sync.
52 changes: 41 additions & 11 deletions src/client/slidingSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -268,6 +269,8 @@ export class SlidingSyncManager {

private sidebarCacheReconciliationQueued = false;

private joinedRoomIdsPromise: Promise<Set<string> | undefined> | undefined;

private readonly serverMembershipRoomIds = new Set<string>();

private initialSyncCompleted = false;
Expand Down Expand Up @@ -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;
}

Expand All @@ -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,
});
}
Expand All @@ -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<boolean>((resolve) => {
this.cacheHydrationResolve = resolve;
});
Expand All @@ -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(() => {
Expand All @@ -691,6 +692,35 @@ export class SlidingSyncManager {
this.slidingSync.on(EventEmitterEvents.NewListener, this.cacheHydrationNewListener as never);
}

private getJoinedRoomIds(): Promise<Set<string> | 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<void> {
if (this.sidebarCache.size === 0) return;
let timer: ReturnType<typeof globalThis.setTimeout> | undefined;
const timeout = new Promise<undefined>((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<boolean> {
return this.cacheHydrationPromise;
}
Expand Down
37 changes: 37 additions & 0 deletions src/client/slidingSyncSidebarCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>>()
.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', {
Expand Down
16 changes: 16 additions & 0 deletions src/client/slidingSyncSidebarCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string>): 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();
Expand Down
Loading