diff --git a/.changeset/smooth-scroll-survives-prepend.md b/.changeset/smooth-scroll-survives-prepend.md new file mode 100644 index 000000000..d293fa1bf --- /dev/null +++ b/.changeset/smooth-scroll-survives-prepend.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +Keep a travelling smooth `scrollToIndex` alive when content is prepended. With `anchorTo: 'end'`, the prepend anchor sync wrote `scrollTop` instantly, which cancelled the browser's smooth animation and left the scroll stranded partway; Chromium drops a smooth request re-issued right after such a cancel, so it could not be resumed. The sync is now skipped while a smooth programmatic scroll is still in flight, and the animation continues to its recomputed target. A smooth scroll that has already landed still receives the anchor sync. 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..5c12cacfb --- /dev/null +++ b/packages/react-virtual/e2e/app/test/smooth-prepend.spec.ts @@ -0,0 +1,58 @@ +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) +} + +// Regression guard: a prepend that lands while a smooth scrollToIndex is still +// travelling must not strand it. The end-anchor prepend sync in _willUpdate +// used to write scrollTop instantly, which cancels the browser's smooth +// animation; Chromium then drops a smooth request re-issued in the very next +// frame, so reconcileScroll could not recover the journey and "Jump to the +// oldest message" died halfway whenever history streamed in mid-animation. +// Core now skips that sync while a smooth programmatic scroll is in flight +// (its index-based target recomputes against the new layout), so the +// animation simply continues to the top. +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 505279170..87bc8b9d7 100644 --- a/packages/react-virtual/e2e/app/vite.config.ts +++ b/packages/react-virtual/e2e/app/vite.config.ts @@ -17,6 +17,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, diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index f273588b6..ec3f30d4c 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1045,6 +1045,21 @@ export class Virtualizer< if (anchorDelta !== 0) { this._iosDeferredAdjustment += anchorDelta } + } else if ( + this.scrollState?.behavior === 'smooth' && + !approxEqual( + this.getScrollOffset() - anchorDelta, + this.scrollState.lastTargetOffset, + ) + ) { + // A smooth programmatic scroll is still travelling. Writing scrollTop + // here would cancel the browser's animation, and Chromium drops a + // smooth request re-issued in the frame right after that cancel, so + // the journey would be stranded. The target is index-based and + // recomputes against the new layout in reconcileScroll, so let the + // animation run; the next scroll event re-syncs the tracked offset. + // A smooth scroll that has already landed (offset at its target, + // reconcile not yet retired it) still gets the anchor sync. } else { this._scrollToOffset(this.getScrollOffset(), { adjustments: undefined, diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 93d1bf5c6..cb1e742de 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -4123,3 +4123,112 @@ test('#1258: cleanup drops a pending clamped write', () => { expect(virtualizer['_clampedAdjustment']).toBeNull() }) + +// ─── a prepend must not strand a travelling smooth scroll ──────────────────── +// The end-anchor prepend sync in _willUpdate writes scrollTop instantly. That +// cancels a smooth scrollToIndex the browser is still animating, and Chromium +// drops a smooth request re-issued in the frame right after that cancel, so +// re-driving from reconcileScroll cannot recover it. While a smooth +// programmatic scroll is travelling, the sync is skipped: its target is +// index-based and recomputes against the new layout, so the animation simply +// continues. Browser coverage: react-virtual e2e/app/test/smooth-prepend.spec.ts. + +function createSmoothPrependVirtualizer() { + const mockWindow = { + requestAnimationFrame: vi.fn(() => 1), + cancelAnimationFrame: vi.fn(), + performance: { now: () => Date.now() }, + ResizeObserver: vi.fn(function () { + return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() } + }), + } + const el = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 400, + scrollHeight: 10000, // 200 x 50 + clientWidth: 400, + clientHeight: 300, + offsetWidth: 400, + offsetHeight: 300, + ownerDocument: { defaultView: mockWindow }, + scrollTo: vi.fn(), + } as unknown as HTMLDivElement + + const scrollToFn = vi.fn() + let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = + null + const messages = Array.from({ length: 200 }, (_, i) => `m-${i}`) + const v = new Virtualizer({ + count: messages.length, + estimateSize: () => 50, + anchorTo: 'end', + getItemKey: (i) => messages[i]!, + getScrollElement: () => el, + scrollToFn, + observeElementRect: (_inst, cb) => { + cb({ width: 400, height: 300 }) + return () => {} + }, + observeElementOffset: (_inst, cb) => { + scrollCallback = cb + cb(9700, false) + return () => {} + }, + }) + v._willUpdate() + v.getVirtualItems() + scrollToFn.mockClear() // drop the mount sync write + + return { + v, + scrollToFn, + scroll: (offset: number, isScrolling = true) => { + scrollCallback!(offset, isScrolling) + v.getVirtualItems() + }, + // 5 messages x 50px land above the reader. + prepend: () => { + messages.unshift('m--5', 'm--4', 'm--3', 'm--2', 'm--1') + ;(el as any).scrollHeight = 10250 + v.setOptions({ + ...v.options, + count: messages.length, + getItemKey: (i: number) => messages[i]!, + }) + v._willUpdate() + }, + } +} + +test('a prepend mid-flight leaves a travelling smooth scrollToIndex alone', () => { + const { v, scrollToFn, scroll, prepend } = createSmoothPrependVirtualizer() + + v.scrollToIndex(0, { behavior: 'smooth' }) + expect(scrollToFn.mock.calls[0]![0]).toBe(0) + scroll(3500) // the browser is mid-animation + scrollToFn.mockClear() + + prepend() + + // No instant scrollTop write that would cancel the animation... + expect(scrollToFn).not.toHaveBeenCalled() + // ...and the journey is still live. + expect(v['scrollState']).toMatchObject({ index: 0, behavior: 'smooth' }) +}) + +test('a prepend right after a smooth scroll landed still syncs the anchor', () => { + const { v, scrollToFn, scroll, prepend } = createSmoothPrependVirtualizer() + + v.scrollToIndex(100, { behavior: 'smooth' }) + const target = scrollToFn.mock.calls[0]![0] as number + expect(target).toBe(5000) + // Arrived; reconcileScroll has not retired scrollState yet. + scroll(target, false) + scrollToFn.mockClear() + + prepend() + + // The reader's position is preserved: the DOM is synced to the shifted offset. + expect(scrollToFn.mock.calls.at(-1)?.[0]).toBe(target + 250) +})