Skip to content

Commit 9f9632b

Browse files
piecykclaude
andcommitted
fix(virtual-core): key scroll retirement off where the scroll parked
Review follow-up. Retirement was gated on a reachedTarget boolean that was re-armed only when the target moved more than a viewport, on the grounds that measurement settling nudges it a pixel or two. That left a gap: a retarget larger than the 1.01px arrival tolerance but smaller than the viewport kept the scroll marked as arrived while it was genuinely travelling to the new target, so the next prepend retired it early and stranded the reader — the same class of bug as cancelling an in-flight smooth scroll, just in a narrower window. Replace the boolean with arrivedAtOffset: the offset the scroll actually came to rest at, or null while travelling. Cleared whenever reconcile re-drives the scroll and recorded again when it next comes to rest, so "arrived, then the reader moved away" is compared against where we parked rather than against a target that measurement settling has since moved. Retirement no longer consults viewport size at all. Adds a regression test for the sub-viewport retarget. Both existing tests still hold, and each of the three fails on exactly one of the earlier revisions: pristine unconditional viewport arrivedAt clobbered anchor FAIL pass pass pass in-flight smooth pass FAIL pass pass sub-viewport retarget pass FAIL FAIL pass react-virtual e2e clean over 20 consecutive runs; marko 82 x2, angular 13 x3, core 131/131, repo-wide eslint/types/lib/build green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 88c894a commit 9f9632b

2 files changed

Lines changed: 120 additions & 32 deletions

File tree

packages/virtual-core/src/index.ts

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -386,13 +386,13 @@ type ScrollState = {
386386
// settling
387387
stableFrames: number
388388

389-
// Whether the scroll position has ever actually reached `lastTargetOffset`.
390-
// Separates "still travelling" from "arrived": only an arrived scroll can be
391-
// stale, so only an arrived scroll may be retired out from under the
392-
// reconcile loop. Re-armed when the target moves more than a viewport — a
393-
// genuinely new journey — but not for the pixel-scale drift that measurement
394-
// settling produces.
395-
reachedTarget: boolean
389+
// The offset this scroll came to rest at on its target, or null while it is
390+
// still travelling. Separates "arrived" from "in flight": only an arrived
391+
// scroll can be stale, so only an arrived scroll may be retired out from
392+
// under the reconcile loop. Cleared whenever reconcile re-drives the scroll,
393+
// and set again when it next comes to rest — so it records where we actually
394+
// parked rather than how far the target happened to move.
395+
arrivedAtOffset: number | null
396396
}
397397

398398
type PendingScrollAnchor = [
@@ -650,6 +650,10 @@ export class Virtualizer<
650650
// frame, producing a visible "jump" on prepend with dynamic sizes.
651651
let anchorResolved = false
652652
let anchorDelta = 0
653+
// Where the reader was sitting before this correction shifted them, used
654+
// below to tell "they left the place a finished scroll parked them" from
655+
// "they are still where it parked them".
656+
let offsetBeforeAnchor = 0
653657
if (anchor && this.scrollOffset !== null) {
654658
const [anchorKey, anchorOffset] = anchor
655659
const newMeasurements = this.getMeasurements()
@@ -668,6 +672,7 @@ export class Virtualizer<
668672
const newOffset = Math.max(0, anchorItem.start + anchorOffset)
669673
if (newOffset !== this.scrollOffset) {
670674
anchorDelta = newOffset - this.scrollOffset
675+
offsetBeforeAnchor = this.scrollOffset
671676
this.scrollOffset = newOffset
672677
anchorResolved = true
673678
}
@@ -689,15 +694,17 @@ export class Virtualizer<
689694
// there — overwriting this correction and dropping the user several
690695
// screens from the message they were reading.
691696
//
692-
// `reachedTarget` is what keeps this off scrolls that are still travelling:
693-
// a prepend during an in-flight `scrollToIndex`, smooth or not, must leave
694-
// `scrollState` alone so reconcile can still land on the requested index
695-
// at its new position.
697+
// `arrivedAtOffset` is what keeps this off scrolls that are still
698+
// travelling: a prepend during an in-flight `scrollToIndex`, smooth or not,
699+
// must leave `scrollState` alone so reconcile can still land on the
700+
// requested index at its new position. Comparing against where the scroll
701+
// parked — not against its current target — is what makes this independent
702+
// of how far measurement settling has since nudged that target.
696703
if (
697704
anchorResolved &&
698705
this.scrollState !== null &&
699-
this.scrollState.reachedTarget &&
700-
!approxEqual(this.getScrollOffset(), this.scrollState.lastTargetOffset)
706+
this.scrollState.arrivedAtOffset !== null &&
707+
!approxEqual(offsetBeforeAnchor, this.scrollState.arrivedAtOffset)
701708
) {
702709
this.scrollState = null
703710
}
@@ -885,15 +892,15 @@ export class Virtualizer<
885892
}
886893
this._intendedScrollOffset = null
887894

888-
// Record arrival at the programmatic target. This only ever *marks*
889-
// — retiring `scrollState` from here would abort long scrolls, since
890-
// the browser moves scrollTop itself as items measure and a smooth
891-
// animation arrives entirely as events we did not write.
895+
// Record where a programmatic scroll came to rest. This only ever
896+
// *records* — retiring `scrollState` from here would abort long
897+
// scrolls, since the browser moves scrollTop itself as items measure
898+
// and a smooth animation arrives entirely as events we did not write.
892899
if (
893900
this.scrollState !== null &&
894901
approxEqual(offset, this.scrollState.lastTargetOffset)
895902
) {
896-
this.scrollState.reachedTarget = true
903+
this.scrollState.arrivedAtOffset = offset
897904
}
898905

899906
this.scrollAdjustments = 0
@@ -1094,7 +1101,7 @@ export class Virtualizer<
10941101
const targetChanged = targetOffset !== this.scrollState.lastTargetOffset
10951102

10961103
if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) {
1097-
this.scrollState.reachedTarget = true
1104+
this.scrollState.arrivedAtOffset = this.getScrollOffset()
10981105
this.scrollState.stableFrames++
10991106
if (this.scrollState.stableFrames >= STABLE_FRAMES) {
11001107
// Final-pass exact landing. The reconcile-stable check uses a 1.01px
@@ -1130,16 +1137,14 @@ export class Virtualizer<
11301137

11311138
this.scrollState.lastTargetOffset = targetOffset
11321139

1133-
// A destination more than a viewport away is a new journey, so
1134-
// "arrived" no longer holds — without this re-arm a scroll that
1135-
// reached an earlier target would stay eligible for retirement while
1136-
// genuinely in flight toward the new one. Small moves do NOT re-arm:
1137-
// measurements settling right after mount routinely nudge the target a
1138-
// pixel or two, and dropping `reachedTarget` for that would forget
1139-
// that we are sitting on the destination.
1140-
if (distance > viewport) {
1141-
this.scrollState.reachedTarget = false
1142-
}
1140+
// We are about to re-drive the scroll, so it is travelling again and
1141+
// any previous resting place is void. It is recorded afresh when the
1142+
// write below lands and the resulting scroll event observes us at the
1143+
// new target. Keying off the re-drive rather than off how far the
1144+
// target moved is what keeps this independent of viewport size: a
1145+
// measurement nudge of a pixel and a genuine retarget are handled the
1146+
// same way, by waiting until we actually come to rest again.
1147+
this.scrollState.arrivedAtOffset = null
11431148
if (!keepSmooth) {
11441149
this.scrollState.behavior = 'auto'
11451150
}
@@ -1854,7 +1859,7 @@ export class Virtualizer<
18541859
startedAt: now,
18551860
lastTargetOffset: offset,
18561861
stableFrames: 0,
1857-
reachedTarget: false,
1862+
arrivedAtOffset: null,
18581863
}
18591864

18601865
this._scrollToOffset(offset, { adjustments: undefined, behavior })
@@ -1889,7 +1894,7 @@ export class Virtualizer<
18891894
startedAt: now,
18901895
lastTargetOffset: offset,
18911896
stableFrames: 0,
1892-
reachedTarget: false,
1897+
arrivedAtOffset: null,
18931898
}
18941899

18951900
this._scrollToOffset(offset, { adjustments: undefined, behavior })
@@ -1911,7 +1916,7 @@ export class Virtualizer<
19111916
startedAt: now,
19121917
lastTargetOffset: offset,
19131918
stableFrames: 0,
1914-
reachedTarget: false,
1919+
arrivedAtOffset: null,
19151920
}
19161921

19171922
this._scrollToOffset(offset, { adjustments: undefined, behavior })

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3802,3 +3802,86 @@ test('a prepend during an in-flight smooth scroll leaves it alone', () => {
38023802
expect(v['scrollState']).not.toBeNull()
38033803
expect(v['scrollState']!.index).toBe(60)
38043804
})
3805+
3806+
test('a sub-viewport retarget mid-flight does not make the scroll retirable', () => {
3807+
// Retirement keys off where the scroll came to REST, not off its current
3808+
// target. An earlier revision re-armed only when the target moved more than a
3809+
// viewport, on the grounds that measurement settling nudges it a pixel or two.
3810+
// That left a gap: a measurement-driven retarget larger than the 1.01px
3811+
// arrival tolerance but smaller than the viewport kept the scroll marked as
3812+
// arrived while it was genuinely travelling to the new target, so the next
3813+
// prepend retired it early and stranded the reader.
3814+
const rafCallbacks: Array<FrameRequestCallback> = []
3815+
const mockWindow = {
3816+
requestAnimationFrame: vi.fn((cb: FrameRequestCallback) => {
3817+
rafCallbacks.push(cb)
3818+
return rafCallbacks.length
3819+
}),
3820+
cancelAnimationFrame: vi.fn(),
3821+
performance: { now: () => Date.now() },
3822+
ResizeObserver: vi.fn(function () {
3823+
return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() }
3824+
}),
3825+
}
3826+
const el = {
3827+
scrollTop: 0,
3828+
scrollLeft: 0,
3829+
scrollWidth: 400,
3830+
scrollHeight: 1000, // 20 x 50
3831+
clientWidth: 400,
3832+
clientHeight: 200,
3833+
offsetWidth: 400,
3834+
offsetHeight: 200,
3835+
ownerDocument: { defaultView: mockWindow },
3836+
scrollTo: vi.fn(),
3837+
} as unknown as HTMLDivElement
3838+
const scrollToFn = vi.fn()
3839+
let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null =
3840+
null
3841+
3842+
const rows = Array.from({ length: 20 }, (_, i) => `r-${i}`)
3843+
const v = new Virtualizer<HTMLDivElement, any>({
3844+
count: rows.length,
3845+
estimateSize: () => 50,
3846+
anchorTo: 'end',
3847+
getItemKey: (i) => rows[i]!,
3848+
getScrollElement: () => el,
3849+
scrollToFn,
3850+
observeElementRect: (_inst, cb) => {
3851+
cb({ width: 400, height: 200 })
3852+
return () => {}
3853+
},
3854+
observeElementOffset: (_inst, cb) => {
3855+
scrollCallback = cb
3856+
cb(0, false)
3857+
return () => {}
3858+
},
3859+
})
3860+
v._willUpdate()
3861+
v.getVirtualItems()
3862+
3863+
// Land on index 10, so the scroll is genuinely "arrived".
3864+
v.scrollToIndex(10, { align: 'start' })
3865+
const target = scrollToFn.mock.calls.at(-1)![0]
3866+
scrollCallback!(target, false)
3867+
v.getVirtualItems()
3868+
3869+
// A row above the viewport measures 100px taller. Index 10 moves by 100 —
3870+
// more than the 1.01px arrival tolerance, less than the 200px viewport — so
3871+
// reconcile re-drives the scroll toward the new target.
3872+
v.resizeItem(0, 150)
3873+
v.getVirtualItems()
3874+
rafCallbacks.splice(0).forEach((cb) => cb(0))
3875+
3876+
// History arrives before that re-driven scroll has landed.
3877+
rows.unshift('r--5', 'r--4', 'r--3', 'r--2', 'r--1')
3878+
;(el as any).scrollHeight = 1350
3879+
v.setOptions({
3880+
...v.options,
3881+
count: rows.length,
3882+
getItemKey: (i: number) => rows[i]!,
3883+
})
3884+
3885+
// It is still in flight, so it must survive to be landed by reconcile.
3886+
expect(v['scrollState']).not.toBeNull()
3887+
})

0 commit comments

Comments
 (0)