-
-
Notifications
You must be signed in to change notification settings - Fork 465
fix(virtual-core): keep a travelling smooth scroll alive through a prepend #1248
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
piecyk
merged 4 commits into
TanStack:main
from
piecyk:fix/anchor-clobbered-by-stale-scroll-state
Sep 11, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
b96bba2
test(react-virtual): record that a mid-flight prepend strands scrollT…
piecyk beae50c
Merge branch 'main' into fix/anchor-clobbered-by-stale-scroll-state
piecyk ba2c744
fix(virtual-core): keep a travelling smooth scroll alive through a pr…
piecyk 032f913
Merge branch 'main' into fix/anchor-clobbered-by-stale-scroll-state
piecyk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| 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
58
packages/react-virtual/e2e/app/test/smooth-prepend.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
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
Source: Linters/SAST tools