From 64f531b4c3c2874fe61a4d239968b32a8f2aa193 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 20:05:50 -0400 Subject: [PATCH 01/62] perf: memoize VList timeline items to prevent mass re-renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useTimelineSync: add mutationVersion counter, incremented only on mutations (reactions, edits, local-echo, thread updates) via a new triggerMutation() callback. Live event arrivals do NOT bump it — the eventsLength change already signals those. - useProcessedTimeline: add stableRefsCache (useRef) that reuses the same ProcessedEvent object across renders when mutationVersion is unchanged and structural fields (collapsed, dividers, eventSender) are identical. New mutationVersion bypasses the cache so fresh objects reach React on actual content mutations. - RoomTimeline: define TimelineItem as React.memo outside the component function so the type is stable. Render via renderFnRef (synchronously updated each cycle) to avoid stale closures without adding to deps. Per-item boolean props (isHighlighted, isEditing, isReplying, isOpenThread) and a settingsEpoch object let memo skip re-renders on unchanged items while still re-rendering the one item that changed. vListIndices deps changed from timelineSync.timeline (always a new object from spread) to timelineSync.timeline.linkedTimelines + timelineSync.mutationVersion. Expected gains: Scrolling: 0 item re-renders (was: all visible items) New message: 1 item re-renders (was: all) focusItem/editId change: 1-2 items (was: all) Reactions/edits/mutations: all items (same as before, content changed) Settings change: all items via settingsEpoch (same as before) --- src/app/features/room/RoomTimeline.tsx | 120 ++++++++++++++++-- .../hooks/timeline/useProcessedTimeline.ts | 44 ++++++- src/app/hooks/timeline/useTimelineSync.ts | 29 +++-- 3 files changed, 168 insertions(+), 25 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d026ec1fa..858807edb 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1,6 +1,7 @@ import { Fragment, ReactNode, + memo, useCallback, useEffect, useLayoutEffect, @@ -79,6 +80,49 @@ import { ProcessedEvent, useProcessedTimeline } from '$hooks/timeline/useProcess import { useTimelineEventRenderer } from '$hooks/timeline/useTimelineEventRenderer'; import * as css from './RoomTimeline.css'; +/** Render function type passed to the memoized TimelineItem via a ref. */ +type TimelineRenderFn = (eventData: ProcessedEvent) => ReactNode; + +/** + * Renders one timeline item. Defined outside RoomTimeline so React never + * recreates the component type, and wrapped in `memo` so it skips re-renders + * when neither the event data nor any per-item volatile state changed. + * + * The actual rendering is delegated to `renderRef.current` (always the latest + * version of `renderMatrixEvent`, set synchronously during each render cycle) + * so stale-closure issues are avoided. + * + * Props not used in the function body (`isHighlighted`, `isEditing`, etc.) are + * intentionally included: React.memo's default shallow-equality comparator + * inspects ALL props, so changing one of them for a specific item causes only + * that item to re-render (e.g. only the message being edited re-renders when + * editId changes). + */ +interface TimelineItemProps { + data: ProcessedEvent; + renderRef: React.MutableRefObject; + /** Changed when this specific item becomes highlighted / un-highlighted. */ + isHighlighted: boolean; + /** Changed when this specific item enters / exits edit mode. */ + isEditing: boolean; + /** Changed when this specific item is the active reply target. */ + isReplying: boolean; + /** Changed when this specific item's thread drawer is open. */ + isOpenThread: boolean; + /** + * Opaque object whose identity changes when any global render-affecting + * setting changes (layout, spacing, nicknames, permissions…). Forces all + * visible items to re-render when settings change. + */ + settingsEpoch: object; +} + +const TimelineItem = memo(function TimelineItem({ data, renderRef }: TimelineItemProps) { + // isHighlighted, isEditing, isReplying, isOpenThread, settingsEpoch are not + // used here directly — their sole purpose is to guide React.memo comparisons. + return <>{renderRef.current?.(data)}; +}); + const TimelineFloat = as<'div', css.TimelineFloatVariants>( ({ position, className, ...props }, ref) => ( (null); + renderFnRef.current = (eventData: ProcessedEvent) => + renderMatrixEvent( + eventData.mEvent.getType(), + typeof eventData.mEvent.getStateKey() === 'string', + eventData.id, + eventData.mEvent, + eventData.itemIndex, + eventData.timelineSet, + eventData.collapsed + ); + + // Object whose identity changes when any global render-affecting setting + // changes. TimelineItem memo sees the new reference and re-renders all items. + const settingsEpoch = useMemo( + () => ({}), + // Any setting that changes how ALL items are rendered should be listed here. + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + messageLayout, + messageSpacing, + hideReads, + showDeveloperTools, + hour24Clock, + dateFormatString, + mediaAutoLoad, + showBundledPreview, + showUrlPreview, + showClientUrlPreview, + autoplayStickers, + hideMemberInReadOnly, + isReadOnly, + hideMembershipEvents, + hideNickAvatarEvents, + showHiddenEvents, + reducedMotion, + nicknames, + imagePackRooms, + htmlReactParserOptions, + linkifyOpts, + ] + ); + const tryAutoMarkAsRead = useCallback(() => { if (!readUptoEventIdRef.current) { requestAnimationFrame(() => markAsRead(mx, room.roomId, hideReads)); @@ -724,13 +814,20 @@ export function RoomTimeline({ : timelineSync.eventsLength; const vListIndices = useMemo( () => Array.from({ length: vListItemCount }, (_, i) => i), + // timelineSync.timeline.linkedTimelines: recompute when the timeline structure + // changes (pagination, room switch). timelineSync.mutationVersion: recompute + // when event content mutates (reactions, edits) without changing the count. + // Using the linkedTimelines reference (not the timeline wrapper object) means + // a setTimeline spread for a live event arrival does NOT recompute this — the + // eventsLength / vListItemCount change already covers that case. // eslint-disable-next-line react-hooks/exhaustive-deps - [vListItemCount, timelineSync.timeline] + [vListItemCount, timelineSync.timeline.linkedTimelines, timelineSync.mutationVersion] ); const processedEvents = useProcessedTimeline({ items: vListIndices, linkedTimelines: timelineSync.timeline.linkedTimelines, + mutationVersion: timelineSync.mutationVersion, ignoredUsersSet, showHiddenEvents, showTombstoneEvents, @@ -901,14 +998,19 @@ export function RoomTimeline({ return ; } - const renderedEvent = renderMatrixEvent( - eventData.mEvent.getType(), - typeof eventData.mEvent.getStateKey() === 'string', - eventData.id, - eventData.mEvent, - eventData.itemIndex, - eventData.timelineSet, - eventData.collapsed + const renderedEvent = ( + ); const dividers = ( diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index 44e9500a2..daa7eab83 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useRef } from 'react'; import { MatrixEvent, EventTimelineSet, EventTimeline } from '$types/matrix-sdk'; import { getTimelineAndBaseIndex, @@ -20,12 +20,22 @@ export interface UseProcessedTimelineOptions { hideNickAvatarEvents: boolean; isReadOnly: boolean; hideMemberInReadOnly: boolean; + /** /** * When true, skip the filter that removes events whose `threadRootId` points * to a different event. Required when processing a thread's own timeline * where every reply legitimately has `threadRootId` set to the root. */ skipThreadFilter?: boolean; + /** + * Increment this whenever existing event content mutates (reactions, edits, + * thread updates, local-echo). When it changes, `useProcessedTimeline` + * creates fresh `ProcessedEvent` objects so downstream `React.memo` item + * components re-render to reflect updated content. When unchanged (e.g. a + * new event was appended), existing objects are reused by identity, letting + * memo bail out for unchanged items. + */ + mutationVersion: number; } export interface ProcessedEvent { @@ -62,8 +72,23 @@ export function useProcessedTimeline({ isReadOnly, hideMemberInReadOnly, skipThreadFilter, + mutationVersion, }: UseProcessedTimelineOptions): ProcessedEvent[] { + // Stable-ref cache: reuse the same ProcessedEvent object for an event when + // nothing structural changed. This lets React.memo on item components bail + // out for the majority of items when only a new message was appended. + const stableRefsCache = useRef>(new Map()); + const prevMutationVersionRef = useRef(-1); + return useMemo(() => { + // When mutationVersion changes, existing event content has mutated (reaction + // added, message edited, local-echo updated, thread reply). Create fresh + // objects so memo item components re-render. When version is unchanged (only + // items count changed), reuse cached refs for structurally-identical events. + const isMutation = mutationVersion !== prevMutationVersionRef.current; + prevMutationVersionRef.current = mutationVersion; + const prevCache = isMutation ? null : stableRefsCache.current; + let prevEvent: MatrixEvent | undefined; let isPrevRendered = false; let newDivider = false; @@ -179,18 +204,33 @@ export function useProcessedTimeline({ willRenderDayDivider, }; + // Reuse the previous ProcessedEvent object if all structural fields match, + // so that React.memo on timeline item components can bail out cheaply. + const prev = prevCache?.get(mEventId); + const stable = + prev && + prev.collapsed === collapsed && + prev.willRenderNewDivider === willRenderNewDivider && + prev.willRenderDayDivider === willRenderDayDivider && + prev.eventSender === eventSender + ? prev + : processed; + prevEvent = mEvent; isPrevRendered = true; if (willRenderNewDivider) newDivider = false; if (willRenderDayDivider) dayDivider = false; - acc.push(processed); + acc.push(stable); return acc; }, []); + // Update the stable-ref cache for the next render. + stableRefsCache.current = new Map(result.map((e) => [e.id, e])); return result; }, [ items, linkedTimelines, + mutationVersion, ignoredUsersSet, showHiddenEvents, showTombstoneEvents, diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 51c85dda8..ca52b5442 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -376,6 +376,16 @@ export function useTimelineSync({ eventId ? getEmptyTimeline() : { linkedTimelines: getInitialTimeline(room).linkedTimelines } ); + // Incremented whenever existing event content mutates (reactions, edits, thread + // updates, local-echo status) but NOT on live-event arrivals (those are signalled + // by eventsLength increasing). Consumers use this to decide whether to + // re-create ProcessedEvent objects for stable-ref memoization. + const [mutationVersion, setMutationVersion] = useState(0); + const triggerMutation = useCallback(() => { + setTimeline((ct) => ({ ...ct })); + setMutationVersion((v) => v + 1); + }, []); + const [focusItem, setFocusItem] = useState< | { index: number; @@ -512,14 +522,14 @@ export function useTimelineSync({ eventRoom: Room | undefined ) => { if (eventRoom?.roomId !== room.roomId) return; - setTimeline((ct) => ({ ...ct })); + triggerMutation(); }; room.on(RoomEvent.LocalEchoUpdated, handleLocalEchoUpdated); return () => { room.removeListener(RoomEvent.LocalEchoUpdated, handleLocalEchoUpdated); }; - }, [room, setTimeline]); + }, [room, triggerMutation]); useLiveTimelineRefresh( room, @@ -533,19 +543,9 @@ export function useTimelineSync({ }, [room, isAtBottomRef, scrollToBottom]) ); - useRelationUpdate( - room, - useCallback(() => { - setTimeline((ct) => ({ ...ct })); - }, []) - ); + useRelationUpdate(room, triggerMutation); - useThreadUpdate( - room, - useCallback(() => { - setTimeline((ct) => ({ ...ct })); - }, []) - ); + useThreadUpdate(room, triggerMutation); useEffect(() => { const resetAutoScrollPending = resetAutoScrollPendingRef.current; @@ -605,5 +605,6 @@ export function useTimelineSync({ loadEventTimeline, focusItem, setFocusItem, + mutationVersion, }; } From c9f45378f26d8f2da53c26fa36b91c26f74e107c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 20:39:12 -0400 Subject: [PATCH 02/62] fix(timeline): make mutationVersion optional in UseProcessedTimelineOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ThreadDrawer calls useProcessedTimeline without an equivalent mutation counter (it doesn't use useTimelineSync). Making the field optional with a default of 0 means: - ThreadDrawer gets stable-ref caching for free on subsequent renders (isMutation=false after first render), which is correct — it doesn't wrap items in React.memo TimelineItem. - RoomTimeline continues to pass the real mutationVersion so its TimelineItem memo components are refreshed when content mutates. - pnpm typecheck / pnpm build no longer fail with TS2345. --- src/app/hooks/timeline/useProcessedTimeline.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index daa7eab83..ca0f4e1aa 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -34,8 +34,13 @@ export interface UseProcessedTimelineOptions { * components re-render to reflect updated content. When unchanged (e.g. a * new event was appended), existing objects are reused by identity, letting * memo bail out for unchanged items. + * + * Optional — defaults to 0 (stable refs always applied after first render). + * Call sites that do NOT use `React.memo` item components (e.g. `ThreadDrawer`) + * can omit this; the SDK mutates `mEvent` in place so rendered content stays + * correct regardless of object identity. */ - mutationVersion: number; + mutationVersion?: number; } export interface ProcessedEvent { @@ -72,7 +77,7 @@ export function useProcessedTimeline({ isReadOnly, hideMemberInReadOnly, skipThreadFilter, - mutationVersion, + mutationVersion = 0, }: UseProcessedTimelineOptions): ProcessedEvent[] { // Stable-ref cache: reuse the same ProcessedEvent object for an event when // nothing structural changed. This lets React.memo on item components bail From 885cf0bcb69756e6bc99a4cc7831d2fe94951534 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 22:51:21 -0400 Subject: [PATCH 03/62] fix(timeline): satisfy lint rules in TimelineItem memo component --- src/app/features/room/RoomTimeline.tsx | 27 +++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 858807edb..a075ceafc 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -101,27 +101,28 @@ type TimelineRenderFn = (eventData: ProcessedEvent) => ReactNode; interface TimelineItemProps { data: ProcessedEvent; renderRef: React.MutableRefObject; - /** Changed when this specific item becomes highlighted / un-highlighted. */ + // The props below are not read in the component body — they exist solely so + // React.memo's shallow-equality comparator sees them and re-renders only the + // affected item when they change. + // eslint-disable-next-line react/no-unused-prop-types isHighlighted: boolean; - /** Changed when this specific item enters / exits edit mode. */ + // eslint-disable-next-line react/no-unused-prop-types isEditing: boolean; - /** Changed when this specific item is the active reply target. */ + // eslint-disable-next-line react/no-unused-prop-types isReplying: boolean; - /** Changed when this specific item's thread drawer is open. */ + // eslint-disable-next-line react/no-unused-prop-types isOpenThread: boolean; - /** - * Opaque object whose identity changes when any global render-affecting - * setting changes (layout, spacing, nicknames, permissions…). Forces all - * visible items to re-render when settings change. - */ + // eslint-disable-next-line react/no-unused-prop-types settingsEpoch: object; } -const TimelineItem = memo(function TimelineItem({ data, renderRef }: TimelineItemProps) { - // isHighlighted, isEditing, isReplying, isOpenThread, settingsEpoch are not - // used here directly — their sole purpose is to guide React.memo comparisons. +// Declared outside memo() so the callback receives a reference, not an inline +// function expression (satisfies prefer-arrow-callback). +function TimelineItemInner({ data, renderRef }: TimelineItemProps) { return <>{renderRef.current?.(data)}; -}); +} +const TimelineItem = memo(TimelineItemInner); +TimelineItem.displayName = 'TimelineItem'; const TimelineFloat = as<'div', css.TimelineFloatVariants>( ({ position, className, ...props }, ref) => ( From 70e4552537eda254c88ab7ceaf4dfab73a9f41ec Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 22:51:34 -0400 Subject: [PATCH 04/62] chore: add changeset for perf-timeline-item-memo --- .changeset/perf-timeline-item-memo.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/perf-timeline-item-memo.md diff --git a/.changeset/perf-timeline-item-memo.md b/.changeset/perf-timeline-item-memo.md new file mode 100644 index 000000000..1471e3d0a --- /dev/null +++ b/.changeset/perf-timeline-item-memo.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Memoize individual VList timeline items to prevent mass re-renders when unrelated state changes (e.g. typing indicators, read receipts, or new messages while not at the bottom). From 2b9758fd202d0c3cc51051ae204de60b82913bb5 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 23:19:45 -0400 Subject: [PATCH 05/62] fix(timeline): preempt atBottom to prevent Jump to Latest flashing at bottom --- src/app/features/room/RoomTimeline.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index a075ceafc..278ef1a1b 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -355,6 +355,9 @@ export function RoomTimeline({ // 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; @@ -364,7 +367,13 @@ export function RoomTimeline({ } // 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). @@ -851,8 +860,11 @@ export function RoomTimeline({ if (processedEvents.length === 0) return; pendingReadyRef.current = false; vListRef.current?.scrollToIndex(processedEvents.length - 1, { align: 'end' }); + // 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, setAtBottom]); useEffect(() => { if (!onEditLastMessageRef) return; From 8aea0a1717461d34601d46d4e14f8ce66496cc4c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 10 Apr 2026 08:58:01 -0400 Subject: [PATCH 06/62] fix(timeline): suppress intermediate VList scroll events after programmatic scroll-to-bottom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After setIsReady(true) commits, virtua can fire onScroll events with isNowAtBottom=false during its height-correction pass (particularly on first visit when item heights above the viewport haven't been rendered yet). These intermediate events were driving atBottomState to false while isReady=true, flashing the 'Jump to Latest' button. Add programmaticScrollToBottomRef: set it before each scrollToIndex bottom-scroll, suppress the first intermediate false event (clearing the guard immediately), so the next event — the corrected position or a real user scroll — is processed normally. --- src/app/features/room/RoomTimeline.tsx | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 278ef1a1b..cde3358e8 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -267,6 +267,12 @@ 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); @@ -276,6 +282,7 @@ export function RoomTimeline({ 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; @@ -350,6 +357,7 @@ export function RoomTimeline({ initialScrollTimerRef.current = setTimeout(() => { initialScrollTimerRef.current = undefined; if (processedEventsRef.current.length > 0) { + programmaticScrollToBottomRef.current = true; 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 @@ -731,8 +739,20 @@ 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; + } } if (offset < 500 && canPaginateBackRef.current && backwardStatusRef.current === 'idle') { @@ -859,6 +879,7 @@ 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' }); // scrollToIndex is async; pre-empt atBottom so the "Jump to Latest" button // doesn't flash for one render cycle before onScroll confirms the position. From 7322febf6128b40248a75ad9f4bfb82d16bce9d0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 10 Apr 2026 18:10:50 -0400 Subject: [PATCH 07/62] fix(timeline): set programmatic guard in scrollToBottom; keep guard active through all intermediate VList events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously programmaticScrollToBottomRef was only set at a few specific call-sites and cleared after the first suppressed intermediate event. VList fires several height-correction scroll events after scrollTo(); the second one (after the clear) would call setAtBottom(false) and flash "Jump to Latest". - Move programmaticScrollToBottomRef.current = true into scrollToBottom() itself so all callers (live message arrival, timeline refresh, auto-scroll effect) are automatically guarded without missing a call-site. - Remove the guard clear in the else branch; the guard now stays active until VList explicitly confirms isNowAtBottom = true. fix(notifications): skip loadEventTimeline when event is already in live timeline When a notification tap opens a room, NotificationJumper was always navigating with the eventId URL path which triggered loadEventTimeline → roomInitialSync. If sliding sync had already delivered the event to the live timeline this produced a sparse historical slice that (a) looked like a brand-new chat and (b) left the room empty when the user navigated away and returned without the eventId. Check whether the event is in the live timeline before navigating; if it is present, open the room at the live bottom instead. Historical events still use the eventId path. --- src/app/features/room/RoomTimeline.tsx | 16 +++++++++------- src/app/hooks/useNotificationJumper.ts | 25 +++++++++++++++++++++---- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index cde3358e8..04afe2422 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -297,6 +297,9 @@ 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 = true; vListRef.current.scrollTo(vListRef.current.scrollSize); }, []); @@ -745,14 +748,13 @@ export function RoomTimeline({ if (isNowAtBottom !== atBottomRef.current) { 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; } + // else: programmatic guard active — suppress the false-negative and keep + // the guard set. VList can fire several intermediate "not at bottom" + // events while it corrects item heights after a scrollTo(); clearing the + // guard on the first one would let the second cause a spurious + // setAtBottom(false) and flash the "Jump to Latest" button. The guard + // is cleared above (unconditionally) when isNowAtBottom becomes true. } if (offset < 500 && canPaginateBackRef.current && backwardStatusRef.current === 'idle') { diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index 43c358317..1eabd1cf4 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -52,13 +52,30 @@ export function NotificationJumper() { const isJoined = room?.getMyMembership() === 'join'; if (isSyncing && isJoined) { - log.log('jumping to:', pending.roomId, pending.eventId); + // If the notification event is already in the room's live timeline (i.e. + // sliding sync has already delivered it), open the room at the live bottom + // rather than using the eventId URL path. The eventId path triggers + // loadEventTimeline → roomInitialSync which loads a historical slice that + // (a) may look like a brand-new chat if the event is the only one in the + // slice, and (b) makes the room appear empty when the user navigates away + // and returns without the eventId, because the sliding-sync live timeline + // hasn't been populated yet. Omitting the eventId for events already in + // the live timeline lets the room open normally at the bottom where the + // new message is visible. Historical events (not in live timeline) still + // use the eventId so loadEventTimeline can jump to the right context. + const liveEvents = room?.getUnfilteredTimelineSet?.()?.getLiveTimeline?.()?.getEvents?.(); + const eventInLive = + pending.eventId && liveEvents + ? liveEvents.some((e) => e.getId() === pending.eventId) + : false; + const resolvedEventId = eventInLive ? undefined : pending.eventId; + log.log('jumping to:', pending.roomId, resolvedEventId, { eventInLive }); jumpingRef.current = true; // Navigate directly to home or direct path — bypasses space routing which // on mobile shows the space-nav panel first instead of the room timeline. const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, pending.roomId); if (mDirects.has(pending.roomId)) { - navigate(getDirectRoomPath(roomIdOrAlias, pending.eventId)); + navigate(getDirectRoomPath(roomIdOrAlias, resolvedEventId)); } else { // If the room lives inside a space, route through the space path so // SpaceRouteRoomProvider can resolve it — HomeRouteRoomProvider only @@ -74,11 +91,11 @@ export function NotificationJumper() { getSpaceRoomPath( getCanonicalAliasOrRoomId(mx, parentSpace), roomIdOrAlias, - pending.eventId + resolvedEventId ) ); } else { - navigate(getHomeRoomPath(roomIdOrAlias, pending.eventId)); + navigate(getHomeRoomPath(roomIdOrAlias, resolvedEventId)); } } setPending(null); From bc4eb26e6854971190b091262c5bcf974f2b4100 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 11:13:25 -0400 Subject: [PATCH 08/62] fix(notifications): defer notification jump until live timeline is non-empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the app wakes from a killed state and the user taps a notification, performJump fires during the initial sync window before the room's live timeline has been populated by sliding sync. Previously, eventInLive was always false in this case, so we fell back to loadEventTimeline → roomInitialSync which loaded a sparse historical slice. On subsequent visits to the room without the eventId the room appeared empty because the live timeline was never populated for the initial roomInitialSync result. Two changes: 1. Guard: if the live timeline is completely empty, return early and wait rather than navigating — the RoomEvent.Timeline listener below retries once events start arriving. 2. Listen on RoomEvent.Timeline for the target room so performJump re-runs as soon as the first event arrives in the room, at which point the notification event is almost certainly also present so we can navigate without eventId (avoiding loadEventTimeline entirely). --- src/app/hooks/useNotificationJumper.ts | 30 ++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index 1eabd1cf4..21ebac96f 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useAtom, useAtomValue } from 'jotai'; import { useNavigate } from 'react-router-dom'; -import { SyncState, ClientEvent } from '$types/matrix-sdk'; +import { SyncState, ClientEvent, Room, RoomEvent, RoomEventHandlerMap } from '$types/matrix-sdk'; import { activeSessionIdAtom, pendingNotificationAtom } from '../state/sessions'; import { mDirectAtom } from '../state/mDirectList'; import { useSyncState } from './useSyncState'; @@ -63,11 +63,19 @@ export function NotificationJumper() { // the live timeline lets the room open normally at the bottom where the // new message is visible. Historical events (not in live timeline) still // use the eventId so loadEventTimeline can jump to the right context. - const liveEvents = room?.getUnfilteredTimelineSet?.()?.getLiveTimeline?.()?.getEvents?.(); - const eventInLive = - pending.eventId && liveEvents - ? liveEvents.some((e) => e.getId() === pending.eventId) - : false; + const liveEvents = + room?.getUnfilteredTimelineSet?.()?.getLiveTimeline?.()?.getEvents?.() ?? []; + const eventInLive = pending.eventId + ? liveEvents.some((e) => e.getId() === pending.eventId) + : false; + // If the live timeline is empty the room hasn't been populated by sliding + // sync yet. Defer navigation and let the RoomEvent.Timeline listener below + // retry once events arrive — by then the notification event will almost + // certainly be in the live timeline and we can skip loadEventTimeline. + if (!eventInLive && liveEvents.length === 0) { + log.log('live timeline empty, deferring jump...', { roomId: pending.roomId }); + return; + } const resolvedEventId = eventInLive ? undefined : pending.eventId; log.log('jumping to:', pending.roomId, resolvedEventId, { eventInLive }); jumpingRef.current = true; @@ -134,11 +142,21 @@ export function NotificationJumper() { if (!pending) return undefined; const onRoom = () => performJumpRef.current(); + // Re-check once events arrive in the target room — this fires shortly after + // the initial sync populates the live timeline, letting us verify whether + // the notification event is already there before falling back to + // loadEventTimeline (which creates a sparse historical slice that may make + // the room appear empty on subsequent visits without the eventId). + const onTimeline = (_evt: unknown, eventRoom: Room | undefined) => { + if (eventRoom?.roomId === pending.roomId) performJumpRef.current(); + }; mx.on(ClientEvent.Room, onRoom); + mx.on(RoomEvent.Timeline, onTimeline as RoomEventHandlerMap[RoomEvent.Timeline]); performJumpRef.current(); return () => { mx.removeListener(ClientEvent.Room, onRoom); + mx.removeListener(RoomEvent.Timeline, onTimeline as RoomEventHandlerMap[RoomEvent.Timeline]); }; }, [pending, mx]); // performJump intentionally omitted — use ref above From c3d7cf5c4b77cf8f310593625936e4e18f9ee01b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 11:15:18 -0400 Subject: [PATCH 09/62] fix(timeline): skip scroll-cache save when viewing a historical eventId slice When the user taps a push notification and the app loads a historical sparse timeline via loadEventTimeline (eventId URL path), the VList item heights for those few events were being written to the room's scroll cache. On the next visit to the room (live timeline, many more events), the RoomTimeline mount read the stale cache and passed its heights to the new VList instance. The height mismatch between the sparse and live timelines caused incorrect scroll-position restoration, making the room appear to show stale or mispositioned messages. Guard the roomScrollCache.save call with !eventId so historical views never overwrite the live-timeline cache. The next live visit will either use the pre-existing (untouched) live cache or fall back to the first-visit 80 ms measurement path. --- src/app/features/room/RoomTimeline.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 04afe2422..bafa13776 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -757,6 +757,20 @@ export function RoomTimeline({ // is cleared above (unconditionally) when isNowAtBottom becomes true. } + // 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. + // Skip when viewing a historical slice via eventId: those item heights are + // for a sparse subset of events and would corrupt the cache for the next + // live-timeline visit, producing stale VList measurements and making the + // room appear to be at the wrong position (or visually empty) on re-entry. + if (!eventId) { + roomScrollCache.save(room.roomId, { + cache: v.cache, + scrollOffset: offset, + atBottom: isNowAtBottom, + }); + } + if (offset < 500 && canPaginateBackRef.current && backwardStatusRef.current === 'idle') { timelineSyncRef.current.handleTimelinePagination(true); } @@ -768,7 +782,7 @@ export function RoomTimeline({ timelineSyncRef.current.handleTimelinePagination(false); } }, - [setAtBottom] + [setAtBottom, room.roomId, eventId] ); const showLoadingPlaceholders = From 76c8f70ae499d5172031c93d8949ced768c51751 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 12:34:34 -0400 Subject: [PATCH 10/62] test(timeline): add useProcessedTimeline unit tests + fix stale itemIndex in stable-ref check --- .../timeline/useProcessedTimeline.test.ts | 290 ++++++++++++++++++ .../hooks/timeline/useProcessedTimeline.ts | 4 + 2 files changed, 294 insertions(+) create mode 100644 src/app/hooks/timeline/useProcessedTimeline.test.ts diff --git a/src/app/hooks/timeline/useProcessedTimeline.test.ts b/src/app/hooks/timeline/useProcessedTimeline.test.ts new file mode 100644 index 000000000..cc6f00072 --- /dev/null +++ b/src/app/hooks/timeline/useProcessedTimeline.test.ts @@ -0,0 +1,290 @@ +import { renderHook } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import type { EventTimeline, EventTimelineSet, MatrixEvent } from '$types/matrix-sdk'; +import { useProcessedTimeline } from './useProcessedTimeline'; + +// --------------------------------------------------------------------------- +// Minimal fakes +// --------------------------------------------------------------------------- + +function makeEvent( + id: string, + opts: { + sender?: string; + type?: string; + ts?: number; + content?: Record; + } = {} +): MatrixEvent { + const { + sender = '@alice:test', + type = 'm.room.message', + ts = 1_000, + content = { body: 'hello' }, + } = opts; + return { + getId: () => id, + getSender: () => sender, + isRedacted: () => false, + getTs: () => ts, + getType: () => type, + threadRootId: undefined, + getContent: () => content, + getRelation: () => null, + isRedaction: () => false, + } as unknown as MatrixEvent; +} + +const fakeTimelineSet = {} as EventTimelineSet; + +function makeTimeline(events: MatrixEvent[]): EventTimeline { + return { + getEvents: () => events, + getTimelineSet: () => fakeTimelineSet, + } as unknown as EventTimeline; +} + +/** Default options — keeps tests concise; individual tests override what they need. */ +const defaults = { + ignoredUsersSet: new Set(), + showHiddenEvents: false, + showTombstoneEvents: false, + mxUserId: '@alice:test', + readUptoEventId: undefined, + hideMembershipEvents: false, + hideNickAvatarEvents: false, + isReadOnly: false, + hideMemberInReadOnly: false, +} as const; + +// --------------------------------------------------------------------------- +// Helpers to derive `items` from a linked-timeline list +// index 0 = first event in first timeline, etc. +// --------------------------------------------------------------------------- +function makeItems(count: number, startIndex = 0): number[] { + return Array.from({ length: count }, (_, i) => startIndex + i); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useProcessedTimeline', () => { + it('returns an empty array when there are no events', () => { + const { result } = renderHook(() => + useProcessedTimeline({ + ...defaults, + items: [], + linkedTimelines: [makeTimeline([])], + }) + ); + expect(result.current).toHaveLength(0); + }); + + it('returns one ProcessedEvent per visible event', () => { + const events = [makeEvent('$e1'), makeEvent('$e2'), makeEvent('$e3')]; + const timeline = makeTimeline(events); + + const { result } = renderHook(() => + useProcessedTimeline({ + ...defaults, + items: makeItems(3), + linkedTimelines: [timeline], + }) + ); + + expect(result.current).toHaveLength(3); + expect(result.current[0].id).toBe('$e1'); + expect(result.current[2].id).toBe('$e3'); + }); + + it('collapses consecutive messages from the same sender within 2 minutes', () => { + const events = [ + makeEvent('$e1', { ts: 1_000 }), + makeEvent('$e2', { ts: 60_000 }), // same sender, ~1 min later + ]; + const timeline = makeTimeline(events); + + const { result } = renderHook(() => + useProcessedTimeline({ + ...defaults, + items: makeItems(2), + linkedTimelines: [timeline], + }) + ); + + expect(result.current[1].collapsed).toBe(true); + }); + + it('does NOT collapse messages from the same sender more than 2 minutes apart', () => { + const events = [ + makeEvent('$e1', { ts: 1_000 }), + makeEvent('$e2', { ts: 3 * 60_000 }), // 3 min later + ]; + const timeline = makeTimeline(events); + + const { result } = renderHook(() => + useProcessedTimeline({ + ...defaults, + items: makeItems(2), + linkedTimelines: [timeline], + }) + ); + + expect(result.current[1].collapsed).toBe(false); + }); + + // ------------------------------------------------------------------------- + // Stable-ref optimisation + // ------------------------------------------------------------------------- + + it('reuses the same ProcessedEvent reference when nothing changed (stable-ref)', () => { + const events = [makeEvent('$e1'), makeEvent('$e2')]; + const timeline = makeTimeline(events); + const items = makeItems(2); + + const { result, rerender } = renderHook( + ({ ver }) => + useProcessedTimeline({ + ...defaults, + items, + linkedTimelines: [timeline], + mutationVersion: ver, + }), + { initialProps: { ver: 0 } } + ); + + const firstRender = result.current; + + // Re-render with the same mutationVersion — refs should be reused + rerender({ ver: 0 }); + + expect(result.current[0]).toBe(firstRender[0]); + expect(result.current[1]).toBe(firstRender[1]); + }); + + it('creates fresh ProcessedEvent objects when mutationVersion increments', () => { + const events = [makeEvent('$e1'), makeEvent('$e2')]; + const timeline = makeTimeline(events); + const items = makeItems(2); + + const { result, rerender } = renderHook( + ({ ver }) => + useProcessedTimeline({ + ...defaults, + items, + linkedTimelines: [timeline], + mutationVersion: ver, + }), + { initialProps: { ver: 0 } } + ); + + const firstRender = result.current; + + // Bump mutation version — stale refs must not be reused + rerender({ ver: 1 }); + + expect(result.current[0]).not.toBe(firstRender[0]); + expect(result.current[1]).not.toBe(firstRender[1]); + }); + + it('creates fresh ProcessedEvent objects when itemIndex shifts after back-pagination', () => { + // Initial: one event at index 0 + const existingEvent = makeEvent('$existing'); + const timelineV1 = makeTimeline([existingEvent]); + + const { result, rerender } = renderHook( + ({ linkedTimelines, items }: { linkedTimelines: EventTimeline[]; items: number[] }) => + useProcessedTimeline({ + ...defaults, + items, + linkedTimelines, + mutationVersion: 0, // unchanged — only the itemIndex changes + }), + { + initialProps: { + linkedTimelines: [timelineV1], + items: [0], + }, + } + ); + + const firstRef = result.current[0]; + expect(firstRef.id).toBe('$existing'); + expect(firstRef.itemIndex).toBe(0); + + // Back-pagination prepends a new event at the front — existing event now at index 1 + const newEvent = makeEvent('$new'); + const timelineV2 = makeTimeline([newEvent, existingEvent]); + + rerender({ linkedTimelines: [timelineV2], items: [0, 1] }); + + const existingProcessed = result.current.find((e) => e.id === '$existing')!; + // itemIndex must be 1 (updated), NOT 0 (stale from previous render) + expect(existingProcessed.itemIndex).toBe(1); + // And it must be a new object, not the stale cached ref + expect(existingProcessed).not.toBe(firstRef); + }); + + it('filters events from ignored users', () => { + const events = [ + makeEvent('$e1', { sender: '@alice:test' }), + makeEvent('$e2', { sender: '@ignored:test' }), + makeEvent('$e3', { sender: '@alice:test' }), + ]; + const timeline = makeTimeline(events); + + const { result } = renderHook(() => + useProcessedTimeline({ + ...defaults, + items: makeItems(3), + linkedTimelines: [timeline], + ignoredUsersSet: new Set(['@ignored:test']), + }) + ); + + const ids = result.current.map((e) => e.id); + expect(ids).not.toContain('$e2'); + expect(ids).toContain('$e1'); + expect(ids).toContain('$e3'); + }); + + it('places willRenderNewDivider on the event immediately after readUptoEventId', () => { + const events = [ + makeEvent('$read', { sender: '@bob:test', ts: 1_000 }), + makeEvent('$new', { sender: '@bob:test', ts: 2_000 }), + ]; + const timeline = makeTimeline(events); + + const { result } = renderHook(() => + useProcessedTimeline({ + ...defaults, + items: makeItems(2), + linkedTimelines: [timeline], + mxUserId: '@alice:test', // different from sender so divider renders + readUptoEventId: '$read', + }) + ); + + expect(result.current[1].willRenderNewDivider).toBe(true); + }); + + it('places willRenderDayDivider between events on different calendar days', () => { + const DAY = 86_400_000; + const events = [ + makeEvent('$e1', { ts: 1_000 }), + makeEvent('$e2', { ts: 1_000 + DAY + 1 }), // next day + ]; + const timeline = makeTimeline(events); + + const { result } = renderHook(() => + useProcessedTimeline({ + ...defaults, + items: makeItems(2), + linkedTimelines: [timeline], + }) + ); + + expect(result.current[1].willRenderDayDivider).toBe(true); + }); +}); diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index ca0f4e1aa..eeb088a08 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -211,9 +211,13 @@ export function useProcessedTimeline({ // Reuse the previous ProcessedEvent object if all structural fields match, // so that React.memo on timeline item components can bail out cheaply. + // itemIndex must also be equal: after back-pagination the same eventId + // shifts to a higher VList index, so a stale itemIndex would break + // getRawIndexToProcessedIndex and focus-highlight comparisons. const prev = prevCache?.get(mEventId); const stable = prev && + prev.itemIndex === processed.itemIndex && prev.collapsed === collapsed && prev.willRenderNewDivider === willRenderNewDivider && prev.willRenderDayDivider === willRenderDayDivider && From 32c504c9285f9022292daafbc52328b71401dba6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 18:14:01 -0400 Subject: [PATCH 11/62] perf(timeline): restore VList cache + skip 80 ms timer on room revisit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When navigating back to a previously-visited room, save the VList CacheSnapshot (item heights) and scroll offset on the way out, then on the way back: • pass the snapshot as cache= to VList so item heights do not need to be remeasured (VList is keyed by room.roomId so it gets a fresh instance with the saved measurements) • skip the 80 ms stabilisation timer — the measurements are already known, so the scroll lands immediately and setIsReady(true) is called without the artificial delay First-visit rooms retain the existing 80 ms behaviour unchanged. --- src/app/features/room/RoomTimeline.tsx | 74 +++++++++++++++++++------- src/app/utils/roomScrollCache.ts | 22 ++++++++ 2 files changed, 76 insertions(+), 20 deletions(-) create mode 100644 src/app/utils/roomScrollCache.ts diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index bafa13776..fba1acacf 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -14,6 +14,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, @@ -246,6 +247,8 @@ export function RoomTimeline({ const setOpenThread = useSetAtom(openThreadAtom); const vListRef = useRef(null); + // Scroll cache snapshot loaded for the current room (populated on room change). + const scrollCacheForRoomRef = useRef(undefined); const [atBottomState, setAtBottomState] = useState(true); const atBottomRef = useRef(atBottomState); const setAtBottom = useCallback((val: boolean) => { @@ -278,6 +281,18 @@ export function RoomTimeline({ const [isReady, setIsReady] = useState(false); if (currentRoomIdRef.current !== room.roomId) { + // Save outgoing room's scroll state so we can restore it on revisit. + const outgoing = vListRef.current; + if (outgoing && isReady) { + roomScrollCache.save(currentRoomIdRef.current, { + cache: outgoing.cache, + scrollOffset: outgoing.scrollOffset, + atBottom: atBottomRef.current, + }); + } + // Load incoming room's scroll cache (undefined for first-visit rooms). + scrollCacheForRoomRef.current = roomScrollCache.load(room.roomId); + hasInitialScrolledRef.current = false; mountScrollWindowRef.current = Date.now() + 3000; currentRoomIdRef.current = room.roomId; @@ -353,28 +368,45 @@ 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) { - programmaticScrollToBottomRef.current = true; - 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. - // 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); + 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) { + vListRef.current.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); } 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', + }); + // 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. @@ -1002,8 +1034,10 @@ export function RoomTimeline({ }} > + key={room.roomId} ref={vListRef} data={processedEvents} + cache={scrollCacheForRoomRef.current?.cache} shift={shift} className={css.messageList} style={{ diff --git a/src/app/utils/roomScrollCache.ts b/src/app/utils/roomScrollCache.ts new file mode 100644 index 000000000..7288ae0e4 --- /dev/null +++ b/src/app/utils/roomScrollCache.ts @@ -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(); + +export const roomScrollCache = { + save(roomId: string, data: RoomScrollCache): void { + scrollCacheMap.set(roomId, data); + }, + load(roomId: string): RoomScrollCache | undefined { + return scrollCacheMap.get(roomId); + }, +}; From 57ce5a085a75ab14f323a70438fe30fde277b666 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 18:52:27 -0400 Subject: [PATCH 12/62] fix(timeline): correct scroll-cache save/load paths RoomTimeline mounts fresh per room (key={roomId} in RoomView), so the render-phase room-change block used for save/load never fires. - Init scrollCacheForRoomRef from roomScrollCache.load() on mount so the CacheSnapshot is actually provided to VList on first render. - Save the cache in handleVListScroll (and after the first-visit 80 ms timer) rather than in the unreachable room-change block. - Trim the room-change block to just the load + state-reset path (kept as a defensive fallback for any future scenario where room prop changes without remount). --- src/app/features/room/RoomTimeline.tsx | 37 ++++++++++++++++++-------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index fba1acacf..a3fc69468 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -247,8 +247,13 @@ export function RoomTimeline({ const setOpenThread = useSetAtom(openThreadAtom); const vListRef = useRef(null); - // Scroll cache snapshot loaded for the current room (populated on room change). - const scrollCacheForRoomRef = useRef(undefined); + // 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(room.roomId) + ); const [atBottomState, setAtBottomState] = useState(true); const atBottomRef = useRef(atBottomState); const setAtBottom = useCallback((val: boolean) => { @@ -281,16 +286,8 @@ export function RoomTimeline({ const [isReady, setIsReady] = useState(false); if (currentRoomIdRef.current !== room.roomId) { - // Save outgoing room's scroll state so we can restore it on revisit. - const outgoing = vListRef.current; - if (outgoing && isReady) { - roomScrollCache.save(currentRoomIdRef.current, { - cache: outgoing.cache, - scrollOffset: outgoing.scrollOffset, - atBottom: atBottomRef.current, - }); - } // 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; @@ -394,6 +391,16 @@ export function RoomTimeline({ 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 @@ -803,6 +810,14 @@ export function RoomTimeline({ }); } + // 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); } From 2ee4554692726737c5ce0cb202ab8e08cdde4c4d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 20:57:10 -0400 Subject: [PATCH 13/62] fix(timeline): save scroll cache in pendingReadyRef recovery path --- src/app/features/room/RoomTimeline.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index a3fc69468..6d20b6880 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -947,8 +947,18 @@ export function RoomTimeline({ // 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); + // 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, + }); + } setIsReady(true); - }, [processedEvents.length, setAtBottom]); + }, [processedEvents.length, setAtBottom, room.roomId]); useEffect(() => { if (!onEditLastMessageRef) return; From bdea2e763f8d7ddd6e7d6bb30ec55f1c9e482b7f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 22:51:51 -0400 Subject: [PATCH 14/62] chore: add changeset for perf-timeline-scroll-cache --- .changeset/perf-timeline-scroll-cache.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/perf-timeline-scroll-cache.md diff --git a/.changeset/perf-timeline-scroll-cache.md b/.changeset/perf-timeline-scroll-cache.md new file mode 100644 index 000000000..259a0dd79 --- /dev/null +++ b/.changeset/perf-timeline-scroll-cache.md @@ -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. From 89f463251ceeed08c52a892582d483173f2e7e82 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 23:11:30 -0400 Subject: [PATCH 15/62] fix(timeline): preempt atBottom to prevent Jump to Latest flashing at bottom --- src/app/features/room/RoomTimeline.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 6d20b6880..0edd329b5 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -373,6 +373,9 @@ export function RoomTimeline({ // immediately and skip the 80 ms stabilisation timer entirely. if (savedCache.atBottom) { 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 { vListRef.current.scrollTo(savedCache.scrollOffset); } @@ -957,6 +960,9 @@ export function RoomTimeline({ 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, setAtBottom, room.roomId]); From 394aca23dded5f5acf1d1149bcccbd367b659f9c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 10 Apr 2026 08:55:55 -0400 Subject: [PATCH 16/62] fix(timeline): suppress intermediate VList scroll events after programmatic scroll-to-bottom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After setIsReady(true) commits, virtua can fire onScroll events with isNowAtBottom=false during its height-correction pass (particularly on first visit when item heights above the viewport haven't been rendered yet). These intermediate events were driving atBottomState to false while isReady=true, flashing the 'Jump to Latest' button. Add programmaticScrollToBottomRef: set it before each scrollToIndex bottom-scroll, suppress the first intermediate false event (clearing the guard immediately), so the next event — the corrected position or a real user scroll — is processed normally. --- src/app/features/room/RoomTimeline.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 0edd329b5..8a7ffd2e3 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -372,6 +372,7 @@ export function RoomTimeline({ // 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. @@ -813,14 +814,6 @@ export function RoomTimeline({ }); } - // 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); } From 44c9dd2913598365513faa4dd180d26ef024a932 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 12:44:52 -0400 Subject: [PATCH 17/62] test(timeline): add roomScrollCache unit tests --- src/app/utils/roomScrollCache.test.ts | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/app/utils/roomScrollCache.test.ts diff --git a/src/app/utils/roomScrollCache.test.ts b/src/app/utils/roomScrollCache.test.ts new file mode 100644 index 000000000..f984feaee --- /dev/null +++ b/src/app/utils/roomScrollCache.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { roomScrollCache } from './roomScrollCache'; + +// CacheSnapshot is opaque in tests — cast a plain object. +const fakeCache = () => ({} as import('./roomScrollCache').RoomScrollCache['cache']); + +describe('roomScrollCache', () => { + it('load returns undefined for an unknown roomId', () => { + expect(roomScrollCache.load('!unknown:test')).toBeUndefined(); + }); + + it('stores and retrieves data for a roomId', () => { + const data = { cache: fakeCache(), scrollOffset: 120, atBottom: false }; + roomScrollCache.save('!room1:test', data); + expect(roomScrollCache.load('!room1:test')).toBe(data); + }); + + it('overwrites existing data when saved again for the same roomId', () => { + const first = { cache: fakeCache(), scrollOffset: 50, atBottom: true }; + const second = { cache: fakeCache(), scrollOffset: 200, atBottom: false }; + roomScrollCache.save('!room2:test', first); + roomScrollCache.save('!room2:test', second); + expect(roomScrollCache.load('!room2:test')).toBe(second); + }); + + it('keeps data for separate rooms independent', () => { + const a = { cache: fakeCache(), scrollOffset: 10, atBottom: true }; + const b = { cache: fakeCache(), scrollOffset: 20, atBottom: false }; + roomScrollCache.save('!roomA:test', a); + roomScrollCache.save('!roomB:test', b); + expect(roomScrollCache.load('!roomA:test')).toBe(a); + expect(roomScrollCache.load('!roomB:test')).toBe(b); + }); +}); From ac4fd94fdf8475d77b5b83ce4dc0fe235be3a6ab Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 17:24:33 -0400 Subject: [PATCH 18/62] fix: auto-format roomScrollCache test file for prettier compliance --- src/app/utils/roomScrollCache.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/utils/roomScrollCache.test.ts b/src/app/utils/roomScrollCache.test.ts index f984feaee..e77be9878 100644 --- a/src/app/utils/roomScrollCache.test.ts +++ b/src/app/utils/roomScrollCache.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { roomScrollCache } from './roomScrollCache'; // CacheSnapshot is opaque in tests — cast a plain object. -const fakeCache = () => ({} as import('./roomScrollCache').RoomScrollCache['cache']); +const fakeCache = () => ({}) as import('./roomScrollCache').RoomScrollCache['cache']; describe('roomScrollCache', () => { it('load returns undefined for an unknown roomId', () => { From 781a8e2b90b37458c6dce75e49906020a98e0d3d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 22:45:17 -0400 Subject: [PATCH 19/62] refactor(timeline): replace eslint-disables with custom areEqual comparator for TimelineItem memo The unused-prop-types eslint disables relied on React.memo's default shallow comparator inspecting props not read in the component body. A custom areEqual function makes the re-render triggers explicit and self-documenting. --- src/app/features/room/RoomTimeline.tsx | 29 +++++++++++++------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 8a7ffd2e3..e0305d4a1 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -93,36 +93,37 @@ type TimelineRenderFn = (eventData: ProcessedEvent) => ReactNode; * version of `renderMatrixEvent`, set synchronously during each render cycle) * so stale-closure issues are avoided. * - * Props not used in the function body (`isHighlighted`, `isEditing`, etc.) are - * intentionally included: React.memo's default shallow-equality comparator - * inspects ALL props, so changing one of them for a specific item causes only - * that item to re-render (e.g. only the message being edited re-renders when - * editId changes). + * The custom `areEqual` comparator checks `data`, `isHighlighted`, `isEditing`, + * `isReplying`, `isOpenThread`, and `settingsEpoch` — re-rendering only the + * specific item whose volatile state changed. */ +/* eslint-disable react/no-unused-prop-types -- props consumed in areEqual, not component body */ interface TimelineItemProps { data: ProcessedEvent; renderRef: React.MutableRefObject; - // The props below are not read in the component body — they exist solely so - // React.memo's shallow-equality comparator sees them and re-renders only the - // affected item when they change. - // eslint-disable-next-line react/no-unused-prop-types isHighlighted: boolean; - // eslint-disable-next-line react/no-unused-prop-types isEditing: boolean; - // eslint-disable-next-line react/no-unused-prop-types isReplying: boolean; - // eslint-disable-next-line react/no-unused-prop-types isOpenThread: boolean; - // eslint-disable-next-line react/no-unused-prop-types settingsEpoch: object; } +/* eslint-enable react/no-unused-prop-types */ // Declared outside memo() so the callback receives a reference, not an inline // function expression (satisfies prefer-arrow-callback). function TimelineItemInner({ data, renderRef }: TimelineItemProps) { return <>{renderRef.current?.(data)}; } -const TimelineItem = memo(TimelineItemInner); +const TimelineItem = memo( + TimelineItemInner, + (prev, next) => + prev.data === next.data && + prev.isHighlighted === next.isHighlighted && + prev.isEditing === next.isEditing && + prev.isReplying === next.isReplying && + prev.isOpenThread === next.isOpenThread && + prev.settingsEpoch === next.settingsEpoch +); TimelineItem.displayName = 'TimelineItem'; const TimelineFloat = as<'div', css.TimelineFloatVariants>( From ce11afe96d23e3564f8e9c09f40fed3993cafc01 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 16:23:37 -0400 Subject: [PATCH 20/62] 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 21/62] =?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 22/62] 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 23/62] =?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 4e5b03780a9f0dbaa189383a97da7a9972554f31 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 22:56:15 -0400 Subject: [PATCH 24/62] fix(timeline): address review feedback for timeline-item-memo - Include mEvent and timelineSet in stable-ref comparison to avoid stale references after timeline reset/rebuild - Only pass VList cache snapshot for live timeline (not eventId navigations) --- src/app/features/room/RoomTimeline.tsx | 2 +- src/app/hooks/timeline/useProcessedTimeline.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index e0305d4a1..ded8e385a 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1062,7 +1062,7 @@ export function RoomTimeline({ key={room.roomId} ref={vListRef} data={processedEvents} - cache={scrollCacheForRoomRef.current?.cache} + cache={!eventId ? scrollCacheForRoomRef.current?.cache : undefined} shift={shift} className={css.messageList} style={{ diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index eeb088a08..f37197309 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -217,6 +217,8 @@ export function useProcessedTimeline({ const prev = prevCache?.get(mEventId); const stable = prev && + prev.mEvent === mEvent && + prev.timelineSet === timelineSet && prev.itemIndex === processed.itemIndex && prev.collapsed === collapsed && prev.willRenderNewDivider === willRenderNewDivider && From f78496d0ff6cacc78abb010590e44550e27e6377 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 23:31:55 -0400 Subject: [PATCH 25/62] fix: scope roomScrollCache to userId Keys scroll cache entries by userId:roomId so switching accounts doesn't reuse stale scroll positions from a different session. --- src/app/features/room/RoomTimeline.tsx | 13 +++++------ src/app/utils/roomScrollCache.test.ts | 30 +++++++++++++++++--------- src/app/utils/roomScrollCache.ts | 10 +++++---- 3 files changed, 33 insertions(+), 20 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index ded8e385a..c9bd03403 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -171,6 +171,7 @@ export function RoomTimeline({ onEditLastMessageRef, }: Readonly) { const mx = useMatrixClient(); + const mxUserId = mx.getUserId()!; const alive = useAlive(); const { editId, handleEdit } = useMessageEdit(editor, { onReset: onEditorReset, alive }); @@ -253,7 +254,7 @@ export function RoomTimeline({ // 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(room.roomId) + roomScrollCache.load(mxUserId, room.roomId) ); const [atBottomState, setAtBottomState] = useState(true); const atBottomRef = useRef(atBottomState); @@ -289,7 +290,7 @@ export function RoomTimeline({ 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); + scrollCacheForRoomRef.current = roomScrollCache.load(mxUserId, room.roomId); hasInitialScrolledRef.current = false; mountScrollWindowRef.current = Date.now() + 3000; @@ -400,7 +401,7 @@ export function RoomTimeline({ // can provide them to VList upfront and skip this 80 ms wait entirely. const v = vListRef.current; if (v) { - roomScrollCache.save(room.roomId, { + roomScrollCache.save(mxUserId, room.roomId, { cache: v.cache, scrollOffset: v.scrollOffset, atBottom: true, @@ -808,7 +809,7 @@ export function RoomTimeline({ // live-timeline visit, producing stale VList measurements and making the // room appear to be at the wrong position (or visually empty) on re-entry. if (!eventId) { - roomScrollCache.save(room.roomId, { + roomScrollCache.save(mxUserId, room.roomId, { cache: v.cache, scrollOffset: offset, atBottom: isNowAtBottom, @@ -921,7 +922,7 @@ export function RoomTimeline({ ignoredUsersSet, showHiddenEvents, showTombstoneEvents, - mxUserId: mx.getUserId(), + mxUserId, readUptoEventId: readUptoEventIdRef.current, hideMembershipEvents, hideNickAvatarEvents, @@ -948,7 +949,7 @@ export function RoomTimeline({ // when it fired. Save now so the next visit skips the timer. const v = vListRef.current; if (v) { - roomScrollCache.save(room.roomId, { + roomScrollCache.save(mxUserId, room.roomId, { cache: v.cache, scrollOffset: v.scrollOffset, atBottom: true, diff --git a/src/app/utils/roomScrollCache.test.ts b/src/app/utils/roomScrollCache.test.ts index e77be9878..039bc634e 100644 --- a/src/app/utils/roomScrollCache.test.ts +++ b/src/app/utils/roomScrollCache.test.ts @@ -3,32 +3,42 @@ import { roomScrollCache } from './roomScrollCache'; // CacheSnapshot is opaque in tests — cast a plain object. const fakeCache = () => ({}) as import('./roomScrollCache').RoomScrollCache['cache']; +const userId = '@alice:test'; describe('roomScrollCache', () => { it('load returns undefined for an unknown roomId', () => { - expect(roomScrollCache.load('!unknown:test')).toBeUndefined(); + expect(roomScrollCache.load(userId, '!unknown:test')).toBeUndefined(); }); it('stores and retrieves data for a roomId', () => { const data = { cache: fakeCache(), scrollOffset: 120, atBottom: false }; - roomScrollCache.save('!room1:test', data); - expect(roomScrollCache.load('!room1:test')).toBe(data); + roomScrollCache.save(userId, '!room1:test', data); + expect(roomScrollCache.load(userId, '!room1:test')).toBe(data); }); it('overwrites existing data when saved again for the same roomId', () => { const first = { cache: fakeCache(), scrollOffset: 50, atBottom: true }; const second = { cache: fakeCache(), scrollOffset: 200, atBottom: false }; - roomScrollCache.save('!room2:test', first); - roomScrollCache.save('!room2:test', second); - expect(roomScrollCache.load('!room2:test')).toBe(second); + roomScrollCache.save(userId, '!room2:test', first); + roomScrollCache.save(userId, '!room2:test', second); + expect(roomScrollCache.load(userId, '!room2:test')).toBe(second); }); it('keeps data for separate rooms independent', () => { const a = { cache: fakeCache(), scrollOffset: 10, atBottom: true }; const b = { cache: fakeCache(), scrollOffset: 20, atBottom: false }; - roomScrollCache.save('!roomA:test', a); - roomScrollCache.save('!roomB:test', b); - expect(roomScrollCache.load('!roomA:test')).toBe(a); - expect(roomScrollCache.load('!roomB:test')).toBe(b); + roomScrollCache.save(userId, '!roomA:test', a); + roomScrollCache.save(userId, '!roomB:test', b); + expect(roomScrollCache.load(userId, '!roomA:test')).toBe(a); + expect(roomScrollCache.load(userId, '!roomB:test')).toBe(b); + }); + + it('scopes data per userId', () => { + const data1 = { cache: fakeCache(), scrollOffset: 100, atBottom: true }; + const data2 = { cache: fakeCache(), scrollOffset: 200, atBottom: false }; + roomScrollCache.save('@alice:test', '!room:test', data1); + roomScrollCache.save('@bob:test', '!room:test', data2); + expect(roomScrollCache.load('@alice:test', '!room:test')).toBe(data1); + expect(roomScrollCache.load('@bob:test', '!room:test')).toBe(data2); }); }); diff --git a/src/app/utils/roomScrollCache.ts b/src/app/utils/roomScrollCache.ts index 7288ae0e4..8336595a4 100644 --- a/src/app/utils/roomScrollCache.ts +++ b/src/app/utils/roomScrollCache.ts @@ -12,11 +12,13 @@ export type RoomScrollCache = { /** Session-scoped, per-room scroll cache. Not persisted across page reloads. */ const scrollCacheMap = new Map(); +const cacheKey = (userId: string, roomId: string): string => `${userId}:${roomId}`; + export const roomScrollCache = { - save(roomId: string, data: RoomScrollCache): void { - scrollCacheMap.set(roomId, data); + save(userId: string, roomId: string, data: RoomScrollCache): void { + scrollCacheMap.set(cacheKey(userId, roomId), data); }, - load(roomId: string): RoomScrollCache | undefined { - return scrollCacheMap.get(roomId); + load(userId: string, roomId: string): RoomScrollCache | undefined { + return scrollCacheMap.get(cacheKey(userId, roomId)); }, }; From 6e8f5729403ad8f7b188a6db7cb3fc21d2cc8c3c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 11:46:46 -0400 Subject: [PATCH 26/62] 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 27/62] 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 5af258a7301245da89ce5c6f344d16bcdaaca8b2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 17:52:38 -0400 Subject: [PATCH 28/62] fix(timeline): use timestamp settling window for programmatic scroll guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the boolean programmaticScrollToBottomRef with a timestamp (ms epoch) and a SCROLL_SETTLE_MS window. The boolean approach had a race condition: VList fires isNowAtBottom=false → true → false during height re-measurement after scrollToIndex(); clearing the guard on the first 'true' event allowed the second 'false' to set atBottomState=false and flash the Jump to Latest button. The timestamp window expires naturally after 200ms without ever being reset by 'true' events, suppressing all intermediate false-negatives. Also reset atBottom and clear the guard when navigating to a specific eventId (e.g. via bookmarks) so the Jump to Latest button appears immediately when viewing a historical slice, even when the cache previously put us at the live bottom. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 58 ++++++++++++++++---------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index c9bd03403..9fa4a9198 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -109,6 +109,12 @@ interface TimelineItemProps { } /* eslint-enable react/no-unused-prop-types */ +// How long (ms) to suppress spurious "not at bottom" events after a +// programmatic scroll-to-bottom. VList can fire several intermediate onScroll +// events while it re-measures item heights after a scrollToIndex(); the window +// lets all of them pass without flashing the "Jump to Latest" button. +const SCROLL_SETTLE_MS = 200; + // Declared outside memo() so the callback receives a reference, not an inline // function expression (satisfies prefer-arrow-callback). function TimelineItemInner({ data, renderRef }: TimelineItemProps) { @@ -277,12 +283,15 @@ 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); + // Set to a timestamp (ms epoch) 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). + // Using a timestamp rather than a boolean means the window expires naturally + // after SCROLL_SETTLE_MS without needing to clear it on `isNowAtBottom=true`. + // That prevents the second wave of false-negative events (which VList fires + // after re-measuring item heights) from slipping through after the first + // `isNowAtBottom=true` confirms the position. + const programmaticScrollToBottomRef = useRef(0); const currentRoomIdRef = useRef(room.roomId); const [isReady, setIsReady] = useState(false); @@ -296,7 +305,7 @@ export function RoomTimeline({ mountScrollWindowRef.current = Date.now() + 3000; currentRoomIdRef.current = room.roomId; pendingReadyRef.current = false; - programmaticScrollToBottomRef.current = false; + programmaticScrollToBottomRef.current = 0; if (initialScrollTimerRef.current !== undefined) { clearTimeout(initialScrollTimerRef.current); initialScrollTimerRef.current = undefined; @@ -313,7 +322,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.scrollTo(vListRef.current.scrollSize); }, []); @@ -374,7 +383,7 @@ export function RoomTimeline({ // 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; + programmaticScrollToBottomRef.current = Date.now(); 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. @@ -393,7 +402,7 @@ export function RoomTimeline({ initialScrollTimerRef.current = setTimeout(() => { initialScrollTimerRef.current = undefined; if (processedEventsRef.current.length > 0) { - programmaticScrollToBottomRef.current = true; + programmaticScrollToBottomRef.current = Date.now(); vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end', }); @@ -517,9 +526,16 @@ export function RoomTimeline({ useEffect(() => { if (!eventId) return; + // Reset atBottom and the programmatic-scroll guard so that: + // 1. The "Jump to Latest" button appears immediately (we are no longer at + // the live bottom — we are viewing a historical slice). + // 2. The guard doesn't suppress the `isNowAtBottom=false` event that VList + // fires when it scrolls to the target eventId. + setAtBottom(false); + programmaticScrollToBottomRef.current = 0; setIsReady(false); timelineSyncRef.current.loadEventTimeline(eventId); - }, [eventId, room.roomId]); + }, [eventId, room.roomId, setAtBottom]); useEffect(() => { if (eventId) return; @@ -787,19 +803,16 @@ 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; + const isSettling = Date.now() - programmaticScrollToBottomRef.current < SCROLL_SETTLE_MS; if (isNowAtBottom !== atBottomRef.current) { - if (isNowAtBottom || !programmaticScrollToBottomRef.current) { + // Suppress intermediate "not at bottom" events that fire while VList + // re-measures item heights after a programmatic scrollToIndex. The + // timestamp window expires naturally — no need to clear it on + // `isNowAtBottom=true`, which prevents a second wave of false-negatives + // (fired after height re-measurement completes) from slipping through. + if (isNowAtBottom || !isSettling) { setAtBottom(isNowAtBottom); } - // else: programmatic guard active — suppress the false-negative and keep - // the guard set. VList can fire several intermediate "not at bottom" - // events while it corrects item heights after a scrollTo(); clearing the - // guard on the first one would let the second cause a spurious - // setAtBottom(false) and flash the "Jump to Latest" button. The guard - // is cleared above (unconditionally) when isNowAtBottom becomes true. } // Keep the scroll cache fresh so the next visit to this room can restore @@ -940,9 +953,8 @@ export function RoomTimeline({ if (!pendingReadyRef.current) return; if (processedEvents.length === 0) return; pendingReadyRef.current = false; - programmaticScrollToBottomRef.current = true; + programmaticScrollToBottomRef.current = Date.now(); vListRef.current?.scrollToIndex(processedEvents.length - 1, { align: 'end' }); - // 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); // The 80 ms timer's cache-save was skipped because processedEvents was empty From 8e07aec34acf670da4cc32165767255bf6534fe7 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:25:16 -0400 Subject: [PATCH 29/62] fix(timeline): arm scroll guard in ResizeObserver and scrollToBottom - Add programmaticScrollToBottomRef.current = Date.now() before the ResizeObserver viewport-shrink scrollTo so VList height-correction onScroll events can't flip atBottomState to false after keyboard open - Add setAtBottom(true) pre-emption inside scrollToBottom callback, preventing a one-frame flash of the Jump to Latest button and keeping atBottom=true when new-message auto-scroll fires before VList settles - Remove dead mountScrollWindowRef (declared + set, never read) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 9fa4a9198..53ade69e7 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -273,7 +273,6 @@ export function RoomTimeline({ const [topSpacerHeight, setTopSpacerHeight] = useState(0); const topSpacerHeightRef = useRef(0); - const mountScrollWindowRef = useRef(Date.now() + 3000); const hasInitialScrolledRef = useRef(false); // Stored in a ref so eventsLength fluctuations (e.g. onLifecycle timeline reset // firing within the window) cannot cancel it via useLayoutEffect cleanup. @@ -302,7 +301,6 @@ export function RoomTimeline({ scrollCacheForRoomRef.current = roomScrollCache.load(mxUserId, room.roomId); hasInitialScrolledRef.current = false; - mountScrollWindowRef.current = Date.now() + 3000; currentRoomIdRef.current = room.roomId; pendingReadyRef.current = false; programmaticScrollToBottomRef.current = 0; @@ -320,11 +318,14 @@ export function RoomTimeline({ if (!vListRef.current) return; const lastIndex = processedEventsRef.current.length - 1; if (lastIndex < 0) return; + // Pre-empt atBottom so the "Jump to Latest" button doesn't flash between + // the scroll call and VList confirming the new position. + setAtBottom(true); // 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.scrollTo(vListRef.current.scrollSize); - }, []); + }, [setAtBottom]); const timelineSync = useTimelineSync({ room, @@ -587,6 +588,9 @@ export function RoomTimeline({ const shrank = newHeight < prev; if (shrank && atBottom) { + // Arm the guard before scrolling so VList's intermediate height-correction + // events (fired during item re-measurement) don't flip atBottom to false. + programmaticScrollToBottomRef.current = Date.now(); vListRef.current?.scrollTo(vListRef.current.scrollSize); } prevViewportHeightRef.current = newHeight; From c9763ab22d5b6cd8fd1163ae3d7aa01d4eb2a8a7 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:32:25 -0400 Subject: [PATCH 30/62] 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 93b7fb8bfc6df5f49471352f6c7512dfd5947929 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 23:33:35 -0400 Subject: [PATCH 31/62] fix(timeline): remove scroll settling guard, match upstream scroll approach Remove SCROLL_SETTLE_MS and programmaticScrollToBottomRef entirely. Use upstream's direct setAtBottom(isNowAtBottom) in handleVListScroll instead of suppressing intermediate events with a settling window. Simplify scrollToBottom to just scrollTo(scrollSize) without pre-emption. Simplify cache restore to use scrollTo(scrollSize) for atBottom rooms. Remove setAtBottom(false) from eventId effect (let scroll determine state). Add mxUserId to hook dependency arrays where used in roomScrollCache calls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 81 +++----------------------- 1 file changed, 8 insertions(+), 73 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 53ade69e7..a18f1cb9c 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -109,12 +109,6 @@ interface TimelineItemProps { } /* eslint-enable react/no-unused-prop-types */ -// How long (ms) to suppress spurious "not at bottom" events after a -// programmatic scroll-to-bottom. VList can fire several intermediate onScroll -// events while it re-measures item heights after a scrollToIndex(); the window -// lets all of them pass without flashing the "Jump to Latest" button. -const SCROLL_SETTLE_MS = 200; - // Declared outside memo() so the callback receives a reference, not an inline // function expression (satisfies prefer-arrow-callback). function TimelineItemInner({ data, renderRef }: TimelineItemProps) { @@ -282,15 +276,6 @@ 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 a timestamp (ms epoch) 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). - // Using a timestamp rather than a boolean means the window expires naturally - // after SCROLL_SETTLE_MS without needing to clear it on `isNowAtBottom=true`. - // That prevents the second wave of false-negative events (which VList fires - // after re-measuring item heights) from slipping through after the first - // `isNowAtBottom=true` confirms the position. - const programmaticScrollToBottomRef = useRef(0); const currentRoomIdRef = useRef(room.roomId); const [isReady, setIsReady] = useState(false); @@ -303,7 +288,6 @@ export function RoomTimeline({ hasInitialScrolledRef.current = false; currentRoomIdRef.current = room.roomId; pendingReadyRef.current = false; - programmaticScrollToBottomRef.current = 0; if (initialScrollTimerRef.current !== undefined) { clearTimeout(initialScrollTimerRef.current); initialScrollTimerRef.current = undefined; @@ -318,14 +302,8 @@ export function RoomTimeline({ if (!vListRef.current) return; const lastIndex = processedEventsRef.current.length - 1; if (lastIndex < 0) return; - // Pre-empt atBottom so the "Jump to Latest" button doesn't flash between - // the scroll call and VList confirming the new position. - setAtBottom(true); - // 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.scrollTo(vListRef.current.scrollSize); - }, [setAtBottom]); + }, []); const timelineSync = useTimelineSync({ room, @@ -384,11 +362,7 @@ export function RoomTimeline({ // Revisiting a room with a cached scroll state — restore position // immediately and skip the 80 ms stabilisation timer entirely. if (savedCache.atBottom) { - programmaticScrollToBottomRef.current = Date.now(); - 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); + vListRef.current.scrollTo(vListRef.current.scrollSize); } else { vListRef.current.scrollTo(savedCache.scrollOffset); } @@ -403,7 +377,6 @@ 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', }); @@ -417,13 +390,6 @@ export function RoomTimeline({ 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; @@ -433,13 +399,7 @@ export function RoomTimeline({ } // 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, - setAtBottom, - ]); + }, [timelineSync.eventsLength, timelineSync.liveTimelineLinked, eventId, room.roomId, mxUserId]); // Cancel the initial-scroll timer on unmount (the useLayoutEffect above // intentionally does not cancel it when deps change). @@ -527,16 +487,9 @@ export function RoomTimeline({ useEffect(() => { if (!eventId) return; - // Reset atBottom and the programmatic-scroll guard so that: - // 1. The "Jump to Latest" button appears immediately (we are no longer at - // the live bottom — we are viewing a historical slice). - // 2. The guard doesn't suppress the `isNowAtBottom=false` event that VList - // fires when it scrolls to the target eventId. - setAtBottom(false); - programmaticScrollToBottomRef.current = 0; setIsReady(false); timelineSyncRef.current.loadEventTimeline(eventId); - }, [eventId, room.roomId, setAtBottom]); + }, [eventId, room.roomId]); useEffect(() => { if (eventId) return; @@ -588,9 +541,6 @@ export function RoomTimeline({ const shrank = newHeight < prev; if (shrank && atBottom) { - // Arm the guard before scrolling so VList's intermediate height-correction - // events (fired during item re-measurement) don't flip atBottom to false. - programmaticScrollToBottomRef.current = Date.now(); vListRef.current?.scrollTo(vListRef.current.scrollSize); } prevViewportHeightRef.current = newHeight; @@ -807,16 +757,8 @@ export function RoomTimeline({ const distanceFromBottom = v.scrollSize - offset - v.viewportSize; const isNowAtBottom = distanceFromBottom < 100; - const isSettling = Date.now() - programmaticScrollToBottomRef.current < SCROLL_SETTLE_MS; if (isNowAtBottom !== atBottomRef.current) { - // Suppress intermediate "not at bottom" events that fire while VList - // re-measures item heights after a programmatic scrollToIndex. The - // timestamp window expires naturally — no need to clear it on - // `isNowAtBottom=true`, which prevents a second wave of false-negatives - // (fired after height re-measurement completes) from slipping through. - if (isNowAtBottom || !isSettling) { - setAtBottom(isNowAtBottom); - } + setAtBottom(isNowAtBottom); } // Keep the scroll cache fresh so the next visit to this room can restore @@ -844,7 +786,7 @@ export function RoomTimeline({ timelineSyncRef.current.handleTimelinePagination(false); } }, - [setAtBottom, room.roomId, eventId] + [setAtBottom, room.roomId, eventId, mxUserId] ); const showLoadingPlaceholders = @@ -957,12 +899,8 @@ 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' }); - // doesn't flash for one render cycle before onScroll confirms the position. - setAtBottom(true); - // 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. + // Save now so the next visit skips the timer. const v = vListRef.current; if (v) { roomScrollCache.save(mxUserId, room.roomId, { @@ -971,11 +909,8 @@ export function RoomTimeline({ 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, setAtBottom, room.roomId]); + }, [processedEvents.length, room.roomId, mxUserId]); useEffect(() => { if (!onEditLastMessageRef) return; From eefceb9d1509ca02a0007b8425dba1191a623538 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 17:52:48 -0400 Subject: [PATCH 32/62] fix(nav): check DM membership before space parents in useRoomNavigate Mirrors the fix already applied in useNotificationJumper: when a room belongs to both the direct-message list and a space, prefer the /direct route over the space route. Previously useRoomNavigate checked orphan space parents first, which caused bookmark jumps and room-nav clicks on DMs-in-spaces to open the room via the space path instead of the direct path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/hooks/useRoomNavigate.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index 51555125c..c2918d5ca 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -37,7 +37,20 @@ export const useRoomNavigate = () => { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); const openSpaceTimeline = developerTools && spaceSelectedId === roomId; - const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId); + // Developer-mode: view the space's own timeline (must be checked first). + if (openSpaceTimeline) { + navigate(getSpaceRoomPath(roomIdOrAlias, roomId, eventId), opts); + return; + } + + // DMs take priority over space membership so direct chats always open + // via the direct route, even when the room also belongs to a space. + if (mDirects.has(roomId)) { + navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts); + return; + } + + const orphanParents = getOrphanParents(roomToParents, roomId); if (orphanParents.length > 0) { let parentSpace: string; if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) { @@ -48,15 +61,7 @@ export const useRoomNavigate = () => { const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace); - navigate( - getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId), - opts - ); - return; - } - - if (mDirects.has(roomId)) { - navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts); + navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId), opts); return; } From 6dcb99e9f337a8b81b3ce57551047768a0a1abde Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 00:39:50 -0400 Subject: [PATCH 33/62] fix(timeline): add hysteresis to atBottom threshold Use a larger threshold (300px) to lose at-bottom state vs 100px to regain it, preventing image/URL-preview layout shifts from flashing the jump button. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index a18f1cb9c..becf032a8 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -756,7 +756,12 @@ export function RoomTimeline({ if (!v) return; const distanceFromBottom = v.scrollSize - offset - v.viewportSize; - const isNowAtBottom = distanceFromBottom < 100; + // Hysteresis: require scrolling further away to lose "at bottom" than to + // regain it, preventing brief image/preview layout shifts from flashing + // the "Jump to Latest" button. + const isNowAtBottom = atBottomRef.current + ? distanceFromBottom < 300 + : distanceFromBottom < 100; if (isNowAtBottom !== atBottomRef.current) { setAtBottom(isNowAtBottom); } From 2727ac79a0419ce7bac12d61283b26b3097424e6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 09:16:32 -0400 Subject: [PATCH 34/62] fix(timeline): suppress jump button flash during initial scroll settle Guard atBottom state changes in handleVListScroll behind isReadyRef so that intermediate VList scroll events fired during the initial scrollTo() in useLayoutEffect cannot flip atBottom to false before the component has fully rendered. This prevents the 'Jump to Present' button from flashing for a single frame when opening a room where the user is already at the bottom. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index becf032a8..1efca7e3b 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -279,6 +279,8 @@ export function RoomTimeline({ const currentRoomIdRef = useRef(room.roomId); const [isReady, setIsReady] = useState(false); + const isReadyRef = useRef(false); + isReadyRef.current = isReady; if (currentRoomIdRef.current !== room.roomId) { // Load incoming room's scroll cache (undefined for first-visit rooms). @@ -762,7 +764,10 @@ export function RoomTimeline({ const isNowAtBottom = atBottomRef.current ? distanceFromBottom < 300 : distanceFromBottom < 100; - if (isNowAtBottom !== atBottomRef.current) { + // Suppress atBottom flips during the initial scroll-to-bottom sequence: + // VList fires intermediate scroll events before the scroll settles, and + // if isReady is set in the same commit the button briefly flashes. + if (isNowAtBottom !== atBottomRef.current && isReadyRef.current) { setAtBottom(isNowAtBottom); } From be8c28aacbde82274ec2bfc6edcc68d646d883bd Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 09:44:13 -0400 Subject: [PATCH 35/62] fix(timeline): remove scroll hysteresis, match upstream 100px threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 300px/100px hysteresis created a dead zone: once atBottom was lost (e.g. video embed loading causing > 300px content shift), the user could never regain it unless distanceFromBottom dropped below 100px — which VList may not report even when the scrollbar appears at the bottom. Replace with upstream's flat 100px threshold. The isReadyRef guard still suppresses initial-load flashes from scroll settling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 1efca7e3b..261335cb9 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -758,12 +758,7 @@ export function RoomTimeline({ if (!v) return; const distanceFromBottom = v.scrollSize - offset - v.viewportSize; - // Hysteresis: require scrolling further away to lose "at bottom" than to - // regain it, preventing brief image/preview layout shifts from flashing - // the "Jump to Latest" button. - const isNowAtBottom = atBottomRef.current - ? distanceFromBottom < 300 - : distanceFromBottom < 100; + const isNowAtBottom = distanceFromBottom < 100; // Suppress atBottom flips during the initial scroll-to-bottom sequence: // VList fires intermediate scroll events before the scroll settles, and // if isReady is set in the same commit the button briefly flashes. From 7bddc4f701a5468a34b1e4957a7f0af4c516615b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 10:13:44 -0400 Subject: [PATCH 36/62] fix(timeline): chase bottom when content grows while user is pinned When images, embeds, or video thumbnails load after the initial render, VList's scrollSize increases while the scroll offset stays the same. This pushed distanceFromBottom above the 100px threshold, causing atBottom to flip to false and the Jump to Latest button to appear even though the user hadn't scrolled. Track prevScrollSizeRef and detect content growth: if atBottom is true and scrollSize increased but distanceFromBottom exceeds the threshold, auto-scroll to the new bottom instead of losing atBottom status. Also removes the temporary debug logging from the previous commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 261335cb9..50453738d 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -226,6 +226,7 @@ export function RoomTimeline({ hideReadsRef.current = hideReads; const prevViewportHeightRef = useRef(0); + const prevScrollSizeRef = useRef(0); const messageListRef = useRef(null); const mediaAuthentication = useMediaAuthentication(); @@ -759,6 +760,20 @@ export function RoomTimeline({ const distanceFromBottom = v.scrollSize - offset - v.viewportSize; const isNowAtBottom = distanceFromBottom < 100; + + // When the user is pinned to the bottom and content grows (images, embeds, + // video thumbnails loading), scrollSize increases while offset stays put, + // pushing distanceFromBottom above the threshold. Instead of flipping + // atBottom to false (which shows the "Jump to Latest" button), chase the + // bottom so the user stays pinned. + const contentGrew = v.scrollSize > prevScrollSizeRef.current; + prevScrollSizeRef.current = v.scrollSize; + + if (atBottomRef.current && !isNowAtBottom && contentGrew) { + v.scrollTo(v.scrollSize); + return; + } + // Suppress atBottom flips during the initial scroll-to-bottom sequence: // VList fires intermediate scroll events before the scroll settles, and // if isReady is set in the same commit the button briefly flashes. From 29fa6eaa19382864ab246ec11caf4b463de5fbc3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 16:20:04 -0400 Subject: [PATCH 37/62] 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 | 18 ++++++++++--- src/app/hooks/timeline/useTimelineSync.ts | 31 +++++++++++++---------- src/client/slidingSync.ts | 6 ++--- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 50453738d..86f4b0c29 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -257,7 +257,7 @@ export function RoomTimeline({ const scrollCacheForRoomRef = useRef( roomScrollCache.load(mxUserId, room.roomId) ); - const [atBottomState, setAtBottomState] = useState(true); + const [atBottomState, setAtBottomState] = useState(!eventId); const atBottomRef = useRef(atBottomState); const setAtBottom = useCallback((val: boolean) => { setAtBottomState(val); @@ -301,11 +301,18 @@ 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; - vListRef.current.scrollTo(vListRef.current.scrollSize); + 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({ @@ -491,8 +498,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 ca52b5442..d935262cd 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 { @@ -476,9 +485,6 @@ export function useTimelineSync({ const lastScrolledAtEventsLengthRef = useRef(eventsLength); - const eventsLengthRef = useRef(eventsLength); - eventsLengthRef.current = eventsLength; - useLiveEventArrive( room, useCallback( @@ -500,9 +506,6 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } - scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth'); - lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; - setTimeline((ct) => ({ ...ct })); return; } @@ -512,7 +515,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, isAtBottomRef, unreadInfo, scrollToBottom, setUnreadInfo, hideReadsRef] + [mx, room, isAtBottomRef, unreadInfo, setUnreadInfo, hideReadsRef] ) ); @@ -537,17 +540,19 @@ 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(room, triggerMutation); useThreadUpdate(room, triggerMutation); - useEffect(() => { + // 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 23dbfc699c3d212342bfb13e385f6585ef7f5d37 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 16:20:04 -0400 Subject: [PATCH 38/62] 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 39/62] 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 4e4cace21324e16162a0a2f1e1383018e42338ee Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 19:33:47 -0400 Subject: [PATCH 40/62] 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 86f4b0c29..335ec4a61 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -308,10 +308,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 d935262cd..664fbd9eb 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -485,6 +485,9 @@ export function useTimelineSync({ const lastScrolledAtEventsLengthRef = useRef(eventsLength); + const eventsLengthRef = useRef(eventsLength); + eventsLengthRef.current = eventsLength; + useLiveEventArrive( room, useCallback( @@ -506,6 +509,9 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } + scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth'); + lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; + setTimeline((ct) => ({ ...ct })); return; } @@ -515,7 +521,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, isAtBottomRef, unreadInfo, setUnreadInfo, hideReadsRef] + [mx, room, isAtBottomRef, unreadInfo, scrollToBottom, setUnreadInfo, hideReadsRef] ) ); @@ -540,10 +546,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(room, triggerMutation); From 4148bfad08a8abd01b2289e0125434a5dfc614aa Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 22:26:19 -0400 Subject: [PATCH 41/62] 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(() => { From 1ac909dd0a3e0cc9490ed07829203b81b36d2377 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 22:26:19 -0400 Subject: [PATCH 42/62] 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 | 14 ++--------- .../hooks/timeline/useTimelineSync.test.tsx | 2 +- src/app/hooks/timeline/useTimelineSync.ts | 25 ++++++------------- 3 files changed, 10 insertions(+), 31 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 335ec4a61..09ea440bf 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -250,10 +250,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) ); @@ -301,15 +297,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({ @@ -495,8 +487,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 d53d74143..e5e7c4cfd 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -129,7 +129,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 664fbd9eb..87d8631db 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; @@ -479,7 +470,7 @@ export function useTimelineSync({ useCallback(() => { if (!alive()) return; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - scrollToBottom('instant'); + scrollToBottom(); }, [alive, room, scrollToBottom]) ); @@ -509,7 +500,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } - scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth'); + scrollToBottom(); lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; setTimeline((ct) => ({ ...ct })); @@ -547,7 +538,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({ useThreadUpdate(room, triggerMutation); - // 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(() => { From 30a26ea0ee40e210a894f628e0d3405e6f8d7875 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 22:45:03 -0400 Subject: [PATCH 43/62] feat(timeline): add video aspectRatio and VList itemSize hint - Add aspectRatio CSS to VideoContent from Matrix info.w/info.h, matching ImageContent pattern to prevent layout shift - Add itemSize={80} to VList for better initial size estimation, reducing scroll jump when items are first measured Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/components/message/content/VideoContent.tsx | 3 +++ src/app/features/room/RoomTimeline.tsx | 1 + 2 files changed, 4 insertions(+) diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx index 71613c598..2b36f2813 100644 --- a/src/app/components/message/content/VideoContent.tsx +++ b/src/app/components/message/content/VideoContent.tsx @@ -110,9 +110,12 @@ export const VideoContent = as<'div', VideoContentProps>( if (autoPlay) loadSrc(); }, [autoPlay, loadSrc]); + const hasDimensions = typeof info?.w === 'number' && typeof info?.h === 'number'; + return ( setIsHovered(true)} diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d026ec1fa..91e63eb90 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -858,6 +858,7 @@ export function RoomTimeline({ ref={vListRef} data={processedEvents} shift={shift} + itemSize={80} className={css.messageList} style={{ flex: 1, From c8189eae56470707af9aef5f00c03eda82bcd93f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 23:09:35 -0400 Subject: [PATCH 44/62] fix(timeline): skip scroll handler during init to prevent jitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the isReadyRef guard to the top of handleVListScroll so that content-chase, atBottom flips, scroll-cache saves, and pagination triggers are all suppressed while the timeline is hidden (opacity 0) during VList measurement. prevScrollSizeRef is still updated so the first post-ready scroll event does not see a false content-grew delta. Previously only setAtBottom was guarded, but content-chase fired cascading scrollTo calls during the 80 ms init window — extra work that upstream does not have, producing visible layout churn when opening a room. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 09ea440bf..51e8a850a 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -766,15 +766,18 @@ export function RoomTimeline({ const contentGrew = v.scrollSize > prevScrollSizeRef.current; prevScrollSizeRef.current = v.scrollSize; + // Skip content-chase and cache saves during init: the timeline is hidden + // (opacity 0) while VList measures items and fires intermediate scroll + // events. Chasing the bottom here causes cascading scrollTo calls that + // upstream doesn't have, producing visible layout churn after isReady. + if (!isReadyRef.current) return; + if (atBottomRef.current && !isNowAtBottom && contentGrew) { v.scrollTo(v.scrollSize); return; } - // Suppress atBottom flips during the initial scroll-to-bottom sequence: - // VList fires intermediate scroll events before the scroll settles, and - // if isReady is set in the same commit the button briefly flashes. - if (isNowAtBottom !== atBottomRef.current && isReadyRef.current) { + if (isNowAtBottom !== atBottomRef.current) { setAtBottom(isNowAtBottom); } From 5c941906534684dc6de6783187e591adbe77e0d9 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 17 Apr 2026 08:14:23 -0400 Subject: [PATCH 45/62] perf(timeline): skip redundant re-render on TimelineReset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compare linked-timeline references before calling setTimeline in useLiveTimelineRefresh. When the SDK fires TimelineReset during initial room load (common with sliding sync), the timeline chain is often identical — skipping the update avoids a full re-render flash. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/hooks/timeline/useTimelineSync.test.tsx | 5 +++++ src/app/hooks/timeline/useTimelineSync.ts | 14 +++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index e5e7c4cfd..9006ee49a 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -125,6 +125,11 @@ describe('useTimelineSync', () => { ); await act(async () => { + // Simulate the SDK replacing the live timeline object, which is what + // a real TimelineReset does (resetLiveTimeline creates a new + // EventTimeline and swaps liveTimeline to it). + const newTimeline = createTimeline([{}, {}]); + timelineSet.getLiveTimeline = () => newTimeline; timelineSet.emit(RoomEvent.TimelineReset); await Promise.resolve(); }); diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 87d8631db..67556c4ef 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -395,6 +395,9 @@ export function useTimelineSync({ | undefined >(); + const timelineRef = useRef(timeline); + timelineRef.current = timeline; + const resetAutoScrollPendingRef = useRef(false); const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines); @@ -534,9 +537,18 @@ export function useTimelineSync({ useLiveTimelineRefresh( room, useCallback(() => { + const newLinked = getInitialTimeline(room).linkedTimelines; + const prev = timelineRef.current.linkedTimelines; + // Skip update when the linked-timeline chain is identical (same + // EventTimeline references). TimelineReset often fires during initial + // room load after the SDK already populated the timeline we are + // showing — re-rendering would only produce a visible flash. + if (prev.length === newLinked.length && prev.every((tl, i) => tl === newLinked[i])) { + return; + } const wasAtBottom = isAtBottomRef.current; resetAutoScrollPendingRef.current = wasAtBottom; - setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); + setTimeline({ linkedTimelines: newLinked }); if (wasAtBottom) { scrollToBottom(); } From 29fc1d00cadf299adf136a9f4496edb75f92abd1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 17 Apr 2026 08:25:24 -0400 Subject: [PATCH 46/62] perf(timeline): hide content during genuine TimelineReset re-render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a genuine TimelineReset replaces the timeline chain (new EventTimeline objects), hide the timeline behind opacity 0 and re-arm the initial-scroll mechanism. This ensures the replacement data renders invisibly, gets measured by VList, and only becomes visible once stable — preventing the visible flash/jitter on room open. Adds timelineResetToken counter to useTimelineSync that increments on genuine resets. RoomTimeline watches it via useLayoutEffect to toggle isReady off before the browser paints the intermediate state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 13 +++++++++++++ src/app/hooks/timeline/useTimelineSync.ts | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 51e8a850a..ea2bc963e 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -420,6 +420,19 @@ export function RoomTimeline({ hasInitialScrolledRef.current = false; }, [isReady, timelineSync.eventsLength]); + // When a genuine TimelineReset replaces the timeline chain (new + // EventTimeline objects from the SDK), hide content behind opacity 0 and + // re-arm the initial-scroll so the new data renders invisibly, gets + // measured, and only then becomes visible — preventing a visible flash. + const prevResetTokenRef = useRef(timelineSync.timelineResetToken); + useLayoutEffect(() => { + if (timelineSync.timelineResetToken === prevResetTokenRef.current) return; + prevResetTokenRef.current = timelineSync.timelineResetToken; + if (!isReady) return; + setIsReady(false); + hasInitialScrolledRef.current = false; + }, [isReady, timelineSync.timelineResetToken]); + const recalcTopSpacer = useCallback(() => { const v = vListRef.current; if (!v) return; diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 67556c4ef..78b0cdac7 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -398,6 +398,11 @@ export function useTimelineSync({ const timelineRef = useRef(timeline); timelineRef.current = timeline; + // Incremented each time a genuine TimelineReset replaces the timeline chain. + // RoomTimeline watches this to hide content (opacity 0) and re-arm initial + // scroll so the replacement renders behind the curtain. + const [timelineResetToken, setTimelineResetToken] = useState(0); + const resetAutoScrollPendingRef = useRef(false); const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines); @@ -548,6 +553,7 @@ export function useTimelineSync({ } const wasAtBottom = isAtBottomRef.current; resetAutoScrollPendingRef.current = wasAtBottom; + setTimelineResetToken((t) => t + 1); setTimeline({ linkedTimelines: newLinked }); if (wasAtBottom) { scrollToBottom(); @@ -618,5 +624,6 @@ export function useTimelineSync({ focusItem, setFocusItem, mutationVersion, + timelineResetToken, }; } From 4f2e3e007b609d16d17fb11db03e80e910e5f7e9 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 17 Apr 2026 08:30:36 -0400 Subject: [PATCH 47/62] feat(timeline): show skeleton overlay while measuring first-visit rooms When opening a room with no cached scroll state, render a skeleton placeholder overlay on top of the VList while it measures real item heights at opacity 0. This gives users immediate visual feedback instead of a blank/invisible area during the 80ms stabilisation window. Cached rooms skip the overlay entirely since they restore instantly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 33 +++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index ea2bc963e..27e8b0058 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -826,6 +826,13 @@ export function RoomTimeline({ timelineSync.eventsLength === 0 && (!isReady || timelineSync.canPaginateBack || timelineSync.backwardStatus === 'loading'); + // Show a skeleton overlay when content is not ready and there is no + // cached scroll state to restore from (first visit or timeline reset). + // The VList still renders underneath at opacity 0 so it can measure real + // item heights — the overlay just gives the user something to look at. + const showSkeletonOverlay = + !isReady && !scrollCacheForRoomRef.current && !showLoadingPlaceholders; + let backPaginationJSX: ReactNode | undefined; if (timelineSync.canPaginateBack || timelineSync.backwardStatus !== 'idle') { if (timelineSync.backwardStatus === 'error') { @@ -1040,9 +1047,32 @@ export function RoomTimeline({ minHeight: 0, overflow: 'hidden', position: 'relative', - opacity: isReady || showLoadingPlaceholders ? 1 : 0, }} > + {showSkeletonOverlay && ( +
+ {Array.from({ length: 8 }, (_, i) => ( + + {messageLayout === MessageLayout.Compact ? ( + + ) : ( + + )} + + ))} +
+ )} key={room.roomId} ref={vListRef} @@ -1057,6 +1087,7 @@ export function RoomTimeline({ flexDirection: 'column', paddingTop: topSpacerHeight > 0 ? topSpacerHeight : config.space.S600, paddingBottom: config.space.S600, + opacity: isReady || showLoadingPlaceholders ? 1 : 0, }} onScroll={handleVListScroll} > From 32917c29558bdc3287bcc12fa3a0bfce61562a42 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 17 Apr 2026 10:24:24 -0400 Subject: [PATCH 48/62] fix(timeline): eliminate room-open and jump-to-message jitter - Always setShift(true) on backward pagination so Virtua anchors the viewport when sliding sync prepends history to a sparse timeline - Remove timelineSync.timeline from vListIndices memo deps to prevent processedEvents recomputing on every live event/relation update - Merge dual focusItem effects into one; delay setIsReady until double- rAF after scrollToIndex so the timeline reveals with the target message already centred and the 2-second highlight is fully visible - Add module-level roomScrollCache; save scroll offset on unmount and restore it via rAF on revisit, skipping the 80 ms opacity timer - Remove key={room.roomId} from both RoomProvider wrappers and the RoomTimeline; extend the currentRoomIdRef render-phase guard to also reset atBottom, shift, topSpacerHeight, and unreadInfo on room change so the full component tree stays mounted across room switches Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 89 +++++++++++++++++---- src/app/features/room/RoomView.tsx | 1 - src/app/pages/client/space/RoomProvider.tsx | 4 +- 3 files changed, 75 insertions(+), 19 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d026ec1fa..e77c7ed98 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -116,6 +116,10 @@ export type RoomTimelineProps = { onEditLastMessageRef?: React.MutableRefObject<(() => void) | undefined>; }; +type ScrollCacheEntry = { offset: number; atBottom: boolean }; +/** Survives component remounts so revisiting a room restores scroll position instantly. */ +const roomScrollCache = new Map(); + export function RoomTimeline({ room, eventId, @@ -143,7 +147,6 @@ export function RoomTimeline({ const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); const [showTombstoneEvents] = useSetting(settingsAtom, 'showTombstoneEvents'); const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); - const [reducedMotion] = useSetting(settingsAtom, 'reducedMotion'); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); const [autoplayStickers] = useSetting(settingsAtom, 'autoplayStickers'); @@ -225,6 +228,8 @@ export function RoomTimeline({ const currentRoomIdRef = useRef(room.roomId); const [isReady, setIsReady] = useState(false); + const isReadyRef = useRef(false); + isReadyRef.current = isReady; if (currentRoomIdRef.current !== room.roomId) { hasInitialScrolledRef.current = false; @@ -236,6 +241,12 @@ export function RoomTimeline({ initialScrollTimerRef.current = undefined; } setIsReady(false); + // Reset per-room scroll/layout state so the new room starts clean. + setAtBottom(true); + setShift(false); + setTopSpacerHeight(0); + topSpacerHeightRef.current = 0; + setUnreadInfo(getRoomUnreadInfo(room, true)); } const processedEventsRef = useRef([]); @@ -298,6 +309,24 @@ export function RoomTimeline({ timelineSync.liveTimelineLinked && vListRef.current ) { + // Fast path: revisiting a room — restore the last scroll position instantly + // without the 80 ms timer so there's no opacity flash. + const cached = roomScrollCache.get(room.roomId); + if (cached !== undefined) { + hasInitialScrolledRef.current = true; + const v = vListRef.current; + requestAnimationFrame(() => { + if (!v) return; + if (cached.atBottom) { + v.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); + } else { + v.scrollTo(cached.offset); + } + setIsReady(true); + }); + return; + } + 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 @@ -330,6 +359,21 @@ export function RoomTimeline({ [] ); + // Save scroll position on unmount or room change so revisits restore instantly. + useEffect( + () => () => { + if (!isReadyRef.current) return; + const v = vListRef.current; + if (!v) return; + const distFromBottom = v.scrollSize - v.scrollOffset - v.viewportSize; + roomScrollCache.set(room.roomId, { + offset: v.scrollOffset, + atBottom: distFromBottom < 100, + }); + }, + [room.roomId] + ); + // If the timeline was blanked while content was already visible — e.g. a // TimelineReset fired by mx.retryImmediately() when the app comes back from // background — hide the timeline (opacity 0) and re-arm the initial-scroll so @@ -371,7 +415,9 @@ export function RoomTimeline({ prevBackwardStatusRef.current = timelineSync.backwardStatus; if (timelineSync.backwardStatus === 'loading') { wasAtBottomBeforePaginationRef.current = atBottomRef.current; - if (!atBottomRef.current) setShift(true); + // Always anchor during backward pagination — even when at the bottom — so + // prepended items don't cause a visible jump (e.g. sliding-sync fill). + setShift(true); } else if (prev === 'loading' && timelineSync.backwardStatus === 'idle') { setShift(false); if (wasAtBottomBeforePaginationRef.current) { @@ -381,29 +427,41 @@ export function RoomTimeline({ }, [timelineSync.backwardStatus]); useEffect(() => { - let timeoutId: ReturnType | undefined; + let cancelled = false; + let outerRafId: ReturnType | undefined; + let clearId: ReturnType | undefined; + if (timelineSync.focusItem) { if (timelineSync.focusItem.scrollTo && vListRef.current) { const processedIndex = getRawIndexToProcessedIndex(timelineSync.focusItem.index); if (processedIndex !== undefined) { vListRef.current.scrollToIndex(processedIndex, { align: 'center' }); + // Setting scrollTo=false triggers effect cleanup then re-run; the reveal + // rAFs below restart from the new run so Virtua has time to settle first. timelineSync.setFocusItem((prev) => (prev ? { ...prev, scrollTo: false } : undefined)); } } - timeoutId = setTimeout(() => { - timelineSync.setFocusItem(undefined); - }, 2000); + + // Reveal the timeline and begin the highlight timer after two frames so + // Virtua has completed the scroll before the content becomes visible. + outerRafId = requestAnimationFrame(() => { + if (cancelled) return; + requestAnimationFrame(() => { + if (cancelled) return; + setIsReady(true); + clearId = setTimeout(() => { + timelineSync.setFocusItem(undefined); + }, 2000); + }); + }); } + return () => { - if (timeoutId !== undefined) clearTimeout(timeoutId); + cancelled = true; + if (outerRafId !== undefined) cancelAnimationFrame(outerRafId); + if (clearId !== undefined) clearTimeout(clearId); }; - }, [timelineSync.focusItem, timelineSync, reducedMotion, getRawIndexToProcessedIndex]); - - useEffect(() => { - if (timelineSync.focusItem) { - setIsReady(true); - } - }, [timelineSync.focusItem]); + }, [timelineSync.focusItem, timelineSync, getRawIndexToProcessedIndex]); useEffect(() => { if (!eventId) return; @@ -724,8 +782,7 @@ export function RoomTimeline({ : timelineSync.eventsLength; const vListIndices = useMemo( () => Array.from({ length: vListItemCount }, (_, i) => i), - // eslint-disable-next-line react-hooks/exhaustive-deps - [vListItemCount, timelineSync.timeline] + [vListItemCount] ); const processedEvents = useProcessedTimeline({ diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 421561616..a3a96b921 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -157,7 +157,6 @@ export function RoomView({ eventId }: { eventId?: string }) {
)} + {children} ); @@ -69,7 +69,7 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { } return ( - + {children} ); From 1b5b2847608043b560f5305bdb372513ce9d7aa8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 17 Apr 2026 11:55:09 -0400 Subject: [PATCH 49/62] fix(timeline): skip TimelineReset hide for additive sliding-sync resets Only fire setTimelineResetToken / scrollToBottom when the live-end event changed (destructive reset, e.g. reconnect). Sliding sync fires TimelineReset to replace the EventTimeline container while keeping the same events, which was causing a visible flash ~1 second after the room appeared stable. Also adds a timelineRef in the main useTimelineSync scope so the callback can compare old vs new linked timelines without capturing stale state. Tests updated: destructive-reset path still triggers scroll-to-bottom; new additive-reset test confirms no spurious scroll. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../hooks/timeline/useTimelineSync.test.tsx | 38 ++++++++++++++++++- src/app/hooks/timeline/useTimelineSync.ts | 28 +++++++++++--- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index d53d74143..1f44e6275 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -106,8 +106,8 @@ describe('useTimelineSync', () => { expect(scrollToBottom).not.toHaveBeenCalled(); }); - it('keeps a bottom-pinned user anchored after TimelineReset', async () => { - const { room, timelineSet } = createRoom(); + it('keeps a bottom-pinned user anchored after destructive TimelineReset', async () => { + const { room, timelineSet } = createRoom('!room:test', [{ getId: () => 'event-before' }]); const scrollToBottom = vi.fn(); renderHook(() => @@ -124,6 +124,10 @@ describe('useTimelineSync', () => { }) ); + // Replace with a new timeline object with a new last event (destructive reset) + const newTimeline = createTimeline([{ getId: () => 'event-after' }]); + timelineSet.getLiveTimeline = () => newTimeline as never; + await act(async () => { timelineSet.emit(RoomEvent.TimelineReset); await Promise.resolve(); @@ -132,6 +136,36 @@ describe('useTimelineSync', () => { expect(scrollToBottom).toHaveBeenCalledWith('instant'); }); + it('does not scroll after additive TimelineReset (same last event, new container)', async () => { + const { room, timelineSet } = createRoom('!room:test', [{ getId: () => 'same-event' }]); + const scrollToBottom = vi.fn(); + + renderHook(() => + useTimelineSync({ + room: room as Room, + mx: { getUserId: () => '@alice:test' } as never, + isAtBottom: true, + isAtBottomRef: { current: true }, + scrollToBottom, + unreadInfo: undefined, + setUnreadInfo: vi.fn(), + hideReadsRef: { current: false }, + readUptoEventIdRef: { current: undefined }, + }) + ); + + // Replace with a new timeline object but same last event ID (additive/structural reset) + const newTimeline = createTimeline([{ getId: () => 'same-event' }]); + timelineSet.getLiveTimeline = () => newTimeline as never; + + await act(async () => { + timelineSet.emit(RoomEvent.TimelineReset); + await Promise.resolve(); + }); + + expect(scrollToBottom).not.toHaveBeenCalled(); + }); + it('resets timeline state when room.roomId changes and eventId is not set', async () => { const roomOne = createRoom('!room:one'); const roomTwo = createRoom('!room:two'); diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 51c85dda8..e46dcb32e 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -387,6 +387,9 @@ export function useTimelineSync({ const resetAutoScrollPendingRef = useRef(false); + const timelineRef = useRef(timeline); + timelineRef.current = timeline; + const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines); const liveTimelineLinked = timeline.linkedTimelines.at(-1) === getLiveTimeline(room); @@ -524,12 +527,27 @@ export function useTimelineSync({ useLiveTimelineRefresh( room, useCallback(() => { - const wasAtBottom = isAtBottomRef.current; - resetAutoScrollPendingRef.current = wasAtBottom; - setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - if (wasAtBottom) { - scrollToBottom('instant'); + const newLinked = getInitialTimeline(room).linkedTimelines; + const prev = timelineRef.current.linkedTimelines; + if (prev.length === newLinked.length && prev.every((tl, i) => tl === newLinked[i])) { + return; + } + // Only trigger the auto-scroll reset for destructive resets where the + // live-end event changed (e.g. reconnect). Sliding sync fires TimelineReset + // to replace the EventTimeline container while keeping the same live-end + // events — treating that as destructive would scroll the user unnecessarily. + const prevLastEventId = prev.at(-1)?.getEvents().at(-1)?.getId(); + const newLastEventId = newLinked.at(-1)?.getEvents().at(-1)?.getId(); + const isDestructive = prevLastEventId !== newLastEventId; + + if (isDestructive) { + const wasAtBottom = isAtBottomRef.current; + resetAutoScrollPendingRef.current = wasAtBottom; + if (wasAtBottom) { + scrollToBottom('instant'); + } } + setTimeline({ linkedTimelines: newLinked }); }, [room, isAtBottomRef, scrollToBottom]) ); From 131fe1ccc37a9dd6878df50a5d3b173a7855816b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 17 Apr 2026 18:42:14 -0400 Subject: [PATCH 50/62] fix(timeline): guard cache restore, unread dots, and jump scroll-back - Guard cache-restore against empty processedEvents to prevent flash on room re-entry (defer to pendingReady recovery instead of revealing an empty VList) - Update pendingReady recovery to respect saved scroll position when restoring from cache - Add focusItem guard to handleVListScroll so jump transitions do not incorrectly flip atBottom during scrollToIndex - Reset lastScrolledAtEventsLengthRef on jump so auto-scroll watcher does not snap back to bottom after event timeline load - Remove aggressive no-receipt unread fallback in getUnreadInfo that showed false dots for sliding-sync rooms without local read receipts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 39 +++++++++++++++++++---- src/app/hooks/timeline/useTimelineSync.ts | 14 +++++--- src/app/utils/room.ts | 25 --------------- 3 files changed, 42 insertions(+), 36 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d9b61798e..6fd8b6cf5 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -371,12 +371,19 @@ export function RoomTimeline({ if (savedCache) { // Revisiting a room with a cached scroll state — restore position // immediately and skip the 80 ms stabilisation timer entirely. - if (savedCache.atBottom) { - vListRef.current.scrollTo(vListRef.current.scrollSize); + // Guard: only reveal when processedEvents are populated. If they're + // still empty (React hasn't yet processed the new timeline), defer + // to the pendingReady recovery path which runs on the next render. + if (processedEventsRef.current.length === 0) { + pendingReadyRef.current = true; } else { - vListRef.current.scrollTo(savedCache.scrollOffset); + if (savedCache.atBottom) { + vListRef.current.scrollTo(vListRef.current.scrollSize); + } else { + vListRef.current.scrollTo(savedCache.scrollOffset); + } + setIsReady(true); } - setIsReady(true); } else { // First visit — scroll to bottom, then wait 80 ms for VList to finish // measuring item heights before revealing the timeline. @@ -862,6 +869,12 @@ export function RoomTimeline({ // upstream doesn't have, producing visible layout churn after isReady. if (!isReadyRef.current) return; + // While a jump is in progress (focusItem set), VList fires scroll events + // from scrollToIndex that can incorrectly flip atBottom=true — especially + // if the target happens to be near the end. Ignore scroll-position + // updates until the jump transition finishes and focusItem is cleared. + if (timelineSyncRef.current.focusItem) return; + if (atBottomRef.current && !isNowAtBottom && contentGrew) { v.scrollTo(v.scrollSize); return; @@ -1016,14 +1029,28 @@ export function RoomTimeline({ if (!pendingReadyRef.current) return; if (processedEvents.length === 0) return; pendingReadyRef.current = false; - vListRef.current?.scrollToIndex(processedEvents.length - 1, { align: 'end' }); + + // If there's a cached scroll state (deferred from the initial-scroll + // path because processedEvents weren't ready yet), restore that position + // instead of blindly scrolling to bottom. + const savedCache = scrollCacheForRoomRef.current; + if (savedCache) { + if (savedCache.atBottom) { + vListRef.current?.scrollToIndex(processedEvents.length - 1, { align: 'end' }); + } else { + vListRef.current?.scrollTo(savedCache.scrollOffset); + } + } else { + vListRef.current?.scrollToIndex(processedEvents.length - 1, { align: 'end' }); + } + // Save now so the next visit skips the timer. const v = vListRef.current; if (v) { roomScrollCache.save(mxUserId, room.roomId, { cache: v.cache, scrollOffset: v.scrollOffset, - atBottom: true, + atBottom: savedCache?.atBottom ?? true, }); } setIsReady(true); diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 15f307dca..a7570a33c 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -453,6 +453,11 @@ export function useTimelineSync({ } }, [eventsLength, liveTimelineLinked, isAtBottom]); + const lastScrolledAtEventsLengthRef = useRef(eventsLength); + + const eventsLengthRef = useRef(eventsLength); + eventsLengthRef.current = eventsLength; + const loadEventTimeline = useEventTimelineLoader( mx, room, @@ -462,6 +467,10 @@ export function useTimelineSync({ setTimeline({ linkedTimelines: lTimelines }); + // Sync the auto-scroll ref so the eventsLength watcher doesn't see a + // delta and immediately scroll-to-bottom after the jump lands. + lastScrolledAtEventsLengthRef.current = getTimelinesEventsCount(lTimelines); + setFocusItem({ index: evtAbsIndex, scrollTo: true, @@ -477,11 +486,6 @@ export function useTimelineSync({ }, [alive, room, scrollToBottom]) ); - const lastScrolledAtEventsLengthRef = useRef(eventsLength); - - const eventsLengthRef = useRef(eventsLength); - eventsLengthRef.current = eventsLength; - useLiveEventArrive( room, useCallback( diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index d7a36b338..3cebdb5c6 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -356,31 +356,6 @@ export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadIn } } - // Sliding sync limitation: unvisited rooms don't have read receipt data, but may have - // timeline activity. Check for notification events from others in the timeline to show a - // badge even when SDK counts are 0 (or unreliable without receipts). - if (userId) { - const readUpToId = room.getEventReadUpTo(userId); - - // If we have no read receipt, SDK counts may be unreliable. Always check timeline. - if (!readUpToId) { - const liveEvents = room.getLiveTimeline().getEvents(); - - const hasActivity = liveEvents.some( - (event) => event.getSender() !== userId && isNotificationEvent(event, room, userId) - ); - - if (hasActivity) { - // If SDK already has counts, use those. Otherwise show dot badge (count=1). - if (total === 0 && highlight === 0) { - return { roomId: room.roomId, highlight: 0, total: 1 }; - } - // SDK has counts but no receipt - trust the counts and show them - return { roomId: room.roomId, highlight, total }; - } - } - } - // For DMs with Default or AllMessages notification type: if there are unread messages, // ensure we show a notification badge (treat as highlight for badge color purposes). // This handles cases where push rules don't properly match (e.g., classic sync with From 4e4d3689028255bb8282b0c20e6f25628f6d771d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 17 Apr 2026 23:37:57 -0400 Subject: [PATCH 51/62] fix(timeline): prevent double-render and content-chase flash - Guard blanked effect against SDK live timeline having events (prevents skeleton re-arm during transient React state lag) - Defer content-chase scroll via requestAnimationFrame to prevent cascading scroll events when images/embeds load - Skip timeline update in useLiveTimelineRefresh microtask when SDK timeline is still empty (avoids partial state) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 15 +++++++++++++-- src/app/hooks/timeline/useTimelineSync.ts | 8 +++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 6fd8b6cf5..5905a9dac 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -445,10 +445,15 @@ export function RoomTimeline({ useLayoutEffect(() => { if (!isReady) return; if (timelineSync.eventsLength > 0) return; + // The SDK may have already added events to the new timeline but React state + // hasn't caught up yet (e.g. useLiveTimelineRefresh deferred via microtask). + // Check the SDK's live timeline directly before blanking to avoid a double + // skeleton cycle (skeleton→content→blank→skeleton→content). + if (room.getLiveTimeline().getEvents().length > 0) return; setIsReady(false); readyBlockedByPaginationRef.current = false; hasInitialScrolledRef.current = false; - }, [isReady, timelineSync.eventsLength]); + }, [isReady, timelineSync.eventsLength, room]); // When a genuine TimelineReset replaces the timeline chain (new // EventTimeline objects from the SDK), hide content behind opacity 0 and @@ -876,7 +881,13 @@ export function RoomTimeline({ if (timelineSyncRef.current.focusItem) return; if (atBottomRef.current && !isNowAtBottom && contentGrew) { - v.scrollTo(v.scrollSize); + // Defer the chase to the next animation frame so VList finishes its + // current layout pass. Synchronous scrollTo causes cascading scroll + // events that produce visible jumps when images/embeds load. + requestAnimationFrame(() => { + const vl = vListRef.current; + if (vl && atBottomRef.current) vl.scrollTo(vl.scrollSize); + }); return; } diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index a7570a33c..d08093324 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -571,7 +571,13 @@ export function useTimelineSync({ // effect, which would hide the content and cause a visible flash. if (getTimelinesEventsCount(newLinked) === 0 && getTimelinesEventsCount(prev) > 0) { Promise.resolve().then(() => { - applyUpdate(getInitialTimeline(room).linkedTimelines); + const refreshed = getInitialTimeline(room).linkedTimelines; + // If still empty after the microtask, the SDK may need more time. + // Skip the update — the blanked effect checks the SDK's live timeline + // directly and won't blank while the room genuinely has events. + if (getTimelinesEventsCount(refreshed) > 0) { + applyUpdate(refreshed); + } }); return; } From f9c5b4d63c6cd58ea46c96ca18f10daaf3f87465 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 19:19:59 -0400 Subject: [PATCH 52/62] fix(timeline): restore room-open position and dedupe overlap --- src/app/features/room/RoomTimeline.tsx | 23 +++++++++++-------- .../timeline/useProcessedTimeline.test.ts | 16 +++++++++++++ .../hooks/timeline/useProcessedTimeline.ts | 8 +++++++ 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 5905a9dac..76b495a5d 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -172,6 +172,8 @@ export function RoomTimeline({ }: Readonly) { const mx = useMatrixClient(); const mxUserId = mx.getUserId()!; + const initialUnreadInfo = getRoomUnreadInfo(room, true); + const initialScrollCache = roomScrollCache.load(mxUserId, room.roomId); const alive = useAlive(); const { editId, handleEdit } = useMessageEdit(editor, { onReset: onEditorReset, alive }); @@ -218,7 +220,7 @@ export function RoomTimeline({ return myPowerLevel < sendLevel; }, [powerLevels, mx]); - const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true)); + const [unreadInfo, setUnreadInfo] = useState(initialUnreadInfo); const readUptoEventIdRef = useRef(undefined); if (unreadInfo) readUptoEventIdRef.current = unreadInfo.readUptoEventId; @@ -250,10 +252,10 @@ export function RoomTimeline({ const setOpenThread = useSetAtom(openThreadAtom); const vListRef = useRef(null); - const scrollCacheForRoomRef = useRef( - roomScrollCache.load(mxUserId, room.roomId) + const scrollCacheForRoomRef = useRef(initialScrollCache); + const [atBottomState, setAtBottomState] = useState( + eventId ? false : (initialScrollCache?.atBottom ?? !initialUnreadInfo?.scrollTo) ); - const [atBottomState, setAtBottomState] = useState(!eventId); const atBottomRef = useRef(atBottomState); const setAtBottom = useCallback((val: boolean) => { setAtBottomState(val); @@ -284,9 +286,11 @@ export function RoomTimeline({ isReadyRef.current = isReady; if (currentRoomIdRef.current !== room.roomId) { + const nextScrollCache = roomScrollCache.load(mxUserId, room.roomId); + const nextUnreadInfo = getRoomUnreadInfo(room, true); // 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(mxUserId, room.roomId); + scrollCacheForRoomRef.current = nextScrollCache; hasInitialScrolledRef.current = false; currentRoomIdRef.current = room.roomId; @@ -298,11 +302,11 @@ export function RoomTimeline({ } setIsReady(false); // Reset per-room scroll/layout state so the new room starts clean. - setAtBottom(true); + setAtBottom(eventId ? false : (nextScrollCache?.atBottom ?? !nextUnreadInfo?.scrollTo)); setShift(false); setTopSpacerHeight(0); topSpacerHeightRef.current = 0; - setUnreadInfo(getRoomUnreadInfo(room, true)); + setUnreadInfo(nextUnreadInfo); } const processedEventsRef = useRef([]); @@ -612,6 +616,7 @@ export function RoomTimeline({ : undefined; if (absoluteIndex !== undefined) { + setAtBottom(false); const processedIndex = getRawIndexToProcessedIndex(absoluteIndex); if (processedIndex !== undefined && vListRef.current) { vListRef.current.scrollToIndex(processedIndex, { align: 'start' }); @@ -630,6 +635,7 @@ export function RoomTimeline({ eventId, isReady, getRawIndexToProcessedIndex, + setAtBottom, ]); useEffect(() => { @@ -1194,7 +1200,6 @@ export function RoomTimeline({ data={processedEvents} cache={!eventId ? scrollCacheForRoomRef.current?.cache : undefined} shift={shift} - itemSize={80} className={css.messageList} style={{ flex: 1, @@ -1297,7 +1302,7 @@ export function RoomTimeline({ } return ( - + {dividers} {renderedEvent} diff --git a/src/app/hooks/timeline/useProcessedTimeline.test.ts b/src/app/hooks/timeline/useProcessedTimeline.test.ts index cc6f00072..abd1f2244 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.test.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.test.ts @@ -287,4 +287,20 @@ describe('useProcessedTimeline', () => { expect(result.current[1].willRenderDayDivider).toBe(true); }); + + it('deduplicates overlapping event IDs across linked timelines', () => { + const shared = makeEvent('$shared', { ts: 2_000 }); + const earlier = makeEvent('$earlier', { ts: 1_000 }); + const later = makeEvent('$later', { ts: 3_000 }); + + const { result } = renderHook(() => + useProcessedTimeline({ + ...defaults, + items: makeItems(4), + linkedTimelines: [makeTimeline([earlier, shared]), makeTimeline([shared, later])], + }) + ); + + expect(result.current.map((event) => event.id)).toEqual(['$earlier', '$shared', '$later']); + }); }); diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index f37197309..e898d77a4 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -93,6 +93,7 @@ export function useProcessedTimeline({ const isMutation = mutationVersion !== prevMutationVersionRef.current; prevMutationVersionRef.current = mutationVersion; const prevCache = isMutation ? null : stableRefsCache.current; + const seenRenderedEventIds = new Set(); let prevEvent: MatrixEvent | undefined; let isPrevRendered = false; @@ -158,6 +159,13 @@ export function useProcessedTimeline({ const isReactionOrEdit = reactionOrEditEvent(mEvent); if (isReactionOrEdit) return acc; + // Sliding-sync timeline resets and overlapping linked timelines can + // transiently surface the same event twice. Rendering duplicate event IDs + // causes unstable React keys and visible timeline artifacts, so keep the + // first visible occurrence only. + if (seenRenderedEventIds.has(mEventId)) return acc; + seenRenderedEventIds.add(mEventId); + if (!newDivider && readUptoEventId) { const prevId = prevEvent ? prevEvent.getId() : undefined; newDivider = prevId === readUptoEventId; From 399c7cbf6e61edb77ed6a71db75c870fea2bce50 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 19:41:03 -0400 Subject: [PATCH 53/62] chore(timeline): clean reveal comment --- src/app/features/room/RoomTimeline.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 76b495a5d..bb311c2e9 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -459,12 +459,9 @@ export function RoomTimeline({ hasInitialScrolledRef.current = false; }, [isReady, timelineSync.eventsLength, room]); - // When a genuine TimelineReset replaces the timeline chain (new - // EventTimeline objects from the SDK), hide content behind opacity 0 and - // re-arm the initial-scroll so the new data renders invisibly, gets - // measured, and only then becomes visible — preventing a visible flash. + // Reveal the timeline once backward pagination has settled and the viewport is // filled. This handles the case where the 80 ms timer fired before sliding sync - // had delivered enough events to fill the screen (readyBlockedByPaginationRef=true). + // had delivered enough events to fill the screen. useLayoutEffect(() => { if (!readyBlockedByPaginationRef.current) return; if (timelineSync.backwardStatus === 'loading') return; From 78cdd244d342351e6b08fac2eb5c0377c894785f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 20:21:13 -0400 Subject: [PATCH 54/62] fix(timeline): invalidate stale room scroll cache --- src/app/features/room/RoomTimeline.tsx | 69 ++++++++++++++++---------- src/app/utils/roomScrollCache.test.ts | 64 +++++++++++++++++++++--- src/app/utils/roomScrollCache.ts | 21 +++++++- src/app/utils/timeline.ts | 12 +++++ 4 files changed, 130 insertions(+), 36 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index bb311c2e9..325803e6f 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -74,6 +74,7 @@ import { getFirstLinkedTimeline, getInitialTimeline, getEventIdAbsoluteIndex, + getTimelineHeadEventIds, } from '$utils/timeline'; import { useTimelineSync } from '$hooks/timeline/useTimelineSync'; import { useTimelineActions } from '$hooks/timeline/useTimelineActions'; @@ -173,7 +174,12 @@ export function RoomTimeline({ const mx = useMatrixClient(); const mxUserId = mx.getUserId()!; const initialUnreadInfo = getRoomUnreadInfo(room, true); - const initialScrollCache = roomScrollCache.load(mxUserId, room.roomId); + const initialTimelineHeadEventIds = eventId + ? [] + : getTimelineHeadEventIds(getInitialTimeline(room).linkedTimelines); + const initialScrollCache = eventId + ? undefined + : roomScrollCache.load(mxUserId, room.roomId, initialTimelineHeadEventIds); const alive = useAlive(); const { editId, handleEdit } = useMessageEdit(editor, { onReset: onEditorReset, alive }); @@ -286,7 +292,13 @@ export function RoomTimeline({ isReadyRef.current = isReady; if (currentRoomIdRef.current !== room.roomId) { - const nextScrollCache = roomScrollCache.load(mxUserId, room.roomId); + const nextInitialTimeline = eventId ? undefined : getInitialTimeline(room); + const nextTimelineHeadEventIds = nextInitialTimeline + ? getTimelineHeadEventIds(nextInitialTimeline.linkedTimelines) + : []; + const nextScrollCache = eventId + ? undefined + : roomScrollCache.load(mxUserId, room.roomId, nextTimelineHeadEventIds); const nextUnreadInfo = getRoomUnreadInfo(room, true); // Load incoming room's scroll cache (undefined for first-visit rooms). // Covers the rare case where room prop changes without a remount. @@ -312,6 +324,20 @@ export function RoomTimeline({ const processedEventsRef = useRef([]); const timelineSyncRef = useRef(null as unknown as typeof timelineSync); + const saveRoomScrollState = useCallback( + (cache: RoomScrollCache['cache'], scrollOffset: number, atBottom: boolean) => { + if (eventId) return; + + roomScrollCache.save(mxUserId, room.roomId, { + cache, + scrollOffset, + atBottom, + headEventIds: getTimelineHeadEventIds(timelineSyncRef.current.timeline.linkedTimelines), + }); + }, + [eventId, mxUserId, room.roomId] + ); + const scrollToBottom = useCallback(() => { if (!vListRef.current) return; const lastIndex = processedEventsRef.current.length - 1; @@ -416,11 +442,7 @@ export function RoomTimeline({ // 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. if (v) { - roomScrollCache.save(mxUserId, room.roomId, { - cache: v.cache, - scrollOffset: v.scrollOffset, - atBottom: true, - }); + saveRoomScrollState(v.cache, v.scrollOffset, true); } setIsReady(true); } else { @@ -431,7 +453,13 @@ export function RoomTimeline({ } // 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, mxUserId]); + }, [ + timelineSync.eventsLength, + timelineSync.liveTimelineLinked, + eventId, + room.roomId, + saveRoomScrollState, + ]); // Cancel the initial-scroll timer on unmount (the useLayoutEffect above // intentionally does not cancel it when deps change). @@ -471,18 +499,13 @@ export function RoomTimeline({ if (canPaginateBackRef.current && v.scrollSize <= v.viewportSize + 100) return; readyBlockedByPaginationRef.current = false; v.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); - roomScrollCache.save(mxUserId, room.roomId, { - cache: v.cache, - scrollOffset: v.scrollOffset, - atBottom: true, - }); + saveRoomScrollState(v.cache, v.scrollOffset, true); setIsReady(true); }, [ timelineSync.backwardStatus, timelineSync.eventsLength, timelineSync.canPaginateBack, - mxUserId, - room.roomId, + saveRoomScrollState, ]); const recalcTopSpacer = useCallback(() => { @@ -905,11 +928,7 @@ export function RoomTimeline({ // live-timeline visit, producing stale VList measurements and making the // room appear to be at the wrong position (or visually empty) on re-entry. if (!eventId) { - roomScrollCache.save(mxUserId, room.roomId, { - cache: v.cache, - scrollOffset: offset, - atBottom: isNowAtBottom, - }); + saveRoomScrollState(v.cache, offset, isNowAtBottom); } if (offset < 500 && canPaginateBackRef.current && backwardStatusRef.current === 'idle') { @@ -923,7 +942,7 @@ export function RoomTimeline({ timelineSyncRef.current.handleTimelinePagination(false); } }, - [setAtBottom, room.roomId, eventId, mxUserId] + [eventId, saveRoomScrollState, setAtBottom] ); const showLoadingPlaceholders = @@ -1061,14 +1080,10 @@ export function RoomTimeline({ // Save now so the next visit skips the timer. const v = vListRef.current; if (v) { - roomScrollCache.save(mxUserId, room.roomId, { - cache: v.cache, - scrollOffset: v.scrollOffset, - atBottom: savedCache?.atBottom ?? true, - }); + saveRoomScrollState(v.cache, v.scrollOffset, savedCache?.atBottom ?? true); } setIsReady(true); - }, [processedEvents.length, room.roomId, mxUserId]); + }, [processedEvents.length, saveRoomScrollState]); useEffect(() => { if (!onEditLastMessageRef) return; diff --git a/src/app/utils/roomScrollCache.test.ts b/src/app/utils/roomScrollCache.test.ts index 039bc634e..784c59fc4 100644 --- a/src/app/utils/roomScrollCache.test.ts +++ b/src/app/utils/roomScrollCache.test.ts @@ -11,22 +11,47 @@ describe('roomScrollCache', () => { }); it('stores and retrieves data for a roomId', () => { - const data = { cache: fakeCache(), scrollOffset: 120, atBottom: false }; + const data = { + cache: fakeCache(), + scrollOffset: 120, + atBottom: false, + headEventIds: ['$a', '$b'], + }; roomScrollCache.save(userId, '!room1:test', data); expect(roomScrollCache.load(userId, '!room1:test')).toBe(data); }); it('overwrites existing data when saved again for the same roomId', () => { - const first = { cache: fakeCache(), scrollOffset: 50, atBottom: true }; - const second = { cache: fakeCache(), scrollOffset: 200, atBottom: false }; + const first = { + cache: fakeCache(), + scrollOffset: 50, + atBottom: true, + headEventIds: ['$a', '$b'], + }; + const second = { + cache: fakeCache(), + scrollOffset: 200, + atBottom: false, + headEventIds: ['$c', '$d'], + }; roomScrollCache.save(userId, '!room2:test', first); roomScrollCache.save(userId, '!room2:test', second); expect(roomScrollCache.load(userId, '!room2:test')).toBe(second); }); it('keeps data for separate rooms independent', () => { - const a = { cache: fakeCache(), scrollOffset: 10, atBottom: true }; - const b = { cache: fakeCache(), scrollOffset: 20, atBottom: false }; + const a = { + cache: fakeCache(), + scrollOffset: 10, + atBottom: true, + headEventIds: ['$a'], + }; + const b = { + cache: fakeCache(), + scrollOffset: 20, + atBottom: false, + headEventIds: ['$b'], + }; roomScrollCache.save(userId, '!roomA:test', a); roomScrollCache.save(userId, '!roomB:test', b); expect(roomScrollCache.load(userId, '!roomA:test')).toBe(a); @@ -34,11 +59,36 @@ describe('roomScrollCache', () => { }); it('scopes data per userId', () => { - const data1 = { cache: fakeCache(), scrollOffset: 100, atBottom: true }; - const data2 = { cache: fakeCache(), scrollOffset: 200, atBottom: false }; + const data1 = { + cache: fakeCache(), + scrollOffset: 100, + atBottom: true, + headEventIds: ['$a'], + }; + const data2 = { + cache: fakeCache(), + scrollOffset: 200, + atBottom: false, + headEventIds: ['$b'], + }; roomScrollCache.save('@alice:test', '!room:test', data1); roomScrollCache.save('@bob:test', '!room:test', data2); expect(roomScrollCache.load('@alice:test', '!room:test')).toBe(data1); expect(roomScrollCache.load('@bob:test', '!room:test')).toBe(data2); }); + + it('invalidates a cache when the current timeline head changes', () => { + const data = { + cache: fakeCache(), + scrollOffset: 120, + atBottom: false, + headEventIds: ['$a', '$b', '$c'], + }; + roomScrollCache.save(userId, '!room3:test', data); + + expect(roomScrollCache.load(userId, '!room3:test', ['$a', '$b', '$c', '$d'])).toBe(data); + expect(roomScrollCache.load(userId, '!room3:test', ['$x', '$a', '$b', '$c'])).toBeUndefined(); + expect(roomScrollCache.load(userId, '!room3:test', ['$a', '$x', '$c', '$d'])).toBeUndefined(); + expect(roomScrollCache.load(userId, '!room3:test', ['$a', '$b'])).toBeUndefined(); + }); }); diff --git a/src/app/utils/roomScrollCache.ts b/src/app/utils/roomScrollCache.ts index 8336595a4..21682604d 100644 --- a/src/app/utils/roomScrollCache.ts +++ b/src/app/utils/roomScrollCache.ts @@ -7,18 +7,35 @@ export type RoomScrollCache = { scrollOffset: number; /** Whether the view was pinned to the bottom (live) when the room was left. */ atBottom: boolean; + /** + * Raw event IDs from the loaded head of the timeline when the snapshot was + * captured. Virtua's cache is index-based, so head changes invalidate it. + */ + headEventIds: string[]; }; /** Session-scoped, per-room scroll cache. Not persisted across page reloads. */ const scrollCacheMap = new Map(); const cacheKey = (userId: string, roomId: string): string => `${userId}:${roomId}`; +const headMatches = (saved: string[], current: string[]): boolean => + saved.length > 0 && + current.length >= saved.length && + saved.every((eventId, index) => current[index] === eventId); export const roomScrollCache = { save(userId: string, roomId: string, data: RoomScrollCache): void { scrollCacheMap.set(cacheKey(userId, roomId), data); }, - load(userId: string, roomId: string): RoomScrollCache | undefined { - return scrollCacheMap.get(cacheKey(userId, roomId)); + load( + userId: string, + roomId: string, + currentHeadEventIds?: string[] + ): RoomScrollCache | undefined { + const cached = scrollCacheMap.get(cacheKey(userId, roomId)); + if (!cached) return undefined; + if (!currentHeadEventIds) return cached; + if (!headMatches(cached.headEventIds, currentHeadEventIds)) return undefined; + return cached; }, }; diff --git a/src/app/utils/timeline.ts b/src/app/utils/timeline.ts index 0934e5b02..e199387ff 100644 --- a/src/app/utils/timeline.ts +++ b/src/app/utils/timeline.ts @@ -46,6 +46,18 @@ export const getTimelinesEventsCount = (timelines: EventTimeline[]): number => { .reduce((accumulator, element) => timelineEventCountReducer(accumulator, element), 0); }; +export const getTimelineHeadEventIds = (timelines: EventTimeline[], limit = 5): string[] => { + if (limit <= 0) return []; + + return timelines + .flatMap((timeline) => timeline.getEvents()) + .flatMap((event) => { + const eventId = event.getId(); + return eventId ? [eventId] : []; + }) + .slice(0, limit); +}; + export const getTimelineAndBaseIndex = ( timelines: EventTimeline[], index: number From e4e6b836b205d2fe15891c3307e44036c7adc28d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 22:33:59 -0400 Subject: [PATCH 55/62] fix(timeline): remove stale room scroll restore --- src/app/features/room/RoomTimeline.tsx | 169 +++++-------------------- 1 file changed, 35 insertions(+), 134 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 325803e6f..10e98eb78 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -14,7 +14,6 @@ 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, @@ -74,7 +73,6 @@ import { getFirstLinkedTimeline, getInitialTimeline, getEventIdAbsoluteIndex, - getTimelineHeadEventIds, } from '$utils/timeline'; import { useTimelineSync } from '$hooks/timeline/useTimelineSync'; import { useTimelineActions } from '$hooks/timeline/useTimelineActions'; @@ -172,14 +170,7 @@ export function RoomTimeline({ onEditLastMessageRef, }: Readonly) { const mx = useMatrixClient(); - const mxUserId = mx.getUserId()!; const initialUnreadInfo = getRoomUnreadInfo(room, true); - const initialTimelineHeadEventIds = eventId - ? [] - : getTimelineHeadEventIds(getInitialTimeline(room).linkedTimelines); - const initialScrollCache = eventId - ? undefined - : roomScrollCache.load(mxUserId, room.roomId, initialTimelineHeadEventIds); const alive = useAlive(); const { editId, handleEdit } = useMessageEdit(editor, { onReset: onEditorReset, alive }); @@ -258,9 +249,8 @@ export function RoomTimeline({ const setOpenThread = useSetAtom(openThreadAtom); const vListRef = useRef(null); - const scrollCacheForRoomRef = useRef(initialScrollCache); const [atBottomState, setAtBottomState] = useState( - eventId ? false : (initialScrollCache?.atBottom ?? !initialUnreadInfo?.scrollTo) + eventId ? false : !initialUnreadInfo?.scrollTo ); const atBottomRef = useRef(atBottomState); const setAtBottom = useCallback((val: boolean) => { @@ -292,18 +282,7 @@ export function RoomTimeline({ isReadyRef.current = isReady; if (currentRoomIdRef.current !== room.roomId) { - const nextInitialTimeline = eventId ? undefined : getInitialTimeline(room); - const nextTimelineHeadEventIds = nextInitialTimeline - ? getTimelineHeadEventIds(nextInitialTimeline.linkedTimelines) - : []; - const nextScrollCache = eventId - ? undefined - : roomScrollCache.load(mxUserId, room.roomId, nextTimelineHeadEventIds); const nextUnreadInfo = getRoomUnreadInfo(room, true); - // Load incoming room's scroll cache (undefined for first-visit rooms). - // Covers the rare case where room prop changes without a remount. - scrollCacheForRoomRef.current = nextScrollCache; - hasInitialScrolledRef.current = false; currentRoomIdRef.current = room.roomId; pendingReadyRef.current = false; @@ -314,7 +293,7 @@ export function RoomTimeline({ } setIsReady(false); // Reset per-room scroll/layout state so the new room starts clean. - setAtBottom(eventId ? false : (nextScrollCache?.atBottom ?? !nextUnreadInfo?.scrollTo)); + setAtBottom(eventId ? false : !nextUnreadInfo?.scrollTo); setShift(false); setTopSpacerHeight(0); topSpacerHeightRef.current = 0; @@ -324,20 +303,6 @@ export function RoomTimeline({ const processedEventsRef = useRef([]); const timelineSyncRef = useRef(null as unknown as typeof timelineSync); - const saveRoomScrollState = useCallback( - (cache: RoomScrollCache['cache'], scrollOffset: number, atBottom: boolean) => { - if (eventId) return; - - roomScrollCache.save(mxUserId, room.roomId, { - cache, - scrollOffset, - atBottom, - headEventIds: getTimelineHeadEventIds(timelineSyncRef.current.timeline.linkedTimelines), - }); - }, - [eventId, mxUserId, room.roomId] - ); - const scrollToBottom = useCallback(() => { if (!vListRef.current) return; const lastIndex = processedEventsRef.current.length - 1; @@ -395,61 +360,36 @@ export function RoomTimeline({ timelineSync.liveTimelineLinked && vListRef.current ) { - 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. - // Guard: only reveal when processedEvents are populated. If they're - // still empty (React hasn't yet processed the new timeline), defer - // to the pendingReady recovery path which runs on the next render. - if (processedEventsRef.current.length === 0) { - pendingReadyRef.current = true; - } else { - if (savedCache.atBottom) { - vListRef.current.scrollTo(vListRef.current.scrollSize); - } else { - vListRef.current.scrollTo(savedCache.scrollOffset); + // 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) { + vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { + align: 'end', + }); + const v = vListRef.current; + // If backward pagination can still fill the viewport, delay revealing + // until that pagination settles so the user never sees the 3→60 event jump. + const needsFill = + canPaginateBackRef.current && + v && + v.scrollSize <= v.viewportSize + 300 && + backwardStatusRef.current !== 'error'; + if (needsFill) { + readyBlockedByPaginationRef.current = true; + return; } setIsReady(true); + } else { + pendingReadyRef.current = true; } - } else { - // First visit — 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) { - vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { - align: 'end', - }); - const v = vListRef.current; - // If backward pagination can still fill the viewport, delay revealing - // until that pagination settles so the user never sees the 3→60 event jump. - const needsFill = - canPaginateBackRef.current && - v && - v.scrollSize <= v.viewportSize + 300 && - backwardStatusRef.current !== 'error'; - if (needsFill) { - readyBlockedByPaginationRef.current = true; - return; - } - // 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. - if (v) { - saveRoomScrollState(v.cache, v.scrollOffset, true); - } - setIsReady(true); - } else { - pendingReadyRef.current = true; - } - }, 80); - } + }, 80); } // No cleanup return — the timer must survive eventsLength fluctuations. // It is cancelled on unmount by the dedicated effect below. @@ -458,7 +398,6 @@ export function RoomTimeline({ timelineSync.liveTimelineLinked, eventId, room.roomId, - saveRoomScrollState, ]); // Cancel the initial-scroll timer on unmount (the useLayoutEffect above @@ -499,14 +438,8 @@ export function RoomTimeline({ if (canPaginateBackRef.current && v.scrollSize <= v.viewportSize + 100) return; readyBlockedByPaginationRef.current = false; v.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); - saveRoomScrollState(v.cache, v.scrollOffset, true); setIsReady(true); - }, [ - timelineSync.backwardStatus, - timelineSync.eventsLength, - timelineSync.canPaginateBack, - saveRoomScrollState, - ]); + }, [timelineSync.backwardStatus, timelineSync.eventsLength, timelineSync.canPaginateBack]); const recalcTopSpacer = useCallback(() => { const v = vListRef.current; @@ -921,16 +854,6 @@ export function RoomTimeline({ setAtBottom(isNowAtBottom); } - // 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. - // Skip when viewing a historical slice via eventId: those item heights are - // for a sparse subset of events and would corrupt the cache for the next - // live-timeline visit, producing stale VList measurements and making the - // room appear to be at the wrong position (or visually empty) on re-entry. - if (!eventId) { - saveRoomScrollState(v.cache, offset, isNowAtBottom); - } - if (offset < 500 && canPaginateBackRef.current && backwardStatusRef.current === 'idle') { timelineSyncRef.current.handleTimelinePagination(true); } @@ -942,19 +865,17 @@ export function RoomTimeline({ timelineSyncRef.current.handleTimelinePagination(false); } }, - [eventId, saveRoomScrollState, setAtBottom] + [setAtBottom] ); const showLoadingPlaceholders = timelineSync.eventsLength === 0 && (!isReady || timelineSync.canPaginateBack || timelineSync.backwardStatus === 'loading'); - // Show a skeleton overlay when content is not ready and there is no - // cached scroll state to restore from (first visit or timeline reset). + // Show a skeleton overlay while content is hidden for measurement. // The VList still renders underneath at opacity 0 so it can measure real // item heights — the overlay just gives the user something to look at. - const showSkeletonOverlay = - !isReady && !scrollCacheForRoomRef.current && !showLoadingPlaceholders; + const showSkeletonOverlay = !isReady && !showLoadingPlaceholders; let backPaginationJSX: ReactNode | undefined; if (timelineSync.canPaginateBack || timelineSync.backwardStatus !== 'idle') { @@ -1044,7 +965,7 @@ export function RoomTimeline({ ignoredUsersSet, showHiddenEvents, showTombstoneEvents, - mxUserId, + mxUserId: mx.getUserId(), readUptoEventId: readUptoEventIdRef.current, hideMembershipEvents, hideNickAvatarEvents, @@ -1062,28 +983,9 @@ export function RoomTimeline({ if (!pendingReadyRef.current) return; if (processedEvents.length === 0) return; pendingReadyRef.current = false; - - // If there's a cached scroll state (deferred from the initial-scroll - // path because processedEvents weren't ready yet), restore that position - // instead of blindly scrolling to bottom. - const savedCache = scrollCacheForRoomRef.current; - if (savedCache) { - if (savedCache.atBottom) { - vListRef.current?.scrollToIndex(processedEvents.length - 1, { align: 'end' }); - } else { - vListRef.current?.scrollTo(savedCache.scrollOffset); - } - } else { - vListRef.current?.scrollToIndex(processedEvents.length - 1, { align: 'end' }); - } - - // Save now so the next visit skips the timer. - const v = vListRef.current; - if (v) { - saveRoomScrollState(v.cache, v.scrollOffset, savedCache?.atBottom ?? true); - } + vListRef.current?.scrollToIndex(processedEvents.length - 1, { align: 'end' }); setIsReady(true); - }, [processedEvents.length, saveRoomScrollState]); + }, [processedEvents.length]); useEffect(() => { if (!onEditLastMessageRef) return; @@ -1210,7 +1112,6 @@ export function RoomTimeline({ key={room.roomId} ref={vListRef} data={processedEvents} - cache={!eventId ? scrollCacheForRoomRef.current?.cache : undefined} shift={shift} className={css.messageList} style={{ From abc83a23997175d4b6eea19c247d3310b8465103 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 22:35:14 -0400 Subject: [PATCH 56/62] chore(timeline): fix room timeline formatting --- src/app/features/room/RoomTimeline.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 10e98eb78..dddd87a68 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -393,12 +393,7 @@ export function RoomTimeline({ } // 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]); // Cancel the initial-scroll timer on unmount (the useLayoutEffect above // intentionally does not cancel it when deps change). From 7835ed1ce054f36910b25ea68459ee91a7d650f6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 22:51:52 -0400 Subject: [PATCH 57/62] feat(timeline): restore room cache by event anchor --- src/app/features/room/RoomTimeline.tsx | 233 ++++++++++++++++++++++++- src/app/utils/roomScrollCache.test.ts | 116 +++++++----- src/app/utils/roomScrollCache.ts | 59 +++++-- 3 files changed, 343 insertions(+), 65 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index dddd87a68..502119bf4 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -14,6 +14,12 @@ 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, + RoomScrollFingerprint, + RoomScrollPosition, +} from '$utils/roomScrollCache'; import { as, Box, @@ -154,6 +160,19 @@ const getDayDividerText = (ts: number) => { return timeDayMonthYear(ts); }; +const TIMELINE_ANCHOR_SELECTOR = '[data-timeline-event-id]'; +const buildRoomScrollFingerprint = ( + eventIds: string[], + readUptoEventId: string | undefined, + layoutKey: string +): RoomScrollFingerprint => ({ + eventCount: eventIds.length, + headEventIds: eventIds.slice(0, 5), + tailEventIds: eventIds.slice(-5), + readUptoEventId, + layoutKey, +}); + export type RoomTimelineProps = { room: Room; eventId?: string; @@ -249,6 +268,7 @@ export function RoomTimeline({ const setOpenThread = useSetAtom(openThreadAtom); const vListRef = useRef(null); + const scrollCacheForRoomRef = useRef(undefined); const [atBottomState, setAtBottomState] = useState( eventId ? false : !initialUnreadInfo?.scrollTo ); @@ -276,6 +296,11 @@ export function RoomTimeline({ // and reveals content once pagination is idle and the viewport is filled. const readyBlockedByPaginationRef = useRef(false); const currentRoomIdRef = useRef(room.roomId); + const restoreExactOffsetRef = useRef(false); + const currentScrollFingerprintRef = useRef(undefined); + const saveRoomScrollStateRef = useRef< + ((measurementCache: RoomScrollCache['measurementCache'], atBottom: boolean) => void) | undefined + >(undefined); const [isReady, setIsReady] = useState(false); const isReadyRef = useRef(false); @@ -283,10 +308,12 @@ export function RoomTimeline({ if (currentRoomIdRef.current !== room.roomId) { const nextUnreadInfo = getRoomUnreadInfo(room, true); + scrollCacheForRoomRef.current = undefined; hasInitialScrolledRef.current = false; currentRoomIdRef.current = room.roomId; pendingReadyRef.current = false; readyBlockedByPaginationRef.current = false; + restoreExactOffsetRef.current = false; if (initialScrollTimerRef.current !== undefined) { clearTimeout(initialScrollTimerRef.current); initialScrollTimerRef.current = undefined; @@ -347,6 +374,68 @@ export function RoomTimeline({ return events.indexOf(match); }, []); + const getProcessedIndexByEventId = useCallback((targetEventId: string): number | undefined => { + const index = processedEventsRef.current.findIndex((event) => event.id === targetEventId); + return index >= 0 ? index : undefined; + }, []); + + const getRenderedAnchorPosition = useCallback((): RoomScrollPosition | undefined => { + const container = messageListRef.current; + if (!container) return undefined; + + const containerTop = container.getBoundingClientRect().top; + const anchors = Array.from(container.querySelectorAll(TIMELINE_ANCHOR_SELECTOR)); + const firstVisibleAnchor = anchors.find((anchor) => anchor.getBoundingClientRect().bottom > containerTop); + if (!firstVisibleAnchor) return undefined; + + const eventId = firstVisibleAnchor.dataset.timelineEventId; + if (!eventId) return undefined; + + return { + kind: 'anchor', + eventId, + offset: firstVisibleAnchor.getBoundingClientRect().top - containerTop, + }; + }, []); + + const restoreRoomScrollPosition = useCallback( + (position: RoomScrollPosition, exactOffset: boolean) => { + const v = vListRef.current; + if (!v) return false; + + if (position.kind === 'live') { + scrollToBottom(); + return true; + } + + const processedIndex = getProcessedIndexByEventId(position.eventId); + if (processedIndex === undefined) return false; + + v.scrollToIndex(processedIndex, { align: 'start' }); + + if (!exactOffset) return true; + + requestAnimationFrame(() => { + const container = messageListRef.current; + if (!container) return; + + const anchor = Array.from( + container.querySelectorAll(TIMELINE_ANCHOR_SELECTOR) + ).find((element) => element.dataset.timelineEventId === position.eventId); + if (!anchor) return; + + const currentOffset = anchor.getBoundingClientRect().top - container.getBoundingClientRect().top; + const delta = currentOffset - position.offset; + if (Math.abs(delta) > 2) { + vListRef.current?.scrollTo(v.scrollOffset + delta); + } + }); + + return true; + }, + [getProcessedIndexByEventId, scrollToBottom] + ); + useLayoutEffect(() => { if ( !eventId && @@ -361,6 +450,27 @@ export function RoomTimeline({ vListRef.current ) { hasInitialScrolledRef.current = true; + const savedCache = !unreadInfo?.scrollTo ? scrollCacheForRoomRef.current : undefined; + if (savedCache) { + if (processedEventsRef.current.length === 0) { + pendingReadyRef.current = true; + restoreExactOffsetRef.current = + savedCache.measurementCache !== undefined && + savedCache.fingerprint.layoutKey === currentScrollFingerprintRef.current?.layoutKey; + } else { + const restored = restoreRoomScrollPosition( + savedCache.position, + savedCache.measurementCache !== undefined && + savedCache.fingerprint.layoutKey === currentScrollFingerprintRef.current?.layoutKey + ); + if (restored) { + setAtBottom(savedCache.position.kind === 'live'); + setIsReady(true); + return; + } + } + } + // 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' }); @@ -385,6 +495,7 @@ export function RoomTimeline({ readyBlockedByPaginationRef.current = true; return; } + saveRoomScrollStateRef.current?.(v?.cache, true); setIsReady(true); } else { pendingReadyRef.current = true; @@ -393,7 +504,15 @@ export function RoomTimeline({ } // 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]); + }, [ + eventId, + room.roomId, + restoreRoomScrollPosition, + setAtBottom, + timelineSync.eventsLength, + timelineSync.liveTimelineLinked, + unreadInfo?.scrollTo, + ]); // Cancel the initial-scroll timer on unmount (the useLayoutEffect above // intentionally does not cancel it when deps change). @@ -433,6 +552,7 @@ export function RoomTimeline({ if (canPaginateBackRef.current && v.scrollSize <= v.viewportSize + 100) return; readyBlockedByPaginationRef.current = false; v.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); + saveRoomScrollStateRef.current?.(v.cache, true); setIsReady(true); }, [timelineSync.backwardStatus, timelineSync.eventsLength, timelineSync.canPaginateBack]); @@ -849,6 +969,10 @@ export function RoomTimeline({ setAtBottom(isNowAtBottom); } + if (!eventId) { + saveRoomScrollState(v.cache, isNowAtBottom); + } + if (offset < 500 && canPaginateBackRef.current && backwardStatusRef.current === 'idle') { timelineSyncRef.current.handleTimelinePagination(true); } @@ -860,7 +984,7 @@ export function RoomTimeline({ timelineSyncRef.current.handleTimelinePagination(false); } }, - [setAtBottom] + [eventId, saveRoomScrollState, setAtBottom] ); const showLoadingPlaceholders = @@ -870,7 +994,8 @@ export function RoomTimeline({ // Show a skeleton overlay while content is hidden for measurement. // The VList still renders underneath at opacity 0 so it can measure real // item heights — the overlay just gives the user something to look at. - const showSkeletonOverlay = !isReady && !showLoadingPlaceholders; + const showSkeletonOverlay = + !isReady && !scrollCacheForRoomRef.current?.measurementCache && !showLoadingPlaceholders; let backPaginationJSX: ReactNode | undefined; if (timelineSync.canPaginateBack || timelineSync.backwardStatus !== 'idle') { @@ -953,6 +1078,44 @@ export function RoomTimeline({ [vListItemCount, timelineSync.timeline.linkedTimelines, timelineSync.mutationVersion] ); + const scrollLayoutKey = useMemo( + () => + [ + messageLayout, + messageSpacing, + hideReads, + hideMembershipEvents, + hideNickAvatarEvents, + showHiddenEvents, + showTombstoneEvents, + mediaAutoLoad, + showBundledPreview, + showUrlPreview, + showClientUrlPreview, + autoplayStickers, + autoplayEmojis, + hideMemberInReadOnly, + isReadOnly, + ].join(':'), + [ + messageLayout, + messageSpacing, + hideReads, + hideMembershipEvents, + hideNickAvatarEvents, + showHiddenEvents, + showTombstoneEvents, + mediaAutoLoad, + showBundledPreview, + showUrlPreview, + showClientUrlPreview, + autoplayStickers, + autoplayEmojis, + hideMemberInReadOnly, + isReadOnly, + ] + ); + const processedEvents = useProcessedTimeline({ items: vListIndices, linkedTimelines: timelineSync.timeline.linkedTimelines, @@ -970,6 +1133,41 @@ export function RoomTimeline({ processedEventsRef.current = processedEvents; + const currentScrollFingerprint = useMemo( + () => + buildRoomScrollFingerprint( + processedEvents.map((event) => event.id), + unreadInfo?.readUptoEventId, + scrollLayoutKey + ), + [processedEvents, scrollLayoutKey, unreadInfo?.readUptoEventId] + ); + currentScrollFingerprintRef.current = currentScrollFingerprint; + + if (!eventId) { + scrollCacheForRoomRef.current = roomScrollCache.load(mx.getUserId()!, room.roomId, currentScrollFingerprint); + } else { + scrollCacheForRoomRef.current = undefined; + } + + const saveRoomScrollState = useCallback( + (measurementCache: RoomScrollCache['measurementCache'], atBottom: boolean) => { + if (eventId) return; + + const position = + atBottom ? ({ kind: 'live' } as RoomScrollPosition) : getRenderedAnchorPosition(); + if (!position) return; + + roomScrollCache.save(mx.getUserId()!, room.roomId, { + measurementCache, + position, + fingerprint: currentScrollFingerprint, + }); + }, + [currentScrollFingerprint, eventId, getRenderedAnchorPosition, mx, room.roomId] + ); + saveRoomScrollStateRef.current = saveRoomScrollState; + // Recovery: if the 80 ms initial-scroll timer fired while processedEvents was // empty (timeline was mid-reset), scroll to bottom and reveal the timeline once // events repopulate. Fires on every processedEvents.length change but is @@ -978,9 +1176,16 @@ export function RoomTimeline({ if (!pendingReadyRef.current) return; if (processedEvents.length === 0) return; pendingReadyRef.current = false; - vListRef.current?.scrollToIndex(processedEvents.length - 1, { align: 'end' }); + const savedCache = !unreadInfo?.scrollTo ? scrollCacheForRoomRef.current : undefined; + const restored = savedCache + ? restoreRoomScrollPosition(savedCache.position, restoreExactOffsetRef.current) + : false; + restoreExactOffsetRef.current = false; + if (!restored) { + vListRef.current?.scrollToIndex(processedEvents.length - 1, { align: 'end' }); + } setIsReady(true); - }, [processedEvents.length]); + }, [processedEvents.length, restoreRoomScrollPosition, unreadInfo?.scrollTo]); useEffect(() => { if (!onEditLastMessageRef) return; @@ -1107,6 +1312,7 @@ export function RoomTimeline({ key={room.roomId} ref={vListRef} data={processedEvents} + cache={!eventId ? scrollCacheForRoomRef.current?.measurementCache : undefined} shift={shift} className={css.messageList} style={{ @@ -1203,17 +1409,26 @@ export function RoomTimeline({ )} {backPaginationJSX} - {dividers} - {renderedEvent} +
+ {dividers} + {renderedEvent} +
); } return ( - +
{dividers} {renderedEvent} - +
); }} diff --git a/src/app/utils/roomScrollCache.test.ts b/src/app/utils/roomScrollCache.test.ts index 784c59fc4..eddd11071 100644 --- a/src/app/utils/roomScrollCache.test.ts +++ b/src/app/utils/roomScrollCache.test.ts @@ -1,10 +1,27 @@ import { describe, it, expect } from 'vitest'; -import { roomScrollCache } from './roomScrollCache'; +import { roomScrollCache, RoomScrollFingerprint, RoomScrollPosition } from './roomScrollCache'; // CacheSnapshot is opaque in tests — cast a plain object. -const fakeCache = () => ({}) as import('./roomScrollCache').RoomScrollCache['cache']; +const fakeCache = () => ({}) as import('./roomScrollCache').RoomScrollCache['measurementCache']; const userId = '@alice:test'; +const fingerprint = (overrides: Partial = {}): RoomScrollFingerprint => ({ + eventCount: 3, + headEventIds: ['$a', '$b'], + tailEventIds: ['$b', '$c'], + readUptoEventId: '$read', + layoutKey: 'compact:space', + ...overrides, +}); + +const position = (overrides: Partial> = {}) => + ({ + kind: 'anchor', + eventId: '$b', + offset: -24, + ...overrides, + }) as RoomScrollPosition; + describe('roomScrollCache', () => { it('load returns undefined for an unknown roomId', () => { expect(roomScrollCache.load(userId, '!unknown:test')).toBeUndefined(); @@ -12,10 +29,9 @@ describe('roomScrollCache', () => { it('stores and retrieves data for a roomId', () => { const data = { - cache: fakeCache(), - scrollOffset: 120, - atBottom: false, - headEventIds: ['$a', '$b'], + measurementCache: fakeCache(), + position: position(), + fingerprint: fingerprint(), }; roomScrollCache.save(userId, '!room1:test', data); expect(roomScrollCache.load(userId, '!room1:test')).toBe(data); @@ -23,16 +39,17 @@ describe('roomScrollCache', () => { it('overwrites existing data when saved again for the same roomId', () => { const first = { - cache: fakeCache(), - scrollOffset: 50, - atBottom: true, - headEventIds: ['$a', '$b'], + measurementCache: fakeCache(), + position: { kind: 'live' } as RoomScrollPosition, + fingerprint: fingerprint({ headEventIds: ['$a', '$b'] }), }; const second = { - cache: fakeCache(), - scrollOffset: 200, - atBottom: false, - headEventIds: ['$c', '$d'], + measurementCache: fakeCache(), + position: position({ eventId: '$d' }), + fingerprint: fingerprint({ + headEventIds: ['$c', '$d'], + tailEventIds: ['$d', '$e'], + }), }; roomScrollCache.save(userId, '!room2:test', first); roomScrollCache.save(userId, '!room2:test', second); @@ -41,16 +58,14 @@ describe('roomScrollCache', () => { it('keeps data for separate rooms independent', () => { const a = { - cache: fakeCache(), - scrollOffset: 10, - atBottom: true, - headEventIds: ['$a'], + measurementCache: fakeCache(), + position: { kind: 'live' } as RoomScrollPosition, + fingerprint: fingerprint({ headEventIds: ['$a'], tailEventIds: ['$a'], eventCount: 1 }), }; const b = { - cache: fakeCache(), - scrollOffset: 20, - atBottom: false, - headEventIds: ['$b'], + measurementCache: fakeCache(), + position: position({ eventId: '$b', offset: -12 }), + fingerprint: fingerprint({ headEventIds: ['$b'], tailEventIds: ['$b'], eventCount: 1 }), }; roomScrollCache.save(userId, '!roomA:test', a); roomScrollCache.save(userId, '!roomB:test', b); @@ -60,16 +75,14 @@ describe('roomScrollCache', () => { it('scopes data per userId', () => { const data1 = { - cache: fakeCache(), - scrollOffset: 100, - atBottom: true, - headEventIds: ['$a'], + measurementCache: fakeCache(), + position: { kind: 'live' } as RoomScrollPosition, + fingerprint: fingerprint({ headEventIds: ['$a'], tailEventIds: ['$a'], eventCount: 1 }), }; const data2 = { - cache: fakeCache(), - scrollOffset: 200, - atBottom: false, - headEventIds: ['$b'], + measurementCache: fakeCache(), + position: position({ eventId: '$b' }), + fingerprint: fingerprint({ headEventIds: ['$b'], tailEventIds: ['$b'], eventCount: 1 }), }; roomScrollCache.save('@alice:test', '!room:test', data1); roomScrollCache.save('@bob:test', '!room:test', data2); @@ -77,18 +90,43 @@ describe('roomScrollCache', () => { expect(roomScrollCache.load('@bob:test', '!room:test')).toBe(data2); }); - it('invalidates a cache when the current timeline head changes', () => { + it('drops only the measurement cache when the fingerprint changes', () => { const data = { - cache: fakeCache(), - scrollOffset: 120, - atBottom: false, - headEventIds: ['$a', '$b', '$c'], + measurementCache: fakeCache(), + position: position(), + fingerprint: fingerprint({ + eventCount: 4, + headEventIds: ['$a', '$b', '$c'], + tailEventIds: ['$b', '$c', '$d'], + }), }; roomScrollCache.save(userId, '!room3:test', data); - expect(roomScrollCache.load(userId, '!room3:test', ['$a', '$b', '$c', '$d'])).toBe(data); - expect(roomScrollCache.load(userId, '!room3:test', ['$x', '$a', '$b', '$c'])).toBeUndefined(); - expect(roomScrollCache.load(userId, '!room3:test', ['$a', '$x', '$c', '$d'])).toBeUndefined(); - expect(roomScrollCache.load(userId, '!room3:test', ['$a', '$b'])).toBeUndefined(); + expect(roomScrollCache.load(userId, '!room3:test', data.fingerprint)).toBe(data); + + const changedHead = roomScrollCache.load( + userId, + '!room3:test', + fingerprint({ + eventCount: 4, + headEventIds: ['$x', '$a', '$b'], + tailEventIds: ['$b', '$c', '$d'], + }) + ); + expect(changedHead?.measurementCache).toBeUndefined(); + expect(changedHead?.position).toEqual(data.position); + + const changedLayout = roomScrollCache.load( + userId, + '!room3:test', + fingerprint({ + eventCount: 4, + headEventIds: ['$a', '$b', '$c'], + tailEventIds: ['$b', '$c', '$d'], + layoutKey: 'modern:wide', + }) + ); + expect(changedLayout?.measurementCache).toBeUndefined(); + expect(changedLayout?.position).toEqual(data.position); }); }); diff --git a/src/app/utils/roomScrollCache.ts b/src/app/utils/roomScrollCache.ts index 21682604d..6e735a7c2 100644 --- a/src/app/utils/roomScrollCache.ts +++ b/src/app/utils/roomScrollCache.ts @@ -1,27 +1,47 @@ import { CacheSnapshot } from 'virtua'; +export type RoomScrollFingerprint = { + eventCount: number; + headEventIds: string[]; + tailEventIds: string[]; + readUptoEventId?: string; + layoutKey: string; +}; + +export type RoomScrollPosition = + | { + kind: 'live'; + } + | { + kind: 'anchor'; + eventId: string; + offset: number; + }; + 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; - /** - * Raw event IDs from the loaded head of the timeline when the snapshot was - * captured. Virtua's cache is index-based, so head changes invalidate it. - */ - headEventIds: string[]; + measurementCache?: CacheSnapshot; + /** Logical restore position captured from the rendered timeline. */ + position: RoomScrollPosition; + /** Timeline/layout fingerprint used to validate index-based measurements. */ + fingerprint: RoomScrollFingerprint; }; /** Session-scoped, per-room scroll cache. Not persisted across page reloads. */ const scrollCacheMap = new Map(); const cacheKey = (userId: string, roomId: string): string => `${userId}:${roomId}`; -const headMatches = (saved: string[], current: string[]): boolean => - saved.length > 0 && - current.length >= saved.length && - saved.every((eventId, index) => current[index] === eventId); +const fingerprintMatches = ( + saved: RoomScrollFingerprint, + current: RoomScrollFingerprint +): boolean => + saved.layoutKey === current.layoutKey && + saved.readUptoEventId === current.readUptoEventId && + saved.eventCount === current.eventCount && + saved.headEventIds.length > 0 && + saved.tailEventIds.length > 0 && + saved.headEventIds.every((eventId, index) => current.headEventIds[index] === eventId) && + saved.tailEventIds.every((eventId, index) => current.tailEventIds[index] === eventId); export const roomScrollCache = { save(userId: string, roomId: string, data: RoomScrollCache): void { @@ -30,12 +50,17 @@ export const roomScrollCache = { load( userId: string, roomId: string, - currentHeadEventIds?: string[] + currentFingerprint?: RoomScrollFingerprint ): RoomScrollCache | undefined { const cached = scrollCacheMap.get(cacheKey(userId, roomId)); if (!cached) return undefined; - if (!currentHeadEventIds) return cached; - if (!headMatches(cached.headEventIds, currentHeadEventIds)) return undefined; + if (!currentFingerprint) return cached; + if (!fingerprintMatches(cached.fingerprint, currentFingerprint)) { + return { + ...cached, + measurementCache: undefined, + }; + } return cached; }, }; From b4ee4eea15d30204a7a0d1bed35fe96d7dfc42ae Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 22:52:44 -0400 Subject: [PATCH 58/62] fix(timeline): resolve anchored cache restore wiring --- src/app/features/room/RoomTimeline.tsx | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 502119bf4..64dc95e39 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -385,7 +385,9 @@ export function RoomTimeline({ const containerTop = container.getBoundingClientRect().top; const anchors = Array.from(container.querySelectorAll(TIMELINE_ANCHOR_SELECTOR)); - const firstVisibleAnchor = anchors.find((anchor) => anchor.getBoundingClientRect().bottom > containerTop); + const firstVisibleAnchor = anchors.find( + (anchor) => anchor.getBoundingClientRect().bottom > containerTop + ); if (!firstVisibleAnchor) return undefined; const eventId = firstVisibleAnchor.dataset.timelineEventId; @@ -424,7 +426,8 @@ export function RoomTimeline({ ).find((element) => element.dataset.timelineEventId === position.eventId); if (!anchor) return; - const currentOffset = anchor.getBoundingClientRect().top - container.getBoundingClientRect().top; + const currentOffset = + anchor.getBoundingClientRect().top - container.getBoundingClientRect().top; const delta = currentOffset - position.offset; if (Math.abs(delta) > 2) { vListRef.current?.scrollTo(v.scrollOffset + delta); @@ -970,7 +973,7 @@ export function RoomTimeline({ } if (!eventId) { - saveRoomScrollState(v.cache, isNowAtBottom); + saveRoomScrollStateRef.current?.(v.cache, isNowAtBottom); } if (offset < 500 && canPaginateBackRef.current && backwardStatusRef.current === 'idle') { @@ -984,7 +987,7 @@ export function RoomTimeline({ timelineSyncRef.current.handleTimelinePagination(false); } }, - [eventId, saveRoomScrollState, setAtBottom] + [eventId, setAtBottom] ); const showLoadingPlaceholders = @@ -1145,7 +1148,11 @@ export function RoomTimeline({ currentScrollFingerprintRef.current = currentScrollFingerprint; if (!eventId) { - scrollCacheForRoomRef.current = roomScrollCache.load(mx.getUserId()!, room.roomId, currentScrollFingerprint); + scrollCacheForRoomRef.current = roomScrollCache.load( + mx.getUserId()!, + room.roomId, + currentScrollFingerprint + ); } else { scrollCacheForRoomRef.current = undefined; } @@ -1154,8 +1161,9 @@ export function RoomTimeline({ (measurementCache: RoomScrollCache['measurementCache'], atBottom: boolean) => { if (eventId) return; - const position = - atBottom ? ({ kind: 'live' } as RoomScrollPosition) : getRenderedAnchorPosition(); + const position = atBottom + ? ({ kind: 'live' } as RoomScrollPosition) + : getRenderedAnchorPosition(); if (!position) return; roomScrollCache.save(mx.getUserId()!, room.roomId, { From ed9915cb4094777a34018f160621be863c1e3ac1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 22:55:33 -0400 Subject: [PATCH 59/62] fix(timeline): finalize anchored cache restore --- src/app/features/room/RoomTimeline.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 64dc95e39..797c794ca 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -390,12 +390,12 @@ export function RoomTimeline({ ); if (!firstVisibleAnchor) return undefined; - const eventId = firstVisibleAnchor.dataset.timelineEventId; - if (!eventId) return undefined; + const anchorEventId = firstVisibleAnchor.dataset.timelineEventId; + if (!anchorEventId) return undefined; return { kind: 'anchor', - eventId, + eventId: anchorEventId, offset: firstVisibleAnchor.getBoundingClientRect().top - containerTop, }; }, []); From efd857877d07fe428ae709dab9798d97ec3cdaf1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 23:31:56 -0400 Subject: [PATCH 60/62] fix(timeline): align reset relinking with upstream --- .../hooks/timeline/useTimelineSync.test.tsx | 43 +--------------- src/app/hooks/timeline/useTimelineSync.ts | 49 ++----------------- 2 files changed, 7 insertions(+), 85 deletions(-) diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index e1fbb876c..46c445767 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -106,8 +106,8 @@ describe('useTimelineSync', () => { expect(scrollToBottom).not.toHaveBeenCalled(); }); - it('keeps a bottom-pinned user anchored after destructive TimelineReset', async () => { - const { room, timelineSet } = createRoom('!room:test', [{ getId: () => 'event-before' }]); + it('keeps a bottom-pinned user anchored after TimelineReset', async () => { + const { room, timelineSet } = createRoom(); const scrollToBottom = vi.fn(); renderHook(() => @@ -123,16 +123,7 @@ describe('useTimelineSync', () => { readUptoEventIdRef: { current: undefined }, }) ); - - // Replace with a new timeline object with a new last event (destructive reset) - const newTimeline = createTimeline([{ getId: () => 'event-after' }]); - timelineSet.getLiveTimeline = () => newTimeline as never; - await act(async () => { - // Simulate the SDK replacing the live timeline object, which is what - // a real TimelineReset does (resetLiveTimeline creates a new - // EventTimeline and swaps liveTimeline to it). - // getLiveTimeline was already updated above with newTimeline (new last event). timelineSet.emit(RoomEvent.TimelineReset); await Promise.resolve(); }); @@ -140,36 +131,6 @@ describe('useTimelineSync', () => { expect(scrollToBottom).toHaveBeenCalled(); }); - it('does not scroll after additive TimelineReset (same last event, new container)', async () => { - const { room, timelineSet } = createRoom('!room:test', [{ getId: () => 'same-event' }]); - const scrollToBottom = vi.fn(); - - renderHook(() => - useTimelineSync({ - room: room as Room, - mx: { getUserId: () => '@alice:test' } as never, - isAtBottom: true, - isAtBottomRef: { current: true }, - scrollToBottom, - unreadInfo: undefined, - setUnreadInfo: vi.fn(), - hideReadsRef: { current: false }, - readUptoEventIdRef: { current: undefined }, - }) - ); - - // Replace with a new timeline object but same last event ID (additive/structural reset) - const newTimeline = createTimeline([{ getId: () => 'same-event' }]); - timelineSet.getLiveTimeline = () => newTimeline as never; - - await act(async () => { - timelineSet.emit(RoomEvent.TimelineReset); - await Promise.resolve(); - }); - - expect(scrollToBottom).not.toHaveBeenCalled(); - }); - it('resets timeline state when room.roomId changes and eventId is not set', async () => { const roomOne = createRoom('!room:one'); const roomTwo = createRoom('!room:two'); diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index d08093324..511cb53c5 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -395,9 +395,6 @@ export function useTimelineSync({ | undefined >(); - const timelineRef = useRef(timeline); - timelineRef.current = timeline; - const resetAutoScrollPendingRef = useRef(false); const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines); @@ -541,48 +538,12 @@ export function useTimelineSync({ useLiveTimelineRefresh( room, useCallback(() => { - const newLinked = getInitialTimeline(room).linkedTimelines; - const prev = timelineRef.current.linkedTimelines; - if (prev.length === newLinked.length && prev.every((tl, i) => tl === newLinked[i])) { - return; - } - - // Only trigger the auto-scroll reset for destructive resets where the - // live-end event changed (e.g. reconnect). Sliding sync fires TimelineReset - // to replace the EventTimeline container while keeping the same live-end - // events — treating that as destructive would scroll the user unnecessarily. - const prevLastEventId = prev.at(-1)?.getEvents().at(-1)?.getId(); - - const applyUpdate = (linkedTimelines: EventTimeline[]) => { - const lastEventId = linkedTimelines.at(-1)?.getEvents().at(-1)?.getId(); - const isDestructive = prevLastEventId !== lastEventId; - if (isDestructive) { - const wasAtBottom = isAtBottomRef.current; - resetAutoScrollPendingRef.current = wasAtBottom; - if (wasAtBottom) scrollToBottom(); - } - setTimeline({ linkedTimelines }); - }; - - // If the new timeline is transiently empty (the SDK fires TimelineReset - // *before* it finishes adding events to the new timeline), defer the state - // update via a microtask so the SDK can finish populating the timeline. - // This prevents the briefly-empty timeline from triggering the blanked - // effect, which would hide the content and cause a visible flash. - if (getTimelinesEventsCount(newLinked) === 0 && getTimelinesEventsCount(prev) > 0) { - Promise.resolve().then(() => { - const refreshed = getInitialTimeline(room).linkedTimelines; - // If still empty after the microtask, the SDK may need more time. - // Skip the update — the blanked effect checks the SDK's live timeline - // directly and won't blank while the room genuinely has events. - if (getTimelinesEventsCount(refreshed) > 0) { - applyUpdate(refreshed); - } - }); - return; + const wasAtBottom = isAtBottomRef.current; + resetAutoScrollPendingRef.current = wasAtBottom; + setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); + if (wasAtBottom) { + scrollToBottom(); } - - applyUpdate(newLinked); }, [room, isAtBottomRef, scrollToBottom]) ); From 5814956c53568da1f08aa8006920f27a585b2eee Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 19 Apr 2026 13:38:16 -0400 Subject: [PATCH 61/62] fix(timeline): stabilize bottom pin and unread fallback --- src/app/features/room/RoomTimeline.tsx | 16 +++++++++++++--- src/app/utils/room.ts | 25 ------------------------- 2 files changed, 13 insertions(+), 28 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 797c794ca..35c364a00 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -160,6 +160,8 @@ const getDayDividerText = (ts: number) => { return timeDayMonthYear(ts); }; +const SCROLL_SETTLE_MS = 250; + const TIMELINE_ANCHOR_SELECTOR = '[data-timeline-event-id]'; const buildRoomScrollFingerprint = ( eventIds: string[], @@ -283,6 +285,7 @@ export function RoomTimeline({ const topSpacerHeightRef = useRef(0); const hasInitialScrolledRef = useRef(false); + const lastProgrammaticBottomPinAtRef = useRef(0); // 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); @@ -334,8 +337,10 @@ export function RoomTimeline({ if (!vListRef.current) return; const lastIndex = processedEventsRef.current.length - 1; if (lastIndex < 0) return; + lastProgrammaticBottomPinAtRef.current = Date.now(); + setAtBottom(true); vListRef.current.scrollTo(vListRef.current.scrollSize); - }, []); + }, [setAtBottom]); const timelineSync = useTimelineSync({ room, @@ -936,6 +941,8 @@ export function RoomTimeline({ const distanceFromBottom = v.scrollSize - offset - v.viewportSize; const isNowAtBottom = distanceFromBottom < 100; + const withinSettleWindow = + Date.now() - lastProgrammaticBottomPinAtRef.current < SCROLL_SETTLE_MS; // When the user is pinned to the bottom and content grows (images, embeds, // video thumbnails loading), scrollSize increases while offset stays put, @@ -957,13 +964,16 @@ export function RoomTimeline({ // updates until the jump transition finishes and focusItem is cleared. if (timelineSyncRef.current.focusItem) return; - if (atBottomRef.current && !isNowAtBottom && contentGrew) { + if (atBottomRef.current && !isNowAtBottom && (contentGrew || withinSettleWindow)) { // Defer the chase to the next animation frame so VList finishes its // current layout pass. Synchronous scrollTo causes cascading scroll // events that produce visible jumps when images/embeds load. requestAnimationFrame(() => { const vl = vListRef.current; - if (vl && atBottomRef.current) vl.scrollTo(vl.scrollSize); + if (vl && atBottomRef.current) { + lastProgrammaticBottomPinAtRef.current = Date.now(); + vl.scrollTo(vl.scrollSize); + } }); return; } diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 3cebdb5c6..69d612af1 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -12,7 +12,6 @@ import { MatrixClient, MatrixEvent, NotificationCountType, - PushProcessor, RelationType, Room, RoomMember, @@ -332,30 +331,6 @@ export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadIn } } - // Fallback: SDK counters are stale/zero but there are receipt-confirmed unread - // messages. Walk the live timeline to compute real counts so the badge number - // and highlight colour reflect actual state rather than a hard-coded stub. - if (total === 0 && highlight === 0 && userId && roomHaveUnread(room.client, room)) { - const readUpToId = room.getEventReadUpTo(userId); - const liveEvents = room.getLiveTimeline().getEvents(); - let fallbackTotal = 0; - let fallbackHighlight = 0; - const pushProcessor = new PushProcessor(room.client); - for (let i = liveEvents.length - 1; i >= 0; i -= 1) { - const event = liveEvents[i]; - if (!event) break; - if (event.getId() === readUpToId) break; - if (isNotificationEvent(event, room, userId) && event.getSender() !== userId) { - fallbackTotal += 1; - const pushActions = pushProcessor.actionsForEvent(event); - if (pushActions?.tweaks?.highlight) fallbackHighlight += 1; - } - } - if (fallbackTotal > 0) { - return { roomId: room.roomId, highlight: fallbackHighlight, total: fallbackTotal }; - } - } - // For DMs with Default or AllMessages notification type: if there are unread messages, // ensure we show a notification badge (treat as highlight for badge color purposes). // This handles cases where push rules don't properly match (e.g., classic sync with From b2878b5b7edb796f8954883049bf12e6ffb69002 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 19 Apr 2026 14:03:29 -0400 Subject: [PATCH 62/62] fix(timeline): align initial room-fill thresholds --- src/app/features/room/RoomTimeline.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 35c364a00..99763c0c3 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -161,6 +161,7 @@ const getDayDividerText = (ts: number) => { }; const SCROLL_SETTLE_MS = 250; +const MIN_INITIAL_SCROLL_ROOM_PX = 300; const TIMELINE_ANCHOR_SELECTOR = '[data-timeline-event-id]'; const buildRoomScrollFingerprint = ( @@ -497,7 +498,7 @@ export function RoomTimeline({ const needsFill = canPaginateBackRef.current && v && - v.scrollSize <= v.viewportSize + 300 && + v.scrollSize <= v.viewportSize + MIN_INITIAL_SCROLL_ROOM_PX && backwardStatusRef.current !== 'error'; if (needsFill) { readyBlockedByPaginationRef.current = true; @@ -557,7 +558,11 @@ export function RoomTimeline({ const v = vListRef.current; if (!v) return; // Still not filled and can paginate more — keep waiting. - if (canPaginateBackRef.current && v.scrollSize <= v.viewportSize + 100) return; + if ( + canPaginateBackRef.current && + v.scrollSize <= v.viewportSize + MIN_INITIAL_SCROLL_ROOM_PX + ) + return; readyBlockedByPaginationRef.current = false; v.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); saveRoomScrollStateRef.current?.(v.cache, true); @@ -1257,7 +1262,7 @@ export function RoomTimeline({ const atTop = v.scrollOffset < 500; const noVisibleGrowth = processedEvents.length === processedLengthAtEffectStart; - const hasRealScrollRoom = v.scrollSize > v.viewportSize + 300; + const hasRealScrollRoom = v.scrollSize > v.viewportSize + MIN_INITIAL_SCROLL_ROOM_PX; if (!hasRealScrollRoom || (atTop && noVisibleGrowth)) { timelineSyncRef.current.handleTimelinePagination(true);