From 2abefe3d6abe2ae01eaffb5c6b845e167143a10f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 11 May 2026 21:57:36 -0400 Subject: [PATCH 1/2] fix(timeline): fix broken scroll and missing jump-to-latest after event jumps Add a short-lived jump scroll block (~350 ms) after scrollToIndex calls so intermediate scroll events from the animation don't prematurely flip atBottom and show the "Jump to Latest" button when the user is already at the intended location. --- src/app/features/room/RoomTimeline.tsx | 32 +++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d63faa989..2c9b32ca9 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -226,6 +226,10 @@ export function RoomTimeline({ const topSpacerHeightRef = useRef(0); const mountScrollWindowRef = useRef(Date.now() + 3000); const hasInitialScrolledRef = useRef(false); + // Short-lived guard set for ~350 ms after a jump scrollToIndex so that + // intermediate scroll events from the animation don't flip atBottom prematurely. + const jumpScrollBlockRef = useRef(false); + const jumpScrollBlockTimerRef = useRef | undefined>(undefined); // Stored in a ref so eventsLength fluctuations (e.g. onLifecycle timeline reset // firing within the window) cannot cancel it via useLayoutEffect cleanup. const initialScrollTimerRef = useRef | undefined>(undefined); @@ -260,6 +264,23 @@ export function RoomTimeline({ vListRef.current.scrollTo(vListRef.current.scrollSize); }, []); + // Start a short scroll-settle block after a programmatic jump scrollToIndex. + // After 350 ms the block lifts and atBottom is recomputed from the actual + // VList position so "Jump to Latest" appears correctly. + const startJumpScrollBlock = useCallback(() => { + jumpScrollBlockRef.current = true; + if (jumpScrollBlockTimerRef.current !== undefined) clearTimeout(jumpScrollBlockTimerRef.current); + jumpScrollBlockTimerRef.current = setTimeout(() => { + jumpScrollBlockRef.current = false; + jumpScrollBlockTimerRef.current = undefined; + const v = vListRef.current; + if (v) { + const dist = v.scrollSize - v.scrollOffset - v.viewportSize; + setAtBottom(dist < 100); + } + }, 350); + }, [setAtBottom]); + const timelineSync = useTimelineSync({ room, mx, @@ -399,6 +420,7 @@ export function RoomTimeline({ const processedIndex = getRawIndexToProcessedIndex(timelineSync.focusItem.index); if (processedIndex !== undefined) { vListRef.current.scrollToIndex(processedIndex, { align: 'center' }); + startJumpScrollBlock(); timelineSync.setFocusItem((prev) => (prev ? { ...prev, scrollTo: false } : undefined)); } } @@ -409,7 +431,7 @@ export function RoomTimeline({ return () => { if (timeoutId !== undefined) clearTimeout(timeoutId); }; - }, [timelineSync.focusItem, timelineSync, reducedMotion, getRawIndexToProcessedIndex]); + }, [timelineSync.focusItem, timelineSync, reducedMotion, getRawIndexToProcessedIndex, startJumpScrollBlock]); useEffect(() => { if (timelineSync.focusItem) { @@ -533,6 +555,7 @@ export function RoomTimeline({ } if (vListRef.current && processedIndex !== undefined) { vListRef.current.scrollToIndex(processedIndex, { align: 'center' }); + startJumpScrollBlock(); } timelineSync.setFocusItem({ index: focusRawIndex, scrollTo: false, highlight: true }); } else { @@ -675,6 +698,13 @@ export function RoomTimeline({ const distanceFromBottom = v.scrollSize - offset - v.viewportSize; const isNowAtBottom = distanceFromBottom < 100; + + // While a jump scroll is settling (briefly after scrollToIndex), VList + // fires intermediate scroll events that can incorrectly flip atBottom. + // Use a short-lived block instead of the full focusItem lifetime so that + // normal scrolling resumes quickly and atBottom is recomputed correctly. + if (jumpScrollBlockRef.current) return; + if (isNowAtBottom !== atBottomRef.current) { setAtBottom(isNowAtBottom); } From 87ebcc3e00dc45b61c7627f4e3470d6ba197e2cc Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 19:12:27 -0400 Subject: [PATCH 2/2] fix(timeline): restore isReady recovery on failed jump-to-event --- src/app/features/room/RoomTimeline.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 2c9b32ca9..48ee66cb4 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -442,6 +442,10 @@ export function RoomTimeline({ useEffect(() => { if (!eventId) return; setIsReady(false); + // Re-arm the initial-scroll guard so that if the jump fails and + // useTimelineSync falls back to the live timeline, the useLayoutEffect + // can fire and call setIsReady(true) via the normal initial-scroll path. + hasInitialScrolledRef.current = false; timelineSyncRef.current.loadEventTimeline(eventId); }, [eventId, room.roomId]);