Skip to content
Merged
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/perf-sliding-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'default': minor
---

Optimize sliding sync with progressive loading and improved timeline management
58 changes: 33 additions & 25 deletions src/app/features/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -896,6 +896,24 @@ export function RoomTimeline({
}, [])
);

// When historical events load (e.g., from active subscription), stay at bottom
// by adjusting the range. The virtual paginator expects the range to match the
// position we want to display. Without this, loading more history makes it look
// like we've scrolled up because the range (0, 10) is now showing the old events
// instead of the latest ones.
useEffect(() => {
if (atBottom && liveTimelineLinked && eventsLength > timeline.range.end) {
// More events exist than our current range shows. Adjust to stay at bottom.
setTimeline((ct) => ({
...ct,
range: {
start: Math.max(eventsLength - PAGINATION_LIMIT, 0),
end: eventsLength,
},
}));
}
}, [atBottom, liveTimelineLinked, eventsLength, timeline.range.end]);

// Recover from transient empty timeline state when the live timeline
// already has events (can happen when opening by event id, then fallbacking).
useEffect(() => {
Expand All @@ -905,21 +923,6 @@ export function RoomTimeline({
setTimeline(getInitialTimeline(room));
}, [eventId, room, timeline.linkedTimelines.length]);

// Fix stale rangeAtEnd after a sliding sync TimelineRefresh. The SDK fires
// TimelineRefresh before adding new events to the freshly-created live
// EventTimeline, so getInitialTimeline captures range.end=0. New events then
// arrive via useLiveEventArrive, but its atLiveEndRef guard is stale-false
// (hasn't re-rendered yet), bypassing the range-advance path. The next render
// ends up with liveTimelineLinked=true but rangeAtEnd=false, making the
// "Jump to Latest" button appear while the user is already at the bottom.
// Re-running getInitialTimeline post-render (after events were added to the
// live EventTimeline object) snaps range.end to the correct event count.
useEffect(() => {
if (liveTimelineLinked && !rangeAtEnd && atBottom) {
setTimeline(getInitialTimeline(room));
}
}, [liveTimelineLinked, rangeAtEnd, atBottom, room]);

// Stay at bottom when room editor resize
useResizeObserver(
useMemo(() => {
Expand Down Expand Up @@ -1111,16 +1114,21 @@ export function RoomTimeline({
const scrollEl = scrollRef.current;
if (scrollEl) {
const behavior = scrollToBottomRef.current.smooth && !reducedMotion ? 'smooth' : 'instant';
scrollToBottom(scrollEl, behavior);
// On Android WebView, layout may still settle after the initial scroll.
// Fire a second instant scroll after a short delay to guarantee we
// reach the true bottom (e.g. after images finish loading or the
// virtual keyboard shifts the viewport).
if (behavior === 'instant') {
setTimeout(() => {
scrollToBottom(scrollEl, 'instant');
}, 80);
}
// Use requestAnimationFrame to ensure the virtual paginator has finished
// updating the DOM before we scroll. This prevents scroll position from
// being stale when new messages arrive while at the bottom.
requestAnimationFrame(() => {
scrollToBottom(scrollEl, behavior);
// On Android WebView, layout may still settle after the initial scroll.
// Fire a second instant scroll after a short delay to guarantee we
// reach the true bottom (e.g. after images finish loading or the
// virtual keyboard shifts the viewport).
if (behavior === 'instant') {
setTimeout(() => {
scrollToBottom(scrollEl, 'instant');
}, 80);
}
});
}
}
}, [scrollToBottomCount, reducedMotion]);
Expand Down
17 changes: 16 additions & 1 deletion src/app/hooks/useSlidingSyncActiveRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,23 @@ export const useSlidingSyncActiveRoom = (): void => {
const manager = getSlidingSyncManager(mx);
if (!manager) return undefined;

manager.subscribeToRoom(roomId);
// Wait for the room to be initialized from list sync before subscribing
// with high timeline limit. This prevents timeline ordering issues where
// the room might be receiving events from list expansion while we're also
// trying to load a large timeline, causing events to be added out of order.
const timeoutId = setTimeout(() => {
const room = mx.getRoom(roomId);
if (room) {
// Room exists and has been initialized from list sync
manager.subscribeToRoom(roomId);
} else {
// Room not in cache yet - subscribe anyway (will use default encrypted subscription)
manager.subscribeToRoom(roomId);
}
}, 100);

return () => {
clearTimeout(timeoutId);
manager.unsubscribeFromRoom(roomId);
};
}, [mx, roomId]);
Expand Down
55 changes: 47 additions & 8 deletions src/client/slidingSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@ const UNENCRYPTED_SUBSCRIPTION_KEY = 'unencrypted';
// Adaptive timeline limits for the room the user is actively viewing.
// Lower limits reduce initial bandwidth on constrained devices/connections;
// the user can always paginate further once the room is open.
const ACTIVE_ROOM_TIMELINE_LIMIT_LOW = 20;
const ACTIVE_ROOM_TIMELINE_LIMIT_MEDIUM = 35;
const ACTIVE_ROOM_TIMELINE_LIMIT_HIGH = 50;
// These values must be high enough to ensure proper timeline initialization and pagination tokens.
const ACTIVE_ROOM_TIMELINE_LIMIT_LOW = 50;
const ACTIVE_ROOM_TIMELINE_LIMIT_MEDIUM = 100;
const ACTIVE_ROOM_TIMELINE_LIMIT_HIGH = 150;

export type PartialSlidingSyncRequest = {
filters?: MSC3575List['filters'];
Expand Down Expand Up @@ -201,8 +202,13 @@ const buildLists = (pageSize: number, includeInviteList: boolean): Map<string, M
const lists = new Map<string, MSC3575List>();
const listRequiredState = buildListRequiredState();

// Start with a reasonable initial range that will quickly expand to full list
// Since timeline_limit=1, loading many rooms is very cheap
// This prevents the white page issue from progressive loading delays
const initialRange = Math.min(pageSize, 100);

lists.set(LIST_JOINED, {
ranges: [[0, Math.max(0, pageSize - 1)]],
ranges: [[0, Math.max(0, initialRange - 1)]],
sort: LIST_SORT_ORDER,
timeline_limit: LIST_TIMELINE_LIMIT,
required_state: listRequiredState,
Expand All @@ -212,7 +218,7 @@ const buildLists = (pageSize: number, includeInviteList: boolean): Map<string, M

if (includeInviteList) {
lists.set(LIST_INVITES, {
ranges: [[0, Math.max(0, pageSize - 1)]],
ranges: [[0, Math.max(0, initialRange - 1)]],
sort: LIST_SORT_ORDER,
timeline_limit: LIST_TIMELINE_LIMIT,
required_state: listRequiredState,
Expand All @@ -222,7 +228,7 @@ const buildLists = (pageSize: number, includeInviteList: boolean): Map<string, M
}

lists.set(LIST_DMS, {
ranges: [[0, Math.max(0, pageSize - 1)]],
ranges: [[0, Math.max(0, initialRange - 1)]],
sort: LIST_SORT_ORDER,
timeline_limit: LIST_TIMELINE_LIMIT,
required_state: listRequiredState,
Expand Down Expand Up @@ -308,6 +314,8 @@ export class SlidingSyncManager {

private presenceExtension!: ExtensionPresence;

private listsFullyLoaded = false;

public readonly slidingSync: SlidingSync;

public readonly probeTimeoutMs: number;
Expand Down Expand Up @@ -441,23 +449,54 @@ export class SlidingSyncManager {
}

private expandListsToKnownCount(): void {
// Stop expanding once we've loaded all rooms - prevents continuous updates
if (this.listsFullyLoaded) return;

let allListsComplete = true;
let expandedAny = false;

this.listKeys.forEach((key) => {
const listData = this.slidingSync.getListData(key);
const knownCount = listData?.joinedCount ?? 0;
if (knownCount <= 0) return;

const desiredEnd = Math.min(knownCount, this.maxRooms) - 1;
const existing = this.slidingSync.getListParams(key);
const currentEnd = getListEndIndex(existing);
if (desiredEnd === currentEnd) return;

// Calculate how many rooms we still need to load
const maxEnd = Math.min(knownCount, this.maxRooms) - 1;

if (currentEnd >= maxEnd) {
// This list is fully loaded
return;
}

allListsComplete = false;

// Progressive expansion: load in moderate chunks to balance speed with stability
// Chunk size reduced to 100 to prevent timeline ordering issues when opening rooms
// while lists are still expanding. Rooms should get at least one clean sync from
// their list before the active subscription requests a high timeline limit.
const chunkSize = 100;
const desiredEnd = Math.min(currentEnd + chunkSize, maxEnd);

this.slidingSync.setListRanges(key, [[0, desiredEnd]]);
expandedAny = true;

if (knownCount > this.maxRooms) {
log.warn(
`Sliding Sync list "${key}" capped at ${this.maxRooms}/${knownCount} rooms for ${this.mx.getUserId()}`
);
}
});

// Mark as fully loaded once all lists are complete
if (allListsComplete) {
this.listsFullyLoaded = true;
log.log(`Sliding Sync all lists fully loaded for ${this.mx.getUserId()}`);
} else if (expandedAny) {
log.log(`Sliding Sync lists expanding... for ${this.mx.getUserId()}`);
}
}

/**
Expand Down
Loading