Skip to content

feat(virtual-core): iOS momentum-safe scroll adjustments via CSS offset#1189

Draft
piecyk wants to merge 1 commit into
TanStack:mainfrom
piecyk:damian/feat/ios-momentum-safe-scroll-adjustments
Draft

feat(virtual-core): iOS momentum-safe scroll adjustments via CSS offset#1189
piecyk wants to merge 1 commit into
TanStack:mainfrom
piecyk:damian/feat/ios-momentum-safe-scroll-adjustments

Conversation

@piecyk

@piecyk piecyk commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

On iOS WebKit, writing scrollTop during momentum scroll cancels the in-flight scroll. Instead of writing scrollTop, we now apply a negative marginTop on the container element to visually compensate for above-viewport size changes. The CSS offset is flushed to a real scrollTop write once momentum fully settles.

  • Defer scroll adjustments during iOS touch and momentum phases using CSS offset (marginTop/marginLeft)
  • Force-flush CSS offset before programmatic scroll operations (scrollToIndex, scrollToOffset, scrollBy)
  • Compensate scrollOffset for active CSS offset in range calculations
  • Guard against Safari elastic overscroll (rubber-band) during flush
  • Clean up CSS offset on unmount
  • Refine backward-scroll suppression: first measurements always adjust regardless of direction; re-measurements skip during backward scroll to avoid the scrollTop cascade 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

  • Improvements

    • Smoother iOS Safari momentum scrolling via deferred CSS-based offset compensation and safer flush handling.
    • Programmatic scrolling now forces resolution of any active iOS deferred offsets before performing scrolls.
  • Behavior

    • Refined resize-scroll adjustments: always apply the first backward-scroll measurement, skip redundant re-measurement adjustments, and keep offset calculations consistent during deferral.
  • Documentation

    • Chat/virtualization guidance now requires stable item keys and recommends disabling scroll anchoring.
  • Tests

    • Updated tests to match refined forward/backward resize-adjustment behavior.

@piecyk piecyk requested a review from tannerlinsley June 8, 2026 06:33
@coderabbitai

coderabbitai Bot commented Jun 8, 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: 56258643-ddf2-488c-b4d0-d37d48d9ed64

📥 Commits

Reviewing files that changed from the base of the PR and between dfe7dc9 and 1e6c48d.

📒 Files selected for processing (5)
  • .changeset/ios-momentum-scroll.md
  • docs/chat.md
  • examples/react/chat/src/index.css
  • packages/virtual-core/src/index.ts
  • packages/virtual-core/tests/index.test.ts
✅ Files skipped from review due to trivial changes (2)
  • examples/react/chat/src/index.css
  • .changeset/ios-momentum-scroll.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/virtual-core/tests/index.test.ts
  • packages/virtual-core/src/index.ts

📝 Walkthrough

Walkthrough

Defers visual scroll adjustments on iOS WebKit into a CSS margin offset during touch/momentum, folds the deferred delta into offset reads, flushes the compensation when safe or before programmatic scrolls, refines backward-scroll resize adjustment based on measurement cache, and updates tests, docs, example CSS, and a changeset.

Changes

iOS Momentum Scroll Safety and Adjustment Refinement

Layer / File(s) Summary
Core iOS CSS deviation mechanism
packages/virtual-core/src/index.ts (690–717, 742–763, 970–979)
Adds _iosDeferredAdjustment, _applyIosCssOffset, and flush logic that computes raw browser scroll by subtracting the deferred delta, guards against Safari elastic overscroll when flushing, clears the deferred delta, removes CSS compensation, and ensures cleanup on unmount.
Deviation integration & programmatic force-flush
packages/virtual-core/src/index.ts (831–838, 929–940, 1745–1798)
When deferring, accumulates deltas into _iosDeferredAdjustment and applies the CSS offset immediately; folds the deferred delta into reported scroll offsets for range calculations; and _forceFlushIosCssOffset is called before scrollToOffset, scrollToIndex, and scrollBy.
Scroll adjustment logic by direction & cache
packages/virtual-core/src/index.ts (1579–1587)
Default shouldAdjustScrollPositionOnItemSizeChange still adjusts for above-viewport resizes, but during backward scroll it allows the first (uncached) measurement to adjust and skips adjustments for re-measurements when the item key exists in itemSizeCache.
Test updates for adjustment behavior
packages/virtual-core/tests/index.test.ts (2069–2072, 2090–2151, 2186)
Tests assert backward-scroll adjustment occurs on first uncached measurement, is skipped on cached re-measurement, forward-scroll adjustments still apply, and idle (null) scrollDirection still triggers adjustment.
User docs, example CSS, and changeset
.changeset/ios-momentum-scroll.md (1–14), docs/chat.md (14–17, 52–59, 138–154), examples/react/chat/src/index.css (73)
Docs require getItemKey to be stable via React.useCallback, recommend overflow-anchor: none, add an "iOS Safari" section describing the CSS margin workaround for momentum, update example CSS, and include a changeset note.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • TanStack/virtual#1176: Modifies iOS WebKit deferred scroll/anchor synchronization logic (_iosDeferredAdjustment/anchor delta handling), overlapping with this PR.
  • TanStack/virtual#1168: Implements iOS Safari momentum-scroll deferral and flush behavior similar to this PR.

Suggested reviewers

  • tannerlinsley

Poem

🐰 Hopping soft where scrolls once fought,

margins hide the offset sought.
First-measure nudges, cache says "stay",
iOS settles — flushes clear the way.
A rabbit cheers for smoother sway. 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main feature: iOS momentum-safe scroll adjustments implemented via CSS offset, which directly aligns with the primary objective of the changeset.
Description check ✅ Passed The description covers the core problem, approach, key implementation details, follows the template structure with completed 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

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

nx-cloud Bot commented Jun 8, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit dfe7dc9

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

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-06-08 15:23:42 UTC

@pkg-pr-new

pkg-pr-new Bot commented Jun 8, 2026

Copy link
Copy Markdown
More templates

@tanstack/angular-virtual

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

@tanstack/lit-virtual

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

@tanstack/react-virtual

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

@tanstack/solid-virtual

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

@tanstack/svelte-virtual

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

@tanstack/virtual-core

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

@tanstack/vue-virtual

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

commit: 1e6c48d

@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 (1)
packages/virtual-core/src/index.ts (1)

742-756: 💤 Low value

Consider edge case: scrollOffset is null when force-flushing.

In _forceFlushIosDeviation, if scrollOffset is null and _iosDeferredAdjustment is non-zero, rawBrowserScroll would compute as a negative value (0 - adjustment). While this scenario is unlikely in practice (deviation is only accumulated after scroll events set scrollOffset), a defensive guard would prevent unexpected behavior if the invariant is violated.

🛡️ Optional defensive guard
 private _forceFlushIosDeviation = () => {
   if (this._iosDeferredAdjustment === 0) return
+  if (this.scrollOffset === null) {
+    // No scroll offset yet; just clear the deviation state
+    this._iosDeferredAdjustment = 0
+    this._applyIosDeviation()
+    return
+  }
   const rawBrowserScroll =
     (this.scrollOffset ?? 0) - this._iosDeferredAdjustment
🤖 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 742 - 756, If
this._iosDeferredAdjustment is non-zero but this.scrollOffset is null, computing
rawBrowserScroll yields an incorrect value; add a defensive guard at the top of
_forceFlushIosDeviation to handle null scrollOffset (e.g. if this.scrollOffset
== null then clear _iosDeferredAdjustment and call _applyIosDeviation and
return) so you don't call _scrollToOffset with an invalid rawBrowserScroll;
reference the method name _forceFlushIosDeviation and the fields scrollOffset,
_iosDeferredAdjustment and the helpers _applyIosDeviation and _scrollToOffset
when making the change.
🤖 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/virtual-core/src/index.ts`:
- Around line 742-756: If this._iosDeferredAdjustment is non-zero but
this.scrollOffset is null, computing rawBrowserScroll yields an incorrect value;
add a defensive guard at the top of _forceFlushIosDeviation to handle null
scrollOffset (e.g. if this.scrollOffset == null then clear
_iosDeferredAdjustment and call _applyIosDeviation and return) so you don't call
_scrollToOffset with an invalid rawBrowserScroll; reference the method name
_forceFlushIosDeviation and the fields scrollOffset, _iosDeferredAdjustment and
the helpers _applyIosDeviation and _scrollToOffset when making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e304110e-eb8c-4e87-8cfc-e899b4dc56ce

📥 Commits

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

📒 Files selected for processing (5)
  • .changeset/ios-momentum-scroll.md
  • docs/chat.md
  • examples/react/chat/src/index.css
  • packages/virtual-core/src/index.ts
  • packages/virtual-core/tests/index.test.ts

@piecyk piecyk changed the title feat(virtual-core): iOS momentum-safe scroll adjustments via CSS devi… feat(virtual-core): iOS momentum-safe scroll adjustments via CSS deviation Jun 8, 2026
@piecyk piecyk changed the title feat(virtual-core): iOS momentum-safe scroll adjustments via CSS deviation feat(virtual-core): iOS momentum-safe scroll adjustments via CSS offset Jun 8, 2026
@piecyk piecyk force-pushed the damian/feat/ios-momentum-safe-scroll-adjustments branch from d9919d9 to dfe7dc9 Compare June 8, 2026 15:06

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

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 `@docs/chat.md`:
- Line 58: Update the rationale to explain that useCallback is applied to
getItemKey to stabilize its function reference so memoized values that include
getItemKey (such as getMeasurements which depends on
this.getMeasurementOptions()) don't treat measurement options as changed on
every render; specifically mention wrapping getItemKey in useCallback to keep a
stable reference, how passing a new getItemKey identity each render can
invalidate getMeasurements/getMeasurementOptions memoization and cause
unnecessary measurement rebuilds/cache invalidation, and note this keeps the
virtualizer from triggering extra recomputation while clarifying that edge-key
change detection itself is based on returned key values.
🪄 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: 77b40f76-f2ad-4786-be9a-e5b0ed7c5cee

📥 Commits

Reviewing files that changed from the base of the PR and between d9919d9 and dfe7dc9.

📒 Files selected for processing (5)
  • .changeset/ios-momentum-scroll.md
  • docs/chat.md
  • examples/react/chat/src/index.css
  • packages/virtual-core/src/index.ts
  • packages/virtual-core/tests/index.test.ts
✅ Files skipped from review due to trivial changes (2)
  • examples/react/chat/src/index.css
  • .changeset/ios-momentum-scroll.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/virtual-core/src/index.ts
  • packages/virtual-core/tests/index.test.ts

Comment thread docs/chat.md Outdated
On iOS WebKit, writing scrollTop during momentum scroll cancels the
in-flight scroll. Instead of writing scrollTop, apply a negative
marginTop on the container element to visually compensate for
above-viewport size changes. The CSS offset is flushed to a real
scrollTop write once momentum fully settles.

Changes:
- Add CSS offset (marginTop) approach for iOS scroll adjustments
- Defer adjustments through touch→momentum→settled lifecycle
- Force-flush CSS offset before programmatic scroll operations
- Compensate scrollOffset for CSS offset in range calculations
- Guard against Safari elastic overscroll during flush
- Clean up CSS offset on unmount
- Refine backward-scroll suppression: first measurements always
  adjust (needed for prepend), re-measurements skip during backward
  scroll (avoids scrollTop cascade jank)
- Add overflow-anchor: none to chat example CSS
- Update chat docs with iOS section and getItemKey best practices
@piecyk piecyk force-pushed the damian/feat/ios-momentum-safe-scroll-adjustments branch from dfe7dc9 to 1e6c48d Compare June 8, 2026 15:20
@piecyk piecyk marked this pull request as draft June 8, 2026 21:13
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