Skip to content

Up-scroll jank from uncompensated above-viewport resizes #1227

Description

@maikhartmann

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):

  1. Since ~3.14, the default scroll-position compensation on item resize is skipped while scrolling up, causing visible jank on up-scroll.
  2. The documented workaround — passing shouldAdjustScrollPositionOnItemSizeChangedoes 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

  1. Virtualized list with measureElement and rows whose real height differs from estimateSize.
  2. Scroll down (rows get measured), then scroll back up → content stutters as above-viewport rows are corrected without compensation.
  3. 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

  1. Open the reproduction (StackBlitz link above). It's a virtualized list using
    measureElement, where each row's real height differs from estimateSize.
  2. Scroll DOWN into the middle of the list so the rows get measured.
  3. Click "Grow rows" — this resizes already-measured rows (simulates images/fonts
    or async content settling after the first measure).
  4. Scroll UP slowly and watch the list content.
  5. 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.
  6. 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

  • 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions