Skip to content

ResizeObserver loop error since virtual-core 3.19.0: the synchronous re-render mounts and unmounts rows during the ResizeObserver delivery #1345

Closed

Description

@passel123

Describe the bug

Since @tanstack/virtual-core 3.19.0 (@tanstack/react-virtual 3.14.15), a list with dynamic row heights (ref={virtualizer.measureElement}) fires ResizeObserver loop completed with undelivered notifications when rows above the viewport change height while the list is scrolled down. For example, the user changes the font size, or the rows are measured for the first time after a jump into the middle of the list. Nothing visible goes wrong, because the browser delivers the skipped entry one frame later. But the error reaches window.onerror, so error trackers and global error handlers record it each time.

What happens, as traced with an instrumented ResizeObserver (a frame counter, plus MutationObserver.takeRecords() before and after each callback):

  1. Rows above the fold change size. resizeItem compensates scrollTop through applyScrollAdjustment, so batch.sync becomes true.
  2. After every entry is measured (_resizeBatch), the observer callback calls notify(true). React-virtual turns that into flushSync(rerender) inside the ResizeObserver callback. That render already sees all the new sizes, so the range shrinks, and React unmounts one to three rows synchronously, still inside the callback.
  3. React calls the ref with null before it removes the host node. measureElement(null) only unobserves cached nodes that are already disconnected, so the row being removed stays observed.
  4. Once detached, the row's size is 0×0 and its depth is 1. That depth is never deeper than the depth just delivered, so the browser skips the observation and reports the loop error. In the next frame it delivers the entry (isConnected === false, block size 0), and measureEntry unobserves it.

With 3.18.0 the same callback also rendered synchronously, once per adjusting entry. That render ran before the rows further down were re-measured, so the range shrank only in the later asynchronous render, outside the ResizeObserver delivery. Measuring the whole batch first (the fix for #1036) moved the shrink into the callback.

The opposite direction has the same root. When rows above the fold get smaller, the synchronous render grows the range and mounts rows inside the callback. measureElement observes them right away, at the same depth as the rows just delivered. The browser only gathers deeper targets in the same round, so it skips them and reports the same error. In our app this case stayed silent, because the scroll element changed size in the same round: its observer delivered a shallower target and lowered the threshold. In the example below it does not change size, and the error shows.

Your minimal, reproducible example

A single HTML file, below. Open it with ?v=3.14.22, scroll to the middle, then set --scale to 1.25 on <html>. The query ?fix=1 turns on the workaround described further down.

Measured with Playwright 1.61.1 (Chromium and WebKit), counting loop errors per step (Chromium fires them on window, WebKit reports them as uncaught errors):

react-virtual (virtual-core) rows grow (rows unmounted) rows shrink back (rows mounted)
3.14.14 (3.18.0) 0 0
3.14.15 (3.19.0) 1 1
3.14.22 (3.19.7) 1 1
3.14.22 + workaround below 0 1

The same numbers in both engines.

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>virtual-core ResizeObserver loop repro</title>
    <style>
      :root {
        --scale: 1;
      }
      body {
        margin: 0;
        font-family: system-ui, sans-serif;
      }
      #scroll {
        height: 600px;
        overflow: auto;
      }
      .row {
        box-sizing: border-box;
        padding: 4px 8px;
        border-top: 1px solid #ddd;
        font-size: calc(16px * var(--scale));
        line-height: 1.5;
      }
    </style>
  </head>
  <body>
    <div id="root"></div>
    <script type="module">
      // ?v=<react-virtual version>&fix=1 (fix = defer the ref-null cleanup to a microtask)
      const params = new URLSearchParams(location.search);
      const v = params.get("v") ?? "3.14.22";
      const fix = params.get("fix") === "1";
      const React = await import("https://esm.sh/react@19.2.0");
      const { createRoot } = await import("https://esm.sh/react-dom@19.2.0/client?deps=react@19.2.0");
      const { useVirtualizer } = await import(
        `https://esm.sh/@tanstack/react-virtual@${v}?deps=react@19.2.0,react-dom@19.2.0`
      );

      window.__loops = 0;
      window.addEventListener("error", (e) => {
        if (String(e.message).includes("ResizeObserver loop")) window.__loops++;
      });

      const h = React.createElement;
      function List() {
        const ref = React.useRef(null);
        const virtualizer = useVirtualizer({
          count: 2000,
          getScrollElement: () => ref.current,
          estimateSize: () => 33,
          overscan: 8,
        });
        const measure = React.useCallback(
          (el) => {
            virtualizer.measureElement(el);
            if (fix && el === null) queueMicrotask(() => virtualizer.measureElement(null));
          },
          [virtualizer],
        );
        return h(
          "div",
          { id: "scroll", ref },
          h(
            "div",
            { style: { height: virtualizer.getTotalSize(), position: "relative" } },
            virtualizer.getVirtualItems().map((item) =>
              h(
                "div",
                {
                  key: item.key,
                  "data-index": item.index,
                  ref: measure,
                  className: "row",
                  style: {
                    position: "absolute",
                    top: 0,
                    left: 0,
                    width: "100%",
                    transform: `translateY(${item.start}px)`,
                  },
                },
                `Row ${item.index}`,
              ),
            ),
          ),
        );
      }
      createRoot(document.getElementById("root")).render(h(List));
    </script>
  </body>
</html>

Steps to reproduce

  1. Render a dynamic-height list with measureElement (see the example: 2000 rows, overscan: 8).
  2. Scroll to the middle (scrollTop = scrollHeight / 2).
  3. Make the rows taller, for example by increasing the font size.
  4. ResizeObserver loop completed with undelivered notifications fires on window.

Expected behavior

No ResizeObserver loop error, as in 3.18.0.

How often does this bug happen?

Every time

Platform

macOS 14 (arm64); Chromium and WebKit as shipped with Playwright 1.61.1; React and react-dom 19.3.1 in our app, 19.2.0 in the example.

tanstack-virtual version

@tanstack/react-virtual 3.14.15 to 3.14.22 (virtual-core 3.19.0 to 3.19.7). The relevant code is unchanged in 3.19.8.

TypeScript version

Not relevant.

Additional context

Workaround (userland): defer the ref-null cleanup to a microtask. A microtask runs after each ResizeObserver callback and before the browser gathers the next observations. At that point the row has been removed, and measureElement(null) unobserves it:

const pending = useRef(false)
const measureRow = useCallback(
  (el: Element | null) => {
    virtualizer.measureElement(el)
    if (el !== null || pending.current) return
    pending.current = true
    queueMicrotask(() => {
      pending.current = false
      virtualizer.measureElement(null)
    })
  },
  [virtualizer],
)
// <div ref={measureRow} data-index={item.index} …>

Calling measureElement(null) synchronously does not help, because the node is still connected at that point. The workaround covers only the unmount case (see the table).

Possible fix in the library (unmount case): after the batched notify(batch.sync) in the observer callback, drop the cached nodes that the synchronous render detached. I tried this in the built dist/esm/index.js of 3.19.7, with plain ref={virtualizer.measureElement} in our app. Our probe went from 4 loop errors (Chromium) and 2 (WebKit) down to 0 in both.

if (batch.notify) {
  this.notify(batch.sync)
  this.elementsCache.forEach((node, key) => {
    if (!node.isConnected) {
      this.observer.unobserve(node)
      this.elementsCache.delete(key)
    }
  })
  // … _noteClampMax(), _flushHoldIfReady(true), _retryClampedAdjustment()
}

measureElement(null) could also defer its sweep to a microtask, which would cover removals in any synchronous render.

Mount case: neither change helps here. The new row is observed during the delivery, and the browser decides at the next gather whether it is deeper than what was just delivered. Deferring observe() to a microtask is still too early. One untested idea: when measureElement attaches a node during the batched synchronous notify, measure it right away as it does now, but call observe() only after the current delivery, for example in the next animation frame.

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