Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/reset-item-size.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 11 additions & 1 deletion docs/api/virtualizer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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
Expand Down
50 changes: 47 additions & 3 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

if (delta !== 0) {
const wasAtEnd =
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
this.getMeasurements()
}
}

Expand Down
147 changes: 147 additions & 0 deletions packages/virtual-core/tests/measurement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<any, any>) => v.resetItemSize(2),
},
{
before: 'a resize to its estimate',
apply: (v: Virtualizer<any, any>) => 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
Expand Down