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:
- 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.
- There is no
requestAnimationFrame defer, so the write stays inside the
observation pass.
- 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
- Open a channel with a live, actively streaming timeline. Leave the view at the
bottom.
- 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.
Describe the bug
useVirtualizedViewportResizeobserves the Virtua scroller and, in a fullysynchronous 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:Three things combine:
entries, so it cannot compare sizes. There is nodelta guard — a resize that round-trips to the same layout still settles.
requestAnimationFramedefer, so the write stays inside theobservation pass.
virtualizerAtBottomRef, isReact.useRef(true)atuseAnchoredScroll.ts:174. It is armed at rest, before Virtua has reportedanything.
The write path:
settleAtBottomissettleatuseVirtualizedBottomSettle.ts:122-126, which callscancelFrame()and thenpinToBottom()directly, deliberately bypassing the rAF-throttledschedulePinToBottomat:50-56.pinToBottomcallslistRef.current?.scrollToIndex(lastIndex, { align: "end" }). That shiftsVirtua'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-118watchingboth 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
bottom.
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
main. All three files unchanged since at least8342dfcc5;useVirtualizedViewportResize.tswas last modified in Polish composer activity layout and transitions #3151.Logs / additional context
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-shimmeranimation is another; I havecommented 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 thepure
shouldSettleVirtualizedViewportResizepredicate. It never mounts the hookor constructs a
ResizeObserver, so it cannot see this.On a fix. The shape I would propose is to read
entries[0].contentRectandskip 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 onunmount. Not a timer — a
setTimeoutwould 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 === falseproduces no scroll write.Happy to open a PR if that direction sounds right.