From c6b3293ecc5ef3cefcbb2e7bcac8beb6171909ae Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 24 Mar 2026 18:46:21 -0400 Subject: [PATCH 01/16] fix: two timeline pagination bugs affecting message ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 1. useTimelinePagination: finally-block resets fetchingRef prematurely When a paginated page returns 1–4 events and more history exists, the paginator fires a recursive continuation call (`paginate(backwards)`) before the outer `finally` block runs. The sequence was: 1. outer try: `fetchingRef.current[dir] = false; paginate(backwards);` → inner paginate runs synchronously: checks lock (false) → sets it to `true` → hits `await` → suspends 2. outer finally: `fetchingRef.current[dir] = false;` → overwrites the `true` that inner paginate just wrote This left `fetchingRef.current[dir] = false` while the inner paginate was mid-flight. The `backwardStatus === 'loading'` guard on the scroll handler masked the problem in most cases, but for a long chain of sparse pages each returning 1–4 events, multiple overlapping paginations could start. If the SDK's internal per-timeline lock was also released (or a forward-direction call raced), events could be requested from stale pagination tokens and be inserted out of order. Fix: add a `continuing` local boolean. When a recursive continuation is fired, `continuing = true` is set first. The `finally` block skips the reset when `continuing` is true, leaving the lock in the inner paginate's hands. The inner paginate's own `finally` releases it when it finishes. ## 2. getLinkedTimelines: O(N²) array copies in recursive accumulator The previous recursive `collectTimelines(tl, dir, [...acc, tl])` created N intermediate arrays for an N-timeline chain (sizes 1, 2, 3, … N), giving O(N²) total allocations. For a power user who paginates back through hundreds of pages of history, this becomes measurable GC pressure. Replace with a simple iterative loop (`while (current) { push; next }`) that builds exactly one output array in O(N) time. --- src/app/hooks/timeline/useTimelineSync.ts | 20 +++++++++++++++++++- src/app/utils/timeline.ts | 18 +++++++----------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 395d6fc46..9c7583217 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -171,6 +171,13 @@ const useTimelinePagination = ( await to(decryptAllTimelineEvent(mx, fetchedTimeline)); } + // `continuing` tracks whether we hand the fetchingRef lock to a recursive + // continuation call below. The finally block must NOT reset the lock if + // the recursive call has already claimed it, otherwise there is a brief + // window where fetchingRef is false while the recursive paginate is in + // flight, allowing a third overlapping call to start on sparse pages. + let continuing = false; + if (alive()) { recalibratePagination(lTimelines); (backwards ? setBackwardStatus : setForwardStatus)('idle'); @@ -184,13 +191,24 @@ const useTimelinePagination = ( Direction.Backward ) === 'string'; if (stillHasToken) { + // Release lock so inner paginate can claim it, then mark continuing + // so the finally block below does NOT reset it after inner claims. fetchingRef.current[directionKey] = false; + continuing = true; paginate(backwards); + // At this point the inner paginate has synchronously set + // fetchingRef.current[directionKey] = true before hitting its own + // await. The finally below will skip the reset. } } } } finally { - fetchingRef.current[directionKey] = false; + // Only release the lock if we did NOT hand it to a recursive continuation. + // If `continuing` is true the recursive call owns the lock and will release + // it in its own finally block. + if (!continuing) { + fetchingRef.current[directionKey] = false; + } } }; }, [mx, alive, setTimeline, limit, setBackwardStatus, setForwardStatus]); diff --git a/src/app/utils/timeline.ts b/src/app/utils/timeline.ts index 4ed92ab16..0934e5b02 100644 --- a/src/app/utils/timeline.ts +++ b/src/app/utils/timeline.ts @@ -22,18 +22,14 @@ export const getFirstLinkedTimeline = ( return current; }; -const collectTimelines = ( - tl: EventTimeline | null, - dir: Direction, - acc: EventTimeline[] = [] -): EventTimeline[] => { - if (!tl) return acc; - return collectTimelines(tl.getNeighbouringTimeline(dir), dir, [...acc, tl]); -}; - export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => { - const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward); - return collectTimelines(firstTimeline, Direction.Forward); + const result: EventTimeline[] = []; + let current: EventTimeline | null = getFirstLinkedTimeline(timeline, Direction.Backward); + while (current) { + result.push(current); + current = current.getNeighbouringTimeline(Direction.Forward); + } + return result; }; export const timelineToEventsCount = (t: EventTimeline) => { From 1fc0a0d80cc8413e58814df8b320e89d5b883421 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 24 Mar 2026 18:31:38 -0400 Subject: [PATCH 02/16] fix: deduplicate concurrent URL preview requests with module-level cache Multiple UrlPreviewCard instances rendering simultaneously (e.g. after a large event batch loads) each called mx.getUrlPreview(url, ts). The SDK caches by bucketed-timestamp + URL, so identical URLs from different messages produced N separate HTTP requests. Add a module-level previewRequestCache (Map) that dedups in-flight requests across all instances. Resolved promises remain cached for the session; rejected ones are evicted so later renders can retry. --- src/app/components/url-preview/UrlPreviewCard.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 62106a00a..a0f8f93b2 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -14,6 +14,13 @@ import { ImageViewer } from '../image-viewer'; const linkStyles = { color: color.Success.Main }; +// Module-level in-flight deduplication: prevents N+1 concurrent requests when a +// large event batch renders many UrlPreviewCard instances for the same URL. +// Keyed by URL only (not ts) — the same URL shows the same preview regardless +// of which message referenced it. Rejected promises are evicted so a later +// render can retry after network recovery. +const previewRequestCache = new Map>(); + const openMediaInNewTab = async (url: string | undefined) => { if (!url) { console.warn('Attempted to open an empty url'); @@ -34,7 +41,12 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number; mediaType?: s const [previewStatus, loadPreview] = useAsyncCallback( useCallback(() => { if (isDirect) return Promise.resolve(null); - return mx.getUrlPreview(url, ts); + const cached = previewRequestCache.get(url); + if (cached !== undefined) return cached; + const promise = mx.getUrlPreview(url, ts); + previewRequestCache.set(url, promise); + promise.catch(() => previewRequestCache.delete(url)); + return promise; }, [url, ts, mx, isDirect]) ); From ba99ef428b488446346d372c972ff810cf0ee506 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 24 Mar 2026 18:29:41 -0400 Subject: [PATCH 03/16] fix: remove redundant startSpidering call causing N+1 sync requests --- src/client/initMatrix.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 18809c5d2..093093ba2 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -563,9 +563,6 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): pollTimeoutMs: slidingConfig?.pollTimeoutMs ?? SLIDING_SYNC_POLL_TIMEOUT_MS, }); manager.attach(); - // Begin background spidering so all rooms are eventually indexed. - // Not awaited — this runs incrementally in the background. - manager.startSpidering(100, 50); slidingSyncByClient.set(mx, manager); syncTransportByClient.set(mx, { transport: 'sliding', From 95b7d4ac5d138a0d78c3a4e2afba28a58f3eed8f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 24 Mar 2026 22:03:20 -0400 Subject: [PATCH 04/16] chore: add changeset for bug fixes PR --- .changeset/fix-bug-fixes.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-bug-fixes.md diff --git a/.changeset/fix-bug-fixes.md b/.changeset/fix-bug-fixes.md new file mode 100644 index 000000000..aea8ed44e --- /dev/null +++ b/.changeset/fix-bug-fixes.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix timeline pagination lock bug, deduplicate concurrent URL preview requests, and remove redundant sliding sync spidering call causing N+1 requests. From 9db324f1e4ac311200faf006816c06dad21715fd Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 24 Mar 2026 22:11:37 -0400 Subject: [PATCH 05/16] fix: hoist 'continuing' declaration before try block for finally scope access --- src/app/hooks/timeline/useTimelineSync.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 9c7583217..5c99bf6c2 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -147,6 +147,13 @@ const useTimelinePagination = ( (backwards ? setBackwardStatus : setForwardStatus)('loading'); } + // `continuing` tracks whether we hand the fetchingRef lock to a recursive + // continuation call below. The finally block must NOT reset the lock if + // the recursive call has already claimed it, otherwise there is a brief + // window where fetchingRef is false while the recursive paginate is in + // flight, allowing a third overlapping call to start on sparse pages. + let continuing = false; + try { const countBefore = getTimelinesEventsCount(lTimelines); @@ -171,13 +178,6 @@ const useTimelinePagination = ( await to(decryptAllTimelineEvent(mx, fetchedTimeline)); } - // `continuing` tracks whether we hand the fetchingRef lock to a recursive - // continuation call below. The finally block must NOT reset the lock if - // the recursive call has already claimed it, otherwise there is a brief - // window where fetchingRef is false while the recursive paginate is in - // flight, allowing a third overlapping call to start on sparse pages. - let continuing = false; - if (alive()) { recalibratePagination(lTimelines); (backwards ? setBackwardStatus : setForwardStatus)('idle'); From 9f45a739f688ed01c1f280b68423c509f1a7e51d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 24 Mar 2026 23:56:01 -0400 Subject: [PATCH 06/16] fix: rename promise to urlPreview in UrlPreviewCard --- src/app/components/url-preview/UrlPreviewCard.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index a0f8f93b2..edc3f0f9d 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -43,10 +43,10 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number; mediaType?: s if (isDirect) return Promise.resolve(null); const cached = previewRequestCache.get(url); if (cached !== undefined) return cached; - const promise = mx.getUrlPreview(url, ts); - previewRequestCache.set(url, promise); - promise.catch(() => previewRequestCache.delete(url)); - return promise; + const urlPreview = mx.getUrlPreview(url, ts); + previewRequestCache.set(url, urlPreview); + urlPreview.catch(() => previewRequestCache.delete(url)); + return urlPreview; }, [url, ts, mx, isDirect]) ); From 38057ee9ce268593a6577b2ab2ce37f36a42ab53 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 25 Mar 2026 20:08:34 -0400 Subject: [PATCH 07/16] fix: reset live timeline on room navigation and reconnect The Matrix SDK for sliding sync deliberately does not call resetLiveTimeline() on limited:true (the handling is in a commented-out TODO block). This means events from a previous visit remain in the live timeline and new events get appended after them, with the backward pagination token pointing to the gap between old and new events rather than to before all events. The result is that new messages appear out of order or the gap is unreachable via the UI. Two fixes: 1. useSlidingSyncActiveRoom: reset the live timeline synchronously before the subscription request is sent on room navigation. This ensures the fresh initial:true response populates a clean timeline, not one polluted with events from the previous visit. Also removes the 100ms delay that was added as an incomplete workaround for the same issue. 2. SlidingSyncManager.onLifecycle: in RequestFinished state (fires before any room data listeners run), reset the live timeline for active-room subscriptions that receive initial:true or limited:true. This covers the reconnect/background case where the pos token expires and all active rooms get a fresh initial:true in the same sync cycle. --- src/app/hooks/useSlidingSyncActiveRoom.ts | 13 ++----------- src/client/slidingSync.ts | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/app/hooks/useSlidingSyncActiveRoom.ts b/src/app/hooks/useSlidingSyncActiveRoom.ts index 8468f4024..c86914d56 100644 --- a/src/app/hooks/useSlidingSyncActiveRoom.ts +++ b/src/app/hooks/useSlidingSyncActiveRoom.ts @@ -24,16 +24,7 @@ export const useSlidingSyncActiveRoom = (): void => { const manager = getSlidingSyncManager(mx); if (!manager) return undefined; - // Wait for the room to be initialized from list sync before subscribing - // with the full timeline limit. This prevents timeline ordering issues where - // the room might be receiving events from list expansion while we're also - // trying to load a large timeline, causing events to be added out of order. - const timeoutId = setTimeout(() => { - manager.subscribeToRoom(roomId); - }, 100); - - return () => { - clearTimeout(timeoutId); - }; + manager.subscribeToRoom(roomId); + return undefined; }, [mx, roomId]); }; diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 84622b0e2..058b87cf1 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -8,6 +8,7 @@ import { MSC3575List, MSC3575RoomData, MSC3575RoomSubscription, + MSC3575SlidingSyncResponse, MSC3575_WILDCARD, RoomMemberEvent, SlidingSync, @@ -350,6 +351,27 @@ export class SlidingSyncManager { return; } + // Before room data is processed, reset live timelines for active rooms that + // are receiving a full refresh (initial: true) or a post-gap update + // (limited: true). The SDK deliberately does not call resetLiveTimeline() for + // sliding sync, so events from previous visits accumulate in the live + // timeline alongside new events. Resetting here — before the SDK's + // onRoomData listener runs — ensures the fresh batch lands on a clean + // timeline with a correct backward pagination token. + if (state === SlidingSyncState.RequestFinished && resp && !err) { + const rooms = (resp as MSC3575SlidingSyncResponse).rooms ?? {}; + Object.entries(rooms) + .filter(([, roomData]) => roomData.initial || roomData.limited) + .filter(([roomId]) => this.activeRoomSubscriptions.has(roomId)) + .forEach(([roomId]) => { + const room = this.mx.getRoom(roomId); + if (!room) return; + const timelineSet = room.getUnfilteredTimelineSet(); + if (timelineSet.getLiveTimeline().getEvents().length === 0) return; + timelineSet.resetLiveTimeline(); + }); + } + if (err || !resp || state !== SlidingSyncState.Complete) return; // Track what changed in this sync cycle From 7062f64e78935bf08a97c8b4b164964d3398b99f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 25 Mar 2026 22:17:13 -0400 Subject: [PATCH 08/16] fix: always open room at bottom of timeline --- src/app/features/room/RoomTimeline.tsx | 42 +++++++++++++++++++++----- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index dd655deec..d4aaebbd9 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -207,6 +207,9 @@ export function RoomTimeline({ 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. + const initialScrollTimerRef = useRef | undefined>(undefined); const currentRoomIdRef = useRef(room.roomId); const [isReady, setIsReady] = useState(false); @@ -273,16 +276,31 @@ export function RoomTimeline({ vListRef.current ) { vListRef.current.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); - const t = setTimeout(() => { - 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' }); + } setIsReady(true); }, 80); hasInitialScrolledRef.current = true; - return () => clearTimeout(t); } - return () => {}; + // No cleanup return — the timer must survive eventsLength fluctuations. + // It is cancelled on unmount by the dedicated effect below. }, [timelineSync.eventsLength, eventId, room.roomId]); + // Cancel the initial-scroll timer on unmount (the useLayoutEffect above + // intentionally does not cancel it when deps change). + useEffect( + () => () => { + if (initialScrollTimerRef.current !== undefined) clearTimeout(initialScrollTimerRef.current); + }, + [] + ); + const recalcTopSpacer = useCallback(() => { const v = vListRef.current; if (!v) return; @@ -355,6 +373,11 @@ export function RoomTimeline({ useEffect(() => { if (eventId) return; + // Guard: once the timeline is visible to the user, do not override their + // scroll position. Without this, a later timeline refresh (e.g. the + // onLifecycle reset delivering a new linkedTimelines reference) can fire + // this effect after isReady and snap the view back to the read marker. + if (isReady) return; const { readUptoEventId, inLiveTimeline, scrollTo } = unreadInfo ?? {}; if (readUptoEventId && inLiveTimeline && scrollTo) { const evtTimeline = getEventTimeline(room, readUptoEventId); @@ -366,12 +389,16 @@ export function RoomTimeline({ ) : undefined; - if (absoluteIndex !== undefined && vListRef.current) { + if (absoluteIndex !== undefined) { const processedIndex = getRawIndexToProcessedIndex(absoluteIndex); - if (processedIndex !== undefined) { + if (processedIndex !== undefined && vListRef.current) { vListRef.current.scrollToIndex(processedIndex, { align: 'start' }); - setUnreadInfo((prev) => (prev ? { ...prev, scrollTo: false } : prev)); } + // Always consume the scroll intent once the event is located in the + // linked timelines, even if its processedIndex is undefined (filtered + // event). Without this, each linkedTimelines reference change retries + // the scroll indefinitely. + setUnreadInfo((prev) => (prev ? { ...prev, scrollTo: false } : prev)); } } }, [ @@ -379,6 +406,7 @@ export function RoomTimeline({ unreadInfo, timelineSync.timeline.linkedTimelines, eventId, + isReady, getRawIndexToProcessedIndex, ]); From f707acc713f0db7c19883533f1584d8d737f000e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 27 Mar 2026 22:10:24 -0400 Subject: [PATCH 09/16] fix: scroll-to-bottom race and stale lTimelines after pagination RoomTimeline.tsx: - Add liveTimelineLinked guard to the initial-scroll useLayoutEffect. Without this, a render with stale data from the previous room (before the room-change reset propagates) fires scrollToIndex at the wrong index and marks hasInitialScrolledRef = true, blocking the correct scroll when the right data arrives. - Add pendingReadyRef to defer setIsReady(true) when the 80 ms timer fires but processedEvents is empty (e.g. the onLifecycle reset cleared the timeline within the window). A recovery useLayoutEffect watches for processedEvents.length becoming non-empty and performs the final scroll + setIsReady when the flag is set. Previously setIsReady(true) was called unconditionally from the timer, revealing the room at position 0 when events had not yet arrived. - Reset pendingReadyRef on room change so dangling recovery state from a previous room cannot fire in the new room context. useTimelineSync.ts: - After await paginateEventTimeline, re-read from timelineRef.current instead of using the pre-await lTimelines capture. A sliding sync reset (resetLiveTimeline) during pagination replaces lTimelines[0] with a new EventTimeline object; the stale reference causes getLinkedTimelines to traverse the wrong chain, making countAfter/countBefore comparisons wrong and triggering incorrect pagination decisions. - Add room-change useEffect that resets the timeline state to the new room's initial linked timelines when room.roomId changes. Without this, the component retains stale data from the previous room, keeping liveTimelineLinked false until a TimelineReset event fires -- which may never happen for revisited rooms where the sliding sync response does not include initial:true. --- src/app/features/room/RoomTimeline.tsx | 34 +++++++++++++++++++++-- src/app/hooks/timeline/useTimelineSync.ts | 30 ++++++++++++++++++-- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d4aaebbd9..95e3f105a 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -210,6 +210,11 @@ export function RoomTimeline({ // 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); + // Set to true when the 80 ms timer fires but processedEvents is still empty + // (e.g. the onLifecycle reset cleared the timeline before events refilled it). + // A recovery useLayoutEffect watches for processedEvents becoming non-empty + // and performs the final scroll + setIsReady when this flag is set. + const pendingReadyRef = useRef(false); const currentRoomIdRef = useRef(room.roomId); const [isReady, setIsReady] = useState(false); @@ -218,6 +223,7 @@ export function RoomTimeline({ hasInitialScrolledRef.current = false; mountScrollWindowRef.current = Date.now() + 3000; currentRoomIdRef.current = room.roomId; + pendingReadyRef.current = false; setIsReady(false); } @@ -273,6 +279,12 @@ export function RoomTimeline({ !eventId && !hasInitialScrolledRef.current && timelineSync.eventsLength > 0 && + // Guard: only scroll once the timeline reflects the current room's live + // timeline. Without this, a render with stale data from the previous room + // (before the room-change reset propagates) fires the scroll at the wrong + // position and marks hasInitialScrolledRef = true, preventing the correct + // scroll when the right data arrives. + timelineSync.liveTimelineLinked && vListRef.current ) { vListRef.current.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); @@ -283,14 +295,20 @@ export function RoomTimeline({ initialScrollTimerRef.current = undefined; if (processedEventsRef.current.length > 0) { 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. + setIsReady(true); + } else { + pendingReadyRef.current = true; } - setIsReady(true); }, 80); hasInitialScrolledRef.current = true; } // No cleanup return — the timer must survive eventsLength fluctuations. // It is cancelled on unmount by the dedicated effect below. - }, [timelineSync.eventsLength, 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). @@ -696,6 +714,18 @@ export function RoomTimeline({ processedEventsRef.current = processedEvents; + // 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 + // guarded by pendingReadyRef so it only acts once per initial-scroll attempt. + useLayoutEffect(() => { + if (!pendingReadyRef.current) return; + if (processedEvents.length === 0) return; + pendingReadyRef.current = false; + vListRef.current?.scrollToIndex(processedEvents.length - 1, { align: 'end' }); + setIsReady(true); + }, [processedEvents.length]); + useEffect(() => { if (!onEditLastMessageRef) return; const ref = onEditLastMessageRef; diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 5c99bf6c2..07abffb10 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -179,15 +179,21 @@ const useTimelinePagination = ( } if (alive()) { - recalibratePagination(lTimelines); + // Re-read linkedTimelines after the await: a sliding sync reset may have + // replaced lTimelines[0] (via resetLiveTimeline) while pagination was in + // flight, making the captured lTimelines stale. Using the fresh ref + // ensures recalibratePagination rebuilds from the current live chain and + // that countAfter/stillHasToken comparisons are meaningful. + const freshLTimelines = timelineRef.current.linkedTimelines; + recalibratePagination(freshLTimelines); (backwards ? setBackwardStatus : setForwardStatus)('idle'); - const countAfter = getTimelinesEventsCount(getLinkedTimelines(lTimelines[0])); + const countAfter = getTimelinesEventsCount(getLinkedTimelines(freshLTimelines[0])); const fetched = countAfter - countBefore; if (fetched > 0 && fetched < 5) { const stillHasToken = - typeof getLinkedTimelines(lTimelines[0])[0]?.getPaginationToken( + typeof getLinkedTimelines(freshLTimelines[0])[0]?.getPaginationToken( Direction.Backward ) === 'string'; if (stillHasToken) { @@ -566,6 +572,24 @@ export function useTimelineSync({ setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); }, [eventId, room, timeline.linkedTimelines.length]); + // When navigating between rooms, reset the timeline state to the new room's + // initial linked timelines. Without this, the component's timeline state + // retains stale data from the previous room, causing liveTimelineLinked to be + // false until a TimelineReset event fires. For revisited rooms with up-to-date + // data (no initial:true in the sliding sync response), that event may never + // arrive — leaving the initial-scroll guard permanently blocked and the room + // invisible. + const prevRoomIdRef = useRef(room.roomId); + useEffect(() => { + if (prevRoomIdRef.current === room.roomId) return; + prevRoomIdRef.current = room.roomId; + if (eventId) return; + setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); + // Intentionally only depends on room: we want this to fire when the room + // identity changes, not on every eventId change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [room]); + return { timeline, setTimeline, From 676478435ea938c97ab9a6725ea38c8ff33954d8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 27 Mar 2026 22:12:36 -0400 Subject: [PATCH 10/16] chore: update changeset for scroll-to-bottom and pagination fixes --- .changeset/fix-bug-fixes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-bug-fixes.md b/.changeset/fix-bug-fixes.md index aea8ed44e..a031ad911 100644 --- a/.changeset/fix-bug-fixes.md +++ b/.changeset/fix-bug-fixes.md @@ -2,4 +2,4 @@ default: patch --- -Fix timeline pagination lock bug, deduplicate concurrent URL preview requests, and remove redundant sliding sync spidering call causing N+1 requests. +Fix timeline pagination lock bug, deduplicate concurrent URL preview requests, remove redundant sliding sync spidering call causing N+1 requests, fix scroll-to-bottom not working after room navigation (stale-room liveTimelineLinked guard + deferred ready on empty processedEvents), and fix wrong timeline chain used for pagination count comparisons after a sliding sync reset. From e1c8c64cda76d9aa7a0dcc1fd3f9edb084327bf5 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 27 Mar 2026 22:40:15 -0400 Subject: [PATCH 11/16] chore: shorten changeset to one-line end-user summary --- .changeset/fix-bug-fixes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-bug-fixes.md b/.changeset/fix-bug-fixes.md index a031ad911..8a1dd4ffa 100644 --- a/.changeset/fix-bug-fixes.md +++ b/.changeset/fix-bug-fixes.md @@ -2,4 +2,4 @@ default: patch --- -Fix timeline pagination lock bug, deduplicate concurrent URL preview requests, remove redundant sliding sync spidering call causing N+1 requests, fix scroll-to-bottom not working after room navigation (stale-room liveTimelineLinked guard + deferred ready on empty processedEvents), and fix wrong timeline chain used for pagination count comparisons after a sliding sync reset. +Fix scroll-to-bottom after room navigation, timeline pagination reliability, and URL preview deduplication. From 1114c047d5c0dd2e8f5dcaa234fbdafdabe333ee Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 27 Mar 2026 23:17:03 -0400 Subject: [PATCH 12/16] fix: resolve Copilot code review comments on PR #529 - Clear initialScrollTimerRef when room changes to prevent stale timer from firing in a new room after navigation (RoomTimeline.tsx) - Scope URL preview cache by MatrixClient to prevent cross-account deduplication when multiple clients/sessions exist (UrlPreviewCard.tsx) - Delete cache entries after promise settles (both success/rejection) to prevent unbounded cache growth over long sessions (UrlPreviewCard.tsx) - Fix pagination token direction check: use appropriate timeline end and token direction based on pagination direction (forward/backward) instead of always checking first timeline's backward token (useTimelineSync.ts) --- .../components/url-preview/UrlPreviewCard.tsx | 28 ++++++++++++++----- src/app/features/room/RoomTimeline.tsx | 4 +++ src/app/hooks/timeline/useTimelineSync.ts | 6 ++-- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index edc3f0f9d..2b5d6b87b 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -16,10 +16,23 @@ const linkStyles = { color: color.Success.Main }; // Module-level in-flight deduplication: prevents N+1 concurrent requests when a // large event batch renders many UrlPreviewCard instances for the same URL. -// Keyed by URL only (not ts) — the same URL shows the same preview regardless -// of which message referenced it. Rejected promises are evicted so a later -// render can retry after network recovery. -const previewRequestCache = new Map>(); +// Scoped by MatrixClient to avoid cross-account dedup if multiple clients exist. +// Inner cache keyed by URL only (not ts) — the same URL shows the same preview +// regardless of which message referenced it. Promises are evicted after settling +// so a later render can retry after network recovery. +const previewRequestCache = new WeakMap< + any, + Map> +>(); + +const getClientCache = (mx: any): Map> => { + let clientCache = previewRequestCache.get(mx); + if (!clientCache) { + clientCache = new Map(); + previewRequestCache.set(mx, clientCache); + } + return clientCache; +}; const openMediaInNewTab = async (url: string | undefined) => { if (!url) { @@ -41,11 +54,12 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number; mediaType?: s const [previewStatus, loadPreview] = useAsyncCallback( useCallback(() => { if (isDirect) return Promise.resolve(null); - const cached = previewRequestCache.get(url); + const clientCache = getClientCache(mx); + const cached = clientCache.get(url); if (cached !== undefined) return cached; const urlPreview = mx.getUrlPreview(url, ts); - previewRequestCache.set(url, urlPreview); - urlPreview.catch(() => previewRequestCache.delete(url)); + clientCache.set(url, urlPreview); + urlPreview.finally(() => clientCache.delete(url)); return urlPreview; }, [url, ts, mx, isDirect]) ); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 95e3f105a..63af8c960 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -224,6 +224,10 @@ export function RoomTimeline({ mountScrollWindowRef.current = Date.now() + 3000; currentRoomIdRef.current = room.roomId; pendingReadyRef.current = false; + if (initialScrollTimerRef.current !== undefined) { + clearTimeout(initialScrollTimerRef.current); + initialScrollTimerRef.current = undefined; + } setIsReady(false); } diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 07abffb10..16127b6e5 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -192,9 +192,11 @@ const useTimelinePagination = ( const fetched = countAfter - countBefore; if (fetched > 0 && fetched < 5) { + const checkTimeline = backwards ? freshLTimelines[0] : freshLTimelines[freshLTimelines.length - 1]; + const checkDirection = backwards ? Direction.Backward : Direction.Forward; const stillHasToken = - typeof getLinkedTimelines(freshLTimelines[0])[0]?.getPaginationToken( - Direction.Backward + typeof getLinkedTimelines(checkTimeline)[0]?.getPaginationToken( + checkDirection ) === 'string'; if (stillHasToken) { // Release lock so inner paginate can claim it, then mark continuing From f9c725e0ab249115005aa0160572aaf63797dc39 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 27 Mar 2026 23:33:47 -0400 Subject: [PATCH 13/16] Add tests --- .../hooks/timeline/useTimelineSync.test.ts | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 src/app/hooks/timeline/useTimelineSync.test.ts diff --git a/src/app/hooks/timeline/useTimelineSync.test.ts b/src/app/hooks/timeline/useTimelineSync.test.ts new file mode 100644 index 000000000..7b8a27b2c --- /dev/null +++ b/src/app/hooks/timeline/useTimelineSync.test.ts @@ -0,0 +1,172 @@ +// Tests for useTimelineSync: focus on room-change timeline reset behavior +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useTimelineSync } from './useTimelineSync'; +import { getInitialTimeline } from '$utils/timeline'; + +// Mock the dependencies +vi.mock('$hooks/useAlive', () => ({ + useAlive: () => () => true, +})); + +vi.mock('$utils/notifications', () => ({ + markAsRead: vi.fn(), +})); + +vi.mock('$utils/room', () => ({ + decryptAllTimelineEvent: vi.fn(), +})); + +vi.mock('$utils/timeline', () => ({ + getInitialTimeline: vi.fn(), + getEmptyTimeline: vi.fn(), + getLinkedTimelines: vi.fn(() => []), + getTimelinesEventsCount: vi.fn(() => 0), + getEventIdAbsoluteIndex: vi.fn(), + getLiveTimeline: vi.fn(), + getRoomUnreadInfo: vi.fn(), + PAGINATION_LIMIT: 30, +})); + +vi.mock('$types/matrix-sdk', () => ({ + Direction: { Backward: 0, Forward: 1 }, +})); + +vi.mock('@sentry/react', () => ({ + startSpan: vi.fn((config, fn) => fn()), + metrics: { + distribution: vi.fn(), + }, +})); + +// Create mock room and client +const createMockRoom = (roomId: string) => ({ + roomId, + getUnfilteredTimelineSet: vi.fn(() => ({ + getTimelineForEvent: vi.fn(() => null), + })), +}); + +const createMockClient = () => ({ + getUserId: vi.fn(() => '@user:example.com'), +}); + +describe('useTimelineSync', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('resets timeline state when room.roomId changes and eventId is not set', () => { + const room1 = createMockRoom('!room1:example.com'); + const room2 = createMockRoom('!room2:example.com'); + const mx = createMockClient(); + + const mockTimeline = { linkedTimelines: [] }; + const getInitialTimelineMock = vi.mocked(getInitialTimeline); + getInitialTimelineMock.mockReturnValue(mockTimeline); + + // Render with first room + const { rerender } = renderHook( + ({ room, eventId }) => + useTimelineSync({ + mx, + room, + eventId, + }), + { + initialProps: { + room: room1, + eventId: undefined, + }, + } + ); + + // Verify initial timeline was set + expect(getInitialTimelineMock).toHaveBeenCalledWith(room1); + + // Clear the mock to track new calls + getInitialTimelineMock.mockClear(); + + // Change to room2 + act(() => { + rerender({ room: room2, eventId: undefined }); + }); + + // Should reset timeline for the new room + expect(getInitialTimelineMock).toHaveBeenCalledWith(room2); + }); + + it('does not reset timeline when eventId is set during room change', () => { + const room1 = createMockRoom('!room1:example.com'); + const room2 = createMockRoom('!room2:example.com'); + const mx = createMockClient(); + + const mockTimeline = { linkedTimelines: [] }; + const getInitialTimelineMock = vi.mocked(getInitialTimeline); + getInitialTimelineMock.mockReturnValue(mockTimeline); + + // Render with first room + const { rerender } = renderHook( + ({ room, eventId }) => + useTimelineSync({ + mx, + room, + eventId, + }), + { + initialProps: { + room: room1, + eventId: undefined, + }, + } + ); + + // Clear the mock to track new calls during room change + getInitialTimelineMock.mockClear(); + + // Change to room2 WITH eventId set (should NOT reset) + act(() => { + rerender({ room: room2, eventId: '$event123' }); + }); + + // Should NOT reset timeline when eventId is set + expect(getInitialTimelineMock).not.toHaveBeenCalledWith(room2); + }); + + it('does not reset timeline when room.roomId stays the same', () => { + const room = createMockRoom('!room1:example.com'); + const mx = createMockClient(); + + const mockTimeline = { linkedTimelines: [] }; + const getInitialTimelineMock = vi.mocked(getInitialTimeline); + getInitialTimelineMock.mockReturnValue(mockTimeline); + + // Render hook + const { rerender } = renderHook( + ({ room, eventId }) => + useTimelineSync({ + mx, + room, + eventId, + }), + { + initialProps: { + room, + eventId: undefined, + }, + } + ); + + // Clear the mock to track new calls + getInitialTimelineMock.mockClear(); + + // Rerender with same room (different prop but same roomId) + const sameRoom = createMockRoom(room.roomId); + act(() => { + rerender({ room: sameRoom, eventId: undefined }); + }); + + // Should NOT reset timeline since roomId is the same + expect(getInitialTimelineMock).not.toHaveBeenCalledWith(sameRoom); + }); +}); From 6ccb85f96bb154dafd75de728b35015e5d31283c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 27 Mar 2026 23:52:45 -0400 Subject: [PATCH 14/16] fix: resolve PR #529 quality check failures --- .../components/url-preview/UrlPreviewCard.tsx | 5 +- .../hooks/timeline/useTimelineSync.test.ts | 172 ------------------ .../hooks/timeline/useTimelineSync.test.tsx | 118 +++++++++++- src/app/hooks/timeline/useTimelineSync.ts | 9 +- 4 files changed, 121 insertions(+), 183 deletions(-) delete mode 100644 src/app/hooks/timeline/useTimelineSync.test.ts diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 2b5d6b87b..3ac780f74 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -20,10 +20,7 @@ const linkStyles = { color: color.Success.Main }; // Inner cache keyed by URL only (not ts) — the same URL shows the same preview // regardless of which message referenced it. Promises are evicted after settling // so a later render can retry after network recovery. -const previewRequestCache = new WeakMap< - any, - Map> ->(); +const previewRequestCache = new WeakMap>>(); const getClientCache = (mx: any): Map> => { let clientCache = previewRequestCache.get(mx); diff --git a/src/app/hooks/timeline/useTimelineSync.test.ts b/src/app/hooks/timeline/useTimelineSync.test.ts deleted file mode 100644 index 7b8a27b2c..000000000 --- a/src/app/hooks/timeline/useTimelineSync.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -// Tests for useTimelineSync: focus on room-change timeline reset behavior -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; -import { useTimelineSync } from './useTimelineSync'; -import { getInitialTimeline } from '$utils/timeline'; - -// Mock the dependencies -vi.mock('$hooks/useAlive', () => ({ - useAlive: () => () => true, -})); - -vi.mock('$utils/notifications', () => ({ - markAsRead: vi.fn(), -})); - -vi.mock('$utils/room', () => ({ - decryptAllTimelineEvent: vi.fn(), -})); - -vi.mock('$utils/timeline', () => ({ - getInitialTimeline: vi.fn(), - getEmptyTimeline: vi.fn(), - getLinkedTimelines: vi.fn(() => []), - getTimelinesEventsCount: vi.fn(() => 0), - getEventIdAbsoluteIndex: vi.fn(), - getLiveTimeline: vi.fn(), - getRoomUnreadInfo: vi.fn(), - PAGINATION_LIMIT: 30, -})); - -vi.mock('$types/matrix-sdk', () => ({ - Direction: { Backward: 0, Forward: 1 }, -})); - -vi.mock('@sentry/react', () => ({ - startSpan: vi.fn((config, fn) => fn()), - metrics: { - distribution: vi.fn(), - }, -})); - -// Create mock room and client -const createMockRoom = (roomId: string) => ({ - roomId, - getUnfilteredTimelineSet: vi.fn(() => ({ - getTimelineForEvent: vi.fn(() => null), - })), -}); - -const createMockClient = () => ({ - getUserId: vi.fn(() => '@user:example.com'), -}); - -describe('useTimelineSync', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('resets timeline state when room.roomId changes and eventId is not set', () => { - const room1 = createMockRoom('!room1:example.com'); - const room2 = createMockRoom('!room2:example.com'); - const mx = createMockClient(); - - const mockTimeline = { linkedTimelines: [] }; - const getInitialTimelineMock = vi.mocked(getInitialTimeline); - getInitialTimelineMock.mockReturnValue(mockTimeline); - - // Render with first room - const { rerender } = renderHook( - ({ room, eventId }) => - useTimelineSync({ - mx, - room, - eventId, - }), - { - initialProps: { - room: room1, - eventId: undefined, - }, - } - ); - - // Verify initial timeline was set - expect(getInitialTimelineMock).toHaveBeenCalledWith(room1); - - // Clear the mock to track new calls - getInitialTimelineMock.mockClear(); - - // Change to room2 - act(() => { - rerender({ room: room2, eventId: undefined }); - }); - - // Should reset timeline for the new room - expect(getInitialTimelineMock).toHaveBeenCalledWith(room2); - }); - - it('does not reset timeline when eventId is set during room change', () => { - const room1 = createMockRoom('!room1:example.com'); - const room2 = createMockRoom('!room2:example.com'); - const mx = createMockClient(); - - const mockTimeline = { linkedTimelines: [] }; - const getInitialTimelineMock = vi.mocked(getInitialTimeline); - getInitialTimelineMock.mockReturnValue(mockTimeline); - - // Render with first room - const { rerender } = renderHook( - ({ room, eventId }) => - useTimelineSync({ - mx, - room, - eventId, - }), - { - initialProps: { - room: room1, - eventId: undefined, - }, - } - ); - - // Clear the mock to track new calls during room change - getInitialTimelineMock.mockClear(); - - // Change to room2 WITH eventId set (should NOT reset) - act(() => { - rerender({ room: room2, eventId: '$event123' }); - }); - - // Should NOT reset timeline when eventId is set - expect(getInitialTimelineMock).not.toHaveBeenCalledWith(room2); - }); - - it('does not reset timeline when room.roomId stays the same', () => { - const room = createMockRoom('!room1:example.com'); - const mx = createMockClient(); - - const mockTimeline = { linkedTimelines: [] }; - const getInitialTimelineMock = vi.mocked(getInitialTimeline); - getInitialTimelineMock.mockReturnValue(mockTimeline); - - // Render hook - const { rerender } = renderHook( - ({ room, eventId }) => - useTimelineSync({ - mx, - room, - eventId, - }), - { - initialProps: { - room, - eventId: undefined, - }, - } - ); - - // Clear the mock to track new calls - getInitialTimelineMock.mockClear(); - - // Rerender with same room (different prop but same roomId) - const sameRoom = createMockRoom(room.roomId); - act(() => { - rerender({ room: sameRoom, eventId: undefined }); - }); - - // Should NOT reset timeline since roomId is the same - expect(getInitialTimelineMock).not.toHaveBeenCalledWith(sameRoom); - }); -}); diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index 71fde31c2..d53d74143 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -40,12 +40,18 @@ function createTimeline(events: unknown[] = [{}]): FakeTimeline { }; } -function createRoom(events: unknown[] = [{}]): { +function createRoom( + roomId = '!room:test', + events: unknown[] = [{}] +): { room: FakeRoom; timelineSet: FakeTimelineSet; events: unknown[]; } { - const timeline = createTimeline(events); + const timeline = { + ...createTimeline(events), + getRoomId: () => roomId, + }; const timelineSet = new EventEmitter() as FakeTimelineSet; timelineSet.getLiveTimeline = () => timeline; timelineSet.getTimelineForEvent = () => undefined; @@ -55,7 +61,7 @@ function createRoom(events: unknown[] = [{}]): { on: roomEmitter.on.bind(roomEmitter), removeListener: roomEmitter.removeListener.bind(roomEmitter), emit: roomEmitter.emit.bind(roomEmitter), - roomId: '!room:test', + roomId, getUnfilteredTimelineSet: () => timelineSet as never, getEventReadUpTo: () => null, getThread: () => null, @@ -125,4 +131,110 @@ describe('useTimelineSync', () => { expect(scrollToBottom).toHaveBeenCalledWith('instant'); }); + + it('resets timeline state when room.roomId changes and eventId is not set', async () => { + const roomOne = createRoom('!room:one'); + const roomTwo = createRoom('!room:two'); + const scrollToBottom = vi.fn(); + + const { result, rerender } = renderHook( + ({ room, eventId }) => + useTimelineSync({ + room, + mx: { getUserId: () => '@alice:test' } as never, + eventId, + isAtBottom: false, + isAtBottomRef: { current: false }, + scrollToBottom, + unreadInfo: undefined, + setUnreadInfo: vi.fn(), + hideReadsRef: { current: false }, + readUptoEventIdRef: { current: undefined }, + }), + { + initialProps: { + room: roomOne.room as Room, + eventId: undefined as string | undefined, + }, + } + ); + + expect(result.current.timeline.linkedTimelines[0]).toBe(roomOne.timelineSet.getLiveTimeline()); + + await act(async () => { + rerender({ room: roomTwo.room as Room, eventId: undefined }); + await Promise.resolve(); + }); + + expect(result.current.timeline.linkedTimelines[0]).toBe(roomTwo.timelineSet.getLiveTimeline()); + }); + + it('does not reset timeline when eventId is set during a room change', async () => { + const roomOne = createRoom('!room:one'); + const roomTwo = createRoom('!room:two'); + const scrollToBottom = vi.fn(); + + const { result, rerender } = renderHook( + ({ room, eventId }) => + useTimelineSync({ + room, + mx: { getUserId: () => '@alice:test' } as never, + eventId, + isAtBottom: false, + isAtBottomRef: { current: false }, + scrollToBottom, + unreadInfo: undefined, + setUnreadInfo: vi.fn(), + hideReadsRef: { current: false }, + readUptoEventIdRef: { current: undefined }, + }), + { + initialProps: { + room: roomOne.room as Room, + eventId: undefined as string | undefined, + }, + } + ); + + await act(async () => { + rerender({ room: roomTwo.room as Room, eventId: '$event:one' }); + await Promise.resolve(); + }); + + expect(result.current.timeline.linkedTimelines[0]).toBe(roomOne.timelineSet.getLiveTimeline()); + }); + + it('does not reset timeline when the roomId stays the same', async () => { + const roomOne = createRoom('!room:one'); + const sameRoomId = createRoom('!room:one'); + const scrollToBottom = vi.fn(); + + const { result, rerender } = renderHook( + ({ room }) => + useTimelineSync({ + room, + mx: { getUserId: () => '@alice:test' } as never, + eventId: undefined, + isAtBottom: false, + isAtBottomRef: { current: false }, + scrollToBottom, + unreadInfo: undefined, + setUnreadInfo: vi.fn(), + hideReadsRef: { current: false }, + readUptoEventIdRef: { current: undefined }, + }), + { + initialProps: { + room: roomOne.room as Room, + }, + } + ); + + await act(async () => { + rerender({ room: sameRoomId.room as Room }); + await Promise.resolve(); + }); + + expect(result.current.timeline.linkedTimelines[0]).toBe(roomOne.timelineSet.getLiveTimeline()); + }); }); diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 16127b6e5..51c85dda8 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -192,12 +192,13 @@ const useTimelinePagination = ( const fetched = countAfter - countBefore; if (fetched > 0 && fetched < 5) { - const checkTimeline = backwards ? freshLTimelines[0] : freshLTimelines[freshLTimelines.length - 1]; + const checkTimeline = backwards + ? freshLTimelines[0] + : freshLTimelines[freshLTimelines.length - 1]; const checkDirection = backwards ? Direction.Backward : Direction.Forward; const stillHasToken = - typeof getLinkedTimelines(checkTimeline)[0]?.getPaginationToken( - checkDirection - ) === 'string'; + typeof getLinkedTimelines(checkTimeline)[0]?.getPaginationToken(checkDirection) === + 'string'; if (stillHasToken) { // Release lock so inner paginate can claim it, then mark continuing // so the finally block below does NOT reset it after inner claims. From fe3900a657568fbf514e7009761ff1652bfe0bd4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 01:12:49 -0400 Subject: [PATCH 15/16] fix: recover profile and avatar loading --- src/app/components/room-avatar/RoomAvatar.tsx | 6 +++++- src/app/components/user-avatar/UserAvatar.tsx | 6 +++++- src/app/hooks/useUserProfile.ts | 15 +++++++++++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/app/components/room-avatar/RoomAvatar.tsx b/src/app/components/room-avatar/RoomAvatar.tsx index 356d9dbc2..33f9f6881 100644 --- a/src/app/components/room-avatar/RoomAvatar.tsx +++ b/src/app/components/room-avatar/RoomAvatar.tsx @@ -1,6 +1,6 @@ import { JoinRule } from '$types/matrix-sdk'; import { AvatarFallback, Icon, Icons, color } from 'folds'; -import { ComponentProps, ReactNode, forwardRef, useState } from 'react'; +import { ComponentProps, ReactNode, forwardRef, useEffect, useState } from 'react'; import { getRoomIconSrc } from '$utils/room'; import colorMXID from '$utils/colorMXID'; import * as css from './RoomAvatar.css'; @@ -17,6 +17,10 @@ type RoomAvatarProps = { export function RoomAvatar({ roomId, src, alt, renderFallback, uniformIcons }: RoomAvatarProps) { const [error, setError] = useState(false); + useEffect(() => { + setError(false); + }, [src]); + if (!src || error) { return ( { + setError(false); + }, [src]); + const handleLoad: ReactEventHandler = (evt) => { evt.currentTarget.setAttribute('data-image-loaded', 'true'); }; diff --git a/src/app/hooks/useUserProfile.ts b/src/app/hooks/useUserProfile.ts index 8de5da050..b50a42960 100644 --- a/src/app/hooks/useUserProfile.ts +++ b/src/app/hooks/useUserProfile.ts @@ -7,10 +7,15 @@ import colorMXID from '$utils/colorMXID'; import { profilesCacheAtom } from '$state/userRoomProfile'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; +import { MSC1767Text } from '$types/matrix/common'; import { useMatrixClient } from './useMatrixClient'; const inFlightProfiles = new Map>(); +export type MSC4440Bio = { + 'm.text': Array; +}; + export type UserProfile = { avatarUrl?: string; displayName?: string; @@ -27,6 +32,7 @@ export type UserProfile = { }; const normalizeInfo = (info: any): UserProfile => { + const msc4440Bio = info['gay.fomx.biography'] as MSC4440Bio | undefined; const knownKeys = new Set([ 'avatar_url', 'displayname', @@ -35,6 +41,7 @@ const normalizeInfo = (info: any): UserProfile => { 'm.tz', 'moe.sable.app.bio', 'chat.commet.profile_bio', + 'gay.fomx.biography', 'chat.commet.profile_banner', 'chat.commet.profile_status', 'moe.sable.app.name_color', @@ -54,7 +61,7 @@ const normalizeInfo = (info: any): UserProfile => { displayName: info.displayname, pronouns: info['io.fsky.nyx.pronouns'], timezone: info['us.cloke.msc4175.tz'] || info['m.tz'], - bio: info['moe.sable.app.bio'] || info['chat.commet.profile_bio'], + bio: msc4440Bio?.['m.text']?.[0]?.body || info['moe.sable.app.bio'] || info['chat.commet.profile_bio'], status: info['chat.commet.profile_status'], bannerUrl: info['chat.commet.profile_banner'], nameColor: info['moe.sable.app.name_color'], @@ -94,7 +101,11 @@ export const useUserProfile = ( const cached = useAtomValue(userSelector); const setGlobalProfiles = useSetAtom(profilesCacheAtom); - const needsFetch = !!userId && userId !== 'undefined' && !cached?._fetched; + const hasOnlyFetchedMarker = + cached?._fetched === true && Object.keys(cached).every((key) => key === '_fetched'); + + const needsFetch = + !!userId && userId !== 'undefined' && (!cached?._fetched || hasOnlyFetchedMarker); useEffect(() => { if (!needsFetch) return undefined; From 7ac19354248ded7043a9339f059723bf0dea0a1f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 01:21:45 -0400 Subject: [PATCH 16/16] Revert "fix: recover profile and avatar loading" This reverts commit fe3900a657568fbf514e7009761ff1652bfe0bd4. --- src/app/components/room-avatar/RoomAvatar.tsx | 6 +----- src/app/components/user-avatar/UserAvatar.tsx | 6 +----- src/app/hooks/useUserProfile.ts | 15 ++------------- 3 files changed, 4 insertions(+), 23 deletions(-) diff --git a/src/app/components/room-avatar/RoomAvatar.tsx b/src/app/components/room-avatar/RoomAvatar.tsx index 33f9f6881..356d9dbc2 100644 --- a/src/app/components/room-avatar/RoomAvatar.tsx +++ b/src/app/components/room-avatar/RoomAvatar.tsx @@ -1,6 +1,6 @@ import { JoinRule } from '$types/matrix-sdk'; import { AvatarFallback, Icon, Icons, color } from 'folds'; -import { ComponentProps, ReactNode, forwardRef, useEffect, useState } from 'react'; +import { ComponentProps, ReactNode, forwardRef, useState } from 'react'; import { getRoomIconSrc } from '$utils/room'; import colorMXID from '$utils/colorMXID'; import * as css from './RoomAvatar.css'; @@ -17,10 +17,6 @@ type RoomAvatarProps = { export function RoomAvatar({ roomId, src, alt, renderFallback, uniformIcons }: RoomAvatarProps) { const [error, setError] = useState(false); - useEffect(() => { - setError(false); - }, [src]); - if (!src || error) { return ( { - setError(false); - }, [src]); - const handleLoad: ReactEventHandler = (evt) => { evt.currentTarget.setAttribute('data-image-loaded', 'true'); }; diff --git a/src/app/hooks/useUserProfile.ts b/src/app/hooks/useUserProfile.ts index b50a42960..8de5da050 100644 --- a/src/app/hooks/useUserProfile.ts +++ b/src/app/hooks/useUserProfile.ts @@ -7,15 +7,10 @@ import colorMXID from '$utils/colorMXID'; import { profilesCacheAtom } from '$state/userRoomProfile'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; -import { MSC1767Text } from '$types/matrix/common'; import { useMatrixClient } from './useMatrixClient'; const inFlightProfiles = new Map>(); -export type MSC4440Bio = { - 'm.text': Array; -}; - export type UserProfile = { avatarUrl?: string; displayName?: string; @@ -32,7 +27,6 @@ export type UserProfile = { }; const normalizeInfo = (info: any): UserProfile => { - const msc4440Bio = info['gay.fomx.biography'] as MSC4440Bio | undefined; const knownKeys = new Set([ 'avatar_url', 'displayname', @@ -41,7 +35,6 @@ const normalizeInfo = (info: any): UserProfile => { 'm.tz', 'moe.sable.app.bio', 'chat.commet.profile_bio', - 'gay.fomx.biography', 'chat.commet.profile_banner', 'chat.commet.profile_status', 'moe.sable.app.name_color', @@ -61,7 +54,7 @@ const normalizeInfo = (info: any): UserProfile => { displayName: info.displayname, pronouns: info['io.fsky.nyx.pronouns'], timezone: info['us.cloke.msc4175.tz'] || info['m.tz'], - bio: msc4440Bio?.['m.text']?.[0]?.body || info['moe.sable.app.bio'] || info['chat.commet.profile_bio'], + bio: info['moe.sable.app.bio'] || info['chat.commet.profile_bio'], status: info['chat.commet.profile_status'], bannerUrl: info['chat.commet.profile_banner'], nameColor: info['moe.sable.app.name_color'], @@ -101,11 +94,7 @@ export const useUserProfile = ( const cached = useAtomValue(userSelector); const setGlobalProfiles = useSetAtom(profilesCacheAtom); - const hasOnlyFetchedMarker = - cached?._fetched === true && Object.keys(cached).every((key) => key === '_fetched'); - - const needsFetch = - !!userId && userId !== 'undefined' && (!cached?._fetched || hasOnlyFetchedMarker); + const needsFetch = !!userId && userId !== 'undefined' && !cached?._fetched; useEffect(() => { if (!needsFetch) return undefined;