diff --git a/.changeset/perf-sliding-sync.md b/.changeset/perf-sliding-sync.md new file mode 100644 index 000000000..48dd8aa39 --- /dev/null +++ b/.changeset/perf-sliding-sync.md @@ -0,0 +1,5 @@ +--- +'default': minor +--- + +Optimize sliding sync with progressive loading and improved timeline management diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 84c9e0108..cbfe8c2fb 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -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(() => { @@ -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(() => { @@ -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]); diff --git a/src/app/hooks/useSlidingSyncActiveRoom.ts b/src/app/hooks/useSlidingSyncActiveRoom.ts index f138a6148..a76efe6f8 100644 --- a/src/app/hooks/useSlidingSyncActiveRoom.ts +++ b/src/app/hooks/useSlidingSyncActiveRoom.ts @@ -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]); diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index f2c9257dc..eacb7a472 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -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']; @@ -201,8 +202,13 @@ const buildLists = (pageSize: number, includeInviteList: boolean): Map(); 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, @@ -212,7 +218,7 @@ const buildLists = (pageSize: number, includeInviteList: boolean): Map { 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()}`); + } } /**