Skip to content

Commit 9d1b31c

Browse files
committed
feat: add e2e and perf tests for useExperimentalDOMVirtualizer with hook injection
1 parent 54d771a commit 9d1b31c

File tree

14 files changed

+404
-9
lines changed

14 files changed

+404
-9
lines changed

packages/react-virtual/e2e/app/measure-element/main.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react'
22
import ReactDOM from 'react-dom/client'
3-
import { useVirtualizer } from '@tanstack/react-virtual'
3+
import { useHook as useVirtualizer } from '../useHook'
44

55
interface Item {
66
id: string
@@ -41,7 +41,7 @@ const App = () => {
4141
<div
4242
ref={parentRef}
4343
id="scroll-container"
44-
style={{ height: 400, overflow: 'auto' }}
44+
style={{ height: 400, overflow: 'auto', contain: 'strict', overflowAnchor: 'none' }}
4545
>
4646
<div
4747
style={{
@@ -64,6 +64,7 @@ const App = () => {
6464
top: 0,
6565
left: 0,
6666
transform: `translateY(${v.start}px)`,
67+
willChange: 'transform',
6768
width: '100%',
6869
}}
6970
>
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: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import React from 'react'
2+
import ReactDOM from 'react-dom/client'
3+
import { useHook as useVirtualizer } from '../useHook'
4+
5+
const ITEM_COUNT = 10_000
6+
7+
const randomHeight = (() => {
8+
const cache = new Map<string, number>()
9+
return (id: string) => {
10+
const value = cache.get(id)
11+
if (value !== undefined) return value
12+
const v = 25 + Math.floor(Math.random() * 76) // 25–100
13+
cache.set(id, v)
14+
return v
15+
}
16+
})()
17+
18+
const App = () => {
19+
const parentRef = React.useRef<HTMLDivElement>(null)
20+
const renderCount = React.useRef(0)
21+
22+
const rowVirtualizer = useVirtualizer({
23+
count: ITEM_COUNT,
24+
getScrollElement: () => parentRef.current,
25+
estimateSize: () => 50,
26+
})
27+
28+
renderCount.current++
29+
30+
// Expose render count to Playwright
31+
React.useEffect(() => {
32+
;(window as any).__RENDER_COUNT__ = renderCount
33+
})
34+
35+
return (
36+
<div>
37+
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
38+
<button
39+
id="scroll-to-5000"
40+
onClick={() => rowVirtualizer.scrollToIndex(5000)}
41+
>
42+
Scroll to 5000
43+
</button>
44+
<button
45+
id="scroll-to-9999"
46+
onClick={() => rowVirtualizer.scrollToIndex(ITEM_COUNT - 1)}
47+
>
48+
Scroll to last
49+
</button>
50+
<button
51+
id="scroll-to-0"
52+
onClick={() => rowVirtualizer.scrollToIndex(0)}
53+
>
54+
Scroll to 0
55+
</button>
56+
</div>
57+
58+
<div id="render-count" data-renders={renderCount.current} />
59+
60+
<div
61+
ref={parentRef}
62+
id="scroll-container"
63+
style={{ height: 400, overflow: 'auto', contain: 'strict', overflowAnchor: 'none' }}
64+
>
65+
<div
66+
style={{
67+
height: rowVirtualizer.getTotalSize(),
68+
position: 'relative',
69+
}}
70+
>
71+
{rowVirtualizer.getVirtualItems().map((v) => (
72+
<div
73+
key={v.key}
74+
data-testid={`item-${v.index}`}
75+
ref={rowVirtualizer.measureElement}
76+
data-index={v.index}
77+
style={{
78+
position: 'absolute',
79+
top: 0,
80+
left: 0,
81+
transform: `translateY(${v.start}px)`,
82+
width: '100%',
83+
}}
84+
>
85+
<div style={{ height: randomHeight(String(v.key)) }}>
86+
Row {v.index}
87+
</div>
88+
</div>
89+
))}
90+
</div>
91+
</div>
92+
</div>
93+
)
94+
}
95+
96+
// Mark initial render timing
97+
performance.mark('app-start')
98+
const root = ReactDOM.createRoot(document.getElementById('root')!)
99+
root.render(<App />)
100+
requestAnimationFrame(() => {
101+
requestAnimationFrame(() => {
102+
performance.mark('app-rendered')
103+
performance.measure('initial-render', 'app-start', 'app-rendered')
104+
})
105+
})

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react'
22
import ReactDOM from 'react-dom/client'
3-
import { useVirtualizer } from '@tanstack/react-virtual'
3+
import { useHook as useVirtualizer } from '../useHook'
44

55
function getRandomInt(min: number, max: number) {
66
return Math.floor(Math.random() * (max - min + 1)) + min
@@ -49,7 +49,7 @@ const App = () => {
4949
<div
5050
ref={parentRef}
5151
id="scroll-container"
52-
style={{ height: 400, overflow: 'auto' }}
52+
style={{ height: 400, overflow: 'auto', contain: 'strict', overflowAnchor: 'none' }}
5353
>
5454
<div
5555
style={{
@@ -68,6 +68,7 @@ const App = () => {
6868
top: 0,
6969
left: 0,
7070
transform: `translateY(${v.start}px)`,
71+
willChange: 'transform',
7172
width: '100%',
7273
}}
7374
>

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react'
22
import ReactDOM from 'react-dom/client'
3-
import { useVirtualizer } from '@tanstack/react-virtual'
3+
import { useHook as useVirtualizer } from '../useHook'
44

55
function getRandomInt(min: number, max: number) {
66
return Math.floor(Math.random() * (max - min + 1)) + min
@@ -90,7 +90,7 @@ const App = () => {
9090
<div
9191
ref={parentRef}
9292
id="scroll-container"
93-
style={{ height: 400, overflow: 'auto' }}
93+
style={{ height: 400, overflow: 'auto', contain: 'strict', overflowAnchor: 'none' }}
9494
>
9595
<div
9696
style={{
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { test as base, expect } from '@playwright/test'
2+
3+
type HookVariant = 'standard' | 'experimental'
4+
5+
export const test = base.extend<{ hookVariant: HookVariant }>({
6+
hookVariant: ['standard', { option: true }],
7+
page: async ({ page, hookVariant }, use) => {
8+
const originalGoto = page.goto.bind(page)
9+
page.goto = async function (url, options) {
10+
if (hookVariant === 'experimental') {
11+
const separator = url.includes('?') ? '&' : '?'
12+
url = `${url}${separator}hook=experimental`
13+
}
14+
return originalGoto(url, options)
15+
} as typeof page.goto
16+
await use(page)
17+
},
18+
})
19+
20+
export { expect }

packages/react-virtual/e2e/app/test/measure-element.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expect, test } from '@playwright/test'
1+
import { expect, test } from './fixtures'
22

33
test('positions items correctly after expand → collapse → delete → expand', async ({
44
page,
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { expect, test } from './fixtures'
2+
import type { Page } from '@playwright/test'
3+
4+
async function getRenderCount(page: Page): Promise<number> {
5+
return page.evaluate(() => (window as any).__RENDER_COUNT__?.current ?? 0)
6+
}
7+
8+
async function collectScrollFPS(
9+
page: Page,
10+
scrollSteps: number,
11+
stepPx: number,
12+
): Promise<{ fps: number; elapsed: number; renderCount: number }> {
13+
const rendersBefore = await getRenderCount(page)
14+
15+
const result = await page.evaluate(
16+
([steps, px]) => {
17+
return new Promise<{ fps: number; elapsed: number }>((resolve) => {
18+
const container = document.querySelector('#scroll-container')!
19+
let frames = 0
20+
let step = 0
21+
const start = performance.now()
22+
23+
function tick() {
24+
container.scrollTop += px
25+
frames++
26+
step++
27+
if (step < steps) {
28+
requestAnimationFrame(tick)
29+
} else {
30+
// Wait one extra frame for final paint
31+
requestAnimationFrame(() => {
32+
const elapsed = performance.now() - start
33+
resolve({ fps: (frames / elapsed) * 1000, elapsed })
34+
})
35+
}
36+
}
37+
38+
requestAnimationFrame(tick)
39+
})
40+
},
41+
[scrollSteps, stepPx] as const,
42+
)
43+
44+
const rendersAfter = await getRenderCount(page)
45+
46+
return {
47+
...result,
48+
renderCount: rendersAfter - rendersBefore,
49+
}
50+
}
51+
52+
test.describe('performance comparison', () => {
53+
test('initial render time', async ({ page, hookVariant }) => {
54+
await page.goto('/perf/')
55+
56+
// Wait for the initial render measurement to be recorded
57+
await page.waitForFunction(
58+
() => performance.getEntriesByName('initial-render').length > 0,
59+
)
60+
61+
const duration = await page.evaluate(
62+
() => performance.getEntriesByName('initial-render')[0].duration,
63+
)
64+
65+
const renders = await getRenderCount(page)
66+
67+
console.log(
68+
`[${hookVariant}] Initial render: ${duration.toFixed(1)}ms, renders: ${renders}`,
69+
)
70+
71+
// Sanity check — initial render should be under 500ms
72+
expect(duration).toBeLessThan(500)
73+
})
74+
75+
test('continuous scroll performance (200 frames × 100px)', async ({
76+
page,
77+
hookVariant,
78+
}) => {
79+
await page.goto('/perf/')
80+
await page.waitForTimeout(500) // settle
81+
82+
const { fps, elapsed, renderCount } = await collectScrollFPS(page, 200, 100)
83+
84+
console.log(
85+
`[${hookVariant}] Scroll 200×100px: ${fps.toFixed(1)} fps, ${elapsed.toFixed(0)}ms, ${renderCount} renders`,
86+
)
87+
88+
// Should maintain at least 30 fps
89+
expect(fps).toBeGreaterThan(30)
90+
})
91+
92+
test('rapid small scroll performance (500 frames × 20px)', async ({
93+
page,
94+
hookVariant,
95+
}) => {
96+
await page.goto('/perf/')
97+
await page.waitForTimeout(500)
98+
99+
const { fps, elapsed, renderCount } = await collectScrollFPS(page, 500, 20)
100+
101+
console.log(
102+
`[${hookVariant}] Scroll 500×20px: ${fps.toFixed(1)} fps, ${elapsed.toFixed(0)}ms, ${renderCount} renders`,
103+
)
104+
105+
expect(fps).toBeGreaterThan(30)
106+
})
107+
108+
test('scrollToIndex render count', async ({ page, hookVariant }) => {
109+
await page.goto('/perf/')
110+
await page.waitForTimeout(500)
111+
112+
const rendersBefore = await getRenderCount(page)
113+
114+
await page.click('#scroll-to-5000')
115+
await page.waitForTimeout(2000) // wait for convergence
116+
117+
await expect(page.locator('[data-testid="item-5000"]')).toBeVisible()
118+
119+
const rendersAfter = await getRenderCount(page)
120+
const scrollRenders = rendersAfter - rendersBefore
121+
122+
console.log(
123+
`[${hookVariant}] scrollToIndex(5000): ${scrollRenders} renders`,
124+
)
125+
126+
// Experimental should use fewer renders (DOM mutations vs React re-renders)
127+
// Just recording — no hard assertion, the value is informational
128+
})
129+
130+
test('scrollToIndex round-trip render count', async ({
131+
page,
132+
hookVariant,
133+
}) => {
134+
await page.goto('/perf/')
135+
await page.waitForTimeout(500)
136+
137+
const rendersBefore = await getRenderCount(page)
138+
139+
// Scroll to end
140+
await page.click('#scroll-to-9999')
141+
await page.waitForTimeout(2000)
142+
await expect(page.locator('[data-testid="item-9999"]')).toBeVisible()
143+
144+
// Scroll back to start
145+
await page.click('#scroll-to-0')
146+
await page.waitForTimeout(2000)
147+
await expect(page.locator('[data-testid="item-0"]')).toBeVisible()
148+
149+
const rendersAfter = await getRenderCount(page)
150+
const totalRenders = rendersAfter - rendersBefore
151+
152+
console.log(
153+
`[${hookVariant}] scrollToIndex round-trip (9999→0): ${totalRenders} renders`,
154+
)
155+
})
156+
})

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expect, test } from '@playwright/test'
1+
import { expect, test } from './fixtures'
22

33
const check = () => {
44
const item = document.querySelector('[data-testid="item-1000"]')

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expect, test } from '@playwright/test'
1+
import { expect, test } from './fixtures'
22

33
test('smooth scrolls to index 1000', async ({ page }) => {
44
await page.goto('/smooth-scroll/')

0 commit comments

Comments
 (0)