From 3e4a7fa12badc07d313e71d53c99d6e506365e4d Mon Sep 17 00:00:00 2001 From: Kebechet Date: Wed, 11 Nov 2026 12:13:35 +0100 Subject: [PATCH 1/2] feat(virtual-core): add resetItemSize to forget one item's measured size --- .changeset/reset-item-size.md | 5 + docs/api/virtualizer.md | 12 +- packages/virtual-core/src/index.ts | 44 ++++++- .../virtual-core/tests/measurement.test.ts | 117 ++++++++++++++++++ 4 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 .changeset/reset-item-size.md 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..d01ac28c7 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,7 +2154,11 @@ 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++ // Before a command's read-back, an index or end target is retargeted @@ -2165,6 +2200,9 @@ 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. + this.itemSizeCache.delete(key) } } diff --git a/packages/virtual-core/tests/measurement.test.ts b/packages/virtual-core/tests/measurement.test.ts index a53db446c..d6ba40eb9 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,118 @@ 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('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 From c794d6636d7083beea4c5a8d576218f0fab787dc Mon Sep 17 00:00:00 2001 From: Kebechet Date: Wed, 11 Nov 2026 13:40:41 +0100 Subject: [PATCH 2/2] fix(virtual-core): rebuild the layout when resetItemSize forgets a size With the key gone from itemSizeCache, the layout was the only record of the item's size, and it still held the size the item was reset from until the next read. A second reset, or a measurement, before that read started from the old size: the scroll correction was applied twice and the next measurement was compensated from the wrong baseline. --- packages/virtual-core/src/index.ts | 6 ++++ .../virtual-core/tests/measurement.test.ts | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index d01ac28c7..a9432336e 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -2160,6 +2160,10 @@ export class Virtualizer< 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 @@ -2202,7 +2206,9 @@ export class Virtualizer< 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 d6ba40eb9..32e38a8f9 100644 --- a/packages/virtual-core/tests/measurement.test.ts +++ b/packages/virtual-core/tests/measurement.test.ts @@ -1093,6 +1093,36 @@ test('resetItemSize measures a rendered item as a re-measurement', () => { 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) => {