Skip to content

refactor(virtual-core): describe an in-flight scroll command by one phase - #1311

Draft
piecyk wants to merge 3 commits into
TanStack:mainfrom
piecyk:refactor/scroll-state-phase
Draft

piecyk wants to merge 3 commits into
TanStack:mainfrom
piecyk:refactor/scroll-state-phase

Conversation

@piecyk

@piecyk piecyk commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #1310 (itself on #1309). This branch contains both of their commits; review only the last commit, refactor(virtual-core): describe an in-flight scroll command by one phase. I'll rebase and mark it ready once they merge.

🎯 Changes

No behaviour change. ScrollState described an in-flight scroll command's lifecycle through three fields: behavior (switched from 'smooth' to 'auto' by reconcile for the final approach, which also re-enables size compensation), hasReachedTarget, and startedSmooth (added in #1310 because WebKit keeps delivering smooth-animation events after that switch). Each fix in this area had added one. Their combinations encode four situations the code tells apart, so they become one field:

 type ScrollState = {
   index, align, toEnd, startedAt, lastTargetOffset, stableFrames,
-  behavior: ScrollBehavior
-  hasReachedTarget: boolean
-  startedSmooth: boolean
+  phase: 'smooth' | 'smoothLanded' | 'smoothFinishing' | 'instant'
 }
smooth           a smooth animation is travelling to the target
smoothLanded     a smooth scroll is at its target, waiting to retire
smoothFinishing  a smooth scroll switched to instant writes for the final
                 approach; its animation can still report positions
instant          instant writes

Two helpers replace the flag combinations:

Helper Used by Equivalent to before
isSmoothScroll (smooth | smoothLanded) size compensation, the prepend anchor sync, shouldMeasureDuringScroll behavior === 'smooth'
isAnimating (smooth | smoothFinishing) the scroll handler: a non-read-back event may not be the user !hasReachedTarget && startedSmooth

reconcileScroll moves the phase where it used to flip the flags: at the target, smooth → smoothLanded and smoothFinishing → instant; on a retarget, a far smooth retarget → smooth, otherwise smooth → smoothFinishing and smoothLanded → instant.

Evidence

  • Differential check against the parent: 3,000 random event sequences (auto, smooth and instant commands, animation steps, resizes, drags, frames, cancelScroll, prepends with anchorTo: 'end', the 5s safety valve, iOS UA, lanes, scrollPaddingStart) produce identical traces before and after: writes, scrollTop, tracked offset, scroll state, and shouldMeasureDuringScroll for sample rows at every step. Breaking any single transition or helper on purpose is caught. A separate independent probe (600 seeds, including useAnimationFrameWithResizeObserver and lanes: 2) also found zero mismatches and exercised every transition.
  • virtual-core: 233 passed + 5 expected failures; types and lint clean. One test that matched scrollState on behavior: 'smooth' now matches phase: 'smooth'.
  • react-virtual cached-measurements, smooth-scroll, smooth-prepend, scroll in Chromium, Firefox and WebKit; react e2e 39/39; Marko chat "does NOT follow appends while reading history" 15/15 interleaved against the parent; pnpm run test:pr green.
  • The ios-deferral diagnostic page (test(virtual-core): pin the iOS deferral and index-scroll orderings measured for #1307 #1308) logged scrollState.hasReachedTarget; it now logs phase.

Merge Danger

Door: two-way. Private state only; no public API or option changes.

Blast Radius: none intended. Every reader of the removed fields is in virtual-core/src and maps onto a phase helper; no adapter, test or doc reads them.

Part of the plan in #1307 (step 2a), ahead of reworking the iOS deferral state.

✅ 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).

🤖 Generated with Claude Code

piecyk and others added 3 commits October 11, 2026 16:49
…ead-back to reconcile

After a scrollToIndex/scrollToEnd/reconcile write, `scrollOffset` keeps the
old position until the scroll event reads the write back, and the rows on
screen are still the ones rendered for it. A resize in that window was
compensated against the old position: scrolling up with scrollToIndex could
jump back to the old position plus the delta, and reconcile (target
unchanged) never rewrote it until the 5s safety valve.

Track the offset of the last non-smooth write until its read-back. While a
scroll state is active and `scrollOffset` doesn't reflect that write yet,
skip resize compensation: reconcileScroll retargets from fresh measurements
on the next frame. Compensation writes and the prepend anchor sync update
`scrollOffset` eagerly, so they are unaffected. scrollBy builds on the
written offset, so a scrollBy right after a scroll command adds to its
target.

Classifying those resizes against the written offset instead was tried and
regressed the chat examples: during scrollToEnd the rows at the old
position were compensated, the end target kept moving, and a user scroll
could no longer interrupt the never-landed pin.

Adds tests/ios-differential.test.ts, which runs scripted event sequences on
an iOS and a non-iOS virtualizer and checks they settle at the same offset
without needing the safety valve (TanStack#1307).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…efore it lands

The scroll handler only treated a scroll event as the user leaving an index
scroll once reconcile had seen the target reached. A target that keeps
moving while rows are measured (or, on iOS, a write ignored under a finger)
never "reaches" it, so the state stayed alive after the user scrolled away
and the next measurement that moved the target wrote them back to it.

A non-smooth write lands at once, so a scroll event that isn't the
read-back of our own write is the user's even before the target is reached.
A write the browser reads back short of what we wrote because the scroll
max shrank reports the max, where the fresh target also clamps, so it keeps
reconciling as before (TanStack#1294); clamped compensation writes stay covered by
`_clampedAdjustment`. Smooth scrolls are unchanged: their animation events
look like a user's, so they still yield only after reaching the target.
That is decided by a new `startedSmooth` flag rather than `behavior`, which
reconcile switches to 'auto' for the final approach while WebKit can still
deliver animation events.

Removes `.fails` from the two TanStack#1308 pull-back tests, and makes the TanStack#1294
"keeps an index scroll active before reaching its target" test model the
max shrinking before the read-back.

Refs TanStack#1307

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hase

ScrollState described its lifecycle through `behavior` (switched from
'smooth' to 'auto' by reconcile for the final approach), `hasReachedTarget`
and `startedSmooth`. Their combinations encode four situations the code
tells apart, so name them in one field instead:

  smooth           a smooth animation is travelling to the target
  smoothLanded     a smooth scroll is at its target, waiting to retire
  smoothFinishing  a smooth scroll switched to instant writes for the final
                   approach; its animation can still report positions
  instant          instant writes

`isSmoothScroll` (compensation, the prepend anchor sync and far
measurements hold off) is true exactly where `behavior === 'smooth'` was,
and `isAnimating` (a non-read-back scroll event may not be the user) exactly
where `!hasReachedTarget && startedSmooth` was. reconcileScroll's
transitions move the phase where it used to flip those flags. The
ios-deferral diagnostic page logs `phase` instead of `hasReachedTarget`.

No behaviour change: 3000 random event sequences (auto, smooth and instant
commands, animation steps, resizes, drags, frames, cancels, prepends, the
safety valve, iOS) produce identical traces before and after, including
`shouldMeasureDuringScroll` for sample rows at every step, and the
comparison catches each transition broken on purpose.

Refs TanStack#1307

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 113f9b4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@tanstack/virtual-core Patch
@tanstack/angular-virtual Patch
@tanstack/lit-virtual Patch
@tanstack/marko-virtual Patch
@tanstack/react-virtual Patch
@tanstack/solid-virtual Patch
@tanstack/svelte-virtual Patch
@tanstack/vue-virtual Patch
@tanstack/virtual-benchmarks Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Oct 11, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

@nx-cloud

nx-cloud Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 113f9b4

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

☁️ Nx Cloud last updated this comment at 2026-10-11 17:45:40 UTC

@pkg-pr-new

pkg-pr-new Bot commented Oct 11, 2026

Copy link
Copy Markdown
More templates

@tanstack/angular-virtual

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

@tanstack/lit-virtual

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

@tanstack/marko-virtual

npm i https://pkg.pr.new/@tanstack/marko-virtual@1311

@tanstack/react-virtual

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

@tanstack/solid-virtual

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

@tanstack/svelte-virtual

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

@tanstack/virtual-core

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

@tanstack/vue-virtual

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

commit: 113f9b4

@phcorp

phcorp commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Reviewed the last commit at 113f9b4 against its parent 2b7ac8b. I could not find a behaviour difference.

Differential run, written independently of yours. Same seeded event script on both revisions, full trace compared at every step: every scrollToFn call, scrollTop, scrollOffset, scrollAdjustments, isScrolling, direction, scroll state fields, _intendedScrollOffset, _lastWrittenOffset, _iosDeferredAdjustment, _clampedAdjustment, shouldMeasureDuringScroll for sample rows, range and total size.

Seeds Trace steps Mismatches
full traces 5,000 340,654 0
trace hashes 50,000 3,408,189 0

Events: all four scroll commands with auto / smooth / instant, read-backs delivered, rounded, clamped and dropped, animation steps including stale ones after the switch to instant writes, user scrolls, resizes, frames, clock jumps past the safety valve, cancelScroll, prepends and appends with and without anchorTo: 'end', touch events with and without an iOS UA, lanes, gap, paddings, scrollMargin, rAF-deferred ResizeObserver, a custom adjust predicate.

Coverage. All four phases and every transition were reached (5,000-seed run: smooth→smoothLanded 2,283, smooth→smoothFinishing 867, smoothFinishing→instant 217, smoothLanded→instant 130, smoothLanded→smooth 103). The parent's (behavior, hasReachedTarget, startedSmooth) mapped onto a phase equals phase at every step. The only two flag combinations never seen are smooth behavior with startedSmooth false, which the old code cannot reach either.

Sensitivity. Each of these, applied alone to this branch, is caught:

Mutation Mismatching seeds / 5,000
isAnimating true only for smooth 253
isSmoothScroll true only for smooth 2,267
no smoothFinishing → instant at the target 21
retarget sends smoothLanded to smoothFinishing 34
initialScrollPhase ignores atTarget 369

Static. No reader of behavior, hasReachedTarget or startedSmooth on the scroll state is left anywhere in the repo; tsc and eslint ./src are clean. The ios-deferral page logging phase is fine by me.

E2E. Nothing that separates it from #1310. One WebKit failure of scroll.spec.ts › scrolls to last item (received 14, expected < 1.01) did not reproduce in 30 more repetitions (30/30 here, 30/30 on main).

Limits: the differential run uses a simulated scroll element, and it did not exercise horizontal, a window scroller, isRtl or more than two lanes.

Nit: the changeset is a patch with a changelog line for a change with no behaviour change. Up to you whether that entry is wanted.

This branch has not been deployed

No deployments
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.

2 participants