From 5e9c3603202b490a64866cb4f665f2fb7171dfc2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 18:14:01 -0400 Subject: [PATCH 1/6] perf(timeline): restore VList cache + skip 80 ms timer on room revisit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When navigating back to a previously-visited room, save the VList CacheSnapshot (item heights) and scroll offset on the way out, then on the way back: • pass the snapshot as cache= to VList so item heights do not need to be remeasured (VList is keyed by room.roomId so it gets a fresh instance with the saved measurements) • skip the 80 ms stabilisation timer — the measurements are already known, so the scroll lands immediately and setIsReady(true) is called without the artificial delay First-visit rooms retain the existing 80 ms behaviour unchanged. --- src/app/features/room/RoomTimeline.tsx | 66 +++++++++++++++++++------- src/app/utils/roomScrollCache.ts | 22 +++++++++ 2 files changed, 72 insertions(+), 16 deletions(-) create mode 100644 src/app/utils/roomScrollCache.ts diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 5c61d9dd4..cebb8c160 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -13,6 +13,7 @@ import { useAtomValue, useSetAtom } from 'jotai'; import { PushProcessor, Room, Direction } from '$types/matrix-sdk'; import classNames from 'classnames'; import { VList, VListHandle } from 'virtua'; +import { roomScrollCache, RoomScrollCache } from '$utils/roomScrollCache'; import { as, Box, @@ -199,6 +200,8 @@ export function RoomTimeline({ const setOpenThread = useSetAtom(openThreadAtom); const vListRef = useRef(null); + // Scroll cache snapshot loaded for the current room (populated on room change). + const scrollCacheForRoomRef = useRef(undefined); const [atBottomState, setAtBottomState] = useState(true); const atBottomRef = useRef(atBottomState); const setAtBottom = useCallback((val: boolean) => { @@ -225,6 +228,18 @@ export function RoomTimeline({ const [isReady, setIsReady] = useState(false); if (currentRoomIdRef.current !== room.roomId) { + // Save outgoing room's scroll state so we can restore it on revisit. + const outgoing = vListRef.current; + if (outgoing && isReady) { + roomScrollCache.save(currentRoomIdRef.current, { + cache: outgoing.cache, + scrollOffset: outgoing.scrollOffset, + atBottom: atBottomRef.current, + }); + } + // Load incoming room's scroll cache (undefined for first-visit rooms). + scrollCacheForRoomRef.current = roomScrollCache.load(room.roomId); + hasInitialScrolledRef.current = false; mountScrollWindowRef.current = Date.now() + 3000; currentRoomIdRef.current = room.roomId; @@ -296,24 +311,41 @@ export function RoomTimeline({ timelineSync.liveTimelineLinked && vListRef.current ) { - vListRef.current.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); - // Store in a ref rather than a local so subsequent eventsLength changes - // (e.g. the onLifecycle timeline reset firing within 80 ms) do NOT - // cancel this timer through the useLayoutEffect cleanup. - initialScrollTimerRef.current = setTimeout(() => { - initialScrollTimerRef.current = undefined; - if (processedEventsRef.current.length > 0) { - vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); - // Only mark ready once we've successfully scrolled. If processedEvents - // was empty when the timer fired (e.g. the onLifecycle reset cleared the - // timeline within the 80 ms window), defer setIsReady until the recovery - // effect below fires once events repopulate. - setIsReady(true); + const savedCache = scrollCacheForRoomRef.current; + hasInitialScrolledRef.current = true; + + if (savedCache) { + // Revisiting a room with a cached scroll state — restore position + // immediately and skip the 80 ms stabilisation timer entirely. + if (savedCache.atBottom) { + vListRef.current.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); } else { - pendingReadyRef.current = true; + vListRef.current.scrollTo(savedCache.scrollOffset); } - }, 80); - hasInitialScrolledRef.current = true; + setIsReady(true); + } else { + // First visit — original behaviour: scroll to bottom, then wait 80 ms + // for VList to finish measuring item heights before revealing the timeline. + vListRef.current.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); + // Store in a ref rather than a local so subsequent eventsLength changes + // (e.g. the onLifecycle timeline reset firing within 80 ms) do NOT + // cancel this timer through the useLayoutEffect cleanup. + initialScrollTimerRef.current = setTimeout(() => { + initialScrollTimerRef.current = undefined; + if (processedEventsRef.current.length > 0) { + vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { + align: 'end', + }); + // Only mark ready once we've successfully scrolled. If processedEvents + // was empty when the timer fired (e.g. the onLifecycle reset cleared the + // timeline within the 80 ms window), defer setIsReady until the recovery + // effect below fires once events repopulate. + setIsReady(true); + } else { + pendingReadyRef.current = true; + } + }, 80); + } } // No cleanup return — the timer must survive eventsLength fluctuations. // It is cancelled on unmount by the dedicated effect below. @@ -844,8 +876,10 @@ export function RoomTimeline({ }} > + key={room.roomId} ref={vListRef} data={processedEvents} + cache={scrollCacheForRoomRef.current?.cache} shift={shift} className={css.messageList} style={{ diff --git a/src/app/utils/roomScrollCache.ts b/src/app/utils/roomScrollCache.ts new file mode 100644 index 000000000..7288ae0e4 --- /dev/null +++ b/src/app/utils/roomScrollCache.ts @@ -0,0 +1,22 @@ +import { CacheSnapshot } from 'virtua'; + +export type RoomScrollCache = { + /** VList item-size snapshot — restored via VList `cache=` prop on remount. */ + cache: CacheSnapshot; + /** Pixel scroll offset at the time the room was left. */ + scrollOffset: number; + /** Whether the view was pinned to the bottom (live) when the room was left. */ + atBottom: boolean; +}; + +/** Session-scoped, per-room scroll cache. Not persisted across page reloads. */ +const scrollCacheMap = new Map(); + +export const roomScrollCache = { + save(roomId: string, data: RoomScrollCache): void { + scrollCacheMap.set(roomId, data); + }, + load(roomId: string): RoomScrollCache | undefined { + return scrollCacheMap.get(roomId); + }, +}; From ee97c5cfcf8985e41720a0292e0bbf3878581fae Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 18:52:27 -0400 Subject: [PATCH 2/6] fix(timeline): correct scroll-cache save/load paths RoomTimeline mounts fresh per room (key={roomId} in RoomView), so the render-phase room-change block used for save/load never fires. - Init scrollCacheForRoomRef from roomScrollCache.load() on mount so the CacheSnapshot is actually provided to VList on first render. - Save the cache in handleVListScroll (and after the first-visit 80 ms timer) rather than in the unreachable room-change block. - Trim the room-change block to just the load + state-reset path (kept as a defensive fallback for any future scenario where room prop changes without remount). --- src/app/features/room/RoomTimeline.tsx | 39 ++++++++++++++++++-------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index cebb8c160..887bcc418 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -200,8 +200,13 @@ export function RoomTimeline({ const setOpenThread = useSetAtom(openThreadAtom); const vListRef = useRef(null); - // Scroll cache snapshot loaded for the current room (populated on room change). - const scrollCacheForRoomRef = useRef(undefined); + // Load any cached scroll state for this room on mount. A fresh RoomTimeline is + // mounted per room (via key={roomId} in RoomView) so this is the only place we + // need to read the cache — the render-phase room-change block below only fires + // in the (hypothetical) case where the room prop changes without a remount. + const scrollCacheForRoomRef = useRef( + roomScrollCache.load(room.roomId) + ); const [atBottomState, setAtBottomState] = useState(true); const atBottomRef = useRef(atBottomState); const setAtBottom = useCallback((val: boolean) => { @@ -228,16 +233,8 @@ export function RoomTimeline({ const [isReady, setIsReady] = useState(false); if (currentRoomIdRef.current !== room.roomId) { - // Save outgoing room's scroll state so we can restore it on revisit. - const outgoing = vListRef.current; - if (outgoing && isReady) { - roomScrollCache.save(currentRoomIdRef.current, { - cache: outgoing.cache, - scrollOffset: outgoing.scrollOffset, - atBottom: atBottomRef.current, - }); - } // Load incoming room's scroll cache (undefined for first-visit rooms). + // Covers the rare case where room prop changes without a remount. scrollCacheForRoomRef.current = roomScrollCache.load(room.roomId); hasInitialScrolledRef.current = false; @@ -336,6 +333,16 @@ export function RoomTimeline({ vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end', }); + // Persist the now-measured item heights so the next visit to this room + // can provide them to VList upfront and skip this 80 ms wait entirely. + const v = vListRef.current; + if (v) { + roomScrollCache.save(room.roomId, { + cache: v.cache, + scrollOffset: v.scrollOffset, + atBottom: true, + }); + } // Only mark ready once we've successfully scrolled. If processedEvents // was empty when the timer fired (e.g. the onLifecycle reset cleared the // timeline within the 80 ms window), defer setIsReady until the recovery @@ -656,6 +663,14 @@ export function RoomTimeline({ setAtBottom(isNowAtBottom); } + // Keep the scroll cache fresh so the next visit to this room can restore + // position (and skip the 80 ms measurement wait) immediately on mount. + roomScrollCache.save(room.roomId, { + cache: v.cache, + scrollOffset: offset, + atBottom: isNowAtBottom, + }); + if (offset < 500 && canPaginateBackRef.current && backwardStatusRef.current === 'idle') { timelineSyncRef.current.handleTimelinePagination(true); } @@ -667,7 +682,7 @@ export function RoomTimeline({ timelineSyncRef.current.handleTimelinePagination(false); } }, - [setAtBottom] + [setAtBottom, room.roomId] ); const showLoadingPlaceholders = From e0576f121293516f8bfc1404c360e712f1da43b1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 20:57:10 -0400 Subject: [PATCH 3/6] fix(timeline): save scroll cache in pendingReadyRef recovery path --- src/app/features/room/RoomTimeline.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 887bcc418..642198530 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -789,8 +789,18 @@ export function RoomTimeline({ if (processedEvents.length === 0) return; pendingReadyRef.current = false; vListRef.current?.scrollToIndex(processedEvents.length - 1, { align: 'end' }); + // The 80 ms timer's cache-save was skipped because processedEvents was empty + // when it fired. Save now so the next visit skips the timer. + const v = vListRef.current; + if (v) { + roomScrollCache.save(room.roomId, { + cache: v.cache, + scrollOffset: v.scrollOffset, + atBottom: true, + }); + } setIsReady(true); - }, [processedEvents.length]); + }, [processedEvents.length, room.roomId]); useEffect(() => { if (!onEditLastMessageRef) return; From ca584a171a06ba944724115a555301fd463e6fef Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 22:51:51 -0400 Subject: [PATCH 4/6] chore: add changeset for perf-timeline-scroll-cache --- .changeset/perf-timeline-scroll-cache.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/perf-timeline-scroll-cache.md diff --git a/.changeset/perf-timeline-scroll-cache.md b/.changeset/perf-timeline-scroll-cache.md new file mode 100644 index 000000000..259a0dd79 --- /dev/null +++ b/.changeset/perf-timeline-scroll-cache.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Cache VList item heights across room visits to restore scroll position instantly and skip the 80 ms opacity-fade stabilisation timer on revisit. From 1d0d425e24b11d8a15d94ad33bb46a59c22bbb96 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 23:11:30 -0400 Subject: [PATCH 5/6] fix(timeline): preempt atBottom to prevent Jump to Latest flashing at bottom --- src/app/features/room/RoomTimeline.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 642198530..0cd404ce9 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -316,6 +316,9 @@ export function RoomTimeline({ // immediately and skip the 80 ms stabilisation timer entirely. if (savedCache.atBottom) { vListRef.current.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); + // scrollToIndex is async; pre-empt the button so it doesn't flash for + // one render cycle before VList's onScroll confirms the position. + setAtBottom(true); } else { vListRef.current.scrollTo(savedCache.scrollOffset); } @@ -347,6 +350,9 @@ export function RoomTimeline({ // was empty when the timer fired (e.g. the onLifecycle reset cleared the // timeline within the 80 ms window), defer setIsReady until the recovery // effect below fires once events repopulate. + // scrollToIndex is async; pre-empt atBottom so the "Jump to Latest" + // button doesn't flash for one render cycle before onScroll confirms. + setAtBottom(true); setIsReady(true); } else { pendingReadyRef.current = true; @@ -356,7 +362,13 @@ export function RoomTimeline({ } // No cleanup return — the timer must survive eventsLength fluctuations. // It is cancelled on unmount by the dedicated effect below. - }, [timelineSync.eventsLength, timelineSync.liveTimelineLinked, eventId, room.roomId]); + }, [ + timelineSync.eventsLength, + timelineSync.liveTimelineLinked, + eventId, + room.roomId, + setAtBottom, + ]); // Cancel the initial-scroll timer on unmount (the useLayoutEffect above // intentionally does not cancel it when deps change). @@ -799,8 +811,11 @@ export function RoomTimeline({ atBottom: true, }); } + // scrollToIndex is async; pre-empt atBottom so the "Jump to Latest" button + // doesn't flash for one render cycle before onScroll confirms the position. + setAtBottom(true); setIsReady(true); - }, [processedEvents.length, room.roomId]); + }, [processedEvents.length, room.roomId, setAtBottom]); useEffect(() => { if (!onEditLastMessageRef) return; From 1f6f28f3b2c0f28477d51007adb165848b8d8279 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 10 Apr 2026 08:55:55 -0400 Subject: [PATCH 6/6] fix(timeline): suppress intermediate VList scroll events after programmatic scroll-to-bottom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After setIsReady(true) commits, virtua can fire onScroll events with isNowAtBottom=false during its height-correction pass (particularly on first visit when item heights above the viewport haven't been rendered yet). These intermediate events were driving atBottomState to false while isReady=true, flashing the 'Jump to Latest' button. Add programmaticScrollToBottomRef: set it before each scrollToIndex bottom-scroll, suppress the first intermediate false event (clearing the guard immediately), so the next event — the corrected position or a real user scroll — is processed normally. --- src/app/features/room/RoomTimeline.tsx | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 0cd404ce9..754865278 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -228,6 +228,12 @@ export function RoomTimeline({ // A recovery useLayoutEffect watches for processedEvents becoming non-empty // and performs the final scroll + setIsReady when this flag is set. const pendingReadyRef = useRef(false); + // Set to true before each programmatic scroll-to-bottom so intermediate + // onScroll events from virtua's height-correction pass cannot drive + // atBottomState to false (flashing the "Jump to Latest" button). + // Cleared when VList confirms isNowAtBottom, or on the first intermediate + // event so subsequent user-initiated scrolls are tracked normally. + const programmaticScrollToBottomRef = useRef(false); const currentRoomIdRef = useRef(room.roomId); const [isReady, setIsReady] = useState(false); @@ -241,6 +247,7 @@ export function RoomTimeline({ mountScrollWindowRef.current = Date.now() + 3000; currentRoomIdRef.current = room.roomId; pendingReadyRef.current = false; + programmaticScrollToBottomRef.current = false; if (initialScrollTimerRef.current !== undefined) { clearTimeout(initialScrollTimerRef.current); initialScrollTimerRef.current = undefined; @@ -315,6 +322,7 @@ export function RoomTimeline({ // Revisiting a room with a cached scroll state — restore position // immediately and skip the 80 ms stabilisation timer entirely. if (savedCache.atBottom) { + programmaticScrollToBottomRef.current = true; vListRef.current.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); // scrollToIndex is async; pre-empt the button so it doesn't flash for // one render cycle before VList's onScroll confirms the position. @@ -333,6 +341,7 @@ export function RoomTimeline({ initialScrollTimerRef.current = setTimeout(() => { initialScrollTimerRef.current = undefined; if (processedEventsRef.current.length > 0) { + programmaticScrollToBottomRef.current = true; vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end', }); @@ -671,8 +680,20 @@ export function RoomTimeline({ const distanceFromBottom = v.scrollSize - offset - v.viewportSize; const isNowAtBottom = distanceFromBottom < 100; + // Clear the programmatic-scroll guard whenever VList confirms we are at the + // bottom, regardless of whether atBottomRef needs updating. + if (isNowAtBottom) programmaticScrollToBottomRef.current = false; if (isNowAtBottom !== atBottomRef.current) { - setAtBottom(isNowAtBottom); + if (isNowAtBottom || !programmaticScrollToBottomRef.current) { + setAtBottom(isNowAtBottom); + } else { + // VList fired an intermediate "not at bottom" event while settling after + // a programmatic scroll-to-bottom (e.g. height-correction pass). Suppress + // the false negative and clear the guard so the next event — either a + // VList correction to the true bottom, or a genuine user scroll — is + // processed normally. + programmaticScrollToBottomRef.current = false; + } } // Keep the scroll cache fresh so the next visit to this room can restore @@ -800,6 +821,7 @@ export function RoomTimeline({ if (!pendingReadyRef.current) return; if (processedEvents.length === 0) return; pendingReadyRef.current = false; + programmaticScrollToBottomRef.current = true; vListRef.current?.scrollToIndex(processedEvents.length - 1, { align: 'end' }); // The 80 ms timer's cache-save was skipped because processedEvents was empty // when it fired. Save now so the next visit skips the timer.