Skip to content

perf(timeline): eliminate room-open jitter, flash, and scroll drift#698

Draft
Just-Insane wants to merge 67 commits intoSableClient:devfrom
Just-Insane:perf/timeline-rendering
Draft

perf(timeline): eliminate room-open jitter, flash, and scroll drift#698
Just-Insane wants to merge 67 commits intoSableClient:devfrom
Just-Insane:perf/timeline-rendering

Conversation

@Just-Insane
Copy link
Copy Markdown
Contributor

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

  • Pass aspectRatio to <video> elements in VideoContent so Virtua can reserve accurate layout space before the video loads, preventing height-change reflows.
  • Pass itemSize hint to the VList item wrapping video content.

fix/timeline-jitter

  • Viewport fill guard: before marking the timeline as ready (showing content), wait until auto-pagination has filled the viewport. Eliminates the visible 3→60 event jump when sliding sync delivers a sparse initial batch.
  • Skeleton overlay: show skeletons during the fill-guard wait so the user sees intentional loading state rather than a blank/partial view.
  • Jump-to-message: delay the opacity reveal until after scrollToIndex settles (~150 ms), so the jumped-to message is already centred when content appears. Extends the highlight animation window to 2 s post-reveal.
  • useLayoutEffect auto-scroll recovery: switch the post-TimelineReset scroll restoration from useEffect to useLayoutEffect so the position is corrected before the browser paints, eliminating the one-frame wrong-position flash.
  • Scroll position cache (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.
  • useRoomNavigate DM fix: check DM membership before space parents when computing the return-to URL.

fix/ui-regressions

  • TimelineReset additive guard: skip the hide-content cycle for sliding-sync TimelineReset events that are purely additive (same live-end event ID), avoiding a spurious flash when the subscription upgrades.
  • atBottom hysteresis / scroll guard: replace the boolean programmaticScrollToBottomRef with a timestamp-based 200 ms settling window; prevents spurious "Jump to Latest" button flashes during Virtua's post-scroll height remeasurement passes.
  • Phantom unread dot / blank notification page: recover gracefully when the notification target event is not present in the live timeline.
  • VList hover transform: fix a CSS transform regression that caused hover effects to misalign on timeline items.

perf/timeline-item-memo

  • vListIndices over-invalidation: remove timelineSync.timeline from the vListIndices memo dep array; the [0…N-1] index array only changes when vListItemCount changes, not on every live event's setTimeline({…ct}) spread.
  • Deferred notification jump: defer the event-scoped scrollToIndex jump 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

  • Open busy room: no 3→60 event jump; skeletons show until viewport filled
  • Open room at beginning (no history): no skeletons, instant reveal
  • Open DM / encrypted room: same behaviour
  • Revisit room after navigating away: instant scroll restore, no flash
  • Jump from pin/bookmark/notification: message centred, full ~2 s highlight visible
  • Jump to unread: correct position, no jitter
  • "Jump to Latest" button: no spurious flashes during scroll settle
  • Reconnect / background recovery (TimelineReset): skeletons → content, no wrong-position flash
  • New message while at bottom: auto-scrolls, no drift

Changeset

  • fix: Timeline jitter, flash, scroll drift, and unread dot regression
  • perf: VList indices memo over-invalidation
  • feat: Video aspect ratio hint, skeleton overlay, scroll position cache

Just-Insane and others added 30 commits April 13, 2026 23:58
- 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.
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>
Just-Insane and others added 30 commits April 16, 2026 16:21
…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>
- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant