From cbacd67a72a6f4cad14425ef14a869a575bf15dd Mon Sep 17 00:00:00 2001 From: Yeongbin Jeong Date: Fri, 6 Nov 2026 16:44:11 +0900 Subject: [PATCH 1/5] fix(virtual-core): keep only the last rows of a list above the viewport in a shared scroll element calculateRange caps the scroll offset at the end of the list (#1351) so that a stale offset past the end, after the list shrank and before the element reports its clamped position, still renders the last viewport of rows. In a scroll element shared with other content, an offset past one list's end is where the element is: the list is above the viewport. The cap then keeps a viewport of rows plus overscan mounted where the old range kept the last row plus overscan (#1371). The cap now applies only while the offset is stale: the element is past an edge, the offset is not the last one the element reported (a write of ours not yet read back), or the list has shrunk since that report. The list end a report is compared with is the one the element has, the sizer as last rendered, taken to layout coordinates with the report, since measurements and held corrections move the layout's own end before the element has the new size. Adapters that redraw on onChange (Vue, Svelte) render the sizer of the last range without `_willUpdate`, so the end of the last range counts too, whichever is longer. The scroll-trace snapshot is unchanged. --- .changeset/shared-scroller-offscreen-range.md | 5 ++ packages/virtual-core/src/index.ts | 50 +++++++++++- packages/virtual-core/tests/range.test.ts | 81 +++++++++++++++++++ 3 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 .changeset/shared-scroller-offscreen-range.md diff --git a/.changeset/shared-scroller-offscreen-range.md b/.changeset/shared-scroller-offscreen-range.md new file mode 100644 index 000000000..0043c1f84 --- /dev/null +++ b/.changeset/shared-scroller-offscreen-range.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +Render only the last rows of a list that is above the viewport in a shared scroll element. The scroll offset is capped at the end of the list only while it is stale (the list shrank, the element is past an edge, or a write of ours has not been reported back yet), not where the element has reported it. diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index ecaaddf7f..0c65f541e 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -549,6 +549,17 @@ export class Virtualizer< // (`followOnAppend does not follow 'trim only'`; reading the element at // write time still moves 49 of 1080 snapshot seeds). private _reportedOverscroll = false + // The last position the element reported, and where the list ended in the + // element then. A `scrollOffset` past the list's end is where the element + // is only if it is that report and the list has not shrunk since, as when + // other content shares the scroll element (#1371). The end is the sizer as + // last rendered (`_willUpdate` runs once the adapter has rendered), taken + // to layout coordinates with the report: measurements and a held + // correction move the layout's own end before the element has the new size. + private _renderedListEnd = Infinity + private _rangeListEnd = -Infinity + private _reportedOffset = NaN + private _reportedListEnd = Infinity // The touch gesture in progress (see `IosGesture`). Its tail closing on // its own makes a write possible again: the last momentum frame may have // come before the timer. The hold is released first, so a command that @@ -1069,6 +1080,7 @@ export class Virtualizer< // its next touch cycle. this._holdOffset = 0 this._reportedOverscroll = false + this._reportedOffset = NaN // (The gesture was ended with its listeners, in the unsubs above.) this._clampedAdjustment = null // A write to the old element will never be read back. @@ -1084,6 +1096,7 @@ export class Virtualizer< } _willUpdate = () => { + this._renderedListEnd = this.getTotalSize() + this.options.scrollMargin const scrollElement = this.options.enabled ? this.options.getScrollElement() : null @@ -1128,6 +1141,14 @@ export class Virtualizer< this._lastElementOffset = elementOffset // Everything below works in layout coordinates. offset = this._toLayoutOffset(offset) + // An adapter that redraws on onChange (Vue, Svelte) renders the + // sizer of the last range without `_willUpdate`, so the element can + // end further down than recorded there; the longer end only makes + // the offset stale sooner. + const listEnd = Math.max( + this._toLayoutOffset(this._renderedListEnd), + this._rangeListEnd, + ) const kind = this._classifyScrollEvent( offset, @@ -1190,6 +1211,8 @@ export class Virtualizer< : 'backward' : null this.scrollOffset = offset + this._reportedOffset = offset + this._reportedListEnd = listEnd this.isScrolling = isScrolling // A finger that lands on a fling fires no touch events (#1307), so @@ -1876,20 +1899,39 @@ export class Virtualizer< // scrollOffset is measured in the scroll element and the layout total // from the list start, so the list ends scrollMargin further down. this._getLayoutTotalSize() + this.options.scrollMargin, + this._reportedOffset, + this._reportedListEnd, + this._reportedOverscroll, ], - (measurements, outerSize, scrollOffset, lanes, listEnd) => { + ( + measurements, + outerSize, + scrollOffset, + lanes, + listEnd, + reportedOffset, + reportedListEnd, + overscrolled, + ) => { + this._rangeListEnd = listEnd if (measurements.length === 0 || outerSize === 0) { this.range = null return null } // Until the element reports a clamped position (the list shrank, a // rubber-band bounce), the offset can sit past the last row, which - // would leave only that row in the range. - const maxScrollOffset = Math.max(listEnd - outerSize, 0) + // would leave only that row in the range. Any other offset past the + // end is where the element is: the list is above the viewport. + const stale = + overscrolled || + scrollOffset !== reportedOffset || + listEnd < reportedListEnd this.range = calculateRangeImpl( measurements, outerSize, - Math.min(scrollOffset, maxScrollOffset), + stale + ? Math.min(scrollOffset, Math.max(listEnd - outerSize, 0)) + : scrollOffset, lanes, // Pass the typed array so binary search + forward-walk can read // start/end directly from Float64Array, skipping the Proxy traps. diff --git a/packages/virtual-core/tests/range.test.ts b/packages/virtual-core/tests/range.test.ts index 29f308298..5c54851bd 100644 --- a/packages/virtual-core/tests/range.test.ts +++ b/packages/virtual-core/tests/range.test.ts @@ -366,3 +366,84 @@ test('a stale offset past the end renders the last viewport of rows with a scrol expect(before).toEqual([40, 41, 42, 43, 44, 45, 46, 47, 48, 49]) expect(after).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19]) }) + +// Two lists share one scroll element: the first starts 100px down and is +// 81 × 25 = 2025px tall, so it ends at 2125px; the second extends the element +// well below that. A position the element reports past the first list's end +// is real, and the first list is wholly above the viewport. +const sharedScroller = () => { + let report: ((offset: number, isScrolling: boolean) => void) | null = null + const options = { + estimateSize: () => 25, + overscan: 6, + scrollMargin: 100, + initialRect: { width: 300, height: 600 }, + getScrollElement: () => + ({ scrollTop: 0, scrollHeight: 6000, clientHeight: 600 }) as any, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: (_: unknown, cb: typeof report) => { + report = cb + return () => {} + }, + } + const virtualizer = new Virtualizer({ ...options, count: 81 }) + virtualizer._willUpdate() + return { virtualizer, options, report: report! } +} + +test('a list above the viewport in a shared scroll element keeps only its last rows', () => { + const { virtualizer, report } = sharedScroller() + report(2500, false) + // The last row plus overscan, as before the shrink cap: not a viewport of + // rows nobody can see. + expect(virtualizer.getVirtualIndexes()).toEqual([74, 75, 76, 77, 78, 79, 80]) +}) + +test('a list that shrinks after the element reported a position still renders its last viewport of rows', () => { + const { virtualizer, options, report } = sharedScroller() + // At rest at the end of the first list: 100 + 2025 - 600. + report(1525, false) + virtualizer.setOptions({ ...options, count: 41 }) + // 41 rows end at 100 + 1025 = 1125: 1525 is stale until the element + // reports its clamped position, so the range is the last 24 rows plus + // overscan, not the last row alone. + expect(virtualizer.getVirtualIndexes()).toEqual( + Array.from({ length: 30 }, (_, i) => i + 11), + ) +}) + +test('a list that grew without an update before the element reported still renders its last viewport once it shrinks', () => { + // Adapters that redraw on onChange (Vue, Svelte) run `_willUpdate` only when + // the options change, so the element can have a sizer longer than the one + // last recorded there by the time it reports a position. + let report: ((offset: number, isScrolling: boolean) => void) | null = null + const element = { scrollTop: 0, scrollHeight: 2000, clientHeight: 300 } + const options = { + estimateSize: () => 30, + overscan: 0, + initialRect: { width: 300, height: 300 }, + getScrollElement: () => element as any, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: (_: unknown, cb: typeof report) => { + report = cb + return () => {} + }, + } + const virtualizer = new Virtualizer({ ...options, count: 50 }) + virtualizer._willUpdate() + // Rows measure 40, not 30: the list ends at 2000, not 1500. The adapter + // redraws on onChange (reading the items and the total size), not through + // `_willUpdate`. + for (let i = 0; i < 50; i++) virtualizer.resizeItem(i, 40) + virtualizer.getVirtualItems() + report!(1700, false) + virtualizer.setOptions({ ...options, count: 45 }) + virtualizer._willUpdate() + // 45 rows end at 1800: 1700 is stale until the element reports its + // clamped position, so the range is the last 300px of rows. + expect(virtualizer.getVirtualIndexes()).toEqual([ + 37, 38, 39, 40, 41, 42, 43, 44, + ]) +}) From 1d06be1fd73a5982537c97cadb7c7adafd0fa709 Mon Sep 17 00:00:00 2001 From: Yeongbin Jeong Date: Fri, 6 Nov 2026 19:42:39 +0900 Subject: [PATCH 2/5] fix(virtual-core): count the initial offset as a report The issue's core reproduction gives the position up front with `initialOffset` and has no element to report it, so the range stayed capped there. The initial offset now counts as a report, against the list end it is first ranged with, so a list that shrinks afterwards is still capped. The marker for "no report yet" was NaN, which never equals itself, so calculateRange recomputed on every call until the first report. It is null now. --- packages/virtual-core/src/index.ts | 24 +++++++++++++++-------- packages/virtual-core/tests/range.test.ts | 17 ++++++++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 0c65f541e..1236226a5 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -556,10 +556,12 @@ export class Virtualizer< // last rendered (`_willUpdate` runs once the adapter has rendered), taken // to layout coordinates with the report: measurements and a held // correction move the layout's own end before the element has the new size. + // The initial offset counts as a report, against the end it is first + // ranged with (a null end until then). private _renderedListEnd = Infinity private _rangeListEnd = -Infinity - private _reportedOffset = NaN - private _reportedListEnd = Infinity + private _reportedOffset: number | null = null + private _reportedListEnd: number | null = null // The touch gesture in progress (see `IosGesture`). Its tail closing on // its own makes a write possible again: the last momentum frame may have // come before the timer. The hold is released first, so a command that @@ -1080,7 +1082,7 @@ export class Virtualizer< // its next touch cycle. this._holdOffset = 0 this._reportedOverscroll = false - this._reportedOffset = NaN + this._reportedOffset = null // (The gesture was ended with its listeners, in the unsubs above.) this._clampedAdjustment = null // A write to the old element will never be read back. @@ -1608,11 +1610,14 @@ export class Virtualizer< return 0 } - this.scrollOffset = - this.scrollOffset ?? - (typeof this.options.initialOffset === 'function' - ? this.options.initialOffset() - : this.options.initialOffset) + if (this.scrollOffset === null) { + this.scrollOffset = + typeof this.options.initialOffset === 'function' + ? this.options.initialOffset() + : this.options.initialOffset + this._reportedOffset = this.scrollOffset + this._reportedListEnd = null + } return this.scrollOffset } @@ -1914,6 +1919,9 @@ export class Virtualizer< overscrolled, ) => { this._rangeListEnd = listEnd + if (reportedListEnd === null) { + this._reportedListEnd = reportedListEnd = listEnd + } if (measurements.length === 0 || outerSize === 0) { this.range = null return null diff --git a/packages/virtual-core/tests/range.test.ts b/packages/virtual-core/tests/range.test.ts index 5c54851bd..c3904899e 100644 --- a/packages/virtual-core/tests/range.test.ts +++ b/packages/virtual-core/tests/range.test.ts @@ -400,6 +400,23 @@ test('a list above the viewport in a shared scroll element keeps only its last r expect(virtualizer.getVirtualIndexes()).toEqual([74, 75, 76, 77, 78, 79, 80]) }) +test('an initial offset past the list end in a shared scroll element keeps only the last rows', () => { + // The issue's core reproduction: no element, the position given up front. + const virtualizer = new Virtualizer({ + count: 81, + estimateSize: () => 25, + overscan: 6, + scrollMargin: 100, + initialRect: { width: 300, height: 600 }, + initialOffset: 2500, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + expect(virtualizer.getVirtualIndexes()).toEqual([74, 75, 76, 77, 78, 79, 80]) +}) + test('a list that shrinks after the element reported a position still renders its last viewport of rows', () => { const { virtualizer, options, report } = sharedScroller() // At rest at the end of the first list: 100 + 2025 - 600. From ab2f86c0c56969c7e80841382404f4b316c048bb Mon Sep 17 00:00:00 2001 From: Yeongbin Jeong Date: Fri, 6 Nov 2026 20:23:29 +0900 Subject: [PATCH 3/5] fix(virtual-core): keep the initial offset reported when the element attaches Attaching the first scroll element ran cleanup, which dropped the initial offset's report, and the write of that offset to an element already there fires no scroll event, so the range stayed capped until the user scrolled. Only a position an earlier element reported is dropped now. The kept offset is dropped too when the element cannot scroll that far: it clamps the write, and if it already rests at that clamp no scroll event reports it. --- packages/virtual-core/src/index.ts | 17 ++++++++- packages/virtual-core/tests/range.test.ts | 44 +++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 1236226a5..4f36b568f 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1082,7 +1082,11 @@ export class Virtualizer< // its next touch cycle. this._holdOffset = 0 this._reportedOverscroll = false - this._reportedOffset = null + // A position the old element reported means nothing for the next one. + // The initial offset, given before any element, stays: `_willUpdate` + // writes it to the element it attaches, and an element already there + // fires no scroll event to report it. + if (this.scrollElement !== null) this._reportedOffset = null // (The gesture was ended with its listeners, in the unsubs above.) this._clampedAdjustment = null // A write to the old element will never be read back. @@ -1284,6 +1288,17 @@ export class Virtualizer< } this._scrollToOffset(this.getScrollOffset()) + // The kept initial offset is where the element is unless the element + // cannot scroll that far: then it clamps, and already resting there it + // fires no scroll event to say so. + const elementMax = this._elementMaxScrollOffset() + if ( + elementMax !== null && + this._reportedOffset !== null && + this._reportedOffset > elementMax + this._holdOffset + ) { + this._reportedOffset = null + } } // The consumer has committed the sizer. diff --git a/packages/virtual-core/tests/range.test.ts b/packages/virtual-core/tests/range.test.ts index c3904899e..e9fc42262 100644 --- a/packages/virtual-core/tests/range.test.ts +++ b/packages/virtual-core/tests/range.test.ts @@ -417,6 +417,50 @@ test('an initial offset past the list end in a shared scroll element keeps only expect(virtualizer.getVirtualIndexes()).toEqual([74, 75, 76, 77, 78, 79, 80]) }) +test('an initial offset the attached element already has stays valid without a scroll event', () => { + // Attaching writes the initial offset to the element; one that is already + // there fires no scroll event to report it. + const element = { scrollTop: 2500, scrollHeight: 6000, clientHeight: 600 } + const virtualizer = new Virtualizer({ + count: 81, + estimateSize: () => 25, + overscan: 6, + scrollMargin: 100, + initialRect: { width: 300, height: 600 }, + initialOffset: 2500, + getScrollElement: () => element as any, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + const lastRows = [74, 75, 76, 77, 78, 79, 80] + expect(virtualizer.getVirtualIndexes()).toEqual(lastRows) + virtualizer._willUpdate() + expect(virtualizer.getVirtualIndexes()).toEqual(lastRows) +}) + +test('an initial offset past where the attached element can scroll is capped', () => { + // The list has the element to itself and the element already rests at its + // end (2125 - 600), so the write of 2500 is clamped without a scroll event. + const element = { scrollTop: 1525, scrollHeight: 2125, clientHeight: 600 } + const virtualizer = new Virtualizer({ + count: 81, + estimateSize: () => 25, + overscan: 6, + scrollMargin: 100, + initialRect: { width: 300, height: 600 }, + initialOffset: 2500, + getScrollElement: () => element as any, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + virtualizer._willUpdate() + expect(virtualizer.getVirtualIndexes()).toEqual( + Array.from({ length: 30 }, (_, i) => i + 51), + ) +}) + test('a list that shrinks after the element reported a position still renders its last viewport of rows', () => { const { virtualizer, options, report } = sharedScroller() // At rest at the end of the first list: 100 + 2025 - 600. From 92a9a844a506c04acfcc55aca65400b5bd86168b Mon Sep 17 00:00:00 2001 From: Yeongbin Jeong Date: Fri, 6 Nov 2026 23:33:25 +0900 Subject: [PATCH 4/5] fix(virtual-core): cap the range at the scroll element's end, not the list's Replaces tracking when the offset is stale with the direction from review: cap at listEnd + trailing - outerSize, where trailing is how far the scroll element extends past the list. A list that has the element to itself gets 0 and the cap from #1351. trailing is read with the element's scroll range on scroll reports (the handler already reads it while scrolling) and once on attach, against the sizer as last drawn: the last getTotalSize() an adapter rendered. Against the layout's own end it grows by a shrink measured after the draw and before the next one, and the #1351 blank comes back (scroll-trace base/web/5). The scroll-trace snapshot is unchanged. The core-only repro (no element) is capped again, and the shared scroller shrink test is gone: that case now keeps rows 34-40, not 30. --- .changeset/shared-scroller-offscreen-range.md | 2 +- packages/virtual-core/src/index.ts | 109 +++++--------- packages/virtual-core/tests/range.test.ts | 138 +++++------------- 3 files changed, 69 insertions(+), 180 deletions(-) diff --git a/.changeset/shared-scroller-offscreen-range.md b/.changeset/shared-scroller-offscreen-range.md index 0043c1f84..a829ca4aa 100644 --- a/.changeset/shared-scroller-offscreen-range.md +++ b/.changeset/shared-scroller-offscreen-range.md @@ -2,4 +2,4 @@ '@tanstack/virtual-core': patch --- -Render only the last rows of a list that is above the viewport in a shared scroll element. The scroll offset is capped at the end of the list only while it is stale (the list shrank, the element is past an edge, or a write of ours has not been reported back yet), not where the element has reported it. +Render only the last rows of a list that is above the viewport in a shared scroll element. The scroll offset is capped at the end of the scroll element, the list's end plus the content after it, rather than at the list's end, so a list that has the element to itself is capped as before. diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 4f36b568f..8992ca71d 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -549,19 +549,13 @@ export class Virtualizer< // (`followOnAppend does not follow 'trim only'`; reading the element at // write time still moves 49 of 1080 snapshot seeds). private _reportedOverscroll = false - // The last position the element reported, and where the list ended in the - // element then. A `scrollOffset` past the list's end is where the element - // is only if it is that report and the list has not shrunk since, as when - // other content shares the scroll element (#1371). The end is the sizer as - // last rendered (`_willUpdate` runs once the adapter has rendered), taken - // to layout coordinates with the report: measurements and a held - // correction move the layout's own end before the element has the new size. - // The initial offset counts as a report, against the end it is first - // ranged with (a null end until then). - private _renderedListEnd = Infinity - private _rangeListEnd = -Infinity - private _reportedOffset: number | null = null - private _reportedListEnd: number | null = null + // How far the scroll element extends past the end of the list: other + // content that shares it (#1371). Read with the element's scroll range on + // scroll reports and on attach, against the sizer the element has (the + // last `getTotalSize()` an adapter drew), not a layout that measurements + // moved since. + private _trailing = 0 + private _drawnListEnd = Infinity // The touch gesture in progress (see `IosGesture`). Its tail closing on // its own makes a write possible again: the last momentum frame may have // come before the timer. The hold is released first, so a command that @@ -1082,11 +1076,7 @@ export class Virtualizer< // its next touch cycle. this._holdOffset = 0 this._reportedOverscroll = false - // A position the old element reported means nothing for the next one. - // The initial offset, given before any element, stays: `_willUpdate` - // writes it to the element it attaches, and an element already there - // fires no scroll event to report it. - if (this.scrollElement !== null) this._reportedOffset = null + this._trailing = 0 // (The gesture was ended with its listeners, in the unsubs above.) this._clampedAdjustment = null // A write to the old element will never be read back. @@ -1102,7 +1092,6 @@ export class Virtualizer< } _willUpdate = () => { - this._renderedListEnd = this.getTotalSize() + this.options.scrollMargin const scrollElement = this.options.enabled ? this.options.getScrollElement() : null @@ -1140,6 +1129,7 @@ export class Virtualizer< // judged only while scrolling: at rest the element has clamped, // and a stale sizer makes no bounce. const elementMax = isScrolling ? this._elementMaxScrollOffset() : null + this._readTrailing(elementMax) const wasOverscrolled = this._reportedOverscroll this._reportedOverscroll = offset < 0 || (elementMax !== null && offset > elementMax + 1) @@ -1147,14 +1137,6 @@ export class Virtualizer< this._lastElementOffset = elementOffset // Everything below works in layout coordinates. offset = this._toLayoutOffset(offset) - // An adapter that redraws on onChange (Vue, Svelte) renders the - // sizer of the last range without `_willUpdate`, so the element can - // end further down than recorded there; the longer end only makes - // the offset stale sooner. - const listEnd = Math.max( - this._toLayoutOffset(this._renderedListEnd), - this._rangeListEnd, - ) const kind = this._classifyScrollEvent( offset, @@ -1217,8 +1199,6 @@ export class Virtualizer< : 'backward' : null this.scrollOffset = offset - this._reportedOffset = offset - this._reportedListEnd = listEnd this.isScrolling = isScrolling // A finger that lands on a fling fires no touch events (#1307), so @@ -1288,17 +1268,7 @@ export class Virtualizer< } this._scrollToOffset(this.getScrollOffset()) - // The kept initial offset is where the element is unless the element - // cannot scroll that far: then it clamps, and already resting there it - // fires no scroll event to say so. - const elementMax = this._elementMaxScrollOffset() - if ( - elementMax !== null && - this._reportedOffset !== null && - this._reportedOffset > elementMax + this._holdOffset - ) { - this._reportedOffset = null - } + this._readTrailing(this._elementMaxScrollOffset()) } // The consumer has committed the sizer. @@ -1625,14 +1595,11 @@ export class Virtualizer< return 0 } - if (this.scrollOffset === null) { - this.scrollOffset = - typeof this.options.initialOffset === 'function' - ? this.options.initialOffset() - : this.options.initialOffset - this._reportedOffset = this.scrollOffset - this._reportedListEnd = null - } + this.scrollOffset = + this.scrollOffset ?? + (typeof this.options.initialOffset === 'function' + ? this.options.initialOffset() + : this.options.initialOffset) return this.scrollOffset } @@ -1919,42 +1886,23 @@ export class Virtualizer< // scrollOffset is measured in the scroll element and the layout total // from the list start, so the list ends scrollMargin further down. this._getLayoutTotalSize() + this.options.scrollMargin, - this._reportedOffset, - this._reportedListEnd, - this._reportedOverscroll, + this._trailing, ], - ( - measurements, - outerSize, - scrollOffset, - lanes, - listEnd, - reportedOffset, - reportedListEnd, - overscrolled, - ) => { - this._rangeListEnd = listEnd - if (reportedListEnd === null) { - this._reportedListEnd = reportedListEnd = listEnd - } + (measurements, outerSize, scrollOffset, lanes, listEnd, trailing) => { if (measurements.length === 0 || outerSize === 0) { this.range = null return null } // Until the element reports a clamped position (the list shrank, a // rubber-band bounce), the offset can sit past the last row, which - // would leave only that row in the range. Any other offset past the - // end is where the element is: the list is above the viewport. - const stale = - overscrolled || - scrollOffset !== reportedOffset || - listEnd < reportedListEnd + // would leave only that row in the range. The element ends `trailing` + // past the list where other content follows it, and an offset up to + // there is where the element is. + const maxScrollOffset = Math.max(listEnd + trailing - outerSize, 0) this.range = calculateRangeImpl( measurements, outerSize, - stale - ? Math.min(scrollOffset, Math.max(listEnd - outerSize, 0)) - : scrollOffset, + Math.min(scrollOffset, maxScrollOffset), lanes, // Pass the typed array so binary search + forward-walk can read // start/end directly from Float64Array, skipping the Proxy traps. @@ -2306,6 +2254,12 @@ export class Virtualizer< return offset } + private _readTrailing = (elementMax: number | null) => { + if (elementMax === null) return + const trailing = elementMax + this.getSize() - this._drawnListEnd + this._trailing = trailing > 0 ? trailing : 0 + } + // The element's own scroll range, or null where it cannot be read (no // element, or a bare test double without `scrollHeight` / `document`). private _elementMaxScrollOffset = (): number | null => { @@ -2663,8 +2617,11 @@ export class Virtualizer< // The size the consumer gives the container: the layout's total, short by // a held correction so the rows exposed shifted by it still fill it (see // `_holdOffset`). - getTotalSize = () => - Math.max(this._getLayoutTotalSize() - this._holdOffset, 0) + getTotalSize = () => { + const total = Math.max(this._getLayoutTotalSize() - this._holdOffset, 0) + this._drawnListEnd = total + this.options.scrollMargin + return total + } private _getLayoutTotalSize = () => { const measurements = this.getMeasurements() diff --git a/packages/virtual-core/tests/range.test.ts b/packages/virtual-core/tests/range.test.ts index e9fc42262..70baff5b1 100644 --- a/packages/virtual-core/tests/range.test.ts +++ b/packages/virtual-core/tests/range.test.ts @@ -369,120 +369,59 @@ test('a stale offset past the end renders the last viewport of rows with a scrol // Two lists share one scroll element: the first starts 100px down and is // 81 × 25 = 2025px tall, so it ends at 2125px; the second extends the element -// well below that. A position the element reports past the first list's end -// is real, and the first list is wholly above the viewport. -const sharedScroller = () => { +// to 6000px. A position past the first list's end is real there. +const sharedScroller = (initialOffset = 0) => { let report: ((offset: number, isScrolling: boolean) => void) | null = null - const options = { + const element = { + scrollTop: initialOffset, + scrollHeight: 6000, + clientHeight: 600, + } + const virtualizer = new Virtualizer({ + count: 81, estimateSize: () => 25, overscan: 6, scrollMargin: 100, + initialOffset, initialRect: { width: 300, height: 600 }, - getScrollElement: () => - ({ scrollTop: 0, scrollHeight: 6000, clientHeight: 600 }) as any, + getScrollElement: () => element as any, scrollToFn: vi.fn(), observeElementRect: vi.fn(), observeElementOffset: (_: unknown, cb: typeof report) => { report = cb return () => {} }, - } - const virtualizer = new Virtualizer({ ...options, count: 81 }) + }) + // What an adapter does on render: draw the sizer, then run the update hook. + virtualizer.getTotalSize() virtualizer._willUpdate() - return { virtualizer, options, report: report! } + return { virtualizer, report: report! } } +const lastRows = [74, 75, 76, 77, 78, 79, 80] + test('a list above the viewport in a shared scroll element keeps only its last rows', () => { const { virtualizer, report } = sharedScroller() - report(2500, false) - // The last row plus overscan, as before the shrink cap: not a viewport of - // rows nobody can see. - expect(virtualizer.getVirtualIndexes()).toEqual([74, 75, 76, 77, 78, 79, 80]) -}) - -test('an initial offset past the list end in a shared scroll element keeps only the last rows', () => { - // The issue's core reproduction: no element, the position given up front. - const virtualizer = new Virtualizer({ - count: 81, - estimateSize: () => 25, - overscan: 6, - scrollMargin: 100, - initialRect: { width: 300, height: 600 }, - initialOffset: 2500, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - expect(virtualizer.getVirtualIndexes()).toEqual([74, 75, 76, 77, 78, 79, 80]) -}) - -test('an initial offset the attached element already has stays valid without a scroll event', () => { - // Attaching writes the initial offset to the element; one that is already - // there fires no scroll event to report it. - const element = { scrollTop: 2500, scrollHeight: 6000, clientHeight: 600 } - const virtualizer = new Virtualizer({ - count: 81, - estimateSize: () => 25, - overscan: 6, - scrollMargin: 100, - initialRect: { width: 300, height: 600 }, - initialOffset: 2500, - getScrollElement: () => element as any, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - const lastRows = [74, 75, 76, 77, 78, 79, 80] - expect(virtualizer.getVirtualIndexes()).toEqual(lastRows) - virtualizer._willUpdate() + report(2500, true) expect(virtualizer.getVirtualIndexes()).toEqual(lastRows) }) -test('an initial offset past where the attached element can scroll is capped', () => { - // The list has the element to itself and the element already rests at its - // end (2125 - 600), so the write of 2500 is clamped without a scroll event. - const element = { scrollTop: 1525, scrollHeight: 2125, clientHeight: 600 } - const virtualizer = new Virtualizer({ - count: 81, - estimateSize: () => 25, - overscan: 6, - scrollMargin: 100, - initialRect: { width: 300, height: 600 }, - initialOffset: 2500, - getScrollElement: () => element as any, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - virtualizer._willUpdate() - expect(virtualizer.getVirtualIndexes()).toEqual( - Array.from({ length: 30 }, (_, i) => i + 51), - ) -}) - -test('a list that shrinks after the element reported a position still renders its last viewport of rows', () => { - const { virtualizer, options, report } = sharedScroller() - // At rest at the end of the first list: 100 + 2025 - 600. - report(1525, false) - virtualizer.setOptions({ ...options, count: 41 }) - // 41 rows end at 100 + 1025 = 1125: 1525 is stale until the element - // reports its clamped position, so the range is the last 24 rows plus - // overscan, not the last row alone. - expect(virtualizer.getVirtualIndexes()).toEqual( - Array.from({ length: 30 }, (_, i) => i + 11), - ) +test('a shared scroll element already past the list when it attaches keeps only its last rows', () => { + // No scroll event comes: the element is where the initial offset says. + const { virtualizer } = sharedScroller(2500) + expect(virtualizer.getVirtualIndexes()).toEqual(lastRows) }) -test('a list that grew without an update before the element reported still renders its last viewport once it shrinks', () => { - // Adapters that redraw on onChange (Vue, Svelte) run `_willUpdate` only when - // the options change, so the element can have a sizer longer than the one - // last recorded there by the time it reports a position. +test('a list measured smaller after it was drawn still renders its last viewport of rows', () => { + // The list has the element to itself. Its rows shrink from 30 to 20 before + // the next draw, and the element, still drawn 1500px tall, reports 1200. let report: ((offset: number, isScrolling: boolean) => void) | null = null - const element = { scrollTop: 0, scrollHeight: 2000, clientHeight: 300 } - const options = { + const element = { scrollTop: 1200, scrollHeight: 1500, clientHeight: 300 } + const virtualizer = new Virtualizer({ + count: 50, estimateSize: () => 30, overscan: 0, + initialOffset: 1200, initialRect: { width: 300, height: 300 }, getScrollElement: () => element as any, scrollToFn: vi.fn(), @@ -491,20 +430,13 @@ test('a list that grew without an update before the element reported still rende report = cb return () => {} }, - } - const virtualizer = new Virtualizer({ ...options, count: 50 }) - virtualizer._willUpdate() - // Rows measure 40, not 30: the list ends at 2000, not 1500. The adapter - // redraws on onChange (reading the items and the total size), not through - // `_willUpdate`. - for (let i = 0; i < 50; i++) virtualizer.resizeItem(i, 40) - virtualizer.getVirtualItems() - report!(1700, false) - virtualizer.setOptions({ ...options, count: 45 }) + }) + virtualizer.getTotalSize() virtualizer._willUpdate() - // 45 rows end at 1800: 1700 is stale until the element reports its - // clamped position, so the range is the last 300px of rows. + for (let i = 0; i < 50; i++) virtualizer.resizeItem(i, 20) + report!(1200, true) + // 50 rows of 20 end at 1000: 1200 is stale, so the range is the last 300px. expect(virtualizer.getVirtualIndexes()).toEqual([ - 37, 38, 39, 40, 41, 42, 43, 44, + 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, ]) }) From 8e70bf1fa04b889e00cf2dba819d015865730eec Mon Sep 17 00:00:00 2001 From: Yeongbin Jeong Date: Sat, 7 Nov 2026 00:00:13 +0900 Subject: [PATCH 5/5] fix(virtual-core): notify after reading trailing on attach The rect observer has already notified by the time the element is attached and trailing is read, so a range that changes with it reached no adapter: an initial offset already past the list kept the first render's 51-80 mounted until the first scroll event. --- packages/virtual-core/src/index.ts | 3 +++ packages/virtual-core/tests/range.test.ts | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 8992ca71d..bd6e7cb23 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1268,7 +1268,10 @@ export class Virtualizer< } this._scrollToOffset(this.getScrollOffset()) + // The rect observer has notified already; reading what follows the + // list can change the range again. this._readTrailing(this._elementMaxScrollOffset()) + this.maybeNotify() } // The consumer has committed the sizer. diff --git a/packages/virtual-core/tests/range.test.ts b/packages/virtual-core/tests/range.test.ts index 70baff5b1..fe51aad5a 100644 --- a/packages/virtual-core/tests/range.test.ts +++ b/packages/virtual-core/tests/range.test.ts @@ -372,6 +372,7 @@ test('a stale offset past the end renders the last viewport of rows with a scrol // to 6000px. A position past the first list's end is real there. const sharedScroller = (initialOffset = 0) => { let report: ((offset: number, isScrolling: boolean) => void) | null = null + const onChange = vi.fn() const element = { scrollTop: initialOffset, scrollHeight: 6000, @@ -391,11 +392,14 @@ const sharedScroller = (initialOffset = 0) => { report = cb return () => {} }, + onChange, }) - // What an adapter does on render: draw the sizer, then run the update hook. + // What an adapter does on its first render: read the rows and draw the + // sizer, then run the update hook, which attaches the element. + const firstRender = virtualizer.getVirtualIndexes() virtualizer.getTotalSize() virtualizer._willUpdate() - return { virtualizer, report: report! } + return { virtualizer, report: report!, firstRender, onChange } } const lastRows = [74, 75, 76, 77, 78, 79, 80] @@ -407,9 +411,13 @@ test('a list above the viewport in a shared scroll element keeps only its last r }) test('a shared scroll element already past the list when it attaches keeps only its last rows', () => { - // No scroll event comes: the element is where the initial offset says. - const { virtualizer } = sharedScroller(2500) + // No scroll event comes: the element is where the initial offset says. The + // first render has no element to read the content after the list from, so + // attaching changes the range and has to tell the adapter. + const { virtualizer, firstRender, onChange } = sharedScroller(2500) + expect(firstRender).toEqual(Array.from({ length: 30 }, (_, i) => i + 51)) expect(virtualizer.getVirtualIndexes()).toEqual(lastRows) + expect(onChange).toHaveBeenCalled() }) test('a list measured smaller after it was drawn still renders its last viewport of rows', () => {