From ce11afe96d23e3564f8e9c09f40fed3993cafc01 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 16:23:37 -0400 Subject: [PATCH 01/10] fix(ui): phantom unread dot + blank notification page recovery - room.ts: exclude own events from unread notification check - RoomTimeline.tsx: recovery effect reveals timeline when event load fails --- src/app/features/room/RoomTimeline.tsx | 15 +++++++++++++++ src/app/utils/room.ts | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d026ec1fa..3ab6560f6 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -405,6 +405,21 @@ 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. + useEffect(() => { + if (eventId && !isReady && timelineSync.liveTimelineLinked && timelineSync.eventsLength > 0) { + scrollToBottom(); + setIsReady(true); + } + }, [ + eventId, + isReady, + timelineSync.liveTimelineLinked, + timelineSync.eventsLength, + scrollToBottom, + ]); + useEffect(() => { if (!eventId) return; setIsReady(false); 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; } } From 1939599f8b714044016bc38ee07a1dae35f4df54 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 15:44:11 -0400 Subject: [PATCH 02/10] =?UTF-8?q?fix:=20use=20useLayoutEffect=20for=20auto?= =?UTF-8?q?-scroll=20recovery=20after=20timeline=20reset=20=20When=20slidi?= =?UTF-8?q?ng=20sync=20upgrades=20a=20room=20subscription=20(timeline=5Fli?= =?UTF-8?q?mit=201=20=E2=86=92=2050),=20a=20TimelineReset=20replaces=20the?= =?UTF-8?q?=20VList=20content.=20The=20auto-scroll=20recovery=20was=20usin?= =?UTF-8?q?g=20useEffect,=20which=20fires=20after=20paint=20=E2=80=94=20ca?= =?UTF-8?q?using=20a=20visible=20flash=20where=20the=20user=20sees=20conte?= =?UTF-8?q?nt=20at=20the=20wrong=20scroll=20position=20for=20one=20frame.?= =?UTF-8?q?=20=20Switch=20to=20useLayoutEffect=20so=20the=20scroll=20posit?= =?UTF-8?q?ion=20is=20corrected=20before=20the=20browser=20paints.=20Also?= =?UTF-8?q?=20remove=20the=20redundant=20scrollToBottom=20call=20from=20th?= =?UTF-8?q?e=20useLiveTimelineRefresh=20callback,=20which=20was=20operatin?= =?UTF-8?q?g=20on=20the=20pre-commit=20DOM=20with=20a=20stale=20scrollSize?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/hooks/timeline/useTimelineSync.ts | 25 +++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 51c85dda8..0fa27af17 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -1,4 +1,13 @@ -import { useState, useMemo, useCallback, useRef, useEffect, Dispatch, SetStateAction } from 'react'; +import { + useState, + useMemo, + useCallback, + useRef, + useEffect, + useLayoutEffect, + Dispatch, + SetStateAction, +} from 'react'; import to from 'await-to-js'; import * as Sentry from '@sentry/react'; import { @@ -527,10 +536,10 @@ export function useTimelineSync({ const wasAtBottom = isAtBottomRef.current; resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - if (wasAtBottom) { - scrollToBottom('instant'); - } - }, [room, isAtBottomRef, scrollToBottom]) + // Scroll is handled by the useLayoutEffect auto-scroll recovery which + // fires after React commits the new timeline state — scrolling here + // would operate on the pre-commit DOM with a stale scrollSize. + }, [room, isAtBottomRef]) ); useRelationUpdate( @@ -547,7 +556,11 @@ export function useTimelineSync({ }, []) ); - useEffect(() => { + // useLayoutEffect so the scroll position is corrected before the browser + // paints. Without this, a sliding-sync subscription upgrade (timeline_limit + // 1 → 50) replaces the VList content and the user sees one frame at the + // wrong scroll position before the useEffect-based scroll fires. + useLayoutEffect(() => { const resetAutoScrollPending = resetAutoScrollPendingRef.current; if (resetAutoScrollPending) resetAutoScrollPendingRef.current = false; From 77fbe22c399661d2a2b52b50b428b58270a3fa83 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 16:06:52 -0400 Subject: [PATCH 03/10] fix(timeline): use scrollToIndex for stay-at-bottom and remove premature scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scrollToBottom used scrollTo(scrollSize) which reads a stale pixel offset before VList has measured newly-arrived items. Switch to scrollToIndex(lastIndex, { align: 'end' }) which works reliably regardless of measurement state. Remove the premature scrollToBottom call from useLiveEventArrive — it fired before React committed the new timeline state, so scrollSize was stale and the auto-scroll useLayoutEffect was suppressed by lastScrolledAtEventsLengthRef. The useLayoutEffect now handles all stay-at-bottom scrolling after commit. --- src/app/features/room/RoomTimeline.tsx | 5 ++++- src/app/hooks/timeline/useTimelineSync.ts | 8 +------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 3ab6560f6..f674009bb 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -245,7 +245,10 @@ export function RoomTimeline({ if (!vListRef.current) return; const lastIndex = processedEventsRef.current.length - 1; if (lastIndex < 0) return; - vListRef.current.scrollTo(vListRef.current.scrollSize); + // Guard against VList's intermediate height-correction scroll events that + // would otherwise call setAtBottom(false) before the scroll settles. + programmaticScrollToBottomRef.current = true; + vListRef.current.scrollToIndex(lastIndex, { align: 'end' }); }, []); const timelineSync = useTimelineSync({ diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 0fa27af17..c8c48f548 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -475,9 +475,6 @@ export function useTimelineSync({ const lastScrolledAtEventsLengthRef = useRef(eventsLength); - const eventsLengthRef = useRef(eventsLength); - eventsLengthRef.current = eventsLength; - useLiveEventArrive( room, useCallback( @@ -499,9 +496,6 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } - scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth'); - lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; - setTimeline((ct) => ({ ...ct })); return; } @@ -511,7 +505,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, isAtBottomRef, unreadInfo, scrollToBottom, setUnreadInfo, hideReadsRef] + [mx, room, isAtBottomRef, unreadInfo, setUnreadInfo, hideReadsRef] ) ); From fa8bb1e7b65031e33b8c363b68f6fedd0040ece3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 19:41:31 -0400 Subject: [PATCH 04/10] =?UTF-8?q?fix(timeline):=20use=20timestamp-based=20?= =?UTF-8?q?settling=20window=20to=20suppress=20spurious=20Jump=20to=20Late?= =?UTF-8?q?st=20button=20=20Replace=20the=20boolean=20programmaticScrollTo?= =?UTF-8?q?BottomRef=20guard=20with=20a=20timestamp=20(Date.now())=20and?= =?UTF-8?q?=20a=20200=20ms=20settling=20window=20(SCROLL=5FSETTLE=5FMS).?= =?UTF-8?q?=20=20VList=20(virtua)=20fires=20multiple=20intermediate=20onSc?= =?UTF-8?q?roll=20events=20while=20re-measuring=20item=20heights=20after?= =?UTF-8?q?=20a=20programmatic=20scrollToIndex();=20the=20old=20boolean=20?= =?UTF-8?q?guard=20was=20cleared=20on=20the=20first=20isNowAtBottom=3Dtrue?= =?UTF-8?q?=20callback,=20leaving=20subsequent=20re-measurement=20callback?= =?UTF-8?q?s=20free=20to=20set=20atBottom=3Dfalse=20and=20flash=20the=20bu?= =?UTF-8?q?tton.=20=20The=20timestamp=20approach=20lets=20the=20settling?= =?UTF-8?q?=20window=20expire=20naturally=20=E2=80=94=20no=20manual=20clea?= =?UTF-8?q?ring=20is=20needed=20=E2=80=94=20and=20correctly=20suppresses?= =?UTF-8?q?=20false-negative=20reports=20for=20the=20entire=20measurement?= =?UTF-8?q?=20pass.=20=20Also=20update=20the=20useTimelineSync=20test=20to?= =?UTF-8?q?=20push=20a=20new=20event=20before=20emitting=20TimelineReset?= =?UTF-8?q?=20so=20the=20useLayoutEffect=20auto-scroll=20recovery=20(which?= =?UTF-8?q?=20depends=20on=20eventsLength=20changing)=20actually=20fires.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/features/room/RoomTimeline.tsx | 24 +++++++++++++++++-- .../hooks/timeline/useTimelineSync.test.tsx | 5 +++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index f674009bb..c42cc0708 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -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; @@ -247,7 +255,7 @@ export function RoomTimeline({ if (lastIndex < 0) return; // Guard against VList's intermediate height-correction scroll events that // would otherwise call setAtBottom(false) before the scroll settles. - programmaticScrollToBottomRef.current = true; + programmaticScrollToBottomRef.current = Date.now(); vListRef.current.scrollToIndex(lastIndex, { align: 'end' }); }, []); @@ -301,6 +309,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 @@ -308,6 +317,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 @@ -649,8 +659,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') { @@ -770,6 +789,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..0b89fb6a2 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,6 +125,9 @@ 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(); }); From 6e8f5729403ad8f7b188a6db7cb3fc21d2cc8c3c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 11:46:46 -0400 Subject: [PATCH 05/10] chore: add changeset for timeline scroll fixes --- .changeset/fix-timeline-scroll-regressions.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-timeline-scroll-regressions.md 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 From eac371abc70ab21f97ca0e954df2b5d67c5ef685 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 13:37:53 -0400 Subject: [PATCH 06/10] fix(timeline): add behavior parameter to scrollToBottom callback Accept optional 'instant' | 'smooth' behavior parameter and pass it through to scrollToIndex. useTimelineSync calls scrollToBottom('instant') so the signature needs to match. --- src/app/features/room/RoomTimeline.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index c42cc0708..fb7fa890c 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -249,14 +249,14 @@ export function RoomTimeline({ const processedEventsRef = useRef([]); const timelineSyncRef = useRef(null as unknown as typeof timelineSync); - const scrollToBottom = useCallback(() => { + const scrollToBottom = useCallback((behavior?: 'instant' | 'smooth') => { if (!vListRef.current) return; const lastIndex = processedEventsRef.current.length - 1; if (lastIndex < 0) return; // Guard against VList's intermediate height-correction scroll events that // would otherwise call setAtBottom(false) before the scroll settles. programmaticScrollToBottomRef.current = Date.now(); - vListRef.current.scrollToIndex(lastIndex, { align: 'end' }); + vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: behavior === 'smooth' }); }, []); const timelineSync = useTimelineSync({ From c9763ab22d5b6cd8fd1163ae3d7aa01d4eb2a8a7 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:32:25 -0400 Subject: [PATCH 07/10] fix(timeline): set programmaticScrollToBottomRef before all bottom-pinning scrolls Resolves flash of the 'Jump to Latest' button during VList re-measurement when (a) the top-spacer collapses after backward-pagination fills the view and (b) backward pagination completes while the user was already at the bottom. Also prevents the recovery effect from calling scrollToBottom() and overriding a successful scroll-to-event by gating on focusItem. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index fb7fa890c..66da00672 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -365,6 +365,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' }); }); } @@ -388,6 +389,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' }); } } @@ -420,14 +422,23 @@ export function RoomTimeline({ // 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.liveTimelineLinked && timelineSync.eventsLength > 0) { + if ( + eventId && + !isReady && + !timelineSync.focusItem && + timelineSync.liveTimelineLinked && + timelineSync.eventsLength > 0 + ) { scrollToBottom(); setIsReady(true); } }, [ eventId, isReady, + timelineSync.focusItem, timelineSync.liveTimelineLinked, timelineSync.eventsLength, scrollToBottom, From 23dbfc699c3d212342bfb13e385f6585ef7f5d37 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 16:20:04 -0400 Subject: [PATCH 08/10] fix(timeline): restore useLayoutEffect auto-scroll, fix new-message scroll, fix eventId drag-to-bottom, increase list timeline limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useTimelineSync: change auto-scroll recovery useEffect → useLayoutEffect to prevent one-frame flash after timeline reset - useTimelineSync: remove premature scrollToBottom from useLiveTimelineRefresh (operated on pre-commit DOM with stale scrollSize) - useTimelineSync: remove scrollToBottom + eventsLengthRef suppression from useLiveEventArrive; let useLayoutEffect handle scroll after React commits - RoomTimeline: init atBottomState to false when eventId is set, and reset it in the eventId useEffect, so auto-scroll doesn't drag to bottom on bookmark nav - RoomTimeline: change instant scrollToBottom to use scrollToIndex instead of scrollTo(scrollSize) — works correctly regardless of VList measurement state - slidingSync: increase DEFAULT_LIST_TIMELINE_LIMIT 1→3 to reduce empty previews when recent events are reactions/edits/state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 26 +++++++++++++++++------ src/app/hooks/timeline/useTimelineSync.ts | 6 ++---- src/client/slidingSync.ts | 6 +++--- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 66da00672..96ef8d593 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -201,7 +201,14 @@ export function RoomTimeline({ const setOpenThread = useSetAtom(openThreadAtom); const vListRef = useRef(null); - const [atBottomState, setAtBottomState] = useState(true); + // 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(mxUserId, room.roomId) + ); + const [atBottomState, setAtBottomState] = useState(!eventId); const atBottomRef = useRef(atBottomState); const setAtBottom = useCallback((val: boolean) => { setAtBottomState(val); @@ -253,10 +260,14 @@ export function RoomTimeline({ if (!vListRef.current) return; const lastIndex = processedEventsRef.current.length - 1; if (lastIndex < 0) return; - // Guard against VList's intermediate height-correction scroll events that - // would otherwise call setAtBottom(false) before the scroll settles. - programmaticScrollToBottomRef.current = Date.now(); - vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: behavior === 'smooth' }); + if (behavior === 'smooth') { + vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: true }); + } else { + // scrollToIndex works reliably regardless of VList measurement state. + // The auto-scroll useLayoutEffect fires after React commits new items, + // so lastIndex is always valid when this is called. + vListRef.current.scrollToIndex(lastIndex, { align: 'end' }); + } }, []); const timelineSync = useTimelineSync({ @@ -447,8 +458,11 @@ export function RoomTimeline({ useEffect(() => { if (!eventId) return; setIsReady(false); + // Ensure auto-scroll to bottom doesn't fire while we're navigating to a + // specific event — atBottom will be updated correctly once the user scrolls. + setAtBottom(false); timelineSyncRef.current.loadEventTimeline(eventId); - }, [eventId, room.roomId]); + }, [eventId, room.roomId, setAtBottom]); useEffect(() => { if (eventId) return; diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index c8c48f548..948630887 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -550,10 +550,8 @@ export function useTimelineSync({ }, []) ); - // useLayoutEffect so the scroll position is corrected before the browser - // paints. Without this, a sliding-sync subscription upgrade (timeline_limit - // 1 → 50) replaces the VList content and the user sees one frame at the - // wrong scroll position before the useEffect-based scroll fires. + // useLayoutEffect so scroll fires before paint — prevents the one-frame flash + // where new VList content is briefly visible at the wrong position. useLayoutEffect(() => { const resetAutoScrollPending = resetAutoScrollPendingRef.current; if (resetAutoScrollPending) resetAutoScrollPendingRef.current = false; 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; From 6cfb0257cb82cddad2a1f90d4c0defdd40c52fda Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 19:33:47 -0400 Subject: [PATCH 09/10] fix(timeline): restore upstream scroll pattern for new messages Restore scrollToBottom call in useLiveEventArrive with instant/smooth based on sender, add back eventsLengthRef and lastScrolledAt suppression, restore scrollToBottom in useLiveTimelineRefresh when wasAtBottom, and revert instant scrollToBottom to scrollTo(scrollSize) matching upstream. The previous changes removed all scroll calls from event arrival handlers and relied solely on the useLayoutEffect auto-scroll recovery, which has timing issues with VList measurement. Upstream's pattern of scrolling in the event handler and suppressing the effect works reliably. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 5 +---- src/app/hooks/timeline/useTimelineSync.ts | 16 +++++++++++----- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 96ef8d593..1bf5f9e14 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -263,10 +263,7 @@ export function RoomTimeline({ if (behavior === 'smooth') { vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: true }); } else { - // scrollToIndex works reliably regardless of VList measurement state. - // The auto-scroll useLayoutEffect fires after React commits new items, - // so lastIndex is always valid when this is called. - vListRef.current.scrollToIndex(lastIndex, { align: 'end' }); + vListRef.current.scrollTo(vListRef.current.scrollSize); } }, []); diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 948630887..f6d50c904 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -475,6 +475,9 @@ export function useTimelineSync({ const lastScrolledAtEventsLengthRef = useRef(eventsLength); + const eventsLengthRef = useRef(eventsLength); + eventsLengthRef.current = eventsLength; + useLiveEventArrive( room, useCallback( @@ -496,6 +499,9 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } + scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth'); + lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; + setTimeline((ct) => ({ ...ct })); return; } @@ -505,7 +511,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, isAtBottomRef, unreadInfo, setUnreadInfo, hideReadsRef] + [mx, room, isAtBottomRef, unreadInfo, scrollToBottom, setUnreadInfo, hideReadsRef] ) ); @@ -530,10 +536,10 @@ export function useTimelineSync({ const wasAtBottom = isAtBottomRef.current; resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - // Scroll is handled by the useLayoutEffect auto-scroll recovery which - // fires after React commits the new timeline state — scrolling here - // would operate on the pre-commit DOM with a stale scrollSize. - }, [room, isAtBottomRef]) + if (wasAtBottom) { + scrollToBottom('instant'); + } + }, [room, isAtBottomRef, scrollToBottom]) ); useRelationUpdate( From 4148bfad08a8abd01b2289e0125434a5dfc614aa Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 22:26:19 -0400 Subject: [PATCH 10/10] fix(timeline): align scrollToBottom with upstream, fix eventId race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove behavior parameter from scrollToBottom — always use scrollTo(scrollSize) matching upstream. The smooth scrollToIndex was scrolling to stale lastIndex (before new item measured), leaving new messages below the fold. - Revert auto-scroll recovery from useLayoutEffect back to useEffect (matches upstream). useLayoutEffect fires before VList measures new items and before setAtBottom(false) in eventId effect. - Remove stale scrollCacheForRoomRef that referenced missing imports. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 17 ++----------- .../hooks/timeline/useTimelineSync.test.tsx | 2 +- src/app/hooks/timeline/useTimelineSync.ts | 25 ++++++------------- 3 files changed, 10 insertions(+), 34 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 1bf5f9e14..bb5d91b58 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -201,13 +201,6 @@ 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(mxUserId, room.roomId) - ); const [atBottomState, setAtBottomState] = useState(!eventId); const atBottomRef = useRef(atBottomState); const setAtBottom = useCallback((val: boolean) => { @@ -256,15 +249,11 @@ export function RoomTimeline({ const processedEventsRef = useRef([]); const timelineSyncRef = useRef(null as unknown as typeof timelineSync); - const scrollToBottom = useCallback((behavior?: 'instant' | 'smooth') => { + const scrollToBottom = useCallback(() => { if (!vListRef.current) return; const lastIndex = processedEventsRef.current.length - 1; if (lastIndex < 0) return; - if (behavior === 'smooth') { - vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: true }); - } else { - vListRef.current.scrollTo(vListRef.current.scrollSize); - } + vListRef.current.scrollTo(vListRef.current.scrollSize); }, []); const timelineSync = useTimelineSync({ @@ -455,8 +444,6 @@ export function RoomTimeline({ useEffect(() => { if (!eventId) return; setIsReady(false); - // Ensure auto-scroll to bottom doesn't fire while we're navigating to a - // specific event — atBottom will be updated correctly once the user scrolls. setAtBottom(false); timelineSyncRef.current.loadEventTimeline(eventId); }, [eventId, room.roomId, setAtBottom]); diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index 0b89fb6a2..61bc0cbdf 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -132,7 +132,7 @@ describe('useTimelineSync', () => { 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 f6d50c904..dda1e0207 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -1,13 +1,4 @@ -import { - useState, - useMemo, - useCallback, - useRef, - useEffect, - useLayoutEffect, - Dispatch, - SetStateAction, -} from 'react'; +import { useState, useMemo, useCallback, useRef, useEffect, Dispatch, SetStateAction } from 'react'; import to from 'await-to-js'; import * as Sentry from '@sentry/react'; import { @@ -360,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; @@ -469,7 +460,7 @@ export function useTimelineSync({ useCallback(() => { if (!alive()) return; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - scrollToBottom('instant'); + scrollToBottom(); }, [alive, room, scrollToBottom]) ); @@ -499,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 })); @@ -537,7 +528,7 @@ export function useTimelineSync({ resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); if (wasAtBottom) { - scrollToBottom('instant'); + scrollToBottom(); } }, [room, isAtBottomRef, scrollToBottom]) ); @@ -556,9 +547,7 @@ export function useTimelineSync({ }, []) ); - // useLayoutEffect so scroll fires before paint — prevents the one-frame flash - // where new VList content is briefly visible at the wrong position. - useLayoutEffect(() => { + useEffect(() => { const resetAutoScrollPending = resetAutoScrollPendingRef.current; if (resetAutoScrollPending) resetAutoScrollPendingRef.current = false; @@ -576,7 +565,7 @@ export function useTimelineSync({ if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return; lastScrolledAtEventsLengthRef.current = eventsLength; - scrollToBottom('instant'); + scrollToBottom(); }, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]); useEffect(() => {