Repository navigation
fix(virtual-core): recognize the virtualizer's own write when it is reported again at rest #1376
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,5 @@ | ||
| --- | ||
| '@tanstack/virtual-core': patch | ||
| --- | ||
|
|
||
| With `useScrollendEvent: true`, `scrollToIndex` and `scrollToEnd` no longer stop short of a target that moved while they settled: the `scrollend` that reports the virtualizer's own write again (a size correction, or a write the browser clamped) is no longer read as the user scrolling away. With `anchorTo: 'end'`, rows prepended once a `scrollToIndex` has landed end the command in either mode, so the view stays on the rows it showed rather than moving to the row the index names in the new list. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,13 +12,16 @@ import { approxEqual } from './utils' | |
| // through since the write | ||
| // clampedEcho a compensation write the browser clamped (#1258) | ||
| // reporting at the max seen at write time | ||
| // restEcho our own write reported again at rest, where the last | ||
| // scroll event reported it | ||
| // external anything else: the user, a fling, a smooth animation | ||
| // frame | ||
| type ScrollEventKind = | ||
| | 'spurious' | ||
| | 'readBack' | ||
| | 'clampedReadBack' | ||
| | 'clampedEcho' | ||
| | 'restEcho' | ||
| | 'external' | ||
|
|
||
| export interface ScrollEventInput { | ||
|
|
@@ -44,6 +47,9 @@ export interface ScrollEventInput { | |
| // The element's max when a clamped compensation write was made (#1258), | ||
| // or null when none is pending. | ||
| clampedMaxAtWrite: number | null | ||
| // Where the last scroll event reported our own write (read back, clamped | ||
| // or echoed), until a report anywhere else. | ||
| ownWriteAt: number | null | ||
| } | ||
|
|
||
| export function classifyScrollEvent({ | ||
|
|
@@ -54,6 +60,7 @@ export function classifyScrollEvent({ | |
| scrollOffset, | ||
| pendingWrite, | ||
| clampedMaxAtWrite, | ||
| ownWriteAt, | ||
| }: ScrollEventInput): ScrollEventKind { | ||
| // A scroll event that reports movement but lands on the offset we already | ||
| // hold — and isn't a self-write read-back — is a spurious no-op re-emit | ||
|
|
@@ -99,6 +106,17 @@ export function classifyScrollEvent({ | |
| ) { | ||
| return 'clampedEcho' | ||
| } | ||
| // A write is reported again when the element comes to rest: `scrollend` | ||
| // follows its scroll event in the same frame's scroll steps, before | ||
| // reconcile; the fallback follows it after `isScrollingResetDelay`. | ||
| // Nothing has moved the element in between. | ||
| if ( | ||
| !isScrolling && | ||
| ownWriteAt !== null && | ||
| Math.abs(offset - ownWriteAt) < 1.5 | ||
| ) { | ||
| return 'restEcho' | ||
| } | ||
| return 'external' | ||
| } | ||
|
|
||
|
|
@@ -142,14 +160,17 @@ export interface RetireInput { | |
|
|
||
| // Whether a scroll event that is not the read-back of our own write retires | ||
| // the in-flight index command: an external scroll moved away from its | ||
| // target. Our own measurement updates (including clamped writes) still need | ||
| // to reconcile. | ||
| // target, or our own write left the element where reconcile will not move | ||
| // it. Our own measurement updates (including clamped writes) still need to | ||
| // reconcile. | ||
| export function externalRetiresCommand({ | ||
| kind, | ||
| offset, | ||
| command, | ||
| target, | ||
| }: RetireInput): boolean { | ||
| let fresh: number | undefined | ||
| const freshTarget = () => (fresh ??= target()) | ||
| return ( | ||
| kind !== 'spurious' && | ||
| kind !== 'readBack' && | ||
|
|
@@ -170,7 +191,14 @@ export function externalRetiresCommand({ | |
| // has landed. | ||
| !isAnimating(command.phase) && | ||
| !approxEqual(offset, command.lastTargetOffset) && | ||
| !approxEqual(offset, target()) && | ||
| !approxEqual(offset, freshTarget()) && | ||
| // Our own write reported again at rest (`restEcho`): nothing else has | ||
| // moved the element, but reconcile writes only a target that moved. Once | ||
| // the target has moved since the command wrote it, reconcile lands the | ||
| // command there (with `scrollend`, a correction's echo arrives before | ||
| // reconcile has run on the measurements behind it); while it has not, | ||
| // nothing will bring the element back, and the command ends here. | ||
| (kind !== 'restEcho' || freshTarget() === command.lastTargetOffset) && | ||
|
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.
|
||
| // A compensation write's clamped echo (#1258) keeps the command. The | ||
| // order is deliberate, not tidiness: this clause stays after `target()` | ||
| // (and `clampedReadBack` before it) because `target()` can rebuild the | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
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.
Script executed:
Repository: TanStack/virtual
Length of output: 17397
Script executed:
Repository: TanStack/virtual
Length of output: 43491
Script executed:
Repository: TanStack/virtual
Length of output: 39083
Script executed:
Repository: TanStack/virtual
Length of output: 23346
Script executed:
Repository: TanStack/virtual
Length of output: 22375
Retire landed index commands after compensation.
If a row grows before the first stable frame, compensation moves the offset without updating
lastTargetOffset. A subsequent prepend can leave the landed command active. Before a rest report retires it, another resize can move the viewport to the new index instead of the anchored row. Track the landing across compensation.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.
Both the correction and the prepend need to land before the read-back frame's reconcile within this window. If this occurs, the command is handed over to reconcile, same as the behavior when main does not have scrollend enabled.
Fixing this would require adding a new state, which I believe is out of scope for this PR, so I will leave it for now.
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.
@Darkatse Understood. The case depends on a narrow timing window, and you’ve chosen to leave it outside this PR.
Would you like me to open a follow-up issue?
You are interacting with an AI system.