From b96bba23111d5c04e18dad8dce4b700ae444ca2e Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Fri, 31 Jul 2026 13:23:33 +0200 Subject: [PATCH] test(react-virtual): record that a mid-flight prepend strands scrollToIndex --- .../e2e/app/smooth-prepend/index.html | 10 ++ .../e2e/app/smooth-prepend/main.tsx | 124 ++++++++++++++++++ .../e2e/app/test/smooth-prepend.spec.ts | 67 ++++++++++ packages/react-virtual/e2e/app/vite.config.ts | 1 + 4 files changed, 202 insertions(+) create mode 100644 packages/react-virtual/e2e/app/smooth-prepend/index.html create mode 100644 packages/react-virtual/e2e/app/smooth-prepend/main.tsx create mode 100644 packages/react-virtual/e2e/app/test/smooth-prepend.spec.ts diff --git a/packages/react-virtual/e2e/app/smooth-prepend/index.html b/packages/react-virtual/e2e/app/smooth-prepend/index.html new file mode 100644 index 000000000..56f418f61 --- /dev/null +++ b/packages/react-virtual/e2e/app/smooth-prepend/index.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/packages/react-virtual/e2e/app/smooth-prepend/main.tsx b/packages/react-virtual/e2e/app/smooth-prepend/main.tsx new file mode 100644 index 000000000..f4a4d7b47 --- /dev/null +++ b/packages/react-virtual/e2e/app/smooth-prepend/main.tsx @@ -0,0 +1,124 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import { useVirtualizer } from '@tanstack/react-virtual' + +// End-anchored list built for one scenario: a long smooth scrollToIndex that is +// still in flight when history is prepended. The list is deliberately tall +// (200 x 50px against a 300px viewport) so the animation lasts long enough for +// the test to reliably observe it mid-flight and prepend into that window. + +type Message = { + id: string + text: string +} + +const makeMessage = (index: number): Message => ({ + id: `m-${index}`, + text: `Message ${index}`, +}) + +const initialMessages = Array.from({ length: 200 }, (_, index) => + makeMessage(index), +) + +function App() { + const [messages, setMessages] = React.useState(initialMessages) + const [didInitialScroll, setDidInitialScroll] = React.useState(false) + const parentRef = React.useRef(null) + const firstMessageIndexRef = React.useRef(0) + + const virtualizer = useVirtualizer({ + count: messages.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, + getItemKey: (index) => messages[index]!.id, + anchorTo: 'end', + followOnAppend: true, + overscan: 4, + }) + + React.useLayoutEffect(() => { + if (didInitialScroll) return + virtualizer.scrollToEnd() + setDidInitialScroll(true) + }, [didInitialScroll, virtualizer]) + + return ( +
+ + + +
+
+ {virtualizer.getVirtualItems().map((item) => { + const message = messages[item.index]! + + return ( +
+
+ {message.text} +
+
+ ) + })} +
+
+
+ ) +} + +createRoot(document.getElementById('root')!).render() diff --git a/packages/react-virtual/e2e/app/test/smooth-prepend.spec.ts b/packages/react-virtual/e2e/app/test/smooth-prepend.spec.ts new file mode 100644 index 000000000..0a11c9b03 --- /dev/null +++ b/packages/react-virtual/e2e/app/test/smooth-prepend.spec.ts @@ -0,0 +1,67 @@ +import { expect, test } from '@playwright/test' +import type { Page } from '@playwright/test' + +const scrollTop = (page: Page) => + page.evaluate(() => { + const container = document.querySelector('#scroll-container') + if (!container) throw new Error('Container not found') + return container.scrollTop + }) + +async function waitForEnd(page: Page) { + await expect + .poll(async () => + page.evaluate(() => { + const container = document.querySelector('#scroll-container') + if (!container) throw new Error('Container not found') + return Math.abs( + container.scrollHeight - container.scrollTop - container.clientHeight, + ) + }), + ) + .toBeLessThan(1.01) +} + +// KNOWN BUG, not a guard on current behaviour — test.fail() asserts this still +// reproduces and turns red the moment it is fixed, at which point drop the +// annotation and keep the assertions. +// +// A prepend that lands while a scrollToIndex is still travelling strands it. The +// anchor sync in _willUpdate writes scrollTop, which cancels the browser's +// smooth animation, and reconcileScroll never resumes the journey because its +// `else` branch only re-asserts when the *target* changed. With uniform rows +// index 0 sits at offset 0 both before and after the prepend, so the target is +// unchanged and the loop just idles. "Jump to the oldest message" therefore dies +// halfway whenever history streams in mid-animation. +// +// Reproduces identically on the commit before the stale-target fix (stranded at +// ~3900 vs ~3500), so it is pre-existing and independent of it. Resuming an +// unfinished scroll needs its own change: reconcileScroll idling is exactly what +// stops it fighting a reader who deliberately scrolls away mid-scroll, so making +// it re-assert is a behavioural decision rather than a local patch. +test.fail() +test('a prepend mid-flight does not abandon a smooth scrollToIndex', async ({ + page, +}) => { + await page.goto('/smooth-prepend/') + await waitForEnd(page) + + const start = await scrollTop(page) + expect(start).toBeGreaterThan(9000) // 200 x 50 - 300 + + // Ask for index 0 and catch the animation in flight — well clear of both + // ends, so this asserts on a genuinely mid-scroll prepend. + await page.click('#smooth-to-0') + await expect + .poll(() => scrollTop(page), { timeout: 5000 }) + .toBeLessThan(start - 1000) + expect(await scrollTop(page)).toBeGreaterThan(500) + + // History arrives while we are still moving. + await page.click('#prepend') + + // The requested scroll should still complete. Index 0 sits at offset 0 both + // before and after the prepend (uniform 50px rows), so the destination is + // unambiguous: the top. + await expect.poll(() => scrollTop(page), { timeout: 3000 }).toBeLessThan(1.01) +}) diff --git a/packages/react-virtual/e2e/app/vite.config.ts b/packages/react-virtual/e2e/app/vite.config.ts index e5594d0f2..2aa91ae77 100644 --- a/packages/react-virtual/e2e/app/vite.config.ts +++ b/packages/react-virtual/e2e/app/vite.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ 'measure-element/index.html', ), 'smooth-scroll': path.resolve(__dirname, 'smooth-scroll/index.html'), + 'smooth-prepend': path.resolve(__dirname, 'smooth-prepend/index.html'), 'stale-index': path.resolve(__dirname, 'stale-index/index.html'), 'direct-dom-updates': path.resolve( __dirname,