From cba256828deacfa14f6f1200af6de2ae7214f815 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Thu, 5 Mar 2026 21:10:57 +0100 Subject: [PATCH 1/3] fix(virtual-core): early return in _measureElement for disconnected nodes --- .../e2e/app/{ => measure-element}/index.html | 2 +- .../e2e/app/measure-element/main.tsx | 114 ++++++++++++++++++ .../react-virtual/e2e/app/scroll/index.html | 10 ++ .../e2e/app/{ => scroll}/main.tsx | 0 .../e2e/app/test/measure-element.spec.ts | 42 +++++++ .../react-virtual/e2e/app/test/scroll.spec.ts | 2 +- packages/react-virtual/e2e/app/vite.config.ts | 11 ++ packages/react-virtual/playwright.config.ts | 2 +- packages/virtual-core/src/index.ts | 9 +- 9 files changed, 186 insertions(+), 6 deletions(-) rename packages/react-virtual/e2e/app/{ => measure-element}/index.html (71%) create mode 100644 packages/react-virtual/e2e/app/measure-element/main.tsx create mode 100644 packages/react-virtual/e2e/app/scroll/index.html rename packages/react-virtual/e2e/app/{ => scroll}/main.tsx (100%) create mode 100644 packages/react-virtual/e2e/app/test/measure-element.spec.ts diff --git a/packages/react-virtual/e2e/app/index.html b/packages/react-virtual/e2e/app/measure-element/index.html similarity index 71% rename from packages/react-virtual/e2e/app/index.html rename to packages/react-virtual/e2e/app/measure-element/index.html index 6d5c94aec..56f418f61 100644 --- a/packages/react-virtual/e2e/app/index.html +++ b/packages/react-virtual/e2e/app/measure-element/index.html @@ -5,6 +5,6 @@
- + diff --git a/packages/react-virtual/e2e/app/measure-element/main.tsx b/packages/react-virtual/e2e/app/measure-element/main.tsx new file mode 100644 index 000000000..6775b39fc --- /dev/null +++ b/packages/react-virtual/e2e/app/measure-element/main.tsx @@ -0,0 +1,114 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { useVirtualizer } from '@tanstack/react-virtual' + +interface Item { + id: string + label: string +} + +const INITIAL_ITEMS: Array = [ + { id: 'item-a', label: 'A' }, + { id: 'item-b', label: 'B' }, + { id: 'item-c', label: 'C' }, +] + +const App = () => { + const parentRef = React.useRef(null) + const [items, setItems] = React.useState(INITIAL_ITEMS) + const [expandedId, setExpandedId] = React.useState(null) + + const rowVirtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 36, + getItemKey: (index) => items[index].id, + }) + + const toggleExpand = (id: string) => { + setExpandedId((prev) => (prev === id ? null : id)) + } + + const deleteItem = (id: string) => { + setItems((prev) => prev.filter((item) => item.id !== id)) + if (expandedId === id) { + setExpandedId(null) + } + } + + return ( +
+
+
+ {rowVirtualizer.getVirtualItems().map((v) => { + const item = items[v.index] + const isExpanded = expandedId === item.id + + return ( +
+
+ Row {item.label} + + +
+ {isExpanded && ( +
+ Expanded content for {item.label} +
+ )} +
+ ) + })} +
+
+
{rowVirtualizer.getTotalSize()}
+
+ ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/packages/react-virtual/e2e/app/scroll/index.html b/packages/react-virtual/e2e/app/scroll/index.html new file mode 100644 index 000000000..56f418f61 --- /dev/null +++ b/packages/react-virtual/e2e/app/scroll/index.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/packages/react-virtual/e2e/app/main.tsx b/packages/react-virtual/e2e/app/scroll/main.tsx similarity index 100% rename from packages/react-virtual/e2e/app/main.tsx rename to packages/react-virtual/e2e/app/scroll/main.tsx diff --git a/packages/react-virtual/e2e/app/test/measure-element.spec.ts b/packages/react-virtual/e2e/app/test/measure-element.spec.ts new file mode 100644 index 000000000..6973fdbb5 --- /dev/null +++ b/packages/react-virtual/e2e/app/test/measure-element.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from '@playwright/test' + +test('positions items correctly after expand → collapse → delete → expand', async ({ + page, +}) => { + await page.goto('/measure-element/') + + // All 3 items visible at ~36px each + await expect(page.locator('[data-testid="item-a"]')).toBeVisible() + await expect(page.locator('[data-testid="item-b"]')).toBeVisible() + await expect(page.locator('[data-testid="item-c"]')).toBeVisible() + + // Step 1: Expand A → should grow to ~160px + await page.click('[data-testid="expand-item-a"]') + await expect(page.locator('[data-testid="content-item-a"]')).toBeVisible() + + // Step 2: Collapse A → back to ~36px + await page.click('[data-testid="expand-item-a"]') + await expect(page.locator('[data-testid="content-item-a"]')).not.toBeVisible() + + // Step 3: Delete A + await page.click('[data-testid="delete-item-a"]') + await expect(page.locator('[data-testid="item-a"]')).not.toBeVisible() + + // Step 4: Expand B → should grow to ~160px + await page.click('[data-testid="expand-item-b"]') + await expect(page.locator('[data-testid="content-item-b"]')).toBeVisible() + + // Wait for ResizeObserver to measure the expanded B + await page.waitForTimeout(200) + + // C should be positioned after the expanded B, not overlapping it + const bBox = await page.locator('[data-testid="item-b"]').boundingBox() + const cBox = await page.locator('[data-testid="item-c"]').boundingBox() + + expect(bBox).not.toBeNull() + expect(cBox).not.toBeNull() + + // C's top should be at or after B's bottom (with no overlap) + const bBottom = bBox!.y + bBox!.height + expect(cBox!.y).toBeGreaterThanOrEqual(bBottom - 1) // 1px tolerance +}) diff --git a/packages/react-virtual/e2e/app/test/scroll.spec.ts b/packages/react-virtual/e2e/app/test/scroll.spec.ts index 65ba73b3a..c1d8de06f 100644 --- a/packages/react-virtual/e2e/app/test/scroll.spec.ts +++ b/packages/react-virtual/e2e/app/test/scroll.spec.ts @@ -19,7 +19,7 @@ const check = () => { } test('scrolls to index 1000', async ({ page }) => { - await page.goto('/') + await page.goto('/scroll/') await page.click('#scroll-to-1000') // Wait for scroll effect (including retries) diff --git a/packages/react-virtual/e2e/app/vite.config.ts b/packages/react-virtual/e2e/app/vite.config.ts index f1479d13e..b4fc9c771 100644 --- a/packages/react-virtual/e2e/app/vite.config.ts +++ b/packages/react-virtual/e2e/app/vite.config.ts @@ -5,6 +5,17 @@ import react from '@vitejs/plugin-react' export default defineConfig({ root: __dirname, plugins: [react()], + build: { + rollupOptions: { + input: { + scroll: path.resolve(__dirname, 'scroll/index.html'), + 'measure-element': path.resolve( + __dirname, + 'measure-element/index.html', + ), + }, + }, + }, resolve: { alias: { '@tanstack/react-virtual': path.resolve(__dirname, '../../src/index'), diff --git a/packages/react-virtual/playwright.config.ts b/packages/react-virtual/playwright.config.ts index 6e9f9a5fb..ccd92d03f 100644 --- a/packages/react-virtual/playwright.config.ts +++ b/packages/react-virtual/playwright.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ }, webServer: { command: `VITE_SERVER_PORT=${PORT} vite build --config e2e/app/vite.config.ts && VITE_SERVER_PORT=${PORT} vite preview --config e2e/app/vite.config.ts --port ${PORT}`, - url: baseURL, + url: `${baseURL}/scroll/`, reuseExistingServer: !process.env.CI, stdout: 'pipe', }, diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 331dd29e9..42843fb37 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -863,6 +863,11 @@ export class Virtualizer< node: TItemElement, entry: ResizeObserverEntry | undefined, ) => { + if (!node.isConnected) { + this.observer.unobserve(node) + return + } + const index = this.indexFromElement(node) const item = this.measurementsCache[index] if (!item) { @@ -879,9 +884,7 @@ export class Virtualizer< this.elementsCache.set(key, node) } - if (node.isConnected) { - this.resizeItem(index, this.options.measureElement(node, entry, this)) - } + this.resizeItem(index, this.options.measureElement(node, entry, this)) } resizeItem = (index: number, size: number) => { From 029dad9aa1ce6884f168d9fd233819312378efd8 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Thu, 5 Mar 2026 21:18:26 +0100 Subject: [PATCH 2/3] Add changes --- .changeset/ninety-games-accept.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/ninety-games-accept.md diff --git a/.changeset/ninety-games-accept.md b/.changeset/ninety-games-accept.md new file mode 100644 index 000000000..bdf80227c --- /dev/null +++ b/.changeset/ninety-games-accept.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +fix(virtual-core): early return in _measureElement for disconnected nodes From d75f9c9143df90f0a6deb65bf8fe92172da4805e Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:19:09 +0000 Subject: [PATCH 3/3] ci: apply automated fixes --- .changeset/ninety-games-accept.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/ninety-games-accept.md b/.changeset/ninety-games-accept.md index bdf80227c..39c2f59f9 100644 --- a/.changeset/ninety-games-accept.md +++ b/.changeset/ninety-games-accept.md @@ -2,4 +2,4 @@ '@tanstack/virtual-core': patch --- -fix(virtual-core): early return in _measureElement for disconnected nodes +fix(virtual-core): early return in \_measureElement for disconnected nodes