From 525ee89d76155f078ac60f062d0acd2aecc28d83 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Mon, 9 Mar 2026 12:19:24 +0100 Subject: [PATCH 1/2] feat: add e2e and perf tests for useExperimentalDOMVirtualizer with hook injection --- .../e2e/app/measure-element/main.tsx | 4 +- .../react-virtual/e2e/app/perf/index.html | 10 ++ packages/react-virtual/e2e/app/perf/main.tsx | 105 ++++++++++++ .../react-virtual/e2e/app/scroll/main.tsx | 4 +- .../e2e/app/smooth-scroll/main.tsx | 4 +- .../react-virtual/e2e/app/test/fixtures.ts | 20 +++ .../e2e/app/test/measure-element.spec.ts | 2 +- .../react-virtual/e2e/app/test/perf.spec.ts | 156 ++++++++++++++++++ .../react-virtual/e2e/app/test/scroll.spec.ts | 2 +- .../e2e/app/test/smooth-scroll.spec.ts | 2 +- packages/react-virtual/e2e/app/useHook.ts | 11 ++ packages/react-virtual/e2e/app/vite.config.ts | 1 + packages/react-virtual/playwright.config.ts | 10 ++ packages/react-virtual/src/index.tsx | 80 +++++++++ 14 files changed, 402 insertions(+), 9 deletions(-) create mode 100644 packages/react-virtual/e2e/app/perf/index.html create mode 100644 packages/react-virtual/e2e/app/perf/main.tsx create mode 100644 packages/react-virtual/e2e/app/test/fixtures.ts create mode 100644 packages/react-virtual/e2e/app/test/perf.spec.ts create mode 100644 packages/react-virtual/e2e/app/useHook.ts diff --git a/packages/react-virtual/e2e/app/measure-element/main.tsx b/packages/react-virtual/e2e/app/measure-element/main.tsx index 6775b39f..5eb2ad9a 100644 --- a/packages/react-virtual/e2e/app/measure-element/main.tsx +++ b/packages/react-virtual/e2e/app/measure-element/main.tsx @@ -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 @@ -41,7 +41,7 @@ const App = () => {
+ + + + + +
+ + + diff --git a/packages/react-virtual/e2e/app/perf/main.tsx b/packages/react-virtual/e2e/app/perf/main.tsx new file mode 100644 index 00000000..17527da9 --- /dev/null +++ b/packages/react-virtual/e2e/app/perf/main.tsx @@ -0,0 +1,105 @@ +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() + 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(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 ( +
+
+ + + +
+ +
+ +
+
+ {rowVirtualizer.getVirtualItems().map((v) => ( +
+
+ Row {v.index} +
+
+ ))} +
+
+
+ ) +} + +// Mark initial render timing +performance.mark('app-start') +const root = ReactDOM.createRoot(document.getElementById('root')!) +root.render() +requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('app-rendered') + performance.measure('initial-render', 'app-start', 'app-rendered') + }) +}) diff --git a/packages/react-virtual/e2e/app/scroll/main.tsx b/packages/react-virtual/e2e/app/scroll/main.tsx index 99c65507..2c857848 100644 --- a/packages/react-virtual/e2e/app/scroll/main.tsx +++ b/packages/react-virtual/e2e/app/scroll/main.tsx @@ -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 @@ -49,7 +49,7 @@ const App = () => {
{
({ + 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 +} From 1cd67a0871a0084cc42669fac41eee88f3cb4509 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 04:03:57 +0000 Subject: [PATCH 2/2] ci: apply automated fixes --- packages/react-virtual/e2e/app/measure-element/main.tsx | 7 ++++++- packages/react-virtual/e2e/app/perf/main.tsx | 7 ++++++- packages/react-virtual/e2e/app/scroll/main.tsx | 7 ++++++- packages/react-virtual/e2e/app/smooth-scroll/main.tsx | 7 ++++++- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/react-virtual/e2e/app/measure-element/main.tsx b/packages/react-virtual/e2e/app/measure-element/main.tsx index 5eb2ad9a..6b7800c6 100644 --- a/packages/react-virtual/e2e/app/measure-element/main.tsx +++ b/packages/react-virtual/e2e/app/measure-element/main.tsx @@ -41,7 +41,12 @@ const App = () => {
{
{
{