Skip to content

perf(timeline): memoize VList timeline items to prevent mass re-renders#660

Open
Just-Insane wants to merge 7 commits intoSableClient:devfrom
Just-Insane:perf/timeline-item-memo
Open

perf(timeline): memoize VList timeline items to prevent mass re-renders#660
Just-Insane wants to merge 7 commits intoSableClient:devfrom
Just-Insane:perf/timeline-item-memo

Conversation

@Just-Insane
Copy link
Copy Markdown
Contributor

Description

Wraps each VList timeline item in React.memo so that only the items whose props actually changed re-render when state updates occur (e.g. typing indicators arriving, read receipts updating, or new messages landing while the user is scrolled away from the bottom).

Props passed to each item beyond data and renderRefisHighlighted, isEditing, isReplying, isOpenThread, settingsEpoch — exist solely to give React.memo's shallow-equality comparator fine-grained control: changing any of them causes only the one affected item to re-render rather than the entire visible list.

Also makes mutationVersion optional (default 0) in UseProcessedTimelineOptions so call sites that don't participate in the mutation tracking (e.g. ThreadDrawer) continue to compile without changes.

Fixes #

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings

AI disclosure:

  • Fully AI generated (explain what all the generated code does in moderate detail).

useProcessedTimeline.ts — added a stableRefsCache (a useRef<Map<string, ProcessedEvent>>) that keeps the previous render's ProcessedEvent objects alive. On each useMemo run, if mutationVersion hasn't changed, events whose identity hasn't changed are returned from the cache (same object reference) rather than newly constructed. This means React.memo sees no prop change for stable items and skips their render.

RoomTimeline.tsxTimelineItem is a React.memo-wrapped component declared outside the parent function so it is never re-created. A renderFnRef ref captures the render function so TimelineItem itself only needs a stable ref in props, not the function. settingsEpoch is a useRef({}).current that is replaced with {} whenever any layout/display setting changes, forcing all visible items to pick up the new settings in one pass.

- 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.
@Just-Insane Just-Insane requested review from 7w1 and hazre as code owners April 10, 2026 02:52
@Just-Insane
Copy link
Copy Markdown
Contributor Author

Added a follow-up fix in the latest commit: setAtBottom(true) is now called synchronously when scrolling to the bottom in the 80ms timer and pendingReadyRef recovery paths, immediately before setIsReady(true). This prevents the "Jump to Latest" button from flashing for one render cycle between isReady becoming true and VList's async onScroll confirming the bottom position.

…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.
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