Skip to content

Commit f846695

Browse files
committed
refactor: remove timeline pruning — unsubscribe on leave is sufficient
1 parent 67c66bb commit f846695

2 files changed

Lines changed: 16 additions & 230 deletions

File tree

src/client/slidingSync.test.ts

Lines changed: 16 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,17 @@
66
* running after the client is "stopped", leaking network traffic and
77
* event listeners.
88
*
9-
* 2. pruneRoomTimeline (via unsubscribeFromRoom) — when a room is explicitly
10-
* unsubscribed (e.g. user leaves the room), its in-memory event chain is
11-
* released if it exceeds PRUNE_TIMELINE_THRESHOLD. The recent tail is
12-
* persisted to IndexedDB (via store.setSyncData + store.save) so the events
13-
* survive an app reload; the full history is always available from the server.
9+
* 2. onMembershipLeave — when the MatrixClient emits a RoomMemberEvent.Membership
10+
* event indicating the local user left or was banned from a room that is
11+
* actively subscribed, unsubscribeFromRoom() should be called automatically.
1412
*
15-
* Note: navigation between rooms no longer calls unsubscribeFromRoom —
13+
* Note: navigation between rooms does not call unsubscribeFromRoom —
1614
* subscriptions accumulate across the session so returning to a room is
1715
* instant (matching Element Web's model).
18-
*
19-
* 3. onMembershipLeave — when the MatrixClient emits a RoomMemberEvent.Membership
20-
* event indicating the local user left or was banned from a room that is
21-
* actively subscribed, unsubscribeFromRoom() should be called automatically.
2216
*/
2317
import { describe, it, expect, vi, beforeEach } from 'vitest';
2418
import { SlidingSyncManager, type SlidingSyncConfig } from './slidingSync';
2519

26-
/** Flush all pending Promise microtasks. */
27-
const flushPromises = (): Promise<void> =>
28-
new Promise((r) => {
29-
setTimeout(r, 0);
30-
});
31-
3220
// ── vi.hoisted mocks ─────────────────────────────────────────────────────────
3321
// Must be defined via vi.hoisted so they're available before vi.mock runs
3422
// (vi.mock calls are hoisted above all imports by vitest's transformer).
@@ -86,33 +74,10 @@ function makeMockMx(overrides: Record<string, unknown> = {}) {
8674
on: vi.fn(),
8775
off: vi.fn(),
8876
removeListener: vi.fn(),
89-
store: {
90-
setSyncData: vi.fn().mockResolvedValue(undefined),
91-
save: vi.fn().mockResolvedValue(undefined),
92-
removeRoom: vi.fn(),
93-
},
9477
...overrides,
9578
} as unknown as import('$types/matrix-sdk').MatrixClient;
9679
}
9780

98-
function makeMockRoom(eventCount: number) {
99-
const events = Array.from({ length: eventCount }, (_, i) => ({
100-
getId: () => `$ev${i}`,
101-
event: { event_id: `$ev${i}`, type: 'm.room.message', content: {} },
102-
}));
103-
const resetLiveTimeline = vi.fn();
104-
return {
105-
getUnfilteredTimelineSet: vi.fn().mockReturnValue({
106-
getLiveTimeline: vi.fn().mockReturnValue({
107-
getEvents: vi.fn().mockReturnValue(events),
108-
getPaginationToken: vi.fn().mockReturnValue('t123'),
109-
}),
110-
resetLiveTimeline,
111-
}),
112-
_resetLiveTimeline: resetLiveTimeline,
113-
};
114-
}
115-
11681
function makeManager(mx: ReturnType<typeof makeMockMx>): SlidingSyncManager {
11782
const config: SlidingSyncConfig = {};
11883
return new SlidingSyncManager(mx, 'https://sliding.example.com', config);
@@ -132,90 +97,6 @@ describe('SlidingSyncManager.dispose()', () => {
13297
});
13398
});
13499

135-
// ── pruneRoomTimeline (exercised via unsubscribeFromRoom) ────────────────────
136-
137-
// This value must match PRUNE_TIMELINE_THRESHOLD in slidingSync.ts.
138-
const PRUNE_THRESHOLD = 100;
139-
140-
describe('SlidingSyncManager — timeline pruning on unsubscribe', () => {
141-
it('resets the live timeline when event count exceeds the threshold', () => {
142-
const room = makeMockRoom(PRUNE_THRESHOLD + 1);
143-
const mx = makeMockMx({ getRoom: vi.fn().mockReturnValue(room) });
144-
const manager = makeManager(mx);
145-
146-
manager.unsubscribeFromRoom('!room:example.com');
147-
148-
expect(room._resetLiveTimeline).toHaveBeenCalledOnce();
149-
});
150-
151-
it('persists events to store.setSyncData and store.save after pruning', async () => {
152-
const room = makeMockRoom(PRUNE_THRESHOLD + 1);
153-
const mx = makeMockMx({ getRoom: vi.fn().mockReturnValue(room) });
154-
const manager = makeManager(mx);
155-
156-
manager.unsubscribeFromRoom('!room:example.com');
157-
await flushPromises();
158-
159-
const { store } = mx as unknown as {
160-
store: { setSyncData: ReturnType<typeof vi.fn>; save: ReturnType<typeof vi.fn> };
161-
};
162-
expect(store.setSyncData).toHaveBeenCalledOnce();
163-
expect(store.save).toHaveBeenCalledWith(true);
164-
165-
// The payload must target the correct room and use limited:true so the
166-
// accumulator replaces any stale timeline rather than appending.
167-
const [payload] = store.setSyncData.mock.calls[0] as [
168-
{ rooms: { join: Record<string, { timeline: { limited: boolean } }> } },
169-
];
170-
expect(payload.rooms.join['!room:example.com'].timeline.limited).toBe(true);
171-
});
172-
173-
it('evicts room from store cache when persist fails', async () => {
174-
const room = makeMockRoom(PRUNE_THRESHOLD + 1);
175-
const mx = makeMockMx({ getRoom: vi.fn().mockReturnValue(room) });
176-
// Make setSyncData reject to simulate an IndexedDB write failure.
177-
(
178-
mx as unknown as { store: { setSyncData: ReturnType<typeof vi.fn> } }
179-
).store.setSyncData.mockRejectedValue(new Error('IndexedDB write failed'));
180-
const manager = makeManager(mx);
181-
182-
manager.unsubscribeFromRoom('!room:example.com');
183-
await flushPromises();
184-
185-
const { store } = mx as unknown as {
186-
store: { removeRoom: ReturnType<typeof vi.fn> };
187-
};
188-
expect(store.removeRoom).toHaveBeenCalledWith('!room:example.com');
189-
});
190-
191-
it('does not reset when event count equals the threshold exactly', () => {
192-
const room = makeMockRoom(PRUNE_THRESHOLD);
193-
const mx = makeMockMx({ getRoom: vi.fn().mockReturnValue(room) });
194-
const manager = makeManager(mx);
195-
196-
manager.unsubscribeFromRoom('!room:example.com');
197-
198-
expect(room._resetLiveTimeline).not.toHaveBeenCalled();
199-
});
200-
201-
it('does not reset for rooms with very few events', () => {
202-
const room = makeMockRoom(5);
203-
const mx = makeMockMx({ getRoom: vi.fn().mockReturnValue(room) });
204-
const manager = makeManager(mx);
205-
206-
manager.unsubscribeFromRoom('!room:example.com');
207-
208-
expect(room._resetLiveTimeline).not.toHaveBeenCalled();
209-
});
210-
211-
it('does not throw when the room is not yet in SDK state', () => {
212-
const mx = makeMockMx({ getRoom: vi.fn().mockReturnValue(null) });
213-
const manager = makeManager(mx);
214-
215-
expect(() => manager.unsubscribeFromRoom('!room:example.com')).not.toThrow();
216-
});
217-
});
218-
219100
// ── onMembershipLeave: auto-unsubscribe on leave/ban ─────────────────────────
220101

221102
describe('SlidingSyncManager — membership leave auto-unsubscribe', () => {
@@ -238,62 +119,58 @@ describe('SlidingSyncManager — membership leave auto-unsubscribe', () => {
238119
}
239120

240121
it('unsubscribes when the local user leaves an active room', () => {
241-
const room = makeMockRoom(PRUNE_THRESHOLD + 1);
242-
const mx = makeMockMx({ getRoom: vi.fn().mockReturnValue(room) });
122+
const mx = makeMockMx();
243123
const manager = makeManager(mx);
244124
manager.attach();
245-
// Manually add to active subscriptions to simulate having visited the room.
246125
manager.subscribeToRoom('!room:example.com');
247126

248127
fireMembershipEvent(mx, 'leave');
249128

250-
expect(room._resetLiveTimeline).toHaveBeenCalledOnce();
129+
// subscribeToRoom + unsubscribeFromRoom = 2 calls
130+
expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenCalledTimes(2);
251131
});
252132

253133
it('unsubscribes when the local user is banned from an active room', () => {
254-
const room = makeMockRoom(PRUNE_THRESHOLD + 1);
255-
const mx = makeMockMx({ getRoom: vi.fn().mockReturnValue(room) });
134+
const mx = makeMockMx();
256135
const manager = makeManager(mx);
257136
manager.attach();
258137
manager.subscribeToRoom('!room:example.com');
259138

260139
fireMembershipEvent(mx, 'ban');
261140

262-
expect(room._resetLiveTimeline).toHaveBeenCalledOnce();
141+
expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenCalledTimes(2);
263142
});
264143

265144
it('does nothing when a different user leaves', () => {
266-
const room = makeMockRoom(PRUNE_THRESHOLD + 1);
267-
const mx = makeMockMx({ getRoom: vi.fn().mockReturnValue(room) });
145+
const mx = makeMockMx();
268146
const manager = makeManager(mx);
269147
manager.attach();
270148
manager.subscribeToRoom('!room:example.com');
271149

272150
fireMembershipEvent(mx, 'leave', '!room:example.com', '@other:example.com');
273151

274-
expect(room._resetLiveTimeline).not.toHaveBeenCalled();
152+
// Only the initial subscribe — no unsubscribe
153+
expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenCalledTimes(1);
275154
});
276155

277156
it('does nothing when membership is join', () => {
278-
const room = makeMockRoom(PRUNE_THRESHOLD + 1);
279-
const mx = makeMockMx({ getRoom: vi.fn().mockReturnValue(room) });
157+
const mx = makeMockMx();
280158
const manager = makeManager(mx);
281159
manager.attach();
282160
manager.subscribeToRoom('!room:example.com');
283161

284162
fireMembershipEvent(mx, 'join');
285163

286-
expect(room._resetLiveTimeline).not.toHaveBeenCalled();
164+
expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenCalledTimes(1);
287165
});
288166

289167
it('does nothing for a room that was never subscribed', () => {
290-
const room = makeMockRoom(PRUNE_THRESHOLD + 1);
291-
const mx = makeMockMx({ getRoom: vi.fn().mockReturnValue(room) });
168+
const mx = makeMockMx();
292169
const manager = makeManager(mx);
293170
manager.attach(); // registers the listener, but no subscribeToRoom call
294171

295172
fireMembershipEvent(mx, 'leave');
296173

297-
expect(room._resetLiveTimeline).not.toHaveBeenCalled();
174+
expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).not.toHaveBeenCalled();
298175
});
299176
});

src/client/slidingSync.ts

Lines changed: 0 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
11
/* eslint-disable max-classes-per-file */
22
import {
33
ClientEvent,
4-
EventTimeline,
54
Extension,
65
ExtensionState,
7-
IJoinedRoom,
8-
IRoomEvent,
9-
ISyncResponse,
106
KnownMembership,
117
MatrixClient,
128
MSC3575List,
@@ -55,8 +51,6 @@ const UNENCRYPTED_SUBSCRIPTION_KEY = 'unencrypted';
5551
// Timeline limit for the active-room subscription (full history load).
5652
// List entries always use LIST_TIMELINE_LIMIT=1 for lightweight previews.
5753
const ACTIVE_ROOM_TIMELINE_LIMIT = 50;
58-
// Events accumulated in a room before its timeline is freed on explicit unsubscribe.
59-
const PRUNE_TIMELINE_THRESHOLD = 100;
6054

6155
export type PartialSlidingSyncRequest = {
6256
filters?: MSC3575List['filters'];
@@ -542,90 +536,6 @@ export class SlidingSyncManager {
542536
});
543537
}
544538

545-
/**
546-
* Reset the live timeline for a room that is no longer actively viewed,
547-
* freeing its in-memory event chain. Only fires when the room has accumulated
548-
* more than PRUNE_TIMELINE_THRESHOLD events.
549-
*
550-
* The timeline is reset synchronously so memory is freed immediately. Then,
551-
* as a best-effort fire-and-forget step, the snapshot of recent events is
552-
* fed into the SyncAccumulator and flushed to IndexedDB so they survive an
553-
* app reload (the accumulator caps storage at ~50 events per room; the full
554-
* history is always available from the server).
555-
*/
556-
private pruneRoomTimeline(roomId: string): void {
557-
const room = this.mx.getRoom(roomId);
558-
if (!room) return;
559-
const tl = room.getUnfilteredTimelineSet().getLiveTimeline();
560-
const events = tl.getEvents();
561-
if (events.length <= PRUNE_TIMELINE_THRESHOLD) return;
562-
563-
// Capture the back-pagination token before resetting the timeline.
564-
const prevBatch = tl.getPaginationToken(EventTimeline.BACKWARDS);
565-
566-
// Free the in-memory event chain immediately.
567-
room.getUnfilteredTimelineSet().resetLiveTimeline();
568-
569-
// Persist the tail of the timeline to IndexedDB asynchronously.
570-
// On failure, evict the room from the store so the next open gets a clean
571-
// server fetch rather than potentially stale cached data.
572-
this.persistRoomTimeline(
573-
roomId,
574-
events.map((e) => e.event as IRoomEvent),
575-
prevBatch
576-
).catch((err: unknown) => {
577-
debugLog.warn('timeline', 'Failed to persist pruned room timeline — evicting store cache', {
578-
roomId,
579-
err,
580-
});
581-
this.mx.store.removeRoom(roomId);
582-
});
583-
584-
debugLog.info('timeline', 'Pruned room timeline from memory', {
585-
roomId,
586-
threshold: PRUNE_TIMELINE_THRESHOLD,
587-
});
588-
}
589-
590-
/**
591-
* Persist a snapshot of room timeline events to the IndexedDB store via a
592-
* synthetic /sync payload. This feeds the SyncAccumulator (which the SDK
593-
* uses for both start-up replay and classic sync persistence) so that the
594-
* last ~50 events are available on the next app load without a server round-
595-
* trip. The sliding sync layer never calls setSyncData itself, so this is
596-
* the only path by which sliding-sync rooms get on-disk history.
597-
*/
598-
private async persistRoomTimeline(
599-
roomId: string,
600-
rawEvents: IRoomEvent[],
601-
prevBatch: string | null
602-
): Promise<void> {
603-
const payload: ISyncResponse = {
604-
next_batch: '',
605-
account_data: { events: [] },
606-
rooms: {
607-
join: {
608-
[roomId]: {
609-
summary: {},
610-
timeline: {
611-
events: rawEvents,
612-
limited: true,
613-
prev_batch: prevBatch,
614-
},
615-
ephemeral: { events: [] },
616-
account_data: { events: [] },
617-
unread_notifications: {},
618-
} as unknown as IJoinedRoom,
619-
},
620-
invite: {},
621-
leave: {},
622-
knock: {},
623-
},
624-
};
625-
await this.mx.store.setSyncData(payload);
626-
await this.mx.store.save(true);
627-
}
628-
629539
public setPresenceEnabled(enabled: boolean): void {
630540
this.presenceExtension.setEnabled(enabled);
631541
}
@@ -1036,7 +946,6 @@ export class SlidingSyncManager {
1036946
remainingSubscriptions: this.activeRoomSubscriptions.size,
1037947
syncCycle: this.syncCount,
1038948
});
1039-
this.pruneRoomTimeline(roomId);
1040949
}
1041950

1042951
public static async probe(

0 commit comments

Comments
 (0)