diff --git a/.changeset/wire-should-adjust-scroll-position-option.md b/.changeset/wire-should-adjust-scroll-position-option.md new file mode 100644 index 00000000..199cefd6 --- /dev/null +++ b/.changeset/wire-should-adjust-scroll-position-option.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +Wire `shouldAdjustScrollPositionOnItemSizeChange` into `VirtualizerOptions` so it can actually be supplied. `resizeItem` read the predicate from an instance field that `setOptions` never assigned, so passing the documented escape hatch through options silently did nothing and the default backward-scroll compensation skip could not be opted out of. The predicate is now read from options first, falling back to a directly-assigned instance field for back-compat. diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index e9ae0086..cf5ba5e3 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -369,6 +369,11 @@ export interface VirtualizerOptions< useAnimationFrameWithResizeObserver?: boolean laneAssignmentMode?: LaneAssignmentMode useCachedMeasurements?: boolean + shouldAdjustScrollPositionOnItemSizeChange?: ( + item: VirtualItem, + delta: number, + instance: Virtualizer, + ) => boolean } type ScrollState = { @@ -1547,10 +1552,21 @@ export class Virtualizer< this.scrollState?.behavior !== 'smooth' && this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold const prevTotalSize = wasAtEnd ? this.getTotalSize() : 0 + // The predicate can be supplied via options (#1227) or, for + // back-compat, by assigning it directly on the instance. + const shouldAdjustScrollPositionOnItemSizeChange: + | (( + item: VirtualItem, + delta: number, + instance: Virtualizer, + ) => boolean) + | undefined = + this.options.shouldAdjustScrollPositionOnItemSizeChange ?? + this.shouldAdjustScrollPositionOnItemSizeChange const shouldAdjustScroll = this.scrollState?.behavior !== 'smooth' && - (this.shouldAdjustScrollPositionOnItemSizeChange !== undefined - ? this.shouldAdjustScrollPositionOnItemSizeChange( + (shouldAdjustScrollPositionOnItemSizeChange !== undefined + ? shouldAdjustScrollPositionOnItemSizeChange( // The callback expects a VirtualItem; build one lazily only // when the consumer actually supplied a custom predicate. this.measurementsCache[index] ?? { diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 947dc613..6a2f919a 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -1,4 +1,5 @@ import { expect, test, vi } from 'vitest' +import type { VirtualItem } from '../src/index' import { Virtualizer, _resetIOSDetectionForTests, @@ -2583,6 +2584,118 @@ test('multi-frame reflow: above-viewport re-measure compensation is never skippe expect(v.scrollOffset).toBe(725) }) +// ─── shouldAdjustScrollPositionOnItemSizeChange option wiring (#1227) ─────── + +function createPredicateVirtualizer({ + count = 30, + offset, + shouldAdjustScrollPositionOnItemSizeChange, +}: { + count?: number + offset: number + shouldAdjustScrollPositionOnItemSizeChange?: ( + item: VirtualItem, + delta: number, + instance: Virtualizer, + ) => boolean +}) { + const scrollToFn = vi.fn() + let scrollCb: ((o: number, s: boolean) => void) | null = null + const v = new Virtualizer({ + count, + estimateSize: () => 50, + getScrollElement: () => + ({ + scrollTop: offset, + scrollLeft: 0, + scrollHeight: count * 50, + clientHeight: 200, + offsetHeight: 200, + }) as any, + scrollToFn, + observeElementRect: (_inst: any, cb: any) => { + cb({ width: 400, height: 200 }) + return () => {} + }, + observeElementOffset: (_inst: any, cb: any) => { + scrollCb = cb + cb(offset, false) + return () => {} + }, + shouldAdjustScrollPositionOnItemSizeChange, + }) + v._didMount() + v._willUpdate() + v['getMeasurements']() + scrollToFn.mockClear() + return { + v, + scrollToFn, + emitScroll: (o: number, isScrolling: boolean) => scrollCb!(o, isScrolling), + } +} + +test('shouldAdjustScrollPositionOnItemSizeChange passed via options overrides the default backward-scroll skip', () => { + // #1227: the option is documented as the escape hatch to restore pre-3.14 + // behavior (always compensate above-viewport resizes), but resizeItem read + // it from an instance field that setOptions never assigned, so passing it + // in options silently did nothing. + const { v, scrollToFn, emitScroll } = createPredicateVirtualizer({ + offset: 600, + shouldAdjustScrollPositionOnItemSizeChange: (item, _delta, instance) => + item.start < instance.getScrollOffset(), + }) + + // Measure an above-viewport row so the next resize is a re-measure — + // the leg the default predicate skips during backward scroll. + v.resizeItem(0, 55) + v['getMeasurements']() + expect(v.scrollOffset).toBe(605) + emitScroll(605, false) + scrollToFn.mockClear() + + // Real backward gesture: the user is scrolling up. + emitScroll(500, true) + expect(v.scrollDirection).toBe('backward') + + // Above-viewport re-measure during backward scroll. The default skips + // compensation here; the user-supplied predicate must win instead. + v.resizeItem(0, 75) + + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(v.scrollOffset).toBe(520) +}) + +test('shouldAdjustScrollPositionOnItemSizeChange receives the resized item, delta and instance', () => { + const calls: Array<{ index: number; size: number; delta: number }> = [] + const { v } = createPredicateVirtualizer({ + offset: 600, + shouldAdjustScrollPositionOnItemSizeChange: (item, delta, instance) => { + expect(instance).toBe(v) + calls.push({ index: item.index, size: item.size, delta }) + return false + }, + }) + + v.resizeItem(2, 90) + + expect(calls).toEqual([{ index: 2, size: 50, delta: 40 }]) +}) + +test('shouldAdjustScrollPositionOnItemSizeChange returning false suppresses the default compensation', () => { + const { v, scrollToFn } = createPredicateVirtualizer({ + offset: 600, + shouldAdjustScrollPositionOnItemSizeChange: () => false, + }) + + // First measurement of an above-viewport row: the default predicate would + // always compensate here; the user predicate must be authoritative. + v.resizeItem(0, 80) + + expect(scrollToFn).not.toHaveBeenCalled() + expect(v.scrollOffset).toBe(600) +}) + // ─── end anchoring / chat-style reverse virtualization ────────────────────── function createChatVirtualizer({