diff --git a/.changeset/shared-scroller-offscreen-range.md b/.changeset/shared-scroller-offscreen-range.md new file mode 100644 index 000000000..a829ca4aa --- /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 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 ecaaddf7f..bd6e7cb23 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -549,6 +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 + // 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 @@ -1069,6 +1076,7 @@ export class Virtualizer< // its next touch cycle. this._holdOffset = 0 this._reportedOverscroll = false + 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. @@ -1121,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) @@ -1259,6 +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. @@ -1876,16 +1889,19 @@ 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._trailing, ], - (measurements, outerSize, scrollOffset, lanes, 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. - const maxScrollOffset = Math.max(listEnd - outerSize, 0) + // 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, @@ -2241,6 +2257,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 => { @@ -2598,8 +2620,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 29f308298..fe51aad5a 100644 --- a/packages/virtual-core/tests/range.test.ts +++ b/packages/virtual-core/tests/range.test.ts @@ -366,3 +366,85 @@ 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 +// 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, + clientHeight: 600, + } + const virtualizer = new Virtualizer({ + count: 81, + estimateSize: () => 25, + overscan: 6, + scrollMargin: 100, + initialOffset, + initialRect: { width: 300, height: 600 }, + getScrollElement: () => element as any, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: (_: unknown, cb: typeof report) => { + report = cb + return () => {} + }, + onChange, + }) + // 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!, firstRender, onChange } +} + +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, true) + expect(virtualizer.getVirtualIndexes()).toEqual(lastRows) +}) + +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. 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', () => { + // 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: 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(), + observeElementRect: vi.fn(), + observeElementOffset: (_: unknown, cb: typeof report) => { + report = cb + return () => {} + }, + }) + virtualizer.getTotalSize() + virtualizer._willUpdate() + 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([ + 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, + ]) +})