Skip to content

A "surviving" item within the virtualizer gets attached to one deleted from the DOM; fails to updateΒ #1133

@michaelfromyeg

Description

@michaelfromyeg

Describe the bug

Given a setup like:

- A
- B
- C

where each of A/B/C can be expanded and collapsed; if we

  • delete A
  • then expand B

we see that the expanded B starts overlapping with C (!) instead of C shifting further down:

Image

What should happen is this:

Image

(Apologies the screenshots say 3.13.0; I believe this is still an issue on latest, which at the time of writing is 3.13.19.)

That's the bug. I've thrown together this repo to reproduce the issue; this also includes patches to tanstack/virtual with some additional logging that pinpoint what's going on (I think).

My theory is that:

  • after deleting the first row (A), we update the measurementsCache appropriately (so the item at index 0 is B, index 1 is C)
  • the ResizeObserver (surprisingly) fires an event when A is removed from the DOM, saying A's size is now 0
  • this fires the _measureElement callback
  • which has this logic:
 // reads data-index from the DOM node
 const index = this.indexFromElement(node);
 // looks up which item is at that index
 const item = this.measurementsCache[index];
 if (!item) return;

 const key = item.key;
 const prevNode = this.elementsCache.get(key);
 if (prevNode !== node) { // "the element for this item changed"
  if (prevNode) this.observer.unobserve(prevNode); // stop watching the OLD element
  this.observer.observe(node); // start watching the NEW element
  this.elementsCache.set(key, node);  // update the cache
 }
  • the index of A is 0, but 0 in measurementsCache is B -- so prevNode is B's div, which is not equal to the current node (A's div), so we update this.elementsCache.set(key, node);, mapping B's key to A's div
  • then, when changing the size of B, those events are missed

Your minimal, reproducible example

https://github.com/michaelfromyeg/tanstack-virtual-repro

Steps to reproduce

Given:

/**
 * Minimal reproduction of a bug in @tanstack/react-virtual v3.13.0.
 *
 * Bug: When an item is removed from a virtualized list, the ResizeObserver
 * fires a final callback for the removed item's DOM element. The element's
 * stale `data-index` attribute can collide with a surviving item's new index,
 * causing `_measureElement` to swap the elementsCache entry β€” unobserving the
 * live element and observing the dead one. After this, the live element's
 * ResizeObserver is permanently lost. Any subsequent size changes to that
 * element are never reported to the virtualizer.
 *
 * Steps to reproduce:
 *   1. Click "Expand A" β€” A's row grows, virtualizer updates totalSize. βœ…
 *   2. Click "Collapse A" β€” A's row shrinks back. βœ…
 *   3. Click "Delete A" β€” A is removed, B shifts from index 1 to index 0.
 *      The ResizeObserver fires for A's dead div with data-index=0,
 *      which now matches B. The virtualizer unobserves B's real div.
 *   4. Click "Expand B" β€” B's row grows, but the virtualizer never learns
 *      about it. The container stays at the collapsed height β†’ OVERLAP.
 *
 * Expected: After step 4, the container should grow to fit B's expanded content.
 * Actual: The container height stays at ~35px. B's expanded content overflows.
 */

import { useVirtualizer } from "@tanstack/react-virtual";
import { useCallback, useRef, useState } from "react";

type Item = {
  id: string;
  label: string;
  color: string;
  expandedColor: string;
};

const INITIAL_ITEMS: Item[] = [
  { id: "item-a", label: "A", color: "#fee2e2", expandedColor: "#fca5a5" },
  { id: "item-b", label: "B", color: "#dbeafe", expandedColor: "#93c5fd" },
  { id: "item-c", label: "C", color: "#fef9c3", expandedColor: "#fde047" },
];

export function App() {
  const [items, setItems] = useState(INITIAL_ITEMS);
  const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());

  const toggleExpand = useCallback((id: string) => {
    const item = INITIAL_ITEMS.find((i) => i.id === id);
    setExpandedIds((prev) => {
      const expanding = !prev.has(id);
      console.log(
        `%c[APP] ${expanding ? "EXPAND" : "COLLAPSE"} ${item?.label ?? id}`,
        "color: magenta; font-weight: bold",
      );
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
      } else {
        next.add(id);
      }
      return next;
    });
  }, []);

  const deleteItem = useCallback((id: string) => {
    const item = INITIAL_ITEMS.find((i) => i.id === id);
    console.log(
      `%c[APP] DELETE ${item?.label ?? id}`,
      "color: red; font-weight: bold",
    );
    setItems((prev) => prev.filter((i) => i.id !== id));
    setExpandedIds((prev) => {
      const next = new Set(prev);
      next.delete(id);
      return next;
    });
  }, []);

  return (
    <div style={{ padding: 24, fontFamily: "system-ui, sans-serif" }}>
      <h2>TanStack Virtual v3.13.0 β€” ResizeObserver Bug</h2>

      <div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
        {items.map((item) => (
          <span key={item.id} style={{ display: "flex", gap: 4 }}>
            <button onClick={() => toggleExpand(item.id)}>
              {expandedIds.has(item.id) ? `Collapse ${item.label}` : `Expand ${item.label}`}
            </button>
            <button onClick={() => deleteItem(item.id)} style={{ color: "red" }}>
              Delete {item.label}
            </button>
          </span>
        ))}
        <button
          onClick={() => setItems(INITIAL_ITEMS)}
          style={{ marginLeft: 16 }}
        >
          Reset
        </button>
      </div>

      <VirtualList
        items={items}
        expandedIds={expandedIds}
      />

      <div
        style={{
          marginTop: 8,
          padding: 8,
          background: "#f0f0f0",
          borderRadius: 4,
          fontSize: 13,
          color: "#666",
        }}
      >
        <strong>Repro steps:</strong> Expand A β†’ Collapse A β†’ Delete A β†’ Expand B.
        <br />
        B's expanded content should be visible, but the container doesn't grow.
      </div>
    </div>
  );
}

function VirtualList({
  items,
  expandedIds,
}: {
  items: Item[];
  expandedIds: Set<string>;
}) {
  const scrollerRef = useRef<HTMLDivElement>(null);

  const getItemKey = useCallback(
    (index: number) => items[index].id,
    [items],
  );

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => scrollerRef.current,
    estimateSize: () => 35,
    getItemKey,
    overscan: 5,
  });

  const totalSize = virtualizer.getTotalSize();
  const virtualItems = virtualizer.getVirtualItems();

  console.log("[VirtualList] render:", {
    items: items.map((i) => i.label),
    totalSize,
    virtualItems: virtualItems.map((row) => ({
      index: row.index,
      key: row.key,
      start: row.start,
      size: row.size,
    })),
  });

  return (
    <div>
      <div style={{ fontSize: 13, marginBottom: 4, color: "#999" }}>
        virtualizer.getTotalSize() = <strong style={{ color: totalSize < 60 && items.some(i => expandedIds.has(i.id)) ? "red" : "#333" }}>{totalSize}px</strong>
        {" | "}items: {items.map((i) => i.label).join(", ")}
      </div>

      <div
        ref={scrollerRef}
        style={{
          height: 400,
          overflow: "auto",
          border: "2px solid #ccc",
          borderRadius: 4,
          position: "relative",
        }}
      >
        <div
          style={{
            height: totalSize,
            width: "100%",
            position: "relative",
          }}
        >
          {virtualItems.map((row) => {
            const item = items[row.index];
            const isExpanded = expandedIds.has(item.id);

            return (
              <div
                key={row.key}
                data-index={row.index}
                ref={virtualizer.measureElement}
                style={{
                  position: "absolute",
                  top: 0,
                  left: 0,
                  width: "100%",
                  transform: `translateY(${row.start}px)`,
                }}
              >
                <div
                  style={{
                    padding: "8px 12px",
                    borderBottom: "1px solid #eee",
                    background: item.color,
                  }}
                >
                  <strong>{item.label}</strong>
                  <span style={{ color: "#666", marginLeft: 8, fontSize: 12 }}>
                    (id: {item.id}, index: {row.index},
                    measured: {row.size}px)
                  </span>
                  {isExpanded && (
                    <div
                      style={{
                        marginTop: 8,
                        padding: 16,
                        background: item.expandedColor,
                        borderRadius: 4,
                        height: 120,
                      }}
                    >
                      Expanded content for <strong>{item.label}</strong>.
                      <br />
                      This makes the row ~160px tall instead of ~35px.
                      <br />
                      If the virtualizer's totalSize doesn't update, this
                      content overflows the container.
                    </div>
                  )}
                </div>
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}
  • Delete the first item
  • Expand the second item
  • Notice the second item overlap the first item

Expected behavior

The content should not overlap.

How often does this bug happen?

Every time

Screenshots or Videos

No response

Platform

  • OS: macOS
  • Browser: Chrome; also Firefox, Electron

tanstack-virtual version

v3.13.19

TypeScript version

v5.9.3

Additional context

No response

Terms & Code of Conduct

  • I agree to follow this project's Code of Conduct
  • I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.

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