Skip to content

fix(virtual-core): eagerly adjust scrollOffset on prepend to prevent jump#1176

Open
piecyk wants to merge 1 commit into
TanStack:mainfrom
piecyk:damian/fix/eager-scroll-anchor-on-prepend
Open

fix(virtual-core): eagerly adjust scrollOffset on prepend to prevent jump#1176
piecyk wants to merge 1 commit into
TanStack:mainfrom
piecyk:damian/fix/eager-scroll-anchor-on-prepend

Conversation

@piecyk
Copy link
Copy Markdown
Collaborator

@piecyk piecyk commented May 26, 2026

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

Fix by eagerly adjusting scrollOffset in setOptions during the render pass so calculateRange/getVirtualItems return the correct items immediately. _willUpdate then only syncs the browser's actual scroll position to match.

🎯 Changes

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm run test:pr.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • Bug Fixes
    • Prevented one-frame jump when items are prepended/appended by eagerly reconciling scroll offset so visible items render consistently.
    • Eliminated deferred platform-specific adjustments in favor of immediate browser scroll synchronization when applicable.
  • Tests
    • Updated virtualization tests to match the new scroll reconciliation behavior.

Review Change Stack

@piecyk piecyk requested a review from tannerlinsley May 26, 2026 04:08
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 26, 2026

📝 Walkthrough

Walkthrough

The virtualizer eagerly corrects scrollOffset in setOptions when an append/prepend anchor is resolvable, a changeset entry is added, and _willUpdate now directly syncs the browser scroll position for pending anchors instead of computing/applying a deferred anchor delta; tests updated accordingly.

Changes

Scroll Anchor Lifecycle Optimization

Layer / File(s) Summary
Changeset entry for eager scroll anchor
.changeset/eager-scroll-anchor.md
Adds a patch changeset describing the eager scrollOffset adjustment fix for prepend scenarios using anchorTo: 'end' and dynamic sizes.
Eager scroll offset adjustment in setOptions
packages/virtual-core/src/index.ts
When an append or prepend anchor is computed and measurements exist, setOptions immediately locates the anchored item and sets this.scrollOffset to anchorItem.start + anchorOffset, ensuring the visible range for the current render is computed with the corrected offset.
Pending anchor sync refactor in _willUpdate (and tests)
packages/virtual-core/src/index.ts, packages/virtual-core/tests/index.test.ts
_willUpdate now, when key !== null and followOnAppend is false, calls _scrollToOffset(getScrollOffset(), { adjustments: undefined, behavior: undefined }) directly instead of computing and applying an anchor delta via applyScrollAdjustment. The anchorTo:end test expectation was updated to expect options.adjustments to be undefined.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • TanStack/virtual#1173: Related end-anchoring scroll reconciliation changes affecting setOptions/_willUpdate and tests.

Poem

A rabbit hops through scroll-time's flow,
Anchors found where older items grow,
Eager steps set the offset true,
_willUpdate nudges the browser too,
Hooray — the list jumps no more! 🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description includes the problem statement, solution approach, and completed checklists. However, the Release Impact section shows a mismatch: the PR contains a changeset file but the checkbox is unchecked. Check the 'This change affects published code, and I have generated a changeset' checkbox to accurately reflect the included .changeset/eager-scroll-anchor.md file.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: eagerly adjusting scrollOffset on prepend to fix a visual jump issue.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

packages/virtual-core/tests/index.test.ts

Parsing error: "parserOptions.project" has been provided for @typescript-eslint/parser.
The file was not found in any of the provided project(s): packages/virtual-core/tests/index.test.ts


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@nx-cloud
Copy link
Copy Markdown

nx-cloud Bot commented May 26, 2026

View your CI Pipeline Execution ↗ for commit 5b972c5

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded 2m 30s View ↗
nx run-many --target=build --exclude=examples/** ✅ Succeeded 17s View ↗

☁️ Nx Cloud last updated this comment at 2026-05-26 05:54:16 UTC

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 26, 2026

More templates

@tanstack/angular-virtual

npm i https://pkg.pr.new/@tanstack/angular-virtual@1176

@tanstack/lit-virtual

npm i https://pkg.pr.new/@tanstack/lit-virtual@1176

@tanstack/react-virtual

npm i https://pkg.pr.new/@tanstack/react-virtual@1176

@tanstack/solid-virtual

npm i https://pkg.pr.new/@tanstack/solid-virtual@1176

@tanstack/svelte-virtual

npm i https://pkg.pr.new/@tanstack/svelte-virtual@1176

@tanstack/virtual-core

npm i https://pkg.pr.new/@tanstack/virtual-core@1176

@tanstack/vue-virtual

npm i https://pkg.pr.new/@tanstack/vue-virtual@1176

commit: 2b35365

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/virtual-core/src/index.ts`:
- Around line 823-834: Tests expect numeric options.adjustments but
_scrollToOffset now forwards undefined for the _willUpdate anchor sync path;
update the two assertions in packages/virtual-core/tests/index.test.ts that
reference anchorTo:end to expect undefined for options.adjustments instead of
100 and 70 (specifically the tests "anchorTo:end keeps visible content stable
when older items are prepended" and "anchorTo:end keeps a pinned streaming
message pinned as it grows"); leave the iOS deferred-flush test that asserts 50
unchanged because it exercises the iOS accumulator path, and ensure references
to _scrollToOffset and scrollToFn are used when reviewing the behavior change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9f21eedb-e3bd-4e6c-a809-62dc0e290649

📥 Commits

Reviewing files that changed from the base of the PR and between 693d915 and f2ec547.

📒 Files selected for processing (1)
  • packages/virtual-core/src/index.ts

Comment thread packages/virtual-core/src/index.ts
@piecyk piecyk force-pushed the damian/fix/eager-scroll-anchor-on-prepend branch from f2ec547 to 5b972c5 Compare May 26, 2026 05:42
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/virtual-core/tests/index.test.ts (1)

2267-2280: ⚡ Quick win

Assert the eager correction before _willUpdate().

This only proves the later DOM sync. The regression here was the one-frame wrong range during render, so it would be better to assert scrollOffset or getVirtualItems() immediately after setOptions() and before _willUpdate() runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/virtual-core/tests/index.test.ts` around lines 2267 - 2280, The test
currently only checks the post-DOM-sync scrollTo call; add an assertion that
verifies the eager correction happened synchronously before the internal update
by checking the virtualizer's scrollOffset or getVirtualItems() immediately
after calling setMessages (and before _willUpdate() runs). Specifically, after
calling setMessages([{ id: 'm--2' }, { id: 'm--1' }, ...messages]) assert
virtualizer.scrollOffset (or virtualizer.getVirtualItems()) has the expected
value/range to prove the one-frame correction occurred, and keep the existing
scrollToFn assertions for the later DOM sync; locate this in the same test
around the setMessages call and place the new assertion before any code that
triggers or waits for _willUpdate().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/virtual-core/src/index.ts`:
- Around line 830-838: The direct call to _scrollToOffset bypasses the iOS
deferral path and can be cancelled by a prepend-triggered anchor sync; instead,
gate this DOM sync through the same iOS deferral used elsewhere or use the
existing applyScrollAdjustment path. Replace the direct
this._scrollToOffset(this.getScrollOffset(), ...) with the code path that honors
_iosDeferredAdjustment (i.e., call applyScrollAdjustment / the helper that
defers/queues adjustments when _iosDeferredAdjustment is set) so the scroll
write is deferred during iOS touch/momentum; ensure you still pass the same
offset from getScrollOffset() and preserve the adjustments/behavior options when
delegating.

---

Nitpick comments:
In `@packages/virtual-core/tests/index.test.ts`:
- Around line 2267-2280: The test currently only checks the post-DOM-sync
scrollTo call; add an assertion that verifies the eager correction happened
synchronously before the internal update by checking the virtualizer's
scrollOffset or getVirtualItems() immediately after calling setMessages (and
before _willUpdate() runs). Specifically, after calling setMessages([{ id:
'm--2' }, { id: 'm--1' }, ...messages]) assert virtualizer.scrollOffset (or
virtualizer.getVirtualItems()) has the expected value/range to prove the
one-frame correction occurred, and keep the existing scrollToFn assertions for
the later DOM sync; locate this in the same test around the setMessages call and
place the new assertion before any code that triggers or waits for
_willUpdate().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7da3ba74-d701-4a3a-962b-68f55dc9942b

📥 Commits

Reviewing files that changed from the base of the PR and between f2ec547 and 5b972c5.

📒 Files selected for processing (2)
  • packages/virtual-core/src/index.ts
  • packages/virtual-core/tests/index.test.ts

Comment on lines +830 to +838
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,
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Preserve the iOS deferral path for pending anchor sync.

This direct _scrollToOffset call skips applyScrollAdjustment, so prepend-triggered anchor sync can still write scrollTop during iOS touch/momentum and cancel the in-flight scroll. The eager logical scrollOffset fix is good, but the DOM sync still needs the same _iosDeferredAdjustment gating as the other mid-scroll corrections.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/virtual-core/src/index.ts` around lines 830 - 838, The direct call
to _scrollToOffset bypasses the iOS deferral path and can be cancelled by a
prepend-triggered anchor sync; instead, gate this DOM sync through the same iOS
deferral used elsewhere or use the existing applyScrollAdjustment path. Replace
the direct this._scrollToOffset(this.getScrollOffset(), ...) with the code path
that honors _iosDeferredAdjustment (i.e., call applyScrollAdjustment / the
helper that defers/queues adjustments when _iosDeferredAdjustment is set) so the
scroll write is deferred during iOS touch/momentum; ensure you still pass the
same offset from getScrollOffset() and preserve the adjustments/behavior options
when delegating.

…jump

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

Fix by eagerly adjusting scrollOffset in setOptions during the render
pass so calculateRange/getVirtualItems return the correct items
immediately. _willUpdate then only syncs the browser's actual scroll
position to match.
@piecyk piecyk force-pushed the damian/fix/eager-scroll-anchor-on-prepend branch from 5b972c5 to 2b35365 Compare May 26, 2026 05:51
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
packages/virtual-core/src/index.ts (1)

830-838: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Preserve the iOS deferred-scroll path for this DOM sync.

This direct _scrollToOffset write bypasses applyScrollAdjustment's isIOSWebKit() gate. If a prepend lands during touch or momentum scrolling, this path still writes scrollTop immediately and cancels the native scroll on iOS, even though the logical scrollOffset was already fixed eagerly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/virtual-core/src/index.ts` around lines 830 - 838, The direct call
to this._scrollToOffset bypasses applyScrollAdjustment's isIOSWebKit() logic and
can cancel native iOS momentum; instead of calling _scrollToOffset directly when
key !== null && !followOnAppend, route the synchronization through
applyScrollAdjustment (or its equivalent helper) so the iOS-specific
deferred-scroll gate is respected; use this.getScrollOffset() as the target
offset and pass through the adjustments/behavior params (or undefined) to
applyScrollAdjustment so iOS WebKit will defer the DOM write while other
platforms still sync immediately.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/virtual-core/src/index.ts`:
- Around line 604-615: The code may read stale measurement data when computing
newOffset via newMeasurements[idx].start; before using newMeasurements[idx]
ensure measurements are rebuilt for the current key ordering: either call your
measurement invalidation/rebuild helper (e.g., this.invalidateMeasurements() or
this.rebuildMeasurements()) or include the item key identity in the measurement
memo so getMeasurements() returns a fresh array for the current getItemKey
order; specifically, after computing idx using getItemKey and before accessing
newMeasurements[idx].start, force a measurements refresh (or verify
newMeasurements[idx] corresponds to getItemKey(idx) and rebuild if it does not)
so this.scrollOffset is computed from up-to-date measurements.

---

Duplicate comments:
In `@packages/virtual-core/src/index.ts`:
- Around line 830-838: The direct call to this._scrollToOffset bypasses
applyScrollAdjustment's isIOSWebKit() logic and can cancel native iOS momentum;
instead of calling _scrollToOffset directly when key !== null &&
!followOnAppend, route the synchronization through applyScrollAdjustment (or its
equivalent helper) so the iOS-specific deferred-scroll gate is respected; use
this.getScrollOffset() as the target offset and pass through the
adjustments/behavior params (or undefined) to applyScrollAdjustment so iOS
WebKit will defer the DOM write while other platforms still sync immediately.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fad320ff-17ef-4872-9c13-0986c5a892c7

📥 Commits

Reviewing files that changed from the base of the PR and between 5b972c5 and 2b35365.

📒 Files selected for processing (3)
  • .changeset/eager-scroll-anchor.md
  • packages/virtual-core/src/index.ts
  • packages/virtual-core/tests/index.test.ts
✅ Files skipped from review due to trivial changes (1)
  • .changeset/eager-scroll-anchor.md

Comment on lines +604 to +615
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Force a fresh measurement rebuild before reading anchorItem.start.

If count stays the same and the caller keeps a stable getItemKey reference, getMeasurements() can still be serving the previous layout here. idx is found from the new key order, but newMeasurements[idx].start can 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 using newMeasurements[idx].

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/virtual-core/src/index.ts` around lines 604 - 615, The code may read
stale measurement data when computing newOffset via newMeasurements[idx].start;
before using newMeasurements[idx] ensure measurements are rebuilt for the
current key ordering: either call your measurement invalidation/rebuild helper
(e.g., this.invalidateMeasurements() or this.rebuildMeasurements()) or include
the item key identity in the measurement memo so getMeasurements() returns a
fresh array for the current getItemKey order; specifically, after computing idx
using getItemKey and before accessing newMeasurements[idx].start, force a
measurements refresh (or verify newMeasurements[idx] corresponds to
getItemKey(idx) and rebuild if it does not) so this.scrollOffset is computed
from up-to-date measurements.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant