Skip to content
Closed
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/fix-timeline-scroll-regressions.md
Original file line number Diff line number Diff line change
@@ -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
53 changes: 50 additions & 3 deletions src/app/features/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export function RoomTimeline({
const setOpenThread = useSetAtom(openThreadAtom);

const vListRef = useRef<VListHandle>(null);
const [atBottomState, setAtBottomState] = useState(true);
const [atBottomState, setAtBottomState] = useState(!eventId);
const atBottomRef = useRef(atBottomState);
const setAtBottom = useCallback((val: boolean) => {
setAtBottomState(val);
Expand All @@ -224,13 +224,21 @@ 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) {
hasInitialScrolledRef.current = false;
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;
Expand Down Expand Up @@ -298,13 +306,15 @@ 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
// cancel this timer through the useLayoutEffect cleanup.
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
Expand Down Expand Up @@ -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' });
});
}
Expand All @@ -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' });
}
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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]);
Expand Down
7 changes: 5 additions & 2 deletions src/app/hooks/timeline/useTimelineSync.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() =>
Expand All @@ -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 () => {
Expand Down
10 changes: 5 additions & 5 deletions src/app/hooks/timeline/useTimelineSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ export interface UseTimelineSyncOptions {
eventId?: string;
isAtBottom: boolean;
isAtBottomRef: React.MutableRefObject<boolean>;
scrollToBottom: (behavior?: 'instant' | 'smooth') => void;
scrollToBottom: () => void;
unreadInfo: ReturnType<typeof getRoomUnreadInfo>;
setUnreadInfo: Dispatch<SetStateAction<ReturnType<typeof getRoomUnreadInfo>>>;
hideReadsRef: React.MutableRefObject<boolean>;
Expand Down Expand Up @@ -460,7 +460,7 @@ export function useTimelineSync({
useCallback(() => {
if (!alive()) return;
setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines });
scrollToBottom('instant');
scrollToBottom();
}, [alive, room, scrollToBottom])
);

Expand Down Expand Up @@ -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 }));
Expand Down Expand Up @@ -528,7 +528,7 @@ export function useTimelineSync({
resetAutoScrollPendingRef.current = wasAtBottom;
setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines });
if (wasAtBottom) {
scrollToBottom('instant');
scrollToBottom();
}
}, [room, isAtBottomRef, scrollToBottom])
);
Expand Down Expand Up @@ -565,7 +565,7 @@ export function useTimelineSync({
if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return;

lastScrolledAtEventsLengthRef.current = eventsLength;
scrollToBottom('instant');
scrollToBottom();
}, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]);

useEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion src/app/utils/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
6 changes: 3 additions & 3 deletions src/client/slidingSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading