From 7f532513f649561a8140f3da0eb7943fbe7cfa72 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Thu, 26 Jun 2025 15:02:33 +0200 Subject: [PATCH 01/14] fix(virtual-core): scroll to index doesn't scroll to bottom correctly --- packages/virtual-core/src/index.ts | 75 ++++++++++++++---------------- packages/virtual-core/src/utils.ts | 2 +- 2 files changed, 36 insertions(+), 41 deletions(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index fc6449839..28440c9c2 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -359,7 +359,6 @@ export class Virtualizer< scrollElement: TScrollElement | null = null targetWindow: (Window & typeof globalThis) | null = null isScrolling = false - private scrollToIndexTimeoutId: number | null = null measurementsCache: Array = [] private itemSizeCache = new Map() private pendingMeasuredCacheIndexes: Array = [] @@ -904,7 +903,7 @@ export class Virtualizer< toOffset -= size } - const maxOffset = this.getTotalSize() - size + const maxOffset = this.getTotalSize() + this.options.scrollMargin - size return Math.max(Math.min(maxOffset, toOffset), 0) } @@ -943,19 +942,10 @@ export class Virtualizer< private isDynamicMode = () => this.elementsCache.size > 0 - private cancelScrollToIndex = () => { - if (this.scrollToIndexTimeoutId !== null && this.targetWindow) { - this.targetWindow.clearTimeout(this.scrollToIndexTimeoutId) - this.scrollToIndexTimeoutId = null - } - } - scrollToOffset = ( toOffset: number, { align = 'start', behavior }: ScrollToOffsetOptions = {}, ) => { - this.cancelScrollToIndex() - if (behavior === 'smooth' && this.isDynamicMode()) { console.warn( 'The `smooth` scroll behavior is not fully supported with dynamic size.', @@ -972,50 +962,55 @@ export class Virtualizer< index: number, { align: initialAlign = 'auto', behavior }: ScrollToIndexOptions = {}, ) => { - index = Math.max(0, Math.min(index, this.options.count - 1)) - - this.cancelScrollToIndex() - if (behavior === 'smooth' && this.isDynamicMode()) { console.warn( 'The `smooth` scroll behavior is not fully supported with dynamic size.', ) } - const offsetAndAlign = this.getOffsetForIndex(index, initialAlign) - if (!offsetAndAlign) return - - const [offset, align] = offsetAndAlign - - this._scrollToOffset(offset, { adjustments: undefined, behavior }) - - if (behavior !== 'smooth' && this.isDynamicMode() && this.targetWindow) { - this.scrollToIndexTimeoutId = this.targetWindow.setTimeout(() => { - this.scrollToIndexTimeoutId = null + index = Math.max(0, Math.min(index, this.options.count - 1)) - const elementInDOM = this.elementsCache.has( - this.options.getItemKey(index), - ) + let attempts = 0 + const maxAttempts = 10 - if (elementInDOM) { - const result = this.getOffsetForIndex(index, align) - if (!result) return - const [latestOffset] = result + const tryScroll = (currentAlign: ScrollAlignment) => { + const offsetInfo = this.getOffsetForIndex(index, currentAlign) + if (!offsetInfo) { + scheduleRetry(currentAlign) + return + } + const [offset, align] = offsetInfo + this._scrollToOffset(offset, { adjustments: undefined, behavior }) + + requestAnimationFrame(() => { + const currentOffset = this.getScrollOffset() + const afterInfo = this.getOffsetForIndex(index, align) + if (!afterInfo) { + scheduleRetry(align) + return + } - const currentScrollOffset = this.getScrollOffset() - if (!approxEqual(latestOffset, currentScrollOffset)) { - this.scrollToIndex(index, { align, behavior }) - } - } else { - this.scrollToIndex(index, { align, behavior }) + if (!approxEqual(afterInfo[0], currentOffset)) { + scheduleRetry(align) } }) } + + function scheduleRetry(align: ScrollAlignment) { + attempts++ + if (attempts < maxAttempts) { + requestAnimationFrame(() => tryScroll(align)) + } else { + console.warn( + `Failed to scroll to index ${index} after ${maxAttempts} attempts.`, + ) + } + } + + tryScroll(initialAlign) } scrollBy = (delta: number, { behavior }: ScrollToOffsetOptions = {}) => { - this.cancelScrollToIndex() - if (behavior === 'smooth' && this.isDynamicMode()) { console.warn( 'The `smooth` scroll behavior is not fully supported with dynamic size.', diff --git a/packages/virtual-core/src/utils.ts b/packages/virtual-core/src/utils.ts index 1bb4615c2..c11b3d38c 100644 --- a/packages/virtual-core/src/utils.ts +++ b/packages/virtual-core/src/utils.ts @@ -83,7 +83,7 @@ export function notUndefined(value: T | undefined, msg?: string): T { } } -export const approxEqual = (a: number, b: number) => Math.abs(a - b) <= 1 +export const approxEqual = (a: number, b: number) => Math.abs(a - b) < 1.01 export const debounce = ( targetWindow: Window & typeof globalThis, From e85d1a07550b9bdb3f73780daec13994701ef0fa Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Thu, 26 Jun 2025 15:05:35 +0200 Subject: [PATCH 02/14] Create chilled-falcons-battle.md --- .changeset/chilled-falcons-battle.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/chilled-falcons-battle.md diff --git a/.changeset/chilled-falcons-battle.md b/.changeset/chilled-falcons-battle.md new file mode 100644 index 000000000..1a6f228bb --- /dev/null +++ b/.changeset/chilled-falcons-battle.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +fix(virtual-core): scroll to index doesn't scroll to bottom correctly From 2501ea1be08b2cb95da4dc4e400e025ee10900c3 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Thu, 26 Jun 2025 15:59:05 +0200 Subject: [PATCH 03/14] Read requestAnimationFrame from targetWindow --- packages/virtual-core/src/index.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 28440c9c2..bbe5febdb 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -974,6 +974,8 @@ export class Virtualizer< const maxAttempts = 10 const tryScroll = (currentAlign: ScrollAlignment) => { + if (!this.targetWindow) return + const offsetInfo = this.getOffsetForIndex(index, currentAlign) if (!offsetInfo) { scheduleRetry(currentAlign) @@ -982,7 +984,7 @@ export class Virtualizer< const [offset, align] = offsetInfo this._scrollToOffset(offset, { adjustments: undefined, behavior }) - requestAnimationFrame(() => { + this.targetWindow.requestAnimationFrame(() => { const currentOffset = this.getScrollOffset() const afterInfo = this.getOffsetForIndex(index, align) if (!afterInfo) { @@ -996,10 +998,12 @@ export class Virtualizer< }) } - function scheduleRetry(align: ScrollAlignment) { + const scheduleRetry = (align: ScrollAlignment) => { + if (!this.targetWindow) return + attempts++ if (attempts < maxAttempts) { - requestAnimationFrame(() => tryScroll(align)) + this.targetWindow.requestAnimationFrame(() => tryScroll(align)) } else { console.warn( `Failed to scroll to index ${index} after ${maxAttempts} attempts.`, From e10592563e3d63f0f7fa6cf1738183b61f4df466 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Fri, 27 Jun 2025 09:39:12 +0200 Subject: [PATCH 04/14] Log warning instead of retrying --- packages/virtual-core/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index bbe5febdb..4192216b7 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -978,7 +978,7 @@ export class Virtualizer< const offsetInfo = this.getOffsetForIndex(index, currentAlign) if (!offsetInfo) { - scheduleRetry(currentAlign) + console.warn('Failed to get offset for index:', index) return } const [offset, align] = offsetInfo @@ -988,7 +988,7 @@ export class Virtualizer< const currentOffset = this.getScrollOffset() const afterInfo = this.getOffsetForIndex(index, align) if (!afterInfo) { - scheduleRetry(align) + console.warn('Failed to get offset for index:', index) return } From 77c8ac5bf049dde30cbf3120bb679f9a6c101bc4 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Fri, 27 Jun 2025 11:52:49 +0200 Subject: [PATCH 05/14] Add playwright tests for react-virtual --- .gitignore | 5 ++ knip.json | 2 +- package.json | 6 +- packages/react-virtual/e2e/app/index.html | 10 +++ packages/react-virtual/e2e/app/main.tsx | 69 ++++++++++++++++ .../react-virtual/e2e/app/test/scroll.spec.ts | 25 ++++++ packages/react-virtual/e2e/app/tsconfig.json | 14 ++++ packages/react-virtual/e2e/app/vite.config.ts | 7 ++ packages/react-virtual/package.json | 3 +- packages/react-virtual/playwright.config.ts | 15 ++++ packages/react-virtual/tsconfig.json | 2 +- pnpm-lock.yaml | 82 ++++++++++++++----- 12 files changed, 213 insertions(+), 27 deletions(-) create mode 100644 packages/react-virtual/e2e/app/index.html create mode 100644 packages/react-virtual/e2e/app/main.tsx create mode 100644 packages/react-virtual/e2e/app/test/scroll.spec.ts create mode 100644 packages/react-virtual/e2e/app/tsconfig.json create mode 100644 packages/react-virtual/e2e/app/vite.config.ts create mode 100644 packages/react-virtual/playwright.config.ts diff --git a/.gitignore b/.gitignore index cc376bd6c..2a91fb574 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,8 @@ stats.html vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# Playwright test artifacts +test-results/ +playwright-report/ +*.log diff --git a/knip.json b/knip.json index 639c5df88..f3a69e7a2 100644 --- a/knip.json +++ b/knip.json @@ -1,4 +1,4 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", - "ignoreWorkspaces": ["examples/**"] + "ignoreWorkspaces": ["examples/**", "packages/react-virtual/e2e/**"] } diff --git a/package.json b/package.json index ee01fe550..370508b6c 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "clean": "pnpm --filter \"./packages/**\" run clean", "preinstall": "node -e \"if(process.env.CI == 'true') {console.log('Skipping preinstall...'); process.exit(1)}\" || npx -y only-allow pnpm", "test": "pnpm run test:ci", - "test:pr": "nx affected --targets=test:sherif,test:knip,test:eslint,test:lib,test:types,test:build,build", - "test:ci": "nx run-many --targets=test:sherif,test:knip,test:eslint,test:lib,test:types,test:build,build", + "test:pr": "nx affected --targets=test:sherif,test:knip,test:eslint,test:lib,test:e2e,test:types,test:build,build", + "test:ci": "nx run-many --targets=test:sherif,test:knip,test:eslint,test:lib,test:e2e,test:types,test:build,build", "test:eslint": "nx affected --target=test:eslint", "test:format": "pnpm run prettier --check", "test:sherif": "sherif", @@ -20,6 +20,7 @@ "test:lib:dev": "pnpm run test:lib && nx watch --all -- pnpm run test:lib", "test:build": "nx affected --target=test:build --exclude=examples/**", "test:types": "nx affected --target=test:types --exclude=examples/**", + "test:e2e": "nx affected --target=test:e2e --exclude=examples/**", "test:knip": "knip", "build": "nx affected --target=build --exclude=examples/**", "build:all": "nx run-many --target=build --exclude=examples/**", @@ -39,6 +40,7 @@ }, "devDependencies": { "@changesets/cli": "^2.29.4", + "@playwright/test": "^1.53.1", "@svitejs/changesets-changelog-github-compact": "^1.2.0", "@tanstack/config": "^0.18.2", "@testing-library/jest-dom": "^6.6.3", diff --git a/packages/react-virtual/e2e/app/index.html b/packages/react-virtual/e2e/app/index.html new file mode 100644 index 000000000..6d5c94aec --- /dev/null +++ b/packages/react-virtual/e2e/app/index.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/packages/react-virtual/e2e/app/main.tsx b/packages/react-virtual/e2e/app/main.tsx new file mode 100644 index 000000000..5978e60e7 --- /dev/null +++ b/packages/react-virtual/e2e/app/main.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { useVirtualizer } from '../../src/index' + +function getRandomInt(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +const randomHeight = (() => { + const cache = new Map() + return (id: string) => { + const value = cache.get(id) + if (value !== undefined) { + return value + } + const v = getRandomInt(25, 100) + cache.set(id, v) + return v + } +})() + +const App = () => { + const parentRef = React.useRef(null) + const rowVirtualizer = useVirtualizer({ + count: 1002, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, + }) + + return ( +
+
+ {rowVirtualizer.getVirtualItems().map((v) => ( +
+
+ Row {v.index} +
+
+ ))} +
+ +
+ ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/packages/react-virtual/e2e/app/test/scroll.spec.ts b/packages/react-virtual/e2e/app/test/scroll.spec.ts new file mode 100644 index 000000000..8e591077f --- /dev/null +++ b/packages/react-virtual/e2e/app/test/scroll.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test' + +test('scrolls to index 1000', async ({ page }) => { + await page.goto('/') + await page.click('#scroll-to-1000') + + const item = page.locator('[data-testid="item-1000"]') + await expect(item).toBeVisible() + + const container = page.locator('#scroll-container') + const [itemBox, scrollTop, containerBox] = await Promise.all([ + item.boundingBox(), + container.evaluate((el) => el.scrollTop), + container.boundingBox(), + ]) + + if (!itemBox || !containerBox) throw new Error('Missing bounding boxes') + + const itemTopRelativeToScroll = itemBox.y + scrollTop - containerBox.y + const itemBottomRelativeToScroll = itemTopRelativeToScroll + itemBox.height + const containerVisibleBottom = scrollTop + containerBox.height + + const delta = Math.abs(itemBottomRelativeToScroll - containerVisibleBottom) + expect(delta).toBeLessThan(1.01) +}) diff --git a/packages/react-virtual/e2e/app/tsconfig.json b/packages/react-virtual/e2e/app/tsconfig.json new file mode 100644 index 000000000..ad08d6c2b --- /dev/null +++ b/packages/react-virtual/e2e/app/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "target": "ESNext", + "moduleResolution": "Bundler", + "module": "ESNext", + "resolveJsonModule": true, + "allowJs": true, + "skipLibCheck": true + }, + "exclude": ["node_modules", "dist"] +} diff --git a/packages/react-virtual/e2e/app/vite.config.ts b/packages/react-virtual/e2e/app/vite.config.ts new file mode 100644 index 000000000..a498bf932 --- /dev/null +++ b/packages/react-virtual/e2e/app/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + root: __dirname, + plugins: [react()], +}) diff --git a/packages/react-virtual/package.json b/packages/react-virtual/package.json index fef3ac781..f311b1cac 100644 --- a/packages/react-virtual/package.json +++ b/packages/react-virtual/package.json @@ -29,7 +29,8 @@ "test:lib": "vitest", "test:lib:dev": "pnpm run test:lib --watch", "test:build": "publint --strict", - "build": "vite build" + "build": "vite build", + "test:e2e": "playwright test" }, "type": "module", "types": "dist/esm/index.d.ts", diff --git a/packages/react-virtual/playwright.config.ts b/packages/react-virtual/playwright.config.ts new file mode 100644 index 000000000..661885f18 --- /dev/null +++ b/packages/react-virtual/playwright.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from '@playwright/test' + +const PORT = 5173 + +export default defineConfig({ + testDir: './e2e/app/test', + use: { + baseURL: `http://localhost:${PORT}`, + }, + webServer: { + command: 'vite --config e2e/app/vite.config.ts', + port: PORT, + reuseExistingServer: !process.env.CI, + }, +}) diff --git a/packages/react-virtual/tsconfig.json b/packages/react-virtual/tsconfig.json index 3655b9d05..e8ab5e0c3 100644 --- a/packages/react-virtual/tsconfig.json +++ b/packages/react-virtual/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "jsx": "react" }, - "include": ["src", "eslint.config.js", "vite.config.ts"] + "include": ["src", "eslint.config.js", "vite.config.ts", "playwright.config.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1859b3f87..5a1caa0f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@changesets/cli': specifier: ^2.29.4 version: 2.29.4 + '@playwright/test': + specifier: ^1.53.1 + version: 1.53.1 '@svitejs/changesets-changelog-github-compact': specifier: ^1.2.0 version: 1.2.0(encoding@0.1.13) @@ -3181,6 +3184,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.53.1': + resolution: {integrity: sha512-Z4c23LHV0muZ8hfv4jw6HngPJkbbtZxTkxPNIg7cJcTc9C28N/p2q7g3JZS2SiKBBHJ3uM1dgDye66bB7LEk5w==} + engines: {node: '>=18'} + hasBin: true + '@publint/pack@0.1.2': resolution: {integrity: sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==} engines: {node: '>=18'} @@ -5218,6 +5226,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -6533,6 +6546,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.53.1: + resolution: {integrity: sha512-Z46Oq7tLAyT0lGoFx4DOuB1IA9D1TPj0QkYxpPVUnGDqHHvDpCftu1J2hM2PiWsNMoZh8+LQaarAWcDfPBc6zg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.53.1: + resolution: {integrity: sha512-LJ13YLr/ocweuwxyGf1XNFWIU4M2zUSo149Qbp+A4cpwDjsxRPj7k6H25LBrEHiEwxvRbD8HdwvQmRMSvquhYw==} + engines: {node: '>=18'} + hasBin: true + postcss-loader@8.1.1: resolution: {integrity: sha512-0IeqyAsG6tYiDRCYKQJLAmgQr47DX6N7sFSWvQxt6AcupX8DIdmykuk/o/tx0Lze3ErGHJEp5OSRxrelC6+NdQ==} engines: {node: '>= 18.12.0'} @@ -7967,12 +7990,12 @@ snapshots: '@vitejs/plugin-basic-ssl': 1.1.0(vite@5.4.19(@types/node@22.15.29)(less@4.2.0)(sass@1.71.1)(terser@5.29.1)) ansi-colors: 4.1.3 autoprefixer: 10.4.18(postcss@8.4.35) - babel-loader: 9.1.3(@babel/core@7.26.10)(webpack@5.94.0) + babel-loader: 9.1.3(@babel/core@7.26.10)(webpack@5.94.0(esbuild@0.20.1)) babel-plugin-istanbul: 6.1.1 browserslist: 4.25.0 - copy-webpack-plugin: 11.0.0(webpack@5.94.0) + copy-webpack-plugin: 11.0.0(webpack@5.94.0(esbuild@0.20.1)) critters: 0.0.22 - css-loader: 6.10.0(webpack@5.94.0) + css-loader: 6.10.0(webpack@5.94.0(esbuild@0.20.1)) esbuild-wasm: 0.20.1 fast-glob: 3.3.2 http-proxy-middleware: 2.0.8(@types/express@4.17.22) @@ -7981,11 +8004,11 @@ snapshots: jsonc-parser: 3.2.1 karma-source-map-support: 1.4.0 less: 4.2.0 - less-loader: 11.1.0(less@4.2.0)(webpack@5.94.0) - license-webpack-plugin: 4.0.2(webpack@5.94.0) + less-loader: 11.1.0(less@4.2.0)(webpack@5.94.0(esbuild@0.20.1)) + license-webpack-plugin: 4.0.2(webpack@5.94.0(esbuild@0.20.1)) loader-utils: 3.2.1 magic-string: 0.30.8 - mini-css-extract-plugin: 2.8.1(webpack@5.94.0) + mini-css-extract-plugin: 2.8.1(webpack@5.94.0(esbuild@0.20.1)) mrmime: 2.0.0 open: 8.4.2 ora: 5.4.1 @@ -7993,13 +8016,13 @@ snapshots: picomatch: 4.0.1 piscina: 4.4.0 postcss: 8.4.35 - postcss-loader: 8.1.1(postcss@8.4.35)(typescript@5.2.2)(webpack@5.94.0) + postcss-loader: 8.1.1(postcss@8.4.35)(typescript@5.2.2)(webpack@5.94.0(esbuild@0.20.1)) resolve-url-loader: 5.0.0 rxjs: 7.8.1 sass: 1.71.1 - sass-loader: 14.1.1(sass@1.71.1)(webpack@5.94.0) + sass-loader: 14.1.1(sass@1.71.1)(webpack@5.94.0(esbuild@0.20.1)) semver: 7.6.0 - source-map-loader: 5.0.0(webpack@5.94.0) + source-map-loader: 5.0.0(webpack@5.94.0(esbuild@0.20.1)) source-map-support: 0.5.21 terser: 5.29.1 tree-kill: 1.2.2 @@ -8008,10 +8031,10 @@ snapshots: vite: 5.4.19(@types/node@22.15.29)(less@4.2.0)(sass@1.71.1)(terser@5.29.1) watchpack: 2.4.0 webpack: 5.94.0(esbuild@0.20.1) - webpack-dev-middleware: 6.1.2(webpack@5.94.0) + webpack-dev-middleware: 6.1.2(webpack@5.94.0(esbuild@0.20.1)) webpack-dev-server: 4.15.1(webpack@5.94.0(esbuild@0.20.1)) webpack-merge: 5.10.0 - webpack-subresource-integrity: 5.1.0(webpack@5.94.0) + webpack-subresource-integrity: 5.1.0(webpack@5.94.0(esbuild@0.20.1)) optionalDependencies: esbuild: 0.20.1 ng-packagr: 17.3.0(@angular/compiler-cli@17.3.12(@angular/compiler@17.3.12(@angular/core@17.3.12(rxjs@7.8.2)(zone.js@0.15.1)))(typescript@5.2.2))(tslib@2.8.1)(typescript@5.2.2) @@ -9842,6 +9865,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.53.1': + dependencies: + playwright: 1.53.1 + '@publint/pack@0.1.2': {} '@react-hookz/web@25.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': @@ -11140,7 +11167,7 @@ snapshots: axobject-query@4.1.0: {} - babel-loader@9.1.3(@babel/core@7.26.10)(webpack@5.94.0): + babel-loader@9.1.3(@babel/core@7.26.10)(webpack@5.94.0(esbuild@0.20.1)): dependencies: '@babel/core': 7.26.10 find-cache-dir: 4.0.0 @@ -11497,7 +11524,7 @@ snapshots: dependencies: is-what: 3.14.1 - copy-webpack-plugin@11.0.0(webpack@5.94.0): + copy-webpack-plugin@11.0.0(webpack@5.94.0(esbuild@0.20.1)): dependencies: fast-glob: 3.3.3 glob-parent: 6.0.2 @@ -11538,7 +11565,7 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-loader@6.10.0(webpack@5.94.0): + css-loader@6.10.0(webpack@5.94.0(esbuild@0.20.1)): dependencies: icss-utils: 5.1.0(postcss@8.5.4) postcss: 8.5.4 @@ -12219,6 +12246,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -12853,7 +12883,7 @@ snapshots: picocolors: 1.1.1 shell-quote: 1.8.2 - less-loader@11.1.0(less@4.2.0)(webpack@5.94.0): + less-loader@11.1.0(less@4.2.0)(webpack@5.94.0(esbuild@0.20.1)): dependencies: klona: 2.0.6 less: 4.2.0 @@ -12892,7 +12922,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - license-webpack-plugin@4.0.2(webpack@5.94.0): + license-webpack-plugin@4.0.2(webpack@5.94.0(esbuild@0.20.1)): dependencies: webpack-sources: 3.3.0 optionalDependencies: @@ -13090,7 +13120,7 @@ snapshots: min-indent@1.0.1: {} - mini-css-extract-plugin@2.8.1(webpack@5.94.0): + mini-css-extract-plugin@2.8.1(webpack@5.94.0(esbuild@0.20.1)): dependencies: schema-utils: 4.3.2 tapable: 2.2.2 @@ -13663,7 +13693,15 @@ snapshots: mlly: 1.7.4 pathe: 2.0.3 - postcss-loader@8.1.1(postcss@8.4.35)(typescript@5.2.2)(webpack@5.94.0): + playwright-core@1.53.1: {} + + playwright@1.53.1: + dependencies: + playwright-core: 1.53.1 + optionalDependencies: + fsevents: 2.3.2 + + postcss-loader@8.1.1(postcss@8.4.35)(typescript@5.2.2)(webpack@5.94.0(esbuild@0.20.1)): dependencies: cosmiconfig: 9.0.0(typescript@5.2.2) jiti: 1.21.7 @@ -14013,7 +14051,7 @@ snapshots: safer-buffer@2.1.2: {} - sass-loader@14.1.1(sass@1.71.1)(webpack@5.94.0): + sass-loader@14.1.1(sass@1.71.1)(webpack@5.94.0(esbuild@0.20.1)): dependencies: neo-async: 2.6.2 optionalDependencies: @@ -14284,7 +14322,7 @@ snapshots: source-map-js@1.2.1: {} - source-map-loader@5.0.0(webpack@5.94.0): + source-map-loader@5.0.0(webpack@5.94.0(esbuild@0.20.1)): dependencies: iconv-lite: 0.6.3 source-map-js: 1.2.1 @@ -14923,7 +14961,7 @@ snapshots: schema-utils: 4.3.2 webpack: 5.94.0(esbuild@0.20.1) - webpack-dev-middleware@6.1.2(webpack@5.94.0): + webpack-dev-middleware@6.1.2(webpack@5.94.0(esbuild@0.20.1)): dependencies: colorette: 2.0.20 memfs: 3.5.3 @@ -14981,7 +15019,7 @@ snapshots: webpack-sources@3.3.0: {} - webpack-subresource-integrity@5.1.0(webpack@5.94.0): + webpack-subresource-integrity@5.1.0(webpack@5.94.0(esbuild@0.20.1)): dependencies: typed-assert: 1.0.9 webpack: 5.94.0(esbuild@0.20.1) From 0244e8a01bb6fde5fcc7eab771690e36415992ca Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 09:53:57 +0000 Subject: [PATCH 06/14] ci: apply automated fixes --- packages/react-virtual/tsconfig.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/react-virtual/tsconfig.json b/packages/react-virtual/tsconfig.json index e8ab5e0c3..effe33b1b 100644 --- a/packages/react-virtual/tsconfig.json +++ b/packages/react-virtual/tsconfig.json @@ -3,5 +3,10 @@ "compilerOptions": { "jsx": "react" }, - "include": ["src", "eslint.config.js", "vite.config.ts", "playwright.config.ts"] + "include": [ + "src", + "eslint.config.js", + "vite.config.ts", + "playwright.config.ts" + ] } From 7eaa2e9734f03a7f2721d0a161c7dd1934fa4567 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Fri, 27 Jun 2025 11:58:08 +0200 Subject: [PATCH 07/14] Install Playwright browsers --- .github/workflows/pr.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d8e41189f..abd69e075 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -28,6 +28,8 @@ jobs: uses: nrwl/nx-set-shas@v4.3.0 with: main-branch-name: main + - name: Install Playwright browsers + run: pnpm exec playwright install --with-deps - name: Run Checks run: pnpm run test:pr preview: From 866180c11034a33670abedc7a8c7171b4c0a9b04 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Fri, 27 Jun 2025 12:13:53 +0200 Subject: [PATCH 08/14] Install only chromium browser, add timeout to scroll test --- .github/workflows/pr.yml | 2 +- packages/react-virtual/e2e/app/test/scroll.spec.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index abd69e075..9b72a44ba 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -29,7 +29,7 @@ jobs: with: main-branch-name: main - name: Install Playwright browsers - run: pnpm exec playwright install --with-deps + run: pnpm exec playwright install chromium - name: Run Checks run: pnpm run test:pr preview: diff --git a/packages/react-virtual/e2e/app/test/scroll.spec.ts b/packages/react-virtual/e2e/app/test/scroll.spec.ts index 8e591077f..55d350dd2 100644 --- a/packages/react-virtual/e2e/app/test/scroll.spec.ts +++ b/packages/react-virtual/e2e/app/test/scroll.spec.ts @@ -7,6 +7,8 @@ test('scrolls to index 1000', async ({ page }) => { const item = page.locator('[data-testid="item-1000"]') await expect(item).toBeVisible() + await page.waitForTimeout(5_000) + const container = page.locator('#scroll-container') const [itemBox, scrollTop, containerBox] = await Promise.all([ item.boundingBox(), From b99db907c67068105e17bfff0c2d7a64853c74df Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Fri, 27 Jun 2025 12:28:38 +0200 Subject: [PATCH 09/14] Use page.evaluate --- .../react-virtual/e2e/app/test/scroll.spec.ts | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/react-virtual/e2e/app/test/scroll.spec.ts b/packages/react-virtual/e2e/app/test/scroll.spec.ts index 55d350dd2..ad3f2ab2d 100644 --- a/packages/react-virtual/e2e/app/test/scroll.spec.ts +++ b/packages/react-virtual/e2e/app/test/scroll.spec.ts @@ -4,24 +4,25 @@ test('scrolls to index 1000', async ({ page }) => { await page.goto('/') await page.click('#scroll-to-1000') - const item = page.locator('[data-testid="item-1000"]') - await expect(item).toBeVisible() + await expect(page.locator('[data-testid="item-1000"]')).toBeVisible() - await page.waitForTimeout(5_000) + const delta = await page.evaluate(() => { + const item = document.querySelector('[data-testid="item-1000"]') + const container = document.querySelector('#scroll-container') - const container = page.locator('#scroll-container') - const [itemBox, scrollTop, containerBox] = await Promise.all([ - item.boundingBox(), - container.evaluate((el) => el.scrollTop), - container.boundingBox(), - ]) + if (!item || !container) throw new Error('Elements not found') - if (!itemBox || !containerBox) throw new Error('Missing bounding boxes') + const itemRect = item.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + const scrollTop = container.scrollTop - const itemTopRelativeToScroll = itemBox.y + scrollTop - containerBox.y - const itemBottomRelativeToScroll = itemTopRelativeToScroll + itemBox.height - const containerVisibleBottom = scrollTop + containerBox.height + const top = itemRect.top + scrollTop - containerRect.top + const botttom = top + itemRect.height + + const containerBottom = scrollTop + container.clientHeight + + return Math.abs(botttom - containerBottom) + }) - const delta = Math.abs(itemBottomRelativeToScroll - containerVisibleBottom) expect(delta).toBeLessThan(1.01) }) From 5a32fe278c539375afa822a97243e2f5bd95464c Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Fri, 27 Jun 2025 13:01:23 +0200 Subject: [PATCH 10/14] WIP --- packages/react-virtual/e2e/app/main.tsx | 1 + packages/react-virtual/e2e/app/test/scroll.spec.ts | 4 ++++ packages/react-virtual/playwright.config.ts | 10 +++++++++- packages/virtual-core/src/index.ts | 3 +++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/react-virtual/e2e/app/main.tsx b/packages/react-virtual/e2e/app/main.tsx index 5978e60e7..7c3869af1 100644 --- a/packages/react-virtual/e2e/app/main.tsx +++ b/packages/react-virtual/e2e/app/main.tsx @@ -25,6 +25,7 @@ const App = () => { count: 1002, getScrollElement: () => parentRef.current, estimateSize: () => 50, + debug: true, }) return ( diff --git a/packages/react-virtual/e2e/app/test/scroll.spec.ts b/packages/react-virtual/e2e/app/test/scroll.spec.ts index ad3f2ab2d..acb7c571c 100644 --- a/packages/react-virtual/e2e/app/test/scroll.spec.ts +++ b/packages/react-virtual/e2e/app/test/scroll.spec.ts @@ -6,6 +6,10 @@ test('scrolls to index 1000', async ({ page }) => { await expect(page.locator('[data-testid="item-1000"]')).toBeVisible() + if (process.env.CI) { + await page.waitForTimeout(1_000) + } + const delta = await page.evaluate(() => { const item = document.querySelector('[data-testid="item-1000"]') const container = document.querySelector('#scroll-container') diff --git a/packages/react-virtual/playwright.config.ts b/packages/react-virtual/playwright.config.ts index 661885f18..170185ad4 100644 --- a/packages/react-virtual/playwright.config.ts +++ b/packages/react-virtual/playwright.config.ts @@ -1,9 +1,10 @@ -import { defineConfig } from '@playwright/test' +import { defineConfig, devices } from '@playwright/test' const PORT = 5173 export default defineConfig({ testDir: './e2e/app/test', + workers: 1, use: { baseURL: `http://localhost:${PORT}`, }, @@ -11,5 +12,12 @@ export default defineConfig({ command: 'vite --config e2e/app/vite.config.ts', port: PORT, reuseExistingServer: !process.env.CI, + stdout: 'pipe', }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], }) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 4192216b7..b4794e06e 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1003,6 +1003,9 @@ export class Virtualizer< attempts++ if (attempts < maxAttempts) { + if (process.env.NODE_ENV !== 'production' && this.options.debug) { + console.info('Schedule retry', attempts, maxAttempts) + } this.targetWindow.requestAnimationFrame(() => tryScroll(align)) } else { console.warn( From 7e604e4fc298bd1335d0a5456416d6267b3600df Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Fri, 27 Jun 2025 13:19:39 +0200 Subject: [PATCH 11/14] WIP --- packages/react-virtual/e2e/app/test/scroll.spec.ts | 10 +++------- packages/react-virtual/playwright.config.ts | 9 +-------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/packages/react-virtual/e2e/app/test/scroll.spec.ts b/packages/react-virtual/e2e/app/test/scroll.spec.ts index acb7c571c..64b1c4cee 100644 --- a/packages/react-virtual/e2e/app/test/scroll.spec.ts +++ b/packages/react-virtual/e2e/app/test/scroll.spec.ts @@ -2,14 +2,9 @@ import { expect, test } from '@playwright/test' test('scrolls to index 1000', async ({ page }) => { await page.goto('/') + await page.waitForSelector('#scroll-to-1000', { state: 'visible' }) await page.click('#scroll-to-1000') - await expect(page.locator('[data-testid="item-1000"]')).toBeVisible() - - if (process.env.CI) { - await page.waitForTimeout(1_000) - } - const delta = await page.evaluate(() => { const item = document.querySelector('[data-testid="item-1000"]') const container = document.querySelector('#scroll-container') @@ -27,6 +22,7 @@ test('scrolls to index 1000', async ({ page }) => { return Math.abs(botttom - containerBottom) }) + console.log('delta:', delta) - expect(delta).toBeLessThan(1.01) + await expect(page.locator('[data-testid="item-1000"]')).toBeVisible() }) diff --git a/packages/react-virtual/playwright.config.ts b/packages/react-virtual/playwright.config.ts index 170185ad4..fd125dba5 100644 --- a/packages/react-virtual/playwright.config.ts +++ b/packages/react-virtual/playwright.config.ts @@ -1,10 +1,9 @@ -import { defineConfig, devices } from '@playwright/test' +import { defineConfig } from '@playwright/test' const PORT = 5173 export default defineConfig({ testDir: './e2e/app/test', - workers: 1, use: { baseURL: `http://localhost:${PORT}`, }, @@ -14,10 +13,4 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, stdout: 'pipe', }, - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - ], }) From 3c759781f05f0aad2f5b7ba483539f9a5a7f8817 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Fri, 27 Jun 2025 13:31:14 +0200 Subject: [PATCH 12/14] WIP --- packages/react-virtual/playwright.config.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/react-virtual/playwright.config.ts b/packages/react-virtual/playwright.config.ts index fd125dba5..6e9f9a5fb 100644 --- a/packages/react-virtual/playwright.config.ts +++ b/packages/react-virtual/playwright.config.ts @@ -1,15 +1,16 @@ import { defineConfig } from '@playwright/test' const PORT = 5173 +const baseURL = `http://localhost:${PORT}` export default defineConfig({ testDir: './e2e/app/test', use: { - baseURL: `http://localhost:${PORT}`, + baseURL, }, webServer: { - command: 'vite --config e2e/app/vite.config.ts', - port: PORT, + 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, reuseExistingServer: !process.env.CI, stdout: 'pipe', }, From 22aa4469ebeeb960892a2d02eede8d915cb81300 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Fri, 27 Jun 2025 13:40:37 +0200 Subject: [PATCH 13/14] WIP --- packages/react-virtual/e2e/app/main.tsx | 62 ++++++++++--------- .../react-virtual/e2e/app/test/scroll.spec.ts | 38 +++++++----- 2 files changed, 55 insertions(+), 45 deletions(-) diff --git a/packages/react-virtual/e2e/app/main.tsx b/packages/react-virtual/e2e/app/main.tsx index 7c3869af1..e5273bca5 100644 --- a/packages/react-virtual/e2e/app/main.tsx +++ b/packages/react-virtual/e2e/app/main.tsx @@ -29,40 +29,46 @@ const App = () => { }) return ( -
-
- {rowVirtualizer.getVirtualItems().map((v) => ( -
-
- Row {v.index} -
-
- ))} -
+
+ +
+
+ {rowVirtualizer.getVirtualItems().map((v) => ( +
+
+ Row {v.index} +
+
+ ))} +
+
) } diff --git a/packages/react-virtual/e2e/app/test/scroll.spec.ts b/packages/react-virtual/e2e/app/test/scroll.spec.ts index 64b1c4cee..a5793e422 100644 --- a/packages/react-virtual/e2e/app/test/scroll.spec.ts +++ b/packages/react-virtual/e2e/app/test/scroll.spec.ts @@ -1,28 +1,32 @@ import { expect, test } from '@playwright/test' -test('scrolls to index 1000', async ({ page }) => { - await page.goto('/') - await page.waitForSelector('#scroll-to-1000', { state: 'visible' }) - await page.click('#scroll-to-1000') +const check = () => { + const item = document.querySelector('[data-testid="item-1000"]') + const container = document.querySelector('#scroll-container') + + if (!item || !container) throw new Error('Elements not found') - const delta = await page.evaluate(() => { - const item = document.querySelector('[data-testid="item-1000"]') - const container = document.querySelector('#scroll-container') + const itemRect = item.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + const scrollTop = container.scrollTop - if (!item || !container) throw new Error('Elements not found') + const top = itemRect.top + scrollTop - containerRect.top + const botttom = top + itemRect.height - const itemRect = item.getBoundingClientRect() - const containerRect = container.getBoundingClientRect() - const scrollTop = container.scrollTop + const containerBottom = scrollTop + container.clientHeight - const top = itemRect.top + scrollTop - containerRect.top - const botttom = top + itemRect.height + return Math.abs(botttom - containerBottom) +} - const containerBottom = scrollTop + container.clientHeight +test('scrolls to index 1000', async ({ page }) => { + await page.goto('/') + await page.click('#scroll-to-1000') - return Math.abs(botttom - containerBottom) - }) - console.log('delta:', delta) + // Wait for scroll effect (including retries) + await page.waitForTimeout(1000) await expect(page.locator('[data-testid="item-1000"]')).toBeVisible() + + const delta = await page.evaluate(check) + console.log('bootom element detla', delta) }) From 33e6ccbfb6f1465ba596e1be60ccbfd33d6ad4b4 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Fri, 27 Jun 2025 13:51:52 +0200 Subject: [PATCH 14/14] Revert expect --- packages/react-virtual/e2e/app/test/scroll.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-virtual/e2e/app/test/scroll.spec.ts b/packages/react-virtual/e2e/app/test/scroll.spec.ts index a5793e422..65ba73b3a 100644 --- a/packages/react-virtual/e2e/app/test/scroll.spec.ts +++ b/packages/react-virtual/e2e/app/test/scroll.spec.ts @@ -29,4 +29,5 @@ test('scrolls to index 1000', async ({ page }) => { const delta = await page.evaluate(check) console.log('bootom element detla', delta) + expect(delta).toBeLessThan(1.01) })