perf(timeline): eliminate room-open jitter, flash, and scroll drift#698
Draft
Just-Insane wants to merge 67 commits intoSableClient:devfrom
Draft
perf(timeline): eliminate room-open jitter, flash, and scroll drift#698Just-Insane wants to merge 67 commits intoSableClient:devfrom
Just-Insane wants to merge 67 commits intoSableClient:devfrom
Conversation
- 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<Map>) 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)
…ptions 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.
…mmatic scroll-to-bottom 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.
…ctive through all intermediate VList events 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.
…n-empty 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).
…Id 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.
…ndex in stable-ref check
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.
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).
…mmatic scroll-to-bottom 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.
…arator 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.
- room.ts: exclude own events from unread notification check - RoomTimeline.tsx: recovery effect reveals timeline when event load fails
…t When sliding sync upgrades a room subscription (timeline_limit 1 → 50), a TimelineReset replaces the VList content. The auto-scroll recovery was using useEffect, which fires after paint — causing a visible flash where the user sees content at the wrong scroll position for one frame. Switch to useLayoutEffect so the scroll position is corrected before the browser paints. Also remove the redundant scrollToBottom call from the useLiveTimelineRefresh callback, which was operating on the pre-commit DOM with a stale scrollSize.
…ure scroll
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.
…us Jump to Latest button Replace the boolean programmaticScrollToBottomRef guard with a timestamp (Date.now()) and a 200 ms settling window (SCROLL_SETTLE_MS). VList (virtua) fires multiple intermediate onScroll events while re-measuring item heights after a programmatic scrollToIndex(); the old boolean guard was cleared on the first isNowAtBottom=true callback, leaving subsequent re-measurement callbacks free to set atBottom=false and flash the button. The timestamp approach lets the settling window expire naturally — no manual clearing is needed — and correctly suppresses false-negative reports for the entire measurement pass. Also update the useTimelineSync test to push a new event before emitting TimelineReset so the useLayoutEffect auto-scroll recovery (which depends on eventsLength changing) actually fires.
- 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)
Keys scroll cache entries by userId:roomId so switching accounts doesn't reuse stale scroll positions from a different session.
Accept optional 'instant' | 'smooth' behavior parameter and pass it
through to scrollToIndex. useTimelineSync calls scrollToBottom('instant')
so the signature needs to match.
…guard 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>
- 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>
…croll, fix eventId drag-to-bottom, increase list timeline limit - 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>
…croll, fix eventId drag-to-bottom, increase list timeline limit - 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>
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>
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>
- 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>
- 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>
- 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>
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>
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>
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>
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>
- 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>
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>
…cts from integration)
…onflicts from integration)
- 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>
- 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Consolidates four timeline improvement branches into a single cohesive PR. Addresses the root causes of timeline jitter, flashing, and scroll drift when opening rooms, jumping to messages, and receiving live events.
Changes
feat/media-dimension-hints
aspectRatioto<video>elements inVideoContentso Virtua can reserve accurate layout space before the video loads, preventing height-change reflows.itemSizehint to the VList item wrapping video content.fix/timeline-jitter
scrollToIndexsettles (~150 ms), so the jumped-to message is already centred when content appears. Extends the highlight animation window to 2 s post-reveal.useLayoutEffectauto-scroll recovery: switch the post-TimelineResetscroll restoration fromuseEffecttouseLayoutEffectso the position is corrected before the browser paints, eliminating the one-frame wrong-position flash.roomScrollCache): save and restore scroll offset when revisiting rooms, scoped per user ID. Revisiting a previously-viewed room skips the opacity timer and restores the exact scroll position.useRoomNavigateDM fix: check DM membership before space parents when computing the return-to URL.fix/ui-regressions
TimelineResetadditive guard: skip the hide-content cycle for sliding-syncTimelineResetevents that are purely additive (same live-end event ID), avoiding a spurious flash when the subscription upgrades.atBottomhysteresis / scroll guard: replace the booleanprogrammaticScrollToBottomRefwith a timestamp-based 200 ms settling window; prevents spurious "Jump to Latest" button flashes during Virtua's post-scroll height remeasurement passes.perf/timeline-item-memo
vListIndicesover-invalidation: removetimelineSync.timelinefrom thevListIndicesmemo dep array; the[0…N-1]index array only changes whenvListItemCountchanges, not on every live event'ssetTimeline({…ct})spread.scrollToIndexjump until the target event is present in the live timeline, preventing an incorrect jump to index 0 on rooms with late-loading events.Testing Checklist
TimelineReset): skeletons → content, no wrong-position flashChangeset
fix:Timeline jitter, flash, scroll drift, and unread dot regressionperf:VList indices memo over-invalidationfeat:Video aspect ratio hint, skeleton overlay, scroll position cache