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.scrollToIndex(0, { behavior: 'smooth' })}
+ >
+ Smooth to 0
+
+
{
+ const start = firstMessageIndexRef.current - 5
+ firstMessageIndexRef.current = start
+ setMessages((current) => [
+ ...Array.from({ length: 5 }, (_, offset) =>
+ makeMessage(start + offset),
+ ),
+ ...current,
+ ])
+ }}
+ >
+ Prepend
+
+
+
+
+ )
+}
+
+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)
+})