-
-
Notifications
You must be signed in to change notification settings - Fork 431
fix(virtual-core): eagerly adjust scrollOffset on prepend to prevent jump #1176
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@tanstack/virtual-core': patch | ||
| --- | ||
|
|
||
| Eagerly adjust scrollOffset on prepend to prevent one-frame jump with anchorTo: 'end' | ||
|
|
||
| When items are prepended with `anchorTo: 'end'` and dynamic sizes, the virtualizer would compute the wrong visible range for one frame (using stale estimate-based positions) and then correct in the next frame via `_willUpdate`, producing a visible jump. This fix eagerly adjusts `scrollOffset` in `setOptions` during the render pass so `calculateRange`/`getVirtualItems` return the correct items immediately. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -592,10 +592,37 @@ export class Virtualizer< | |
|
|
||
| this.options = merged | ||
|
|
||
| if (anchor || followOnAppend) { | ||
| // Eagerly adjust scrollOffset so the virtualizer computes the correct | ||
| // visible range during the current render pass — before _willUpdate | ||
| // syncs the DOM scroll position in a layout effect. Without this, | ||
| // the virtualizer would render the wrong items for one frame (the | ||
| // estimate-based positions are stale) and then correct in the next | ||
| // frame, producing a visible "jump" on prepend with dynamic sizes. | ||
| let anchorResolved = false | ||
| if (anchor && this.scrollOffset !== null) { | ||
| const [anchorKey, anchorOffset] = anchor | ||
| const newMeasurements = this.getMeasurements() | ||
| const { count, getItemKey } = this.options | ||
| let idx = 0 | ||
| while (idx < count && getItemKey(idx) !== anchorKey) { | ||
| idx++ | ||
| } | ||
| if (idx < count) { | ||
| const anchorItem = newMeasurements[idx] | ||
| if (anchorItem) { | ||
| const newOffset = anchorItem.start + anchorOffset | ||
| if (newOffset !== this.scrollOffset) { | ||
| this.scrollOffset = newOffset | ||
| anchorResolved = true | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (anchorResolved || followOnAppend) { | ||
| this.pendingScrollAnchor = [ | ||
| anchor?.[0] ?? null, | ||
| anchor?.[1] ?? 0, | ||
| anchorResolved ? anchor![0] : null, | ||
| anchorResolved ? anchor![1] : 0, | ||
| followOnAppend, | ||
| ] | ||
| } | ||
|
|
@@ -798,23 +825,17 @@ export class Virtualizer< | |
| this.pendingScrollAnchor = null | ||
|
|
||
| if (anchor && this.scrollElement && this.options.enabled) { | ||
| const [key, offset, followOnAppend] = anchor | ||
|
|
||
| if (key !== null) { | ||
| const { count, getItemKey } = this.options | ||
| let index = 0 | ||
| while (index < count && getItemKey(index) !== key) { | ||
| index++ | ||
| } | ||
|
|
||
| const item = index < count ? this.getMeasurements()[index] : undefined | ||
| if (item) { | ||
| const delta = item.start + offset - this.getScrollOffset() | ||
|
|
||
| if (!approxEqual(delta, 0)) { | ||
| this.applyScrollAdjustment(delta) | ||
| } | ||
| } | ||
| const [key, _offset, followOnAppend] = anchor | ||
|
|
||
| if (key !== null && !followOnAppend) { | ||
| // scrollOffset was eagerly adjusted in setOptions so the | ||
| // virtualizer already computed the correct range during render. | ||
| // Now sync the browser's actual scroll position to match. | ||
| // Skip when followOnAppend is set — scrollToEnd will handle it. | ||
| this._scrollToOffset(this.getScrollOffset(), { | ||
| adjustments: undefined, | ||
| behavior: undefined, | ||
| }) | ||
|
Comment on lines
+830
to
+838
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Preserve the iOS deferral path for pending anchor sync. This direct 🤖 Prompt for AI Agents |
||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| if (followOnAppend) { | ||
|
|
||
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.
Force a fresh measurement rebuild before reading
anchorItem.start.If
countstays the same and the caller keeps a stablegetItemKeyreference,getMeasurements()can still be serving the previous layout here.idxis found from the new key order, butnewMeasurements[idx].startcan still belong to the old item/order, so the eager correction picks the wrong offset for prepend+trim or other edge-key swaps with dynamic sizes. Invalidate measurements on edge-key changes, or include key identity in the measurement memo deps before usingnewMeasurements[idx].🤖 Prompt for AI Agents