From 2dc9532a1a8079e1a69b551d9d61ae45d0797872 Mon Sep 17 00:00:00 2001 From: MarkXian Date: Sat, 18 Jul 2026 17:29:09 +0800 Subject: [PATCH] fix(virtual-core): read shouldAdjustScrollPositionOnItemSizeChange from options The predicate was documented as the escape hatch to restore pre-3.14 scroll compensation behavior, but resizeItem read it from an instance field that setOptions never assigned, so supplying it through options silently did nothing and there was no working way to opt out of the backward-scroll compensation skip. Read it from options first (and expose it on VirtualizerOptions), falling back to a directly-assigned instance field for back-compat. Closes #1227 --- ...re-should-adjust-scroll-position-option.md | 5 + packages/virtual-core/src/index.ts | 20 +++- packages/virtual-core/tests/index.test.ts | 113 ++++++++++++++++++ 3 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 .changeset/wire-should-adjust-scroll-position-option.md 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({