Skip to content

Commit b96bba2

Browse files
committed
test(react-virtual): record that a mid-flight prepend strands scrollToIndex
1 parent d2cf98b commit b96bba2

4 files changed

Lines changed: 202 additions & 0 deletions

File tree

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: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import React from 'react'
2+
import { createRoot } from 'react-dom/client'
3+
import { useVirtualizer } from '@tanstack/react-virtual'
4+
5+
// End-anchored list built for one scenario: a long smooth scrollToIndex that is
6+
// still in flight when history is prepended. The list is deliberately tall
7+
// (200 x 50px against a 300px viewport) so the animation lasts long enough for
8+
// the test to reliably observe it mid-flight and prepend into that window.
9+
10+
type Message = {
11+
id: string
12+
text: string
13+
}
14+
15+
const makeMessage = (index: number): Message => ({
16+
id: `m-${index}`,
17+
text: `Message ${index}`,
18+
})
19+
20+
const initialMessages = Array.from({ length: 200 }, (_, index) =>
21+
makeMessage(index),
22+
)
23+
24+
function App() {
25+
const [messages, setMessages] = React.useState(initialMessages)
26+
const [didInitialScroll, setDidInitialScroll] = React.useState(false)
27+
const parentRef = React.useRef<HTMLDivElement>(null)
28+
const firstMessageIndexRef = React.useRef(0)
29+
30+
const virtualizer = useVirtualizer({
31+
count: messages.length,
32+
getScrollElement: () => parentRef.current,
33+
estimateSize: () => 50,
34+
getItemKey: (index) => messages[index]!.id,
35+
anchorTo: 'end',
36+
followOnAppend: true,
37+
overscan: 4,
38+
})
39+
40+
React.useLayoutEffect(() => {
41+
if (didInitialScroll) return
42+
virtualizer.scrollToEnd()
43+
setDidInitialScroll(true)
44+
}, [didInitialScroll, virtualizer])
45+
46+
return (
47+
<div>
48+
<button
49+
id="smooth-to-0"
50+
onClick={() => virtualizer.scrollToIndex(0, { behavior: 'smooth' })}
51+
>
52+
Smooth to 0
53+
</button>
54+
<button
55+
id="prepend"
56+
onClick={() => {
57+
const start = firstMessageIndexRef.current - 5
58+
firstMessageIndexRef.current = start
59+
setMessages((current) => [
60+
...Array.from({ length: 5 }, (_, offset) =>
61+
makeMessage(start + offset),
62+
),
63+
...current,
64+
])
65+
}}
66+
>
67+
Prepend
68+
</button>
69+
70+
<div
71+
ref={parentRef}
72+
id="scroll-container"
73+
style={{
74+
height: 300,
75+
overflow: 'auto',
76+
width: 420,
77+
border: '1px solid #ddd',
78+
}}
79+
>
80+
<div
81+
style={{
82+
height: virtualizer.getTotalSize(),
83+
position: 'relative',
84+
width: '100%',
85+
}}
86+
>
87+
{virtualizer.getVirtualItems().map((item) => {
88+
const message = messages[item.index]!
89+
90+
return (
91+
<div
92+
key={item.key}
93+
ref={virtualizer.measureElement}
94+
data-index={item.index}
95+
data-message-id={message.id}
96+
data-testid={`message-${message.id}`}
97+
style={{
98+
position: 'absolute',
99+
top: 0,
100+
left: 0,
101+
transform: `translateY(${item.start}px)`,
102+
width: '100%',
103+
}}
104+
>
105+
<div
106+
style={{
107+
boxSizing: 'border-box',
108+
height: 50,
109+
padding: 8,
110+
borderBottom: '1px solid #eee',
111+
}}
112+
>
113+
{message.text}
114+
</div>
115+
</div>
116+
)
117+
})}
118+
</div>
119+
</div>
120+
</div>
121+
)
122+
}
123+
124+
createRoot(document.getElementById('root')!).render(<App />)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { expect, test } from '@playwright/test'
2+
import type { Page } from '@playwright/test'
3+
4+
const scrollTop = (page: Page) =>
5+
page.evaluate(() => {
6+
const container = document.querySelector('#scroll-container')
7+
if (!container) throw new Error('Container not found')
8+
return container.scrollTop
9+
})
10+
11+
async function waitForEnd(page: Page) {
12+
await expect
13+
.poll(async () =>
14+
page.evaluate(() => {
15+
const container = document.querySelector('#scroll-container')
16+
if (!container) throw new Error('Container not found')
17+
return Math.abs(
18+
container.scrollHeight - container.scrollTop - container.clientHeight,
19+
)
20+
}),
21+
)
22+
.toBeLessThan(1.01)
23+
}
24+
25+
// KNOWN BUG, not a guard on current behaviour — test.fail() asserts this still
26+
// reproduces and turns red the moment it is fixed, at which point drop the
27+
// annotation and keep the assertions.
28+
//
29+
// A prepend that lands while a scrollToIndex is still travelling strands it. The
30+
// anchor sync in _willUpdate writes scrollTop, which cancels the browser's
31+
// smooth animation, and reconcileScroll never resumes the journey because its
32+
// `else` branch only re-asserts when the *target* changed. With uniform rows
33+
// index 0 sits at offset 0 both before and after the prepend, so the target is
34+
// unchanged and the loop just idles. "Jump to the oldest message" therefore dies
35+
// halfway whenever history streams in mid-animation.
36+
//
37+
// Reproduces identically on the commit before the stale-target fix (stranded at
38+
// ~3900 vs ~3500), so it is pre-existing and independent of it. Resuming an
39+
// unfinished scroll needs its own change: reconcileScroll idling is exactly what
40+
// stops it fighting a reader who deliberately scrolls away mid-scroll, so making
41+
// it re-assert is a behavioural decision rather than a local patch.
42+
test.fail()
43+
test('a prepend mid-flight does not abandon a smooth scrollToIndex', async ({
44+
page,
45+
}) => {
46+
await page.goto('/smooth-prepend/')
47+
await waitForEnd(page)
48+
49+
const start = await scrollTop(page)
50+
expect(start).toBeGreaterThan(9000) // 200 x 50 - 300
51+
52+
// Ask for index 0 and catch the animation in flight — well clear of both
53+
// ends, so this asserts on a genuinely mid-scroll prepend.
54+
await page.click('#smooth-to-0')
55+
await expect
56+
.poll(() => scrollTop(page), { timeout: 5000 })
57+
.toBeLessThan(start - 1000)
58+
expect(await scrollTop(page)).toBeGreaterThan(500)
59+
60+
// History arrives while we are still moving.
61+
await page.click('#prepend')
62+
63+
// The requested scroll should still complete. Index 0 sits at offset 0 both
64+
// before and after the prepend (uniform 50px rows), so the destination is
65+
// unambiguous: the top.
66+
await expect.poll(() => scrollTop(page), { timeout: 3000 }).toBeLessThan(1.01)
67+
})

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export default defineConfig({
1616
'measure-element/index.html',
1717
),
1818
'smooth-scroll': path.resolve(__dirname, 'smooth-scroll/index.html'),
19+
'smooth-prepend': path.resolve(__dirname, 'smooth-prepend/index.html'),
1920
'stale-index': path.resolve(__dirname, 'stale-index/index.html'),
2021
'direct-dom-updates': path.resolve(
2122
__dirname,

0 commit comments

Comments
 (0)