Skip to content
Draft
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
9 changes: 7 additions & 2 deletions packages/react-virtual/e2e/app/measure-element/main.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useHook as useVirtualizer } from '../useHook'

interface Item {
id: string
Expand Down Expand Up @@ -41,7 +41,12 @@ const App = () => {
<div
ref={parentRef}
id="scroll-container"
style={{ height: 400, overflow: 'auto' }}
style={{
height: 400,
overflow: 'auto',
contain: 'strict',
overflowAnchor: 'none',
}}
>
<div
style={{
Expand Down
10 changes: 10 additions & 0 deletions packages/react-virtual/e2e/app/perf/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>
110 changes: 110 additions & 0 deletions packages/react-virtual/e2e/app/perf/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { useHook as useVirtualizer } from '../useHook'

const ITEM_COUNT = 10_000

const randomHeight = (() => {
const cache = new Map<string, number>()
return (id: string) => {
const value = cache.get(id)
if (value !== undefined) return value
const v = 25 + Math.floor(Math.random() * 76) // 25–100
cache.set(id, v)
return v
}
})()

const App = () => {
const parentRef = React.useRef<HTMLDivElement>(null)
const renderCount = React.useRef(0)

const rowVirtualizer = useVirtualizer({
count: ITEM_COUNT,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
})

renderCount.current++

// Expose render count to Playwright
React.useEffect(() => {
;(window as any).__RENDER_COUNT__ = renderCount
})

return (
<div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<button
id="scroll-to-5000"
onClick={() => rowVirtualizer.scrollToIndex(5000)}
>
Scroll to 5000
</button>
<button
id="scroll-to-9999"
onClick={() => rowVirtualizer.scrollToIndex(ITEM_COUNT - 1)}
>
Scroll to last
</button>
<button
id="scroll-to-0"
onClick={() => rowVirtualizer.scrollToIndex(0)}
>
Scroll to 0
</button>
</div>

<div id="render-count" data-renders={renderCount.current} />

<div
ref={parentRef}
id="scroll-container"
style={{
height: 400,
overflow: 'auto',
contain: 'strict',
overflowAnchor: 'none',
}}
>
<div
style={{
height: rowVirtualizer.getTotalSize(),
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((v) => (
<div
key={v.key}
data-testid={`item-${v.index}`}
ref={rowVirtualizer.measureElement}
data-index={v.index}
style={{
position: 'absolute',
top: 0,
left: 0,
transform: `translateY(${v.start}px)`,
width: '100%',
}}
>
<div style={{ height: randomHeight(String(v.key)) }}>
Row {v.index}
</div>
</div>
))}
</div>
</div>
</div>
)
}

// Mark initial render timing
performance.mark('app-start')
const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render(<App />)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
performance.mark('app-rendered')
performance.measure('initial-render', 'app-start', 'app-rendered')
})
})
9 changes: 7 additions & 2 deletions packages/react-virtual/e2e/app/scroll/main.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useHook as useVirtualizer } from '../useHook'

function getRandomInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min
Expand Down Expand Up @@ -49,7 +49,12 @@ const App = () => {
<div
ref={parentRef}
id="scroll-container"
style={{ height: 400, overflow: 'auto' }}
style={{
height: 400,
overflow: 'auto',
contain: 'strict',
overflowAnchor: 'none',
}}
>
<div
style={{
Expand Down
9 changes: 7 additions & 2 deletions packages/react-virtual/e2e/app/smooth-scroll/main.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useHook as useVirtualizer } from '../useHook'

function getRandomInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min
Expand Down Expand Up @@ -90,7 +90,12 @@ const App = () => {
<div
ref={parentRef}
id="scroll-container"
style={{ height: 400, overflow: 'auto' }}
style={{
height: 400,
overflow: 'auto',
contain: 'strict',
overflowAnchor: 'none',
}}
>
<div
style={{
Expand Down
20 changes: 20 additions & 0 deletions packages/react-virtual/e2e/app/test/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { test as base, expect } from '@playwright/test'

type HookVariant = 'standard' | 'experimental'

export const test = base.extend<{ hookVariant: HookVariant }>({
hookVariant: ['standard', { option: true }],
page: async ({ page, hookVariant }, use) => {
const originalGoto = page.goto.bind(page)
page.goto = async function (url, options) {
if (hookVariant === 'experimental') {
const separator = url.includes('?') ? '&' : '?'
url = `${url}${separator}hook=experimental`
}
return originalGoto(url, options)
} as typeof page.goto
await use(page)
},
})

export { expect }
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test'
import { expect, test } from './fixtures'

test('positions items correctly after expand → collapse → delete → expand', async ({
page,
Expand Down
156 changes: 156 additions & 0 deletions packages/react-virtual/e2e/app/test/perf.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { expect, test } from './fixtures'
import type { Page } from '@playwright/test'

async function getRenderCount(page: Page): Promise<number> {
return page.evaluate(() => (window as any).__RENDER_COUNT__?.current ?? 0)
}

async function collectScrollFPS(
page: Page,
scrollSteps: number,
stepPx: number,
): Promise<{ fps: number; elapsed: number; renderCount: number }> {
const rendersBefore = await getRenderCount(page)

const result = await page.evaluate(
([steps, px]) => {
return new Promise<{ fps: number; elapsed: number }>((resolve) => {
const container = document.querySelector('#scroll-container')!
let frames = 0
let step = 0
const start = performance.now()

function tick() {
container.scrollTop += px
frames++
step++
if (step < steps) {
requestAnimationFrame(tick)
} else {
// Wait one extra frame for final paint
requestAnimationFrame(() => {
const elapsed = performance.now() - start
resolve({ fps: (frames / elapsed) * 1000, elapsed })
})
}
}

requestAnimationFrame(tick)
})
},
[scrollSteps, stepPx] as const,
)

const rendersAfter = await getRenderCount(page)

return {
...result,
renderCount: rendersAfter - rendersBefore,
}
}

test.describe('performance comparison', () => {
test('initial render time', async ({ page, hookVariant }) => {
await page.goto('/perf/')

// Wait for the initial render measurement to be recorded
await page.waitForFunction(
() => performance.getEntriesByName('initial-render').length > 0,
)

const duration = await page.evaluate(
() => performance.getEntriesByName('initial-render')[0].duration,
)

const renders = await getRenderCount(page)

console.log(
`[${hookVariant}] Initial render: ${duration.toFixed(1)}ms, renders: ${renders}`,
)

// Sanity check — initial render should be under 500ms
expect(duration).toBeLessThan(500)
})

test('continuous scroll performance (200 frames × 100px)', async ({
page,
hookVariant,
}) => {
await page.goto('/perf/')
await page.waitForTimeout(500) // settle

const { fps, elapsed, renderCount } = await collectScrollFPS(page, 200, 100)

console.log(
`[${hookVariant}] Scroll 200×100px: ${fps.toFixed(1)} fps, ${elapsed.toFixed(0)}ms, ${renderCount} renders`,
)

// Should maintain at least 30 fps
expect(fps).toBeGreaterThan(30)
})

test('rapid small scroll performance (500 frames × 20px)', async ({
page,
hookVariant,
}) => {
await page.goto('/perf/')
await page.waitForTimeout(500)

const { fps, elapsed, renderCount } = await collectScrollFPS(page, 500, 20)

console.log(
`[${hookVariant}] Scroll 500×20px: ${fps.toFixed(1)} fps, ${elapsed.toFixed(0)}ms, ${renderCount} renders`,
)

expect(fps).toBeGreaterThan(30)
})

test('scrollToIndex render count', async ({ page, hookVariant }) => {
await page.goto('/perf/')
await page.waitForTimeout(500)

const rendersBefore = await getRenderCount(page)

await page.click('#scroll-to-5000')
await page.waitForTimeout(2000) // wait for convergence

await expect(page.locator('[data-testid="item-5000"]')).toBeVisible()

const rendersAfter = await getRenderCount(page)
const scrollRenders = rendersAfter - rendersBefore

console.log(
`[${hookVariant}] scrollToIndex(5000): ${scrollRenders} renders`,
)

// Experimental should use fewer renders (DOM mutations vs React re-renders)
// Just recording — no hard assertion, the value is informational
})

test('scrollToIndex round-trip render count', async ({
page,
hookVariant,
}) => {
await page.goto('/perf/')
await page.waitForTimeout(500)

const rendersBefore = await getRenderCount(page)

// Scroll to end
await page.click('#scroll-to-9999')
await page.waitForTimeout(2000)
await expect(page.locator('[data-testid="item-9999"]')).toBeVisible()

// Scroll back to start
await page.click('#scroll-to-0')
await page.waitForTimeout(2000)
await expect(page.locator('[data-testid="item-0"]')).toBeVisible()

const rendersAfter = await getRenderCount(page)
const totalRenders = rendersAfter - rendersBefore

console.log(
`[${hookVariant}] scrollToIndex round-trip (9999→0): ${totalRenders} renders`,
)
})
})
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
@@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test'
import { expect, test } from './fixtures'

const check = () => {
const item = document.querySelector('[data-testid="item-1000"]')
Expand Down
2 changes: 1 addition & 1 deletion packages/react-virtual/e2e/app/test/smooth-scroll.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test'
import { expect, test } from './fixtures'

test('smooth scrolls to index 1000', async ({ page }) => {
await page.goto('/smooth-scroll/')
Expand Down
11 changes: 11 additions & 0 deletions packages/react-virtual/e2e/app/useHook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {
useVirtualizer,
useExperimentalDOMVirtualizer,
} from '@tanstack/react-virtual'

const isExperimental =
new URLSearchParams(window.location.search).get('hook') === 'experimental'

export const useHook = (
isExperimental ? useExperimentalDOMVirtualizer : useVirtualizer
) as typeof useVirtualizer
Loading
Loading