-
-
Notifications
You must be signed in to change notification settings - Fork 423
Open
Description
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:
What should happen is this:
(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
measurementsCacheappropriately (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
_measureElementcallback - 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.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels