Skip to content

fix(timeline): stable refs, scroll guard, and listener leak fixes#279

Open
Just-Insane wants to merge 3 commits intoSableClient:devfrom
Just-Insane:fix/timeline-all-fixes
Open

fix(timeline): stable refs, scroll guard, and listener leak fixes#279
Just-Insane wants to merge 3 commits intoSableClient:devfrom
Just-Insane:fix/timeline-all-fixes

Conversation

@Just-Insane
Copy link
Contributor

Five related fixes for scroll stability and event-listener accumulation triggered by sync error/retry cycles.

Stable callback ref pattern for room event listener hooks (1bd5281c)
eventsLength was included in the useCallback deps of onRangeChange, causing a cascade: every new event → new onRangeChange → new paginate fn → new IntersectionObserver (and a stale one firing in the brief overlap window) → spurious paginate(Forward) calls with an old range. Fixed by storing eventsLength in a ref and removing it from the dep array.

Don't force scroll-to-bottom on sync gap when scrolled up (832f597d)
When adaptive sliding-sync bulk-loads historical events the large jump in eventsLength causes a brief DOM layout shift → IntersectionObserver fires a spurious isIntersecting=true for the bottom anchor → the "stay at bottom" effect resets the user's scroll position even if they were reading old messages. Fixed by tracking prevEventsLengthRef and only running the effect when timeline.range.end was already tracking the live end before the jump.

Eliminate LocalEchoUpdated and useLiveEventArrive listener churn from unreadInfo state changes (76fa7f30)
unreadInfo (a piece of React state) was included in the useCallback dep array for useLiveEventArrive and in the LocalEchoUpdated useEffect dep array. unreadInfo transitions nearly every received message, causing both closures to be recreated and — in the case of LocalEchoUpdated which has no stable-ref wrapper — the listener to be detached and re-attached every time. Fixed by introducing unreadInfoRef alongside the existing readUptoEventIdRef and removing unreadInfo from both dep arrays.

Fix MaxListeners accumulation in CallEmbedRoomState.events leak (76fa7f30)
CallEmbed.start() registered four MatrixClient listeners using .bind(this) inline, and dispose() tried to remove them with fresh .bind(this) calls. Every .bind() creates a new function object, so the removeListener was a no-op and listeners leaked on every call embed creation (51 RoomState.events listeners). Fixed by binding once inside start(), capturing the four refs as local consts, and pushing a single disposable for cleanup.

Fix MaxListeners accumulation in useCallSignalingsession_ended / RoomState.events leak (76fa7f30)
The main useEffect in useCallSignaling included mutedRoomId, playRinging, stopRinging, and playOutgoingRinging in its dep array. mutedRoomId toggles on every call session end, causing the effect to re-run and re-register the SessionEnded and RoomState.events listeners in rapid succession during sync retry cycles (11 session_ended listeners). Fixed by storing all four volatile values in stable refs and tightening the dep array to [mx, mDirects, setMutedRoomId].

Closes the MaxListeners warnings reported in the Sentry bug:

Possible EventEmitter memory leak detected. 11 session_ended listeners added.
Possible EventEmitter memory leak detected. 51 RoomState.events listeners added.

Supersedes #277 and #278.

Evie Gauthier and others added 3 commits March 15, 2026 01:27
When a sliding-sync limited response fires RoomEvent.TimelineReset (or
the app fires RoomEvent.TimelineRefresh) the useLiveTimelineRefresh
callback was unconditionally calling setAtBottom(true) and incrementing
scrollToBottomRef, scrolling the user to the bottom even if they had
scrolled up to read history.

Three coordinated changes:

1. Add RoomEvent.TimelineReset handler to useLiveTimelineRefresh.
   The SDK emits TimelineReset on the EventTimelineSet (not the Room)
   when a limited sync response replaces the live EventTimeline. Without
   this listener the stored linkedTimelines reference the old detached
   chain; back-pagination silently no-ops, freezing the room.

2. Gate the viewport scroll on atBottomRef.current (prior position).
   Capture wasAtBottom before calling setTimeline(getInitialTimeline).
   Only call setAtBottom(true) and increment scrollToBottomRef when the
   user was already at the bottom. When scrolled up we still reinit the
   timeline (the old chain is gone) but avoid the forced scroll.

3. Add timelineJustResetRef to allow the self-heal effect to run when
   atBottom=false after a reset. The SDK fires TimelineReset before
   populating the fresh timeline, so getInitialTimeline sees range.end=0.
   Without the atBottom guard bypass the range stays at {0,0} as events
   arrive, leaving the virtual paginator rendering nothing. The ref is
   cleared on first successful heal, after which normal atBottom-gated
   behaviour resumes.
useLiveEventArrive, useRelationUpdate, useLiveTimelineRefresh, and
useThreadUpdate each passed their callback argument into the useEffect
dependency array. This caused the listener to be removed and re-added
every time the callback identity changed (e.g. when unreadInfo toggled
or when any other parent-scope value included in the useCallback deps
changed).

Under normal conditions React's synchronous effect cleanup guarantees
one listener at a time. But during sliding-sync error/retry cycles the
client emits MaxListenersExceededWarning (11 session_ended, 51
RoomState.events) confirming that listeners accumulate. When the
callbacks change fast enough — or React defers cleanup in concurrent
mode — multiple handleTimelineEvent instances are live simultaneously.
Each one independently calls setTimeline({ range: start+1, end+1 }) for
the same arriving event, producing the spurious scroll jumps reported.

Fix: use the stable callback ref pattern in all four hooks.
- Add a ref (onArriveRef / onRelationRef / onRefreshRef / onUpdateRef)
- Assign ref.current = callback on every render (always up-to-date)
- Remove the callback from the useEffect dep array (effect runs only
  when `room` changes)
- Inside handlers, delegate via ref.current() instead of the captured
  closure value

The event listener identity is now stable for the entire lifetime of
each room mount. Listener counts stay at 1 regardless of how many
re-renders occur or how many sync error/retry cycles fire.
- Add unreadInfoRef in RoomTimeline; remove unreadInfo from useLiveEventArrive
  callback deps and LocalEchoUpdated effect deps to stop per-message listener churn
- Fix CallEmbed .bind(this) leak: bind handlers once in start(), push cleanup to
  disposables so dispose() correctly removes all listeners
- Add stable refs in useCallSignaling for mutedRoomId/ring callbacks; tighten
  effect deps to [mx, mDirects, setMutedRoomId] to prevent SessionEnded listener
  accumulation during sync retry cycles
@Just-Insane Just-Insane requested a review from a team March 15, 2026 05:28
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