Skip to content

fix(virtual-core): adjust scroll on first measurement during backward scroll#1199

Merged
piecyk merged 1 commit into
TanStack:mainfrom
piecyk:fix/scroll-adjust-first-measurement-backward
Jun 15, 2026
Merged

fix(virtual-core): adjust scroll on first measurement during backward scroll#1199
piecyk merged 1 commit into
TanStack:mainfrom
piecyk:fix/scroll-adjust-first-measurement-backward

Conversation

@piecyk

@piecyk piecyk commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

The default shouldAdjustScroll predicate skipped scroll compensation for any above-viewport resize while scrolling backward. That over-applied to items being measured for the first time: their estimate→actual delta lives above the viewport and must be compensated, or the anchored content jumps when scrolling up into never-seen rows.

Distinguish first measurement (!itemSizeCache.has(key)) — always compensate — from re-measurement, which still skips during backward scroll to avoid the cascading "items fight the scroll" jank.

🎯 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
    • Fixed scroll anchoring by correcting how the virtualizer compensates for estimate-to-actual size changes when first measuring above-viewport items during backward scrolling.
  • Tests
    • Added new end-to-end regression coverage to ensure anchored items keep stable on-screen positioning when scrolling upward into previously unmeasured rows.

@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 79e20853-0917-4434-a63d-5c7a5a7579c1

📥 Commits

Reviewing files that changed from the base of the PR and between ba5d095 and 779fa6f.

📒 Files selected for processing (8)
  • .changeset/adjust-scroll-first-measurement-backward.md
  • examples/react/chat/src/index.css
  • packages/react-virtual/e2e/app/scroll-anchor/index.html
  • packages/react-virtual/e2e/app/scroll-anchor/main.tsx
  • packages/react-virtual/e2e/app/test/scroll-anchor.spec.ts
  • packages/react-virtual/e2e/app/vite.config.ts
  • packages/virtual-core/src/index.ts
  • packages/virtual-core/tests/index.test.ts
✅ Files skipped from review due to trivial changes (3)
  • examples/react/chat/src/index.css
  • .changeset/adjust-scroll-first-measurement-backward.md
  • packages/react-virtual/e2e/app/scroll-anchor/index.html
🚧 Files skipped from review as they are similar to previous changes (5)
  • packages/react-virtual/e2e/app/vite.config.ts
  • packages/react-virtual/e2e/app/scroll-anchor/main.tsx
  • packages/virtual-core/tests/index.test.ts
  • packages/virtual-core/src/index.ts
  • packages/react-virtual/e2e/app/test/scroll-anchor.spec.ts

📝 Walkthrough

Walkthrough

The default shouldAdjustScrollPositionOnItemSizeChange predicate in resizeItem is updated to allow scroll compensation on the first measurement of an above-viewport item during backward scrolling (when the item has no cached size), while re-measurements remain blocked. Unit tests, an e2e fixture app, and a Playwright regression test are added, and the chat example disables browser scroll anchoring.

Changes

Backward Scroll First-Measurement Compensation

Layer / File(s) Summary
resizeItem predicate change
packages/virtual-core/src/index.ts, .changeset/adjust-scroll-first-measurement-backward.md
The default shouldAdjustScrollPositionOnItemSizeChange condition now checks itemSizeCache.has(key) during backward scrolling: items without a cached size (first measurement) still trigger scroll adjustment, while re-measurements are skipped to prevent jank. Changeset declares a patch bump.
Unit test updates for backward-scroll cases
packages/virtual-core/tests/index.test.ts
The existing re-measurement test is renamed to clarify it targets re-measurements and seeds a prior size; a new companion test asserts scrollToFn is called on the first backward-scroll measurement.
E2E scroll-anchor app and build wiring
packages/react-virtual/e2e/app/scroll-anchor/index.html, packages/react-virtual/e2e/app/scroll-anchor/main.tsx, packages/react-virtual/e2e/app/vite.config.ts
A minimal HTML entrypoint and React app with deterministic item sizing (ITEM_SIZE, ESTIMATE) and URL-driven initialOffset are added; Vite rollup inputs are extended with the scroll-anchor entry.
Playwright regression test for scroll stability
packages/react-virtual/e2e/app/test/scroll-anchor.spec.ts
Test navigates to the scroll-anchor page with a large initial offset, selects a mid-viewport anchor item, scrolls upward by a fixed delta, waits for measurement settling, and asserts the anchor's position change matches the delta within a small tolerance.
Disable browser scroll anchoring in chat example
examples/react/chat/src/index.css
Adds overflow-anchor: none to .Messages to prevent browser-native scroll anchoring from conflicting with the virtualizer's own compensation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • TanStack/virtual#1168: Introduced the original "skip scroll-position adjustment when scrolling backward" behavior in resizeItem that this PR refines for first-measurement vs re-measurement cases.
  • TanStack/virtual#1183: Modified measureElement and itemSizeCache exposure in virtual-core, directly affecting the itemSizeCache.has(key) predicate added in this PR.

Suggested reviewers

  • tannerlinsley

Poem

🐇 Hopping up the list I go,
First glimpse of an item? Scroll stays so!
Re-measured? No jank, just a gentle skip,
The cache holds the key on each upward trip.
No jumps, no bumps — smooth as a carrot dip! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: fixing scroll adjustment behavior during backward scrolling with first measurements, which accurately reflects the core fix in the changeset.
Description check ✅ Passed The description covers all required template sections with substantive content: explains the problem and motivation clearly, provides a detailed explanation of the fix, marks all checklist items, and confirms a changeset was generated.
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

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

nx-cloud Bot commented Jun 15, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit ba5d095

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

☁️ Nx Cloud last updated this comment at 2026-06-15 18:47:53 UTC

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
packages/react-virtual/e2e/app/scroll-anchor/main.tsx (1)

15-17: ⚡ Quick win

Harden initialOffset parsing against NaN input.

Line 15–Line 17 converts a query param with Number(...) and can pass NaN into initialOffset. Guarding with a finite check makes this fixture resilient to malformed URLs during local/debug runs.

Proposed patch
-  const initialOffset = Number(
-    new URLSearchParams(window.location.search).get('initialOffset') ?? 0,
-  )
+  const rawInitialOffset = new URLSearchParams(window.location.search).get(
+    'initialOffset',
+  )
+  const parsedInitialOffset =
+    rawInitialOffset == null ? 0 : Number(rawInitialOffset)
+  const initialOffset = Number.isFinite(parsedInitialOffset)
+    ? parsedInitialOffset
+    : 0
🤖 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/react-virtual/e2e/app/scroll-anchor/main.tsx` around lines 15 - 17,
The initialOffset variable assignment uses Number() which can result in NaN if
the query parameter is not a valid number. Add a finite check after the Number
conversion using Number.isFinite() to validate that the parsed value is not NaN
or Infinity, and provide a fallback to the default value of 0 if the check
fails.
packages/react-virtual/e2e/app/test/scroll-anchor.spec.ts (1)

47-47: ⚡ Quick win

Replace fixed sleeps with condition-based waits to reduce flakiness.

Line 47 and Line 59 use hard-coded waitForTimeout(500), which can be brittle under variable CI load. Polling for the measurable condition keeps this regression stable and faster.

Proposed patch
-  await page.waitForTimeout(500)
+  await expect
+    .poll(async () => {
+      return await page.evaluate(() => {
+        const container = document.querySelector('`#scroll-container`')
+        if (!container) return 0
+        return container.querySelectorAll('[data-index]').length
+      })
+    })
+    .toBeGreaterThan(0)
@@
-  // Let the async ResizeObserver measurements + scroll adjustments settle.
-  await page.waitForTimeout(500)
+  // Wait until the anchor displacement converges near expected delta.
+  await expect
+    .poll(async () => {
+      const top = await page.evaluate(topOfIndex, anchor.index)
+      if (top == null) return Number.POSITIVE_INFINITY
+      return Math.abs(top - (anchor.top + SCROLL_UP))
+    })
+    .toBeLessThan(8)
@@
-  expect(Math.abs(newTop! - (anchor.top + SCROLL_UP))).toBeLessThan(8)
+  expect(Math.abs(newTop! - (anchor.top + SCROLL_UP))).toBeLessThan(8)

Also applies to: 59-59

🤖 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/react-virtual/e2e/app/test/scroll-anchor.spec.ts` at line 47,
Replace the hard-coded waitForTimeout(500) calls in the scroll-anchor.spec.ts
test with condition-based waits to eliminate flakiness under variable CI load.
Instead of sleeping for a fixed duration, identify the measurable condition that
should be satisfied (such as an element reaching a specific state, becoming
visible, or stabilizing in position) and use page.waitForFunction() or
page.locator().waitFor() to poll for that condition. This approach will make the
test more reliable and potentially faster since it won't unnecessarily wait the
full timeout if the condition is met earlier.
🤖 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.

Nitpick comments:
In `@packages/react-virtual/e2e/app/scroll-anchor/main.tsx`:
- Around line 15-17: The initialOffset variable assignment uses Number() which
can result in NaN if the query parameter is not a valid number. Add a finite
check after the Number conversion using Number.isFinite() to validate that the
parsed value is not NaN or Infinity, and provide a fallback to the default value
of 0 if the check fails.

In `@packages/react-virtual/e2e/app/test/scroll-anchor.spec.ts`:
- Line 47: Replace the hard-coded waitForTimeout(500) calls in the
scroll-anchor.spec.ts test with condition-based waits to eliminate flakiness
under variable CI load. Instead of sleeping for a fixed duration, identify the
measurable condition that should be satisfied (such as an element reaching a
specific state, becoming visible, or stabilizing in position) and use
page.waitForFunction() or page.locator().waitFor() to poll for that condition.
This approach will make the test more reliable and potentially faster since it
won't unnecessarily wait the full timeout if the condition is met earlier.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e5deb8ed-b464-4949-a88c-53b2a09a5aeb

📥 Commits

Reviewing files that changed from the base of the PR and between 932c358 and ba5d095.

📒 Files selected for processing (8)
  • .changeset/adjust-scroll-first-measurement-backward.md
  • examples/react/chat/src/index.css
  • packages/react-virtual/e2e/app/scroll-anchor/index.html
  • packages/react-virtual/e2e/app/scroll-anchor/main.tsx
  • packages/react-virtual/e2e/app/test/scroll-anchor.spec.ts
  • packages/react-virtual/e2e/app/vite.config.ts
  • packages/virtual-core/src/index.ts
  • packages/virtual-core/tests/index.test.ts

@pkg-pr-new

pkg-pr-new Bot commented Jun 15, 2026

Copy link
Copy Markdown
More templates

@tanstack/angular-virtual

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

@tanstack/lit-virtual

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

@tanstack/react-virtual

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

@tanstack/solid-virtual

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

@tanstack/svelte-virtual

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

@tanstack/virtual-core

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

@tanstack/vue-virtual

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

commit: 779fa6f

… scroll

The default shouldAdjustScroll predicate skipped scroll compensation for any
above-viewport resize while scrolling backward. That over-applied to items
being measured for the first time: their estimate→actual delta lives above the
viewport and must be compensated, or the anchored content jumps when scrolling
up into never-seen rows.

Distinguish first measurement (!itemSizeCache.has(key)) — always compensate —
from re-measurement, which still skips during backward scroll to avoid the
cascading "items fight the scroll" jank.
@piecyk piecyk force-pushed the fix/scroll-adjust-first-measurement-backward branch from ba5d095 to 779fa6f Compare June 15, 2026 18:44
@piecyk piecyk merged commit ef69ea3 into TanStack:main Jun 15, 2026
10 checks passed
@github-actions github-actions Bot mentioned this pull request Jun 15, 2026
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