diff --git a/.changeset/fix-timeline-scroll-regressions.md b/.changeset/fix-timeline-scroll-regressions.md new file mode 100644 index 000000000..892cf3ed0 --- /dev/null +++ b/.changeset/fix-timeline-scroll-regressions.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix timeline scroll regressions: stay-at-bottom, Jump to Latest button flicker, phantom unread dot, and blank notification page recovery diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d026ec1fa..bb5d91b58 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -201,7 +201,7 @@ export function RoomTimeline({ const setOpenThread = useSetAtom(openThreadAtom); const vListRef = useRef(null); - const [atBottomState, setAtBottomState] = useState(true); + const [atBottomState, setAtBottomState] = useState(!eventId); const atBottomRef = useRef(atBottomState); const setAtBottom = useCallback((val: boolean) => { setAtBottomState(val); @@ -224,6 +224,13 @@ export function RoomTimeline({ const pendingReadyRef = useRef(false); const currentRoomIdRef = useRef(room.roomId); + // Timestamp (epoch ms) of the last programmatic scrollToIndex call. + // While Date.now() - ref < SCROLL_SETTLE_MS the handleVListScroll callback + // suppresses false-negative "not at bottom" reports that VList fires during + // its height re-measurement pass. + const SCROLL_SETTLE_MS = 200; + const programmaticScrollToBottomRef = useRef(0); + const [isReady, setIsReady] = useState(false); if (currentRoomIdRef.current !== room.roomId) { @@ -231,6 +238,7 @@ export function RoomTimeline({ mountScrollWindowRef.current = Date.now() + 3000; currentRoomIdRef.current = room.roomId; pendingReadyRef.current = false; + programmaticScrollToBottomRef.current = 0; if (initialScrollTimerRef.current !== undefined) { clearTimeout(initialScrollTimerRef.current); initialScrollTimerRef.current = undefined; @@ -298,6 +306,7 @@ export function RoomTimeline({ timelineSync.liveTimelineLinked && vListRef.current ) { + programmaticScrollToBottomRef.current = Date.now(); 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 @@ -305,6 +314,7 @@ export function RoomTimeline({ initialScrollTimerRef.current = setTimeout(() => { initialScrollTimerRef.current = undefined; if (processedEventsRef.current.length > 0) { + programmaticScrollToBottomRef.current = Date.now(); 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 @@ -352,6 +362,7 @@ export function RoomTimeline({ setTopSpacerHeight(newH); if (prev > 0 && newH === 0 && processedEventsRef.current.length > 0) { requestAnimationFrame(() => { + programmaticScrollToBottomRef.current = Date.now(); vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); }); } @@ -375,6 +386,7 @@ export function RoomTimeline({ } else if (prev === 'loading' && timelineSync.backwardStatus === 'idle') { setShift(false); if (wasAtBottomBeforePaginationRef.current) { + programmaticScrollToBottomRef.current = Date.now(); vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); } } @@ -405,11 +417,36 @@ export function RoomTimeline({ } }, [timelineSync.focusItem]); + // Recovery: if event timeline load failed and fell back to live timeline, + // reveal the timeline so the user doesn't see a blank page. + // Skip when focusItem is set — that means loadEventTimeline succeeded and + // the success effects (415–419) already handle setIsReady. + useEffect(() => { + if ( + eventId && + !isReady && + !timelineSync.focusItem && + timelineSync.liveTimelineLinked && + timelineSync.eventsLength > 0 + ) { + scrollToBottom(); + setIsReady(true); + } + }, [ + eventId, + isReady, + timelineSync.focusItem, + timelineSync.liveTimelineLinked, + timelineSync.eventsLength, + scrollToBottom, + ]); + useEffect(() => { if (!eventId) return; setIsReady(false); + setAtBottom(false); timelineSyncRef.current.loadEventTimeline(eventId); - }, [eventId, room.roomId]); + }, [eventId, room.roomId, setAtBottom]); useEffect(() => { if (eventId) return; @@ -631,8 +668,17 @@ export function RoomTimeline({ const distanceFromBottom = v.scrollSize - offset - v.viewportSize; const isNowAtBottom = distanceFromBottom < 100; + + // During the settling window after a programmatic scroll, suppress + // false-negative "not at bottom" reports from VList. Virtua fires + // several intermediate onScroll events while re-measuring item heights + // after scrollToIndex(); without this guard those would flash the + // "Jump to Latest" button for one or more render frames. + const isSettling = Date.now() - programmaticScrollToBottomRef.current < SCROLL_SETTLE_MS; if (isNowAtBottom !== atBottomRef.current) { - setAtBottom(isNowAtBottom); + if (isNowAtBottom || !isSettling) { + setAtBottom(isNowAtBottom); + } } if (offset < 500 && canPaginateBackRef.current && backwardStatusRef.current === 'idle') { @@ -752,6 +798,7 @@ export function RoomTimeline({ if (!pendingReadyRef.current) return; if (processedEvents.length === 0) return; pendingReadyRef.current = false; + programmaticScrollToBottomRef.current = Date.now(); vListRef.current?.scrollToIndex(processedEvents.length - 1, { align: 'end' }); setIsReady(true); }, [processedEvents.length]); diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index d53d74143..61bc0cbdf 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -107,7 +107,7 @@ describe('useTimelineSync', () => { }); it('keeps a bottom-pinned user anchored after TimelineReset', async () => { - const { room, timelineSet } = createRoom(); + const { room, timelineSet, events } = createRoom(); const scrollToBottom = vi.fn(); renderHook(() => @@ -125,11 +125,14 @@ describe('useTimelineSync', () => { ); await act(async () => { + // Simulate the SDK replacing the live timeline with new events, + // which is what a real TimelineReset does. + events.push({}); timelineSet.emit(RoomEvent.TimelineReset); await Promise.resolve(); }); - expect(scrollToBottom).toHaveBeenCalledWith('instant'); + expect(scrollToBottom).toHaveBeenCalled(); }); it('resets timeline state when room.roomId changes and eventId is not set', async () => { diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 51c85dda8..dda1e0207 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -351,7 +351,7 @@ export interface UseTimelineSyncOptions { eventId?: string; isAtBottom: boolean; isAtBottomRef: React.MutableRefObject; - scrollToBottom: (behavior?: 'instant' | 'smooth') => void; + scrollToBottom: () => void; unreadInfo: ReturnType; setUnreadInfo: Dispatch>>; hideReadsRef: React.MutableRefObject; @@ -460,7 +460,7 @@ export function useTimelineSync({ useCallback(() => { if (!alive()) return; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - scrollToBottom('instant'); + scrollToBottom(); }, [alive, room, scrollToBottom]) ); @@ -490,7 +490,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } - scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth'); + scrollToBottom(); lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; setTimeline((ct) => ({ ...ct })); @@ -528,7 +528,7 @@ export function useTimelineSync({ resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); if (wasAtBottom) { - scrollToBottom('instant'); + scrollToBottom(); } }, [room, isAtBottomRef, scrollToBottom]) ); @@ -565,7 +565,7 @@ export function useTimelineSync({ if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return; lastScrolledAtEventsLengthRef.current = eventsLength; - scrollToBottom('instant'); + scrollToBottom(); }, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]); useEffect(() => { diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 9391dbc90..d7a36b338 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -276,7 +276,7 @@ export const roomHaveUnread = (mx: MatrixClient, room: Room) => { if (event.getId() === readUpToId) { return false; } - if (isNotificationEvent(event, room, userId)) { + if (isNotificationEvent(event, room, userId) && event.getSender() !== userId) { return true; } } diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 43fdf39ea..5c147179c 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -34,9 +34,9 @@ export const LIST_SEARCH = 'search'; export const LIST_ROOM_SEARCH = 'room_search'; // Dynamic list key used for space-scoped room views. export const LIST_SPACE = 'space'; -// One event of timeline per list room is enough to compute unread counts; -// the full history is loaded when the user opens the room. -const LIST_TIMELINE_LIMIT = 1; +// Higher limit avoids empty previews when the most-recent events are +// reactions/edits/state that useRoomLatestRenderedEvent skips over. +const LIST_TIMELINE_LIMIT = 3; const DEFAULT_LIST_PAGE_SIZE = 250; const DEFAULT_POLL_TIMEOUT_MS = 20000; const DEFAULT_MAX_ROOMS = 5000;