scrollMargin is ignored when deciding the viewport is at the end
Les mainteneurs répondent en général sous 1 jour
Évaluation
- Difficulté
- 2/5
- Temps estimé
- 1-3 heures
- Accessibilité débutants
- 25/100
- Type d'issue
- Bug
- Clarté
- Clairement spécifiée
- Activité
- À l'abandon
- Stack technique
- typescript
- Domaine
- frontend
Piste de recherche
Start with getVirtualDistanceFromEnd() in the virtual-core source and compare its result with getDistanceFromEnd() when scrollMargin is nonzero. The issue provides a regression test to add under packages/virtual-core/tests/; run the virtual-core test suite and confirm a growing last item does not scroll the viewport when it is not at the end. A pull request is already open for this issue.
Rédigé par le modèle d'indexation à partir du texte de l'issue.
Description
Describe the bug
scrollMargin is not taken into account when the virtualizer decides whether the viewport is at the end, so with anchorTo: 'end' a reader who has scrolled up is pulled back down as long as they are within scrollMargin px of the bottom.
getVirtualDistanceFromEnd() is what resizeItem checks for wasAtEnd:
private getVirtualDistanceFromEnd = () => {
return Math.max(
this._getLayoutTotalSize() - this.getSize() - this.getScrollOffset(),
0,
)
}
_getLayoutTotalSize() is the sizer's height, which is end - scrollMargin + paddingEnd, so it does not include the margin. getScrollOffset() is the element's scroll position, measured from the top of the scroll content, so it does include it. Subtracting one from the other reports the viewport scrollMargin px closer to the end than it is.
The public getDistanceFromEnd() is computed from the element's own max scroll and is correct, so the two disagree by exactly scrollMargin:
| scrollTop | getDistanceFromEnd() |
getVirtualDistanceFromEnd() |
|---|---|---|
| 0 | 9900 | 9600 |
| 7425 | 2475 | 2175 |
| 9600 | 300 | 0 |
| 9900 | 0 | 0 |
(200 rows of 50px, 400px viewport, scrollMargin: 300.) With scrollMargin: 0 the two agree at every offset.
Your minimal, reproducible example
Dropping this in packages/virtual-core/tests/ reproduces it on main. The first test passes, the second fails.
import { expect, test, vi } from 'vitest'
import { Virtualizer } from '../src/index'
function setup(scrollMargin: number) {
const count = 200
const rowSize = 50
const clientHeight = 400
const el = {
scrollTop: 0,
scrollLeft: 0,
scrollHeight: scrollMargin + count * rowSize,
clientHeight,
offsetHeight: clientHeight,
}
const scrollToFn = vi.fn()
let scrollCb: ((o: number, s: boolean) => void) | null = null
const v = new Virtualizer<any, any>({
count,
estimateSize: () => rowSize,
scrollMargin,
anchorTo: 'end',
getScrollElement: () => el as any,
scrollToFn,
observeElementRect: (_i, cb) => {
cb({ width: 800, height: clientHeight })
return () => {}
},
observeElementOffset: (_i, cb) => {
scrollCb = cb
cb(0, false)
return () => {}
},
initialRect: { width: 800, height: clientHeight },
})
v._willUpdate()
v['getMeasurements']()
// the reader scrolls up 300px from the bottom to look at older rows
const max = el.scrollHeight - clientHeight
el.scrollTop = max - 300
scrollCb!(max - 300, true)
scrollToFn.mockClear()
return { v, scrollToFn }
}
test('scrollMargin 0: a row below the fold grows, the reader stays put', () => {
const { v, scrollToFn } = setup(0)
expect(v.getDistanceFromEnd()).toBe(300)
expect(v.isAtEnd()).toBe(false)
v.resizeItem(199, 150)
expect(scrollToFn).not.toHaveBeenCalled()
})
test('scrollMargin 300: same list, same position, the reader is pulled down', () => {
const { v, scrollToFn } = setup(300)
expect(v.getDistanceFromEnd()).toBe(300)
expect(v.isAtEnd()).toBe(false)
v.resizeItem(199, 150)
expect(scrollToFn).not.toHaveBeenCalled() // fails: called once, scrollTop 9600 + 100
})
It is not specific to an element scroller. The same thing happens with a window virtualizer and a header above the list, which is the case the scrollMargin docs describe.
Steps to reproduce
- Render an end-anchored list with a header above it inside the same scroller, and set
scrollMarginto the header's height. - Scroll up from the bottom by less than
scrollMargin. - Let an item below the fold grow, for example an image finishing loading or streamed text.
- The viewport is scrolled down by the growth.
Expected behavior
A reader who is not at the end is left where they are. The anchorTo docs describe this path as keeping "an end-pinned viewport pinned when the last item grows", and a viewport 300px above the end is not end-pinned. isAtEnd() already reports false for it.
How often does this bug happen?
Every time
Screenshots or Videos
No response
Platform
- OS: macOS 26.7
- Browser: reproduced in jsdom with the package's own test setup, not browser specific
tanstack-virtual version
@tanstack/virtual-core 3.19.7, the version @tanstack/react-virtual 3.14.22 installs. Also on main at c462c61.
TypeScript version
5.5.4
Additional context
I use scrollMargin for a table where a sticky header shares the scroller with the virtualized rows, and read through the core to check how it is threaded. That list is start-anchored, so none of this is biting me, but the mismatch looked wrong enough to report.
A couple of things that suggest it is an oversight rather than intentional. getVirtualDistanceFromEnd arrived with the end-anchoring work in #1173, and scrollMargin does not appear anywhere in the end-detection or scroll-adjustment paths, only in the measurement code and in getTotalSize. The same feature already asks the same question through the public helper elsewhere, in setOptions for followOnAppend, where it calls isAtEnd(prevOptions.scrollEndThreshold) and therefore does account for the margin.
Adding the margin back makes the private value match the public one at every offset:
return Math.max(
this._getLayoutTotalSize() +
this.options.scrollMargin -
this.getSize() -
this.getScrollOffset(),
0,
)
Reading it the other way round gives the same correction: if the intent is the distance from the end of the list rather than of the element, then the scroll offset is the value that needs the margin subtracted from it, which works out to the same expression.
With that change both tests above pass and the existing suite is unaffected: 438 passing and 1 skipped across the 15 test files in packages/virtual-core/tests, the same counts as before the change. scrollMargin defaults to 0, so nothing changes for a list that does not set it. Growing the last row at a few distances from the end, with scrollMargin: 300 and the default scrollEndThreshold of 1:
| distance from end | isAtEnd() |
adjusted today | adjusted with the change |
|---|---|---|---|
| 0 | true | yes | yes |
| 1 | true | yes | yes |
| 300 | false | yes | no |
| 900 | false | no | no |
The pinning itself is untouched. Only the window between the threshold and scrollMargin changes.
Happy to open a PR with the fix and the regression test if that is the direction you want.
Terms & Code of Conduct
- I agree to follow this project's Code of Conduct
- I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.
- Langage dominant
- TypeScript
- Étoiles
- 7.2k
- Forks
- 479
- Merge moyen
- 1 j 2 h
- PR mergées (30 j)
- 34
Préparer son environnement
- Aucun Dockerfile ni fichier Docker Compose
- Propose un modèle de pull request
- Lire le guide de contribution
Par où commencer
- Lisez l'issue en entier, puis le guide de contribution du projet.
- Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
- Forkez le dépôt et travaillez sur une branche.
- Ouvrez une pull request qui référence le numéro de l'issue.
Autres issues de TanStack/virtual
-
anchorTo: 'end' loses the end when the scroll element shrinksPeut-être pris @langleyd l’a pris il y a 18 jours. Ouverte
Difficulté 3/5 1-2 jours Accessibilité débutants 68/100
Les mainteneurs répondent en général sous 1 jour
-
marko-virtual: scrollToEnd during an in-flight prepend strands the view one prepend above the bottom (chat-pretext e2e fails on CI)Peut-être pris @prayaggaikwad96 l’a pris il y a 12 jours. Ouverte
Difficulté 4/5 3-5 jours Accessibilité débutants 55/100
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 4/5 3-5 jours Accessibilité débutants 42/100
TanStack/virtual#1252 · 2 commentaires ·
Les mainteneurs répondent en général sous 1 jour
-
lit-virtual: VirtualizerController never applies updated options after constructionPeut-être pris @itisrmk l’a pris il y a 5 jours. Ouverte
Difficulté 5/5 Plus d'une semaine Accessibilité débutants 45/100
Les mainteneurs répondent en général sous 1 jour
-
eslint-plugin-react-hooks v7 warning for useVirtualizer: Compilation Skipped: Use of incompatible libraryPeut-être pris Une pull request liée à cette issue est ouverte ou déjà fusionnée. Ouverte
Difficulté 4/5 3-5 jours Accessibilité débutants 45/100
TanStack/virtual#1119 · 11 commentaires · 32 réactions ·
Les mainteneurs répondent en général sous 1 jour
Toutes les issues de TanStack/virtual
Issues similaires
-
a10y a10y-audit ai-driven-qa claude
Difficulté 2/5 1-3 heures Accessibilité débutants 86/100
linagora/twake-directory-manager#219 ·
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 2/5 1-3 heures Accessibilité débutants 68/100
Les mainteneurs répondent en général sous 1 jour
-
type: bug
Difficulté 2/5 1-3 heures Accessibilité débutants 79/100
Les mainteneurs répondent en général sous 1 jour
-
0️⃣ phase/new
Difficulté 2/5 1-3 heures Accessibilité débutants 82/100
Les mainteneurs répondent en général sous 1 jour
-
Pulse v7: buildPreferencesFromTelos() bypasses telosSectionOrFile(), renders empty on unified-TELOS installsPeut-être pris @zephyr91 l’a pris aujourd’hui. Ouverte
Difficulté 2/5 1-3 heures Accessibilité débutants 75/100
danielmiessler/LifeOS#2393 ·
Les mainteneurs répondent en général sous 4 jours