Skip to content

Commit 98d182b

Browse files
committed
fix(timeline): reset live timeline on room re-navigation to prevent stale linked chain
When the user navigates away from a room, the list subscription may deliver a limited:true response that appends a gap to the existing paginated chain (T0\u2192T1\u2192T2\u2192live). On re-navigation the server does not resend initial:true (room was already delivered via list), so onInitialRoomData never fired and the stale chain was rendered in useTimelineSync, causing events to appear out of order or anchored at the wrong position. Fix: subscribeToRoom() adds the room to a pendingTimelineResets Set when it has an existing timeline. onInitialRoomData (registered before SlidingSyncSdk in attach(), so it fires first) checks the set and calls resetLiveTimeline() before the active-room snapshot events are processed. Both operations land in the same React render cycle, so there is no blank-screen window between the reset and the fresh 50-event pa When the user cd /Users/evie/git/Sable && cat > /tmp/commit_msg.txt << 'COMMIT_EOF' fix(timeline): reset live timeline on room re-navigation to prevent stale linked chain When the user navigates away from a room, the list subscription may deliver a limited:true response that appends a gap to the existing paginated chain (T0->T1->T2->live). On re-navigation the server does not resend initial:true (room was already delivered via list), so onInitialRoomData never fired and the stale chain was rendered in useTimelineSync, causing events to appear out of order or anchored at the wrong position. Fix: subscribeToRoom() adds the room to a pendingTimelineResets Set when it has an existing timeline. onInitialRoomData (registered before SlidingSyncSdk in attach(), so it fires first) checks the set and calls resetLiveTimeline() before the active-room snapshot events are processed. Both operations land in the same React render cycle, so there is no blank-screen window between the reset and the fresh 50-event page appearing. COMMIT_Efix(timeline): reset live timeline on room re-navigation to prevent s.t Wh q' git status
1 parent 3146a71 commit 98d182b

1 file changed

Lines changed: 49 additions & 7 deletions

File tree

src/client/slidingSync.ts

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,14 @@ export class SlidingSyncManager {
244244

245245
private readonly activeRoomSubscriptions = new Set<string>();
246246

247+
/**
248+
* Rooms that subscribeToRoom() has marked as needing a live-timeline reset
249+
* on the next RoomData event. Consumed by onInitialRoomData before the SDK
250+
* processes the incoming events, so the reset and new events land in the
251+
* same React render (no blank-screen window).
252+
*/
253+
private readonly pendingTimelineResets = new Set<string>();
254+
247255
private readonly listPageSize: number;
248256

249257
private readonly roomTimelineLimit: number;
@@ -432,15 +440,39 @@ export class SlidingSyncManager {
432440
}
433441
};
434442

435-
// Reset the live timeline when the server re-sends initial:true (reconnect after
436-
// pos token expiry). Must be assigned in the constructor so the reference is stable
437-
// for removeListener(); registered in attach() before mx.startClient() so it fires
438-
// ahead of SlidingSyncSdk.onRoomData.
443+
// Fires before SlidingSyncSdk.onRoomData (registered in attach() ahead of
444+
// mx.startClient()) so any timeline resets happen before events are added.
445+
// Must be assigned in the constructor so the reference is stable for removeListener().
446+
//
447+
// Handles two cases:
448+
// 1. Reconnect: server re-sends initial:true for all rooms after pos token expiry.
449+
// The live timeline may have since drifted; reset it so the fresh snapshot lands
450+
// cleanly without a stale linked chain.
451+
// 2. Room navigation: subscribeToRoom() adds the room to pendingTimelineResets when
452+
// the user actively navigates there. The server may not resend initial:true if the
453+
// room was already delivered via list subscription, leaving old paginated timelines
454+
// (T0→T1→T2→live) with a limited:true gap. This handler fires before the 50-event
455+
// active-room snapshot is processed, so the reset and new events land in the same
456+
// React render cycle with no blank-screen window.
439457
this.onInitialRoomData = (roomId: string, roomData: MSC3575RoomData) => {
440-
if (!roomData.initial) return;
441458
const room = this.mx.getRoom(roomId);
442-
if (!room || room.getLiveTimeline().getEvents().length === 0) return;
443-
room.getUnfilteredTimelineSet().resetLiveTimeline();
459+
if (!room) return;
460+
461+
// Case 1: reconnect — server re-delivers initial:true for all known rooms.
462+
if (roomData.initial) {
463+
if (room.getLiveTimeline().getEvents().length > 0) {
464+
room.getUnfilteredTimelineSet().resetLiveTimeline();
465+
}
466+
return;
467+
}
468+
469+
// Case 2: room navigation — subscribeToRoom() marked this room for reset.
470+
if (this.pendingTimelineResets.has(roomId)) {
471+
this.pendingTimelineResets.delete(roomId);
472+
if (room.getLiveTimeline().getEvents().length > 0) {
473+
room.getUnfilteredTimelineSet().resetLiveTimeline();
474+
}
475+
}
444476
};
445477

446478
this.onMembershipLeave = (_event, member) => {
@@ -890,6 +922,14 @@ export class SlidingSyncManager {
890922
// encrypted default.
891923
this.slidingSync.useCustomSubscription(roomId, UNENCRYPTED_SUBSCRIPTION_KEY);
892924
}
925+
// Mark the room for a live-timeline reset on the next RoomData event. The
926+
// reset is deferred (handled by onInitialRoomData which fires before the
927+
// SDK processes events) so it happens in the same sync cycle as the new
928+
// events, avoiding a blank-screen window. See onInitialRoomData for the
929+
// full explanation of why a reset is required here.
930+
if (room && room.getLiveTimeline().getEvents().length > 0) {
931+
this.pendingTimelineResets.add(roomId);
932+
}
893933
this.activeRoomSubscriptions.add(roomId);
894934
this.slidingSync.modifyRoomSubscriptions(new Set(this.activeRoomSubscriptions));
895935
Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, {
@@ -959,6 +999,8 @@ export class SlidingSyncManager {
959999
this.slidingSync.removeListener(SlidingSyncEvent.RoomData, pendingListener);
9601000
this.pendingRoomDataListeners.delete(roomId);
9611001
}
1002+
// Discard any deferred reset so it doesn't fire if the room is re-subscribed later.
1003+
this.pendingTimelineResets.delete(roomId);
9621004
this.activeRoomSubscriptions.delete(roomId);
9631005
this.slidingSync.modifyRoomSubscriptions(new Set(this.activeRoomSubscriptions));
9641006
Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, {

0 commit comments

Comments
 (0)