({
+ 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 }
diff --git a/packages/react-virtual/e2e/app/test/measure-element.spec.ts b/packages/react-virtual/e2e/app/test/measure-element.spec.ts
index 6973fdbb..7aacc233 100644
--- a/packages/react-virtual/e2e/app/test/measure-element.spec.ts
+++ b/packages/react-virtual/e2e/app/test/measure-element.spec.ts
@@ -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,
diff --git a/packages/react-virtual/e2e/app/test/perf.spec.ts b/packages/react-virtual/e2e/app/test/perf.spec.ts
new file mode 100644
index 00000000..03f02bc8
--- /dev/null
+++ b/packages/react-virtual/e2e/app/test/perf.spec.ts
@@ -0,0 +1,156 @@
+import { expect, test } from './fixtures'
+import type { Page } from '@playwright/test'
+
+async function getRenderCount(page: Page): Promise
{
+ 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`,
+ )
+ })
+})
diff --git a/packages/react-virtual/e2e/app/test/scroll.spec.ts b/packages/react-virtual/e2e/app/test/scroll.spec.ts
index b47a2483..c433b3ea 100644
--- a/packages/react-virtual/e2e/app/test/scroll.spec.ts
+++ b/packages/react-virtual/e2e/app/test/scroll.spec.ts
@@ -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"]')
diff --git a/packages/react-virtual/e2e/app/test/smooth-scroll.spec.ts b/packages/react-virtual/e2e/app/test/smooth-scroll.spec.ts
index d8650db9..fef73140 100644
--- a/packages/react-virtual/e2e/app/test/smooth-scroll.spec.ts
+++ b/packages/react-virtual/e2e/app/test/smooth-scroll.spec.ts
@@ -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/')
diff --git a/packages/react-virtual/e2e/app/useHook.ts b/packages/react-virtual/e2e/app/useHook.ts
new file mode 100644
index 00000000..bd911a0d
--- /dev/null
+++ b/packages/react-virtual/e2e/app/useHook.ts
@@ -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
diff --git a/packages/react-virtual/e2e/app/vite.config.ts b/packages/react-virtual/e2e/app/vite.config.ts
index 005ecd9c..8aa8fb2c 100644
--- a/packages/react-virtual/e2e/app/vite.config.ts
+++ b/packages/react-virtual/e2e/app/vite.config.ts
@@ -14,6 +14,7 @@ export default defineConfig({
'measure-element/index.html',
),
'smooth-scroll': path.resolve(__dirname, 'smooth-scroll/index.html'),
+ perf: path.resolve(__dirname, 'perf/index.html'),
},
},
},
diff --git a/packages/react-virtual/playwright.config.ts b/packages/react-virtual/playwright.config.ts
index ccd92d03..e1003a2b 100644
--- a/packages/react-virtual/playwright.config.ts
+++ b/packages/react-virtual/playwright.config.ts
@@ -8,6 +8,16 @@ export default defineConfig({
use: {
baseURL,
},
+ projects: [
+ {
+ name: 'useVirtualizer',
+ use: { hookVariant: 'standard' } as any,
+ },
+ {
+ name: 'useExperimentalDOMVirtualizer',
+ use: { hookVariant: 'experimental' } as any,
+ },
+ ],
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}/scroll/`,
diff --git a/packages/react-virtual/src/index.tsx b/packages/react-virtual/src/index.tsx
index 313c3d4f..3b492e81 100644
--- a/packages/react-virtual/src/index.tsx
+++ b/packages/react-virtual/src/index.tsx
@@ -99,3 +99,83 @@ export function useWindowVirtualizer(
...options,
})
}
+
+export function useExperimentalDOMVirtualizer<
+ TScrollElement extends HTMLElement,
+ TItemElement extends HTMLElement,
+>({
+ useFlushSync: shouldFlushSync = true,
+ ...options
+}: PartialKeys<
+ ReactVirtualizerOptions,
+ 'observeElementRect' | 'observeElementOffset' | 'scrollToFn'
+>): Virtualizer {
+ const rerender = React.useReducer(() => ({}), {})[1]
+
+ const prev = React.useRef<{
+ range: { startIndex: number; endIndex: number } | null
+ totalSize: number
+ positions: Map
+ isScrolling: boolean
+ }>({ range: null, totalSize: 0, positions: new Map(), isScrolling: false })
+
+ const onChange = (
+ instance: Virtualizer,
+ sync: boolean,
+ ) => {
+ const items = instance.getVirtualItems()
+ const totalSize = instance.getTotalSize()
+
+ if (prev.current.totalSize !== totalSize) {
+ const firstItem = items[0]
+ const el = instance.elementsCache.get(firstItem?.key ?? '')?.parentElement
+ if (el) {
+ prev.current.totalSize = totalSize
+ el.style.height = `${totalSize}px`
+ }
+ }
+
+ const positions = new Map()
+ items.forEach((item) => {
+ positions.set(item.key, item.start)
+ })
+
+ for (const [key, nextValue] of positions) {
+ const prevValue = prev.current.positions.get(key)
+ if (prevValue !== nextValue) {
+ const el = instance.elementsCache.get(key)
+ if (el) {
+ prev.current.positions.set(key, nextValue)
+ el.style.transform = `translateY(${
+ nextValue - instance.options.scrollMargin
+ }px)`
+ }
+ }
+ }
+
+ if (
+ prev.current.isScrolling !== instance.isScrolling ||
+ prev.current.range?.startIndex !== instance.range?.startIndex ||
+ prev.current.range?.endIndex !== instance.range?.endIndex
+ ) {
+ prev.current.isScrolling = instance.isScrolling
+ prev.current.range = instance.range
+
+ if (shouldFlushSync && sync) {
+ flushSync(rerender)
+ } else {
+ rerender()
+ }
+ }
+ }
+
+ const instance = useVirtualizerBase({
+ observeElementRect: observeElementRect,
+ observeElementOffset: observeElementOffset,
+ scrollToFn: elementScroll,
+ ...options,
+ })
+ instance.options.onChange = onChange
+
+ return instance
+}