Describe the bug
Up-scroll jank from uncompensated above-viewport resizes (regressed in virtual-core 3.14), and the shouldAdjustScrollPositionOnItemSizeChange escape hatch is not wired in 3.17.x
Summary
Two related problems in Virtualizer.resizeItem affect lists with dynamically measured rows (rich text / images / fonts, where measured height ≠ estimateSize):
- Since ~3.14, the default scroll-position compensation on item resize is skipped while scrolling up, causing visible jank on up-scroll.
- The documented workaround — passing
shouldAdjustScrollPositionOnItemSizeChange — does nothing in 3.17.x because the option is never read from options, so there is no way to opt out.
Versions
@tanstack/virtual-core: last good 3.13.12, first bad 3.16.0 (bisected via @tanstack/react-virtual 3.13.12 → 3.14.0). Also present in 3.17.1.
@tanstack/react-virtual 3.14.x, React 19.
1) Behavior change in resizeItem (3.14+)
3.13.12 (good) — compensate whenever the resized item is above the viewport:
item.start < this.getScrollOffset() + this.scrollAdjustments
3.16.0 (bad) — same, but excluded during backward scroll:
itemStart < this.getScrollOffset() + this.scrollAdjustments && this.scrollDirection !== "backward"
3.17.1 — variant of the same exclusion:
itemStart < this.getScrollOffset() + this.scrollAdjustments
&& (!this.itemSizeCache.has(key) || this.scrollDirection !== "backward")
When rows are measured lazily and their real size differs from estimateSize, they are corrected as they scroll in from the top. Without compensation during backward scroll, each correction shifts the content below it downward → stutter while scrolling up (scrolling down is unaffected, since newly measured rows enter below the viewport). Anchored side content (e.g. connector lines positioned from item offsets) also drifts out of sync.
The in-source comment states this exclusion was added to fix "items jump while scrolling up" for the common case — but for dynamically-measured lists it causes exactly that.
2) The escape hatch is broken in 3.17.x
The same comment says: "Users who want the old behavior can pass shouldAdjustScrollPositionOnItemSizeChange."
In 3.17.1, resizeItem reads it off the instance:
this.shouldAdjustScrollPositionOnItemSizeChange !== void 0
? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this)
: /* default with backward exclusion */
…but setOptions only stores options into this.options (this.options = merged). this.shouldAdjustScrollPositionOnItemSizeChange is declared on the class yet never assigned, so it is always undefined and the custom predicate is ignored — the default branch always runs. Consequently the option is also absent from the VirtualizerOptions input type. There is no working way to restore the pre-3.14 behavior.
Impact
Up-scroll jank on any virtualized list with dynamic measurement and imperfect estimates, with no working workaround, forcing a pin to virtual-core@3.13.12.
Repro
- Virtualized list with
measureElement and rows whose real height differs from estimateSize.
- Scroll down (rows get measured), then scroll back up → content stutters as above-viewport rows are corrected without compensation.
- Pass
shouldAdjustScrollPositionOnItemSizeChange: (item, _d, i) => item.start < i.getScrollOffset() + i.scrollAdjustments → no effect.
Suggested fix
- Read the option from
this.options.shouldAdjustScrollPositionOnItemSizeChange (or assign it onto the instance in setOptions) so the escape hatch works again, and expose it on VirtualizerOptions.
- Reconsider the
scrollDirection !== "backward" default: it trades one jank class for another. At minimum a working opt-out is needed for dynamically-measured lists.
Your minimal, reproducible example
https://vitejsvitefd5uzfmy-zaso--5173--87cf54cd.local-credentialless.webcontainer.io/
Steps to reproduce
- Open the reproduction (StackBlitz link above). It's a virtualized list using
measureElement, where each row's real height differs from estimateSize.
- Scroll DOWN into the middle of the list so the rows get measured.
- Click "Grow rows" — this resizes already-measured rows (simulates images/fonts
or async content settling after the first measure).
- Scroll UP slowly and watch the list content.
- On virtual-core 3.14+ (repro ships @tanstack/react-virtual 3.14.6 → virtual-core
3.17.4) the rows jump/stutter while scrolling up. Set the dependency to
"@tanstack/react-virtual": "3.13.12" (→ virtual-core 3.13.12) and repeat: the
list stays anchored, no jank.
- The repro passes
shouldAdjustScrollPositionOnItemSizeChange (the documented
opt-out). Note it has no effect on 3.17.x.
Expected behavior
When a row above the viewport changes size, the virtualizer should compensate
scrollTop so the visible content stays anchored — regardless of scroll direction,
as it did up to virtual-core 3.13.12. Scrolling up should remain smooth.
Since 3.14 the default skips this compensation while scrolling backward
(scrollDirection !== "backward" added to the resizeItem condition), so every
above-viewport correction visibly shifts content during up-scroll. At minimum the
documented shouldAdjustScrollPositionOnItemSizeChange option should work so the
pre-3.14 behavior can be restored — but in 3.17.x it is read from the instance
(this.shouldAdjust…) while setOptions only stores it on this.options, so it
is always undefined and ignored.
How often does this bug happen?
Always
Screenshots or Videos
No response
Platform
macOS
tanstack-virtual version
v3.14.6
TypeScript version
No response
Additional context
Bisected in @tanstack/virtual-core:
- last good: 3.13.12
- first bad: 3.16.0 (reached via @tanstack/react-virtual 3.14.0; also in 3.17.1/3.17.4)
Root cause — the default scroll-compensation condition in resizeItem gained a
backward-scroll exclusion:
3.13.12: item.start < this.getScrollOffset() + this.scrollAdjustments
3.16.0: ... && this.scrollDirection !== "backward"
3.17.x: ... && (!this.itemSizeCache.has(key) || this.scrollDirection !== "backward")
For dynamically measured rows whose size differs from estimateSize (and that can
resize again after their first measure — images/fonts/async content), corrections
to above-viewport rows are no longer compensated while scrolling up, so the visible
content shifts → up-scroll jank. Down-scroll is unaffected (new rows enter below
the viewport).
Broken escape hatch in 3.17.x — resizeItem reads
this.shouldAdjustScrollPositionOnItemSizeChange, but setOptions only stores the
merged options on this.options (this.options = merged); the instance field is
never assigned, so it is always undefined and the custom predicate is ignored. The
option is therefore also absent from the VirtualizerOptions input type. There is
no working way to restore the pre-3.14 behavior.
Suggested fix: read the option from this.options.shouldAdjustScrollPositionOnItemSizeChange
(or assign it in setOptions) and expose it on VirtualizerOptions; and/or reconsider
the scrollDirection !== "backward" default.
Terms & Code of Conduct
Describe the bug
Up-scroll jank from uncompensated above-viewport resizes (regressed in virtual-core 3.14), and the
shouldAdjustScrollPositionOnItemSizeChangeescape hatch is not wired in 3.17.xSummary
Two related problems in
Virtualizer.resizeItemaffect lists with dynamically measured rows (rich text / images / fonts, where measured height ≠estimateSize):shouldAdjustScrollPositionOnItemSizeChange— does nothing in 3.17.x because the option is never read fromoptions, so there is no way to opt out.Versions
@tanstack/virtual-core: last good 3.13.12, first bad 3.16.0 (bisected via@tanstack/react-virtual3.13.12 → 3.14.0). Also present in 3.17.1.@tanstack/react-virtual3.14.x, React 19.1) Behavior change in
resizeItem(3.14+)3.13.12 (good) — compensate whenever the resized item is above the viewport:
3.16.0 (bad) — same, but excluded during backward scroll:
3.17.1 — variant of the same exclusion:
When rows are measured lazily and their real size differs from
estimateSize, they are corrected as they scroll in from the top. Without compensation during backward scroll, each correction shifts the content below it downward → stutter while scrolling up (scrolling down is unaffected, since newly measured rows enter below the viewport). Anchored side content (e.g. connector lines positioned from item offsets) also drifts out of sync.The in-source comment states this exclusion was added to fix "items jump while scrolling up" for the common case — but for dynamically-measured lists it causes exactly that.
2) The escape hatch is broken in 3.17.x
The same comment says: "Users who want the old behavior can pass
shouldAdjustScrollPositionOnItemSizeChange."In 3.17.1,
resizeItemreads it off the instance:…but
setOptionsonly stores options intothis.options(this.options = merged).this.shouldAdjustScrollPositionOnItemSizeChangeis declared on the class yet never assigned, so it is alwaysundefinedand the custom predicate is ignored — the default branch always runs. Consequently the option is also absent from theVirtualizerOptionsinput type. There is no working way to restore the pre-3.14 behavior.Impact
Up-scroll jank on any virtualized list with dynamic measurement and imperfect estimates, with no working workaround, forcing a pin to
virtual-core@3.13.12.Repro
measureElementand rows whose real height differs fromestimateSize.shouldAdjustScrollPositionOnItemSizeChange: (item, _d, i) => item.start < i.getScrollOffset() + i.scrollAdjustments→ no effect.Suggested fix
this.options.shouldAdjustScrollPositionOnItemSizeChange(or assign it onto the instance insetOptions) so the escape hatch works again, and expose it onVirtualizerOptions.scrollDirection !== "backward"default: it trades one jank class for another. At minimum a working opt-out is needed for dynamically-measured lists.Your minimal, reproducible example
https://vitejsvitefd5uzfmy-zaso--5173--87cf54cd.local-credentialless.webcontainer.io/
Steps to reproduce
measureElement, where each row's real height differs fromestimateSize.or async content settling after the first measure).
3.17.4) the rows jump/stutter while scrolling up. Set the dependency to
"@tanstack/react-virtual": "3.13.12" (→ virtual-core 3.13.12) and repeat: the
list stays anchored, no jank.
shouldAdjustScrollPositionOnItemSizeChange(the documentedopt-out). Note it has no effect on 3.17.x.
Expected behavior
When a row above the viewport changes size, the virtualizer should compensate
scrollTop so the visible content stays anchored — regardless of scroll direction,
as it did up to virtual-core 3.13.12. Scrolling up should remain smooth.
Since 3.14 the default skips this compensation while scrolling backward
(
scrollDirection !== "backward"added to the resizeItem condition), so everyabove-viewport correction visibly shifts content during up-scroll. At minimum the
documented
shouldAdjustScrollPositionOnItemSizeChangeoption should work so thepre-3.14 behavior can be restored — but in 3.17.x it is read from the instance
(
this.shouldAdjust…) whilesetOptionsonly stores it onthis.options, so itis always undefined and ignored.
How often does this bug happen?
Always
Screenshots or Videos
No response
Platform
macOS
tanstack-virtual version
v3.14.6
TypeScript version
No response
Additional context
Bisected in @tanstack/virtual-core:
Root cause — the default scroll-compensation condition in
resizeItemgained abackward-scroll exclusion:
3.13.12: item.start < this.getScrollOffset() + this.scrollAdjustments
3.16.0: ... && this.scrollDirection !== "backward"
3.17.x: ... && (!this.itemSizeCache.has(key) || this.scrollDirection !== "backward")
For dynamically measured rows whose size differs from estimateSize (and that can
resize again after their first measure — images/fonts/async content), corrections
to above-viewport rows are no longer compensated while scrolling up, so the visible
content shifts → up-scroll jank. Down-scroll is unaffected (new rows enter below
the viewport).
Broken escape hatch in 3.17.x —
resizeItemreadsthis.shouldAdjustScrollPositionOnItemSizeChange, butsetOptionsonly stores themerged options on
this.options(this.options = merged); the instance field isnever assigned, so it is always undefined and the custom predicate is ignored. The
option is therefore also absent from the
VirtualizerOptionsinput type. There isno working way to restore the pre-3.14 behavior.
Suggested fix: read the option from
this.options.shouldAdjustScrollPositionOnItemSizeChange(or assign it in setOptions) and expose it on VirtualizerOptions; and/or reconsider
the
scrollDirection !== "backward"default.Terms & Code of Conduct