Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/smooth-scroll-survives-prepend.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions packages/react-virtual/e2e/app/smooth-prepend/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
124 changes: 124 additions & 0 deletions packages/react-virtual/e2e/app/smooth-prepend/main.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null)
const firstMessageIndexRef = React.useRef(0)

const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
getItemKey: (index) => messages[index]!.id,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the unnecessary non-null assertions.

ESLint reports errors at Line 34 and Line 88. Remove ! from both array accesses.

Proposed fix
-    getItemKey: (index) => messages[index]!.id,
+    getItemKey: (index) => messages[index].id,
...
-            const message = messages[item.index]!
+            const message = messages[item.index]

Also applies to: 88-88

🧰 Tools
🪛 ESLint

[error] 34-34: This assertion is unnecessary since it does not change the type of the expression.

(@typescript-eslint/no-unnecessary-type-assertion)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-virtual/e2e/app/smooth-prepend/main.tsx` at line 34, Remove
the unnecessary non-null assertions from the array accesses in the getItemKey
callback and the corresponding access at the second reported location,
preserving their existing indexing and key behavior.

Source: Linters/SAST tools

anchorTo: 'end',
followOnAppend: true,
overscan: 4,
})

React.useLayoutEffect(() => {
if (didInitialScroll) return
virtualizer.scrollToEnd()
setDidInitialScroll(true)
}, [didInitialScroll, virtualizer])

return (
<div>
<button
id="smooth-to-0"
onClick={() => virtualizer.scrollToIndex(0, { behavior: 'smooth' })}
>
Smooth to 0
</button>
<button
id="prepend"
onClick={() => {
const start = firstMessageIndexRef.current - 5
firstMessageIndexRef.current = start
setMessages((current) => [
...Array.from({ length: 5 }, (_, offset) =>
makeMessage(start + offset),
),
...current,
])
}}
>
Prepend
</button>

<div
ref={parentRef}
id="scroll-container"
style={{
height: 300,
overflow: 'auto',
width: 420,
border: '1px solid #ddd',
}}
>
<div
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
width: '100%',
}}
>
{virtualizer.getVirtualItems().map((item) => {
const message = messages[item.index]!

return (
<div
key={item.key}
ref={virtualizer.measureElement}
data-index={item.index}
data-message-id={message.id}
data-testid={`message-${message.id}`}
style={{
position: 'absolute',
top: 0,
left: 0,
transform: `translateY(${item.start}px)`,
width: '100%',
}}
>
<div
style={{
boxSizing: 'border-box',
height: 50,
padding: 8,
borderBottom: '1px solid #eee',
}}
>
{message.text}
</div>
</div>
)
})}
</div>
</div>
</div>
)
}

createRoot(document.getElementById('root')!).render(<App />)
58 changes: 58 additions & 0 deletions packages/react-virtual/e2e/app/test/smooth-prepend.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
1 change: 1 addition & 0 deletions packages/react-virtual/e2e/app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
109 changes: 109 additions & 0 deletions packages/virtual-core/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement, any>({
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)
})
Loading