Skip to content

ResizeObserver feedback loop in the virtualized timeline logs "undelivered notifications" continuously #5717

Description

@GeneralJah215

Describe the bug

useVirtualizedViewportResize observes the Virtua scroller and, in a fully
synchronous callback, performs a scroll write that resizes that same element in
the same delivery pass. The browser detects the re-entrancy and logs
ResizeObserver loop completed with undelivered notifications.

desktop/src/features/messages/ui/useVirtualizedViewportResize.ts:27-36:

const observer = new ResizeObserver(() => {
  if (
    shouldSettleVirtualizedViewportResize({
      virtualizerAtBottom: virtualizerAtBottomRef.current,
    })
  ) {
    settleAtBottom();
  }
});
observer.observe(container);

Three things combine:

  1. The callback takes no entries, so it cannot compare sizes. There is no
    delta guard — a resize that round-trips to the same layout still settles.
  2. There is no requestAnimationFrame defer, so the write stays inside the
    observation pass.
  3. Its only gate, virtualizerAtBottomRef, is React.useRef(true) at
    useAnchoredScroll.ts:174. It is armed at rest, before Virtua has reported
    anything.

The write path: settleAtBottom is settle at
useVirtualizedBottomSettle.ts:122-126, which calls cancelFrame() and then
pinToBottom() directly, deliberately bypassing the rAF-throttled
schedulePinToBottom at :50-56. pinToBottom calls
listRef.current?.scrollToIndex(lastIndex, { align: "end" }). That shifts
Virtua's rendered range, new rows are measured, the inner sizing element's height
changes and the scrollbar can toggle — resizing the observed element, at a deeper
depth in the same pass.

There is a second observer at useVirtualizedBottomSettle.ts:116-118 watching
both the content element and the scroller. That one is rAF-deferred so it throws
no error, but it free-runs a frame of work while bottom intent is armed.

Steps to reproduce

  1. Open a channel with a live, actively streaming timeline. Leave the view at the
    bottom.
  2. Watch the renderer console.

Expected behavior

Staying pinned to the bottom of a live channel should not require a scroll write
inside a ResizeObserver delivery pass, and a resize that does not change geometry
should do nothing at all.

Version and platform

Logs / additional context

ResizeObserver loop completed with undelivered notifications

Repeated every few seconds, for hours, on an otherwise idle window.

Measured alongside it: the GPU process held 97.1% of one logical core while
visible and 18.5% while minimized
.

That visible-versus-minimized split matches the symptom in #2959. I suspect this
is one contributor there and the .buzz-shimmer animation is another; I have
commented on that issue separately with the shimmer measurement. I have not
isolated how much of the GPU load belongs to each, so please treat the
attribution as unproven — the mechanism described above is what I am confident
about.

The existing test, useVirtualizedViewportResize.test.mjs, only exercises the
pure shouldSettleVirtualizedViewportResize predicate. It never mounts the hook
or constructs a ResizeObserver, so it cannot see this.

On a fix. The shape I would propose is to read entries[0].contentRect and
skip when width and height are both within a sub-pixel epsilon of the previous
observed size, treating unknown geometry as changed so the guard fails safe; and
to defer the write by one requestAnimationFrame, coalescing and cancelling on
unmount. Not a timer — a setTimeout would be a guess at how long layout takes,
whereas a frame boundary is the property that actually matters.

The thing that must not break is that staying pinned to the bottom of a live
channel is the entire point of this machinery. A change that stops the loop by no
longer scrolling would be a regression, so both directions need testing: a new
message with the reader at the bottom still pins, and a resize with
virtualizerAtBottomRef.current === false produces no scroll write.

Happy to open a PR if that direction sounds right.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions