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. diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 5c61d9dd4..754865278 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,13 @@ export function RoomTimeline({ const setOpenThread = useSetAtom(openThreadAtom); const vListRef = useRef(null); + // 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) => { @@ -220,15 +228,26 @@ 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); if (currentRoomIdRef.current !== room.roomId) { + // 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; 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; @@ -296,28 +315,69 @@ 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) { + 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. + setAtBottom(true); } 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) { + programmaticScrollToBottomRef.current = true; + 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 + // 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; + } + }, 80); + } } // 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). @@ -620,10 +680,30 @@ 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 + // 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); } @@ -635,7 +715,7 @@ export function RoomTimeline({ timelineSyncRef.current.handleTimelinePagination(false); } }, - [setAtBottom] + [setAtBottom, room.roomId] ); const showLoadingPlaceholders = @@ -741,9 +821,23 @@ 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. + const v = vListRef.current; + if (v) { + roomScrollCache.save(room.roomId, { + cache: v.cache, + scrollOffset: v.scrollOffset, + 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]); + }, [processedEvents.length, room.roomId, setAtBottom]); useEffect(() => { if (!onEditLastMessageRef) return; @@ -844,8 +938,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); + }, +};