diff --git a/.changeset/reset-item-size.md b/.changeset/reset-item-size.md new file mode 100644 index 000000000..10a45e5e5 --- /dev/null +++ b/.changeset/reset-item-size.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': minor +--- + +Add `resetItemSize(index)` to forget one item's measured size, for content that changed shape while its key stayed the same. The item falls back to `estimateSize` until it is measured again, that measurement counts as a first one for scroll anchoring, and `takeSnapshot()` leaves the item out until then. A rendered item is measured again right away. diff --git a/docs/api/virtualizer.md b/docs/api/virtualizer.md index a5715fbc1..6717af2da 100644 --- a/docs/api/virtualizer.md +++ b/docs/api/virtualizer.md @@ -505,7 +505,7 @@ Returns the total size in pixels for the virtualized items. This measurement wil measure: () => void ``` -Resets any prev item measurements. +Resets any prev item measurements. To forget one item's size, use `resetItemSize`. ### `takeSnapshot` @@ -570,6 +570,16 @@ You can also use this method with a throttled ResizeObserver instead of `Virtual > ⚠️ Please be aware that manually changing the size of an item when using `Virtualizer.measureElement` to monitor that item, will result in unpredictable behaviour as the `Virtualizer.measureElement` is also changing the size. However you can use one of resizeItem or measureElement in the same virtualizer instance but on different item indexes. +### `resetItemSize` + +```tsx +resetItemSize: (index: number) => void +``` + +Forgets the measured size of the item at `index`. Use it when an item's content changes shape while its key stays the same (a group expands or collapses, a message is edited) and the item may not be rendered, so nothing measures it again until it scrolls back into view. + +The item falls back to `estimateSize` until it is measured again, and `takeSnapshot()` leaves it out until then. That measurement counts as a first one, so the scroll position is corrected for it even while scrolling backward. If the item is rendered, it is measured again right away instead. + ### `scrollRect` ```tsx diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index bd6e7cb23..a9432336e 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -2045,6 +2045,37 @@ export class Virtualizer< } resizeItem = (index: number, size: number) => { + this._resizeItem(index, size) + } + + /** + * Forgets the measured size of the item at `index`, for content that + * changed shape while its key stayed the same. The item falls back to + * `estimateSize` until it is measured again, that measurement counts as a + * first one, and `takeSnapshot()` leaves the item out until then. A + * rendered item is measured again right away instead. + */ + resetItemSize = (index: number) => { + if (!this.isIndexInRange(index)) return + const key = this.options.getItemKey(index) + const node = this.elementsCache.get(key) + if (!node?.isConnected || this.options.useCachedMeasurements) { + this._resizeItem(index, null) + return + } + // The default `measureElement` answers from the cache when it has the + // key, so take it out while the element is read. Put it back for + // `_resizeItem`: the change is measured against it, as a re-measurement. + const cachedSize = this.itemSizeCache.get(key) + this.itemSizeCache.delete(key) + const size = this.options.measureElement(node, undefined, this) + if (cachedSize !== undefined) this.itemSizeCache.set(key, cachedSize) + this._resizeItem(index, size) + } + + // `size` null forgets the item's measured size (`resetItemSize`): the + // layout falls back to its estimate, anchored like a first measurement. + private _resizeItem = (index: number, size: number | null) => { if (!this.isIndexInRange(index)) return // An earlier entry in this ResizeObserver batch moved `scrollOffset`. @@ -2075,7 +2106,7 @@ export class Virtualizer< } const itemSize = this.itemSizeCache.get(key) ?? cachedSize - const delta = size - itemSize + const delta = (size ?? this.options.estimateSize(index)) - itemSize if (delta !== 0) { const wasAtEnd = @@ -2086,7 +2117,7 @@ export class Virtualizer< // Default anchoring predicate (used unless the consumer supplies a // custom shouldAdjustScrollPositionOnItemSizeChange). const scrollOffset = this.getScrollOffset() - const isFirstMeasure = !this.itemSizeCache.has(key) + const isFirstMeasure = size === null || !this.itemSizeCache.has(key) const defaultShouldAdjust = isFirstMeasure ? // First measurement: compensate any item whose top sits above the // fold — the estimate→actual delta must be corrected regardless of @@ -2123,8 +2154,16 @@ export class Virtualizer< if (this.pendingMin === null || index < this.pendingMin) { this.pendingMin = index } - this.itemSizeCache.set(key, size) + if (size === null) { + this.itemSizeCache.delete(key) + } else { + this.itemSizeCache.set(key, size) + } this.itemSizeCacheVersion++ + // Without a cache entry, the layout is the only record of the item's + // size: rebuild it now, or a change to the item before the next read + // starts from the size it was reset from. + if (size === null) this.getMeasurements() // Before a command's read-back, an index or end target is retargeted // from fresh measurements by reconcileScroll next frame; an offset @@ -2165,6 +2204,11 @@ export class Virtualizer< // `_willUpdate` (#1266). this._flushHoldIfReady(true) this._retryClampedAdjustment() + } else if (size === null) { + // Measured at its estimate: nothing moves, but it is no longer measured. + // A pending rebuild still has to land, as above. + this.itemSizeCache.delete(key) + this.getMeasurements() } } diff --git a/packages/virtual-core/tests/measurement.test.ts b/packages/virtual-core/tests/measurement.test.ts index a53db446c..32e38a8f9 100644 --- a/packages/virtual-core/tests/measurement.test.ts +++ b/packages/virtual-core/tests/measurement.test.ts @@ -887,11 +887,16 @@ function setupResizeBatch(onChange: (instance: any, sync: boolean) => void) { frames.clear() queued.forEach((callback) => callback(0)) } + const scroll = (offset: number) => { + el.scrollTop = offset + setOffset(offset, true) + } return { virtualizer, sizes, node, deliver, + scroll, observe, unobserve, frames, @@ -999,6 +1004,148 @@ test.each(['removed', 'replaced', 'disconnected'] as const)( }, ) +// ─── resetItemSize ─────────────────────────────────────────────────────────── + +function createListVirtualizer() { + const el = { + scrollTop: 0, + scrollHeight: 10000, + clientHeight: 200, + ownerDocument: { defaultView: null }, + } as unknown as HTMLDivElement + const virtualizer = new Virtualizer({ + count: 100, + estimateSize: () => 50, + getScrollElement: () => el, + scrollToFn: vi.fn(), + observeElementRect: (_instance, cb) => { + cb({ width: 400, height: 200 }) + }, + observeElementOffset: (_instance, cb) => { + cb(0, false) + }, + }) + virtualizer._willUpdate() + virtualizer.getVirtualItems() + return virtualizer +} + +test('resetItemSize returns an item that is not rendered to its estimate', () => { + const virtualizer = createListVirtualizer() + virtualizer.resizeItem(0, 120) + virtualizer.resizeItem(5, 80) + virtualizer.getVirtualItems() + + virtualizer.resetItemSize(0) + + expect(virtualizer.getOffsetForIndex(3, 'start')?.[0]).toBe(150) + expect( + virtualizer.takeSnapshot().map(({ key, size }) => ({ key, size })), + ).toEqual([{ key: 5, size: 80 }]) +}) + +test('resetItemSize forgets an item measured at its estimate', () => { + const virtualizer = createListVirtualizer() + virtualizer.resizeItem(5, 80) + virtualizer.resizeItem(5, 50) + + virtualizer.resetItemSize(5) + + expect(virtualizer.takeSnapshot()).toEqual([]) +}) + +test('resetItemSize below an index resized since the last read', () => { + const virtualizer = createListVirtualizer() + virtualizer.resizeItem(3, 200) + virtualizer.getVirtualItems() + virtualizer.resizeItem(10, 100) + + virtualizer.resetItemSize(3) + + expect(virtualizer.getTotalSize()).toBe(5050) +}) + +test('resetItemSize reads a rendered element, not its cached size', () => { + const virtualizer = createListVirtualizer() + const node = { + getAttribute: (name: string) => (name === 'data-index' ? '2' : null), + isConnected: true, + offsetHeight: 80, + } + virtualizer.measureElement(node as unknown as HTMLDivElement) + node.offsetHeight = 140 + + virtualizer.resetItemSize(2) + + expect(virtualizer.getOffsetForIndex(3, 'start')?.[0]).toBe(240) +}) + +test('resetItemSize measures a rendered item as a re-measurement', () => { + const { virtualizer, sizes } = setupResizeBatch((instance) => { + instance.getVirtualItems() + }) + + // Item 4 spans the fold, so its growth is below the anchor point (#1218). + sizes.set(4, 150) + virtualizer.resetItemSize(4) + + expect(virtualizer.getMeasurements()[4]!.size).toBe(150) + expect(virtualizer.scrollOffset).toBe(450) +}) + +test.each([ + { + before: 'another reset', + apply: (v: Virtualizer) => v.resetItemSize(2), + }, + { + before: 'a resize to its estimate', + apply: (v: Virtualizer) => v.resizeItem(2, 90), + }, +])( + 'resetItemSize after $before, before the layout is read again: the next change starts from the estimate', + ({ apply }) => { + // onChange does not read the layout, as with an adapter that renders later. + const { virtualizer, sizes, node, deliver } = setupResizeBatch(() => {}) + + // Item 2 (200..300) is not rendered: back at its estimate (90), it moves + // the rows below it up by 10, once. + node(2).isConnected = false + apply(virtualizer) + virtualizer.resetItemSize(2) + expect(virtualizer.scrollOffset).toBe(440) + + node(2).isConnected = true + sizes.set(2, 160) + deliver([2]) + + expect(virtualizer.scrollOffset).toBe(510) + }, +) + +test('resetItemSize anchors like a first measurement, also while scrolling backward', () => { + const { virtualizer, sizes, node, deliver, scroll } = setupResizeBatch( + (instance) => { + instance.getVirtualItems() + }, + ) + + // A re-measurement above the viewport is not compensated while scrolling + // backward; a first measurement is. Item 2 (200..300) is not rendered: + // back at its estimate (90), it moves the rows below it up by 10. + scroll(300) + node(2).isConnected = false + virtualizer.resetItemSize(2) + expect(virtualizer.scrollOffset).toBe(290) + + // Still scrolling up, it renders again at 160. + node(2).isConnected = true + sizes.set(2, 160) + deliver([2]) + + expect(virtualizer.scrollOffset).toBe(360) +}) + // ─── useAnimationFrameWithResizeObserver: frames of the scroll element's window // A window other than the global one, as for a list rendered into a