Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ninety-games-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/virtual-core': patch
---

fix(virtual-core): early return in \_measureElement for disconnected nodes
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
<script type="module" src="./main.tsx"></script>
</body>
</html>
114 changes: 114 additions & 0 deletions packages/react-virtual/e2e/app/measure-element/main.tsx
Original file line number Diff line number Diff line change
@@ -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<Item> = [
{ id: 'item-a', label: 'A' },
{ id: 'item-b', label: 'B' },
{ id: 'item-c', label: 'C' },
]

const App = () => {
const parentRef = React.useRef<HTMLDivElement>(null)
const [items, setItems] = React.useState(INITIAL_ITEMS)
const [expandedId, setExpandedId] = React.useState<string | null>(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 (
<div>
<div
ref={parentRef}
id="scroll-container"
style={{ height: 400, overflow: 'auto' }}
>
<div
style={{
height: rowVirtualizer.getTotalSize(),
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((v) => {
const item = items[v.index]
const isExpanded = expandedId === item.id

return (
<div
key={item.id}
data-testid={item.id}
ref={rowVirtualizer.measureElement}
data-index={v.index}
style={{
position: 'absolute',
top: 0,
left: 0,
transform: `translateY(${v.start}px)`,
width: '100%',
}}
>
<div
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
padding: 4,
}}
>
<span>Row {item.label}</span>
<button
data-testid={`expand-${item.id}`}
onClick={() => toggleExpand(item.id)}
>
{isExpanded ? 'Collapse' : 'Expand'}
</button>
<button
data-testid={`delete-${item.id}`}
onClick={() => deleteItem(item.id)}
>
Delete
</button>
</div>
{isExpanded && (
<div
data-testid={`content-${item.id}`}
style={{
height: 124,
background: '#eee',
padding: 8,
}}
>
Expanded content for {item.label}
</div>
)}
</div>
)
})}
</div>
</div>
<div data-testid="total-size">{rowVirtualizer.getTotalSize()}</div>
</div>
)
}

ReactDOM.createRoot(document.getElementById('root')!).render(<App />)
10 changes: 10 additions & 0 deletions packages/react-virtual/e2e/app/scroll/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
42 changes: 42 additions & 0 deletions packages/react-virtual/e2e/app/test/measure-element.spec.ts
Original file line number Diff line number Diff line change
@@ -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
})
2 changes: 1 addition & 1 deletion packages/react-virtual/e2e/app/test/scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions packages/react-virtual/e2e/app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
2 changes: 1 addition & 1 deletion packages/react-virtual/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
9 changes: 6 additions & 3 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) => {
Expand Down
Loading