Skip to content

Commit 315c65c

Browse files
committed
fix(virtual-core): smooth scrolling for dynamic item sizes
1 parent ff83e94 commit 315c65c

9 files changed

Lines changed: 692 additions & 98 deletions

File tree

examples/react/dynamic/src/main.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ function RowVirtualizerDynamic() {
2626
enabled,
2727
})
2828

29+
React.useEffect(() => {
30+
virtualizer.scrollToIndex(count - 1, { align: 'end' })
31+
}, [])
32+
2933
const items = virtualizer.getVirtualItems()
3034

3135
return (
@@ -40,7 +44,7 @@ function RowVirtualizerDynamic() {
4044
<span style={{ padding: '0 4px' }} />
4145
<button
4246
onClick={() => {
43-
virtualizer.scrollToIndex(count / 2)
47+
virtualizer.scrollToIndex(count / 2, { behavior: 'smooth' })
4448
}}
4549
>
4650
scroll to the middle

packages/react-virtual/e2e/app/scroll/main.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ const App = () => {
3636
>
3737
Scroll to 1000
3838
</button>
39+
<button
40+
id="scroll-to-last"
41+
onClick={() => rowVirtualizer.scrollToIndex(1001)}
42+
>
43+
Scroll to last
44+
</button>
45+
<button
46+
id="scroll-to-0"
47+
onClick={() => rowVirtualizer.scrollToIndex(0)}
48+
>
49+
Scroll to 0
50+
</button>
3951

4052
<div
4153
ref={parentRef}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
</head>
6+
<body>
7+
<div id="root"></div>
8+
<script type="module" src="./main.tsx"></script>
9+
</body>
10+
</html>
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import React from 'react'
2+
import ReactDOM from 'react-dom/client'
3+
import { useVirtualizer } from '@tanstack/react-virtual'
4+
5+
function getRandomInt(min: number, max: number) {
6+
return Math.floor(Math.random() * (max - min + 1)) + min
7+
}
8+
9+
const randomHeight = (() => {
10+
const cache = new Map<string, number>()
11+
return (id: string) => {
12+
const value = cache.get(id)
13+
if (value !== undefined) {
14+
return value
15+
}
16+
const v = getRandomInt(25, 100)
17+
cache.set(id, v)
18+
return v
19+
}
20+
})()
21+
22+
const App = () => {
23+
const parentRef = React.useRef<HTMLDivElement>(null)
24+
25+
const rowVirtualizer = useVirtualizer({
26+
count: 1002,
27+
getScrollElement: () => parentRef.current,
28+
estimateSize: () => 50,
29+
})
30+
31+
return (
32+
<div>
33+
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
34+
<button
35+
id="scroll-to-100"
36+
onClick={() =>
37+
rowVirtualizer.scrollToIndex(100, { behavior: 'smooth' })
38+
}
39+
>
40+
Smooth scroll to 100
41+
</button>
42+
<button
43+
id="scroll-to-500"
44+
onClick={() =>
45+
rowVirtualizer.scrollToIndex(500, { behavior: 'smooth' })
46+
}
47+
>
48+
Smooth scroll to 500
49+
</button>
50+
<button
51+
id="scroll-to-1000"
52+
onClick={() =>
53+
rowVirtualizer.scrollToIndex(1000, { behavior: 'smooth' })
54+
}
55+
>
56+
Smooth scroll to 1000
57+
</button>
58+
<button
59+
id="scroll-to-0"
60+
onClick={() =>
61+
rowVirtualizer.scrollToIndex(0, { behavior: 'smooth' })
62+
}
63+
>
64+
Smooth scroll to 0
65+
</button>
66+
<button
67+
id="scroll-to-500-start"
68+
onClick={() =>
69+
rowVirtualizer.scrollToIndex(500, {
70+
behavior: 'smooth',
71+
align: 'start',
72+
})
73+
}
74+
>
75+
Smooth scroll to 500 (start)
76+
</button>
77+
<button
78+
id="scroll-to-500-center"
79+
onClick={() =>
80+
rowVirtualizer.scrollToIndex(500, {
81+
behavior: 'smooth',
82+
align: 'center',
83+
})
84+
}
85+
>
86+
Smooth scroll to 500 (center)
87+
</button>
88+
</div>
89+
90+
<div
91+
ref={parentRef}
92+
id="scroll-container"
93+
style={{ height: 400, overflow: 'auto' }}
94+
>
95+
<div
96+
style={{
97+
height: rowVirtualizer.getTotalSize(),
98+
position: 'relative',
99+
}}
100+
>
101+
{rowVirtualizer.getVirtualItems().map((v) => (
102+
<div
103+
key={v.key}
104+
data-testid={`item-${v.index}`}
105+
ref={rowVirtualizer.measureElement}
106+
data-index={v.index}
107+
style={{
108+
position: 'absolute',
109+
top: 0,
110+
left: 0,
111+
transform: `translateY(${v.start}px)`,
112+
width: '100%',
113+
}}
114+
>
115+
<div style={{ height: randomHeight(String(v.key)) }}>
116+
Row {v.index}
117+
</div>
118+
</div>
119+
))}
120+
</div>
121+
</div>
122+
</div>
123+
)
124+
}
125+
126+
ReactDOM.createRoot(document.getElementById('root')!).render(<App />)

packages/react-virtual/e2e/app/test/scroll.spec.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,46 @@ test('scrolls to index 1000', async ({ page }) => {
2828
await expect(page.locator('[data-testid="item-1000"]')).toBeVisible()
2929

3030
const delta = await page.evaluate(check)
31-
console.log('bootom element detla', delta)
31+
console.log('bottom element delta', delta)
3232
expect(delta).toBeLessThan(1.01)
3333
})
34+
35+
test('scrolls to last item', async ({ page }) => {
36+
await page.goto('/scroll/')
37+
await page.click('#scroll-to-last')
38+
39+
await page.waitForTimeout(1000)
40+
41+
// Last item (index 1001) should be visible
42+
await expect(page.locator('[data-testid="item-1001"]')).toBeVisible()
43+
44+
// Container should be scrolled to the very bottom
45+
const atBottom = await page.evaluate(() => {
46+
const container = document.querySelector('#scroll-container')
47+
if (!container) throw new Error('Container not found')
48+
return Math.abs(
49+
container.scrollTop + container.clientHeight - container.scrollHeight,
50+
)
51+
})
52+
expect(atBottom).toBeLessThan(1.01)
53+
})
54+
55+
test('scrolls to index 0', async ({ page }) => {
56+
await page.goto('/scroll/')
57+
58+
// First scroll down
59+
await page.click('#scroll-to-1000')
60+
await page.waitForTimeout(1000)
61+
62+
// Then scroll to first item
63+
await page.click('#scroll-to-0')
64+
await page.waitForTimeout(1000)
65+
66+
await expect(page.locator('[data-testid="item-0"]')).toBeVisible()
67+
68+
const scrollTop = await page.evaluate(() => {
69+
const container = document.querySelector('#scroll-container')
70+
return container?.scrollTop ?? -1
71+
})
72+
expect(scrollTop).toBeLessThan(1.01)
73+
})
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { expect, test } from '@playwright/test'
2+
3+
test('smooth scrolls to index 1000', async ({ page }) => {
4+
await page.goto('/smooth-scroll/')
5+
await page.click('#scroll-to-1000')
6+
7+
// Smooth scroll animation is 500ms + reconciliation time
8+
await page.waitForTimeout(2000)
9+
10+
await expect(page.locator('[data-testid="item-1000"]')).toBeVisible()
11+
12+
const delta = await page.evaluate(() => {
13+
const item = document.querySelector('[data-testid="item-1000"]')
14+
const container = document.querySelector('#scroll-container')
15+
if (!item || !container) throw new Error('Elements not found')
16+
17+
const itemRect = item.getBoundingClientRect()
18+
const containerRect = container.getBoundingClientRect()
19+
const scrollTop = container.scrollTop
20+
const top = itemRect.top + scrollTop - containerRect.top
21+
const bottom = top + itemRect.height
22+
const containerBottom = scrollTop + container.clientHeight
23+
return Math.abs(bottom - containerBottom)
24+
})
25+
expect(delta).toBeLessThan(1.01)
26+
})
27+
28+
test('smooth scrolls to index 100', async ({ page }) => {
29+
await page.goto('/smooth-scroll/')
30+
await page.click('#scroll-to-100')
31+
32+
await page.waitForTimeout(2000)
33+
34+
await expect(page.locator('[data-testid="item-100"]')).toBeVisible()
35+
})
36+
37+
test('smooth scrolls to index 0 after scrolling away', async ({ page }) => {
38+
await page.goto('/smooth-scroll/')
39+
40+
// First scroll down
41+
await page.click('#scroll-to-500')
42+
await page.waitForTimeout(2000)
43+
await expect(page.locator('[data-testid="item-500"]')).toBeVisible()
44+
45+
// Then smooth scroll back to top
46+
await page.click('#scroll-to-0')
47+
await page.waitForTimeout(2000)
48+
49+
await expect(page.locator('[data-testid="item-0"]')).toBeVisible()
50+
51+
const scrollTop = await page.evaluate(() => {
52+
const container = document.querySelector('#scroll-container')
53+
return container?.scrollTop ?? -1
54+
})
55+
expect(scrollTop).toBeLessThan(1.01)
56+
})
57+
58+
test('smooth scrolls to index 500 with start alignment', async ({ page }) => {
59+
await page.goto('/smooth-scroll/')
60+
await page.click('#scroll-to-500-start')
61+
62+
await page.waitForTimeout(2000)
63+
64+
await expect(page.locator('[data-testid="item-500"]')).toBeVisible()
65+
66+
const delta = await page.evaluate(
67+
([idx, align]) => {
68+
const item = document.querySelector(`[data-testid="item-${idx}"]`)
69+
const container = document.querySelector('#scroll-container')
70+
if (!item || !container) throw new Error('Elements not found')
71+
const itemRect = item.getBoundingClientRect()
72+
const containerRect = container.getBoundingClientRect()
73+
if (align === 'start') {
74+
return Math.abs(itemRect.top - containerRect.top)
75+
}
76+
return 0
77+
},
78+
[500, 'start'] as const,
79+
)
80+
expect(delta).toBeLessThan(1.01)
81+
})
82+
83+
test('smooth scrolls to index 500 with center alignment', async ({ page }) => {
84+
await page.goto('/smooth-scroll/')
85+
await page.click('#scroll-to-500-center')
86+
87+
await page.waitForTimeout(2000)
88+
89+
await expect(page.locator('[data-testid="item-500"]')).toBeVisible()
90+
91+
const delta = await page.evaluate(
92+
([idx]) => {
93+
const item = document.querySelector(`[data-testid="item-${idx}"]`)
94+
const container = document.querySelector('#scroll-container')
95+
if (!item || !container) throw new Error('Elements not found')
96+
const itemRect = item.getBoundingClientRect()
97+
const containerRect = container.getBoundingClientRect()
98+
const containerCenter = containerRect.top + containerRect.height / 2
99+
const itemCenter = itemRect.top + itemRect.height / 2
100+
return Math.abs(itemCenter - containerCenter)
101+
},
102+
[500] as const,
103+
)
104+
// Center alignment has slightly more tolerance due to rounding
105+
expect(delta).toBeLessThan(50)
106+
})
107+
108+
test('smooth scrolls sequentially to multiple targets', async ({ page }) => {
109+
await page.goto('/smooth-scroll/')
110+
111+
// Scroll to 100 first
112+
await page.click('#scroll-to-100')
113+
await page.waitForTimeout(2000)
114+
await expect(page.locator('[data-testid="item-100"]')).toBeVisible()
115+
116+
// Then scroll to 500
117+
await page.click('#scroll-to-500')
118+
await page.waitForTimeout(2000)
119+
await expect(page.locator('[data-testid="item-500"]')).toBeVisible()
120+
121+
// Then scroll to 1000
122+
await page.click('#scroll-to-1000')
123+
await page.waitForTimeout(2000)
124+
await expect(page.locator('[data-testid="item-1000"]')).toBeVisible()
125+
})
126+
127+
test('interrupting smooth scroll with another smooth scroll', async ({
128+
page,
129+
}) => {
130+
await page.goto('/smooth-scroll/')
131+
132+
// Start scrolling to 1000
133+
await page.click('#scroll-to-1000')
134+
// Interrupt mid-animation (before the 500ms animation completes)
135+
await page.waitForTimeout(200)
136+
await page.click('#scroll-to-100')
137+
138+
// Wait for the second scroll to complete
139+
await page.waitForTimeout(2000)
140+
141+
// Should have ended at 100, not 1000
142+
await expect(page.locator('[data-testid="item-100"]')).toBeVisible()
143+
})

packages/react-virtual/e2e/app/vite.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ export default defineConfig({
1313
__dirname,
1414
'measure-element/index.html',
1515
),
16+
'smooth-scroll': path.resolve(
17+
__dirname,
18+
'smooth-scroll/index.html',
19+
),
1620
},
1721
},
1822
},

0 commit comments

Comments
 (0)