Skip to content
Open
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-timeline-scroll-cache.md
Original file line number Diff line number Diff line change
@@ -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.
136 changes: 116 additions & 20 deletions src/app/features/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -199,6 +200,13 @@ export function RoomTimeline({
const setOpenThread = useSetAtom(openThreadAtom);

const vListRef = useRef<VListHandle>(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 | undefined>(
roomScrollCache.load(room.roomId)
);
const [atBottomState, setAtBottomState] = useState(true);
const atBottomRef = useRef(atBottomState);
const setAtBottom = useCallback((val: boolean) => {
Expand All @@ -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;
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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);
}
Expand All @@ -635,7 +715,7 @@ export function RoomTimeline({
timelineSyncRef.current.handleTimelinePagination(false);
}
},
[setAtBottom]
[setAtBottom, room.roomId]
);

const showLoadingPlaceholders =
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -844,8 +938,10 @@ export function RoomTimeline({
}}
>
<VList<ProcessedEvent>
key={room.roomId}
ref={vListRef}
data={processedEvents}
cache={scrollCacheForRoomRef.current?.cache}
shift={shift}
className={css.messageList}
style={{
Expand Down
22 changes: 22 additions & 0 deletions src/app/utils/roomScrollCache.ts
Original file line number Diff line number Diff line change
@@ -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<string, RoomScrollCache>();

export const roomScrollCache = {
save(roomId: string, data: RoomScrollCache): void {
scrollCacheMap.set(roomId, data);
},
load(roomId: string): RoomScrollCache | undefined {
return scrollCacheMap.get(roomId);
},
};
Loading