diff --git a/.changeset/cancel-scroll.md b/.changeset/cancel-scroll.md new file mode 100644 index 000000000..e219a6670 --- /dev/null +++ b/.changeset/cancel-scroll.md @@ -0,0 +1,7 @@ +--- +'@tanstack/virtual-core': minor +'@tanstack/angular-virtual': minor +'@tanstack/marko-virtual': minor +--- + +feat(virtual-core): add `cancelScroll()` to stop an in-flight `scrollToIndex` / `scrollToOffset` / `scrollBy` / `scrollToEnd` from correcting toward its target, so a user gesture can take over the viewport (#1285) diff --git a/docs/api/virtualizer.md b/docs/api/virtualizer.md index fcbc4cf5a..0a910e25c 100644 --- a/docs/api/virtualizer.md +++ b/docs/api/virtualizer.md @@ -459,6 +459,16 @@ Scrolls the virtualizer to the end of the content. For vertical lists this is th This is useful for "Jump to latest" controls in chat and log views. +### `cancelScroll` + +```tsx +cancelScroll: () => void +``` + +Cancels an in-flight `scrollToIndex`, `scrollToOffset`, `scrollBy` or `scrollToEnd`. After those calls, the virtualizer keeps correcting toward the target for a short time while items are measured. Call `cancelScroll` when the user starts their own scroll (for example from `wheel`, `touchstart` or `keydown` handlers) so the viewport stays where the user takes it. + +The current scroll position is not changed. It does nothing when no scroll is in flight. + ### `getDistanceFromEnd` ```tsx diff --git a/docs/chat.md b/docs/chat.md index 7eb6bdf6f..47cbe12c2 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -141,5 +141,6 @@ Use a normal scroll container and normal item order. You do not need `flex-direc - [`followOnAppend`](api/virtualizer#followonappend) - [`scrollEndThreshold`](api/virtualizer#scrollendthreshold) - [`scrollToEnd`](api/virtualizer#scrolltoend) +- [`cancelScroll`](api/virtualizer#cancelscroll) - [`getDistanceFromEnd`](api/virtualizer#getdistancefromend) - [`isAtEnd`](api/virtualizer#isatend) diff --git a/docs/framework/marko/marko-virtual.md b/docs/framework/marko/marko-virtual.md index 57d5231d2..1392f01b2 100644 --- a/docs/framework/marko/marko-virtual.md +++ b/docs/framework/marko/marko-virtual.md @@ -198,6 +198,7 @@ Both tags are self-closing and expose the same tag-variable shape. Capture it wi | `measure` | `() => void` | Drop all measured sizes and re-measure everything (after a width/font change) | | `resizeItem` | `(index: number, size: number) => void` | Set one item's size directly, without a DOM measure | | `scrollToEnd` | `(options?: { behavior?: ScrollBehavior }) => void` | Scroll to the very end of the list | +| `cancelScroll` | `() => void` | Stop an in-flight `scrollToIndex` / `scrollToOffset` / `scrollToEnd` from correcting toward its target, so a user scroll can take over | | `isAtEnd` | `(threshold?: number) => boolean` | Whether the scroll position is at (or within `threshold` px of) the end. `false` before mount | | `getDistanceFromEnd` | `() => number` | Pixels between the current scroll position and the end. `Infinity` before mount | diff --git a/packages/angular-virtual/src/index.ts b/packages/angular-virtual/src/index.ts index 453377fa3..a841f213d 100644 --- a/packages/angular-virtual/src/index.ts +++ b/packages/angular-virtual/src/index.ts @@ -133,6 +133,7 @@ function injectVirtualizerBase< '_didMount', '_willUpdate', 'calculateRange', + 'cancelScroll', 'getVirtualIndexes', 'measure', 'measureElement', diff --git a/packages/marko-virtual/README.md b/packages/marko-virtual/README.md index c77353f1a..47f52daf5 100644 --- a/packages/marko-virtual/README.md +++ b/packages/marko-virtual/README.md @@ -222,6 +222,7 @@ Both tags expose the same shape. Capture it with `` and read | `measure` | `() => void` | Drop all measured sizes and re-measure everything (after a width/font change) | | `resizeItem` | `(index: number, size: number) => void` | Set one item's size directly, without a DOM measure | | `scrollToEnd` | `(options?: { behavior?: ScrollBehavior }) => void` | Scroll to the very end of the list | +| `cancelScroll` | `() => void` | Stop an in-flight scroll command from correcting toward its target | | `isAtEnd` | `(threshold?: number) => boolean` | Whether the scroll position is at (or within `threshold` px of) the end. `false` before mount | | `getDistanceFromEnd` | `() => number` | Pixels between the current scroll position and the end. `Infinity` before mount | diff --git a/packages/marko-virtual/src/tags/virtualizer/index.marko b/packages/marko-virtual/src/tags/virtualizer/index.marko index 03835294c..720ff6e54 100644 --- a/packages/marko-virtual/src/tags/virtualizer/index.marko +++ b/packages/marko-virtual/src/tags/virtualizer/index.marko @@ -12,6 +12,7 @@ export interface VirtualizerHandle { measure: () => void resizeItem: (index: number, size: number) => void scrollToEnd: (options?: { behavior?: ScrollToOptions['behavior'] }) => void + cancelScroll: () => void isAtEnd: (threshold?: number) => boolean getDistanceFromEnd: () => number } @@ -115,6 +116,7 @@ export interface Input { measure: () => v?.measure(), resizeItem: (index: number, itemSize: number) => v?.resizeItem(index, itemSize), scrollToEnd: (options?: { behavior?: ScrollToOptions['behavior'] }) => v?.scrollToEnd(options), + cancelScroll: () => v?.cancelScroll(), isAtEnd: (threshold?: number) => v?.isAtEnd(threshold) ?? false, getDistanceFromEnd: () => v?.getDistanceFromEnd() ?? Infinity, }) as VirtualizerHandle)> diff --git a/packages/marko-virtual/src/tags/window-virtualizer/index.marko b/packages/marko-virtual/src/tags/window-virtualizer/index.marko index 8d662380f..995c02680 100644 --- a/packages/marko-virtual/src/tags/window-virtualizer/index.marko +++ b/packages/marko-virtual/src/tags/window-virtualizer/index.marko @@ -12,6 +12,7 @@ export interface WindowVirtualizerHandle { measure: () => void resizeItem: (index: number, size: number) => void scrollToEnd: (options?: { behavior?: ScrollToOptions['behavior'] }) => void + cancelScroll: () => void isAtEnd: (threshold?: number) => boolean getDistanceFromEnd: () => number } @@ -112,6 +113,7 @@ export interface Input { measure: () => v?.measure(), resizeItem: (index: number, itemSize: number) => v?.resizeItem(index, itemSize), scrollToEnd: (options?: { behavior?: ScrollToOptions['behavior'] }) => v?.scrollToEnd(options), + cancelScroll: () => v?.cancelScroll(), isAtEnd: (threshold?: number) => v?.isAtEnd(threshold) ?? false, getDistanceFromEnd: () => v?.getDistanceFromEnd() ?? Infinity, }) as WindowVirtualizerHandle)> diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 31830842f..68ebc66bd 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -2046,6 +2046,18 @@ export class Virtualizer< }) } + // Drops the target of an in-flight scrollToIndex / scrollToOffset / + // scrollBy / scrollToEnd so a user gesture can take over the viewport. + // The scroll position is left untouched: writing it would stop iOS + // momentum, and a real gesture already interrupts a native smooth scroll. + cancelScroll = () => { + if (this.rafId != null && this.targetWindow) { + this.targetWindow.cancelAnimationFrame(this.rafId) + this.rafId = null + } + this.scrollState = null + } + getTotalSize = () => { const measurements = this.getMeasurements() diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 51e2728d3..013f735a5 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -511,6 +511,73 @@ test('cleanup should cancel pending RAF and clear scrollState', () => { expect(mockWindow.cancelAnimationFrame).toHaveBeenCalled() }) +test('cancelScroll stops reconciling toward the old target (#1285)', () => { + const { rafCallbacks, mockWindow, mockScrollElement, scrollToFn } = + createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + + virtualizer._willUpdate() + virtualizer.scrollToIndex(50, { align: 'start' }) + + expect(virtualizer['scrollState']).not.toBeNull() + expect(virtualizer['rafId']).not.toBeNull() + + // The user takes over mid-travel. + virtualizer.cancelScroll() + + expect(virtualizer['scrollState']).toBeNull() + expect(virtualizer['rafId']).toBeNull() + expect(mockWindow.cancelAnimationFrame).toHaveBeenCalled() + + // A measurement above the old target would move it; without the cancel, + // reconcile chases the new target and yanks the viewport back. + scrollToFn.mockClear() + virtualizer.resizeItem(10, 200) + rafCallbacks.forEach((cb) => cb(0)) + + expect(scrollToFn).not.toHaveBeenCalled() +}) + +test('cancelScroll restores size-change compensation after a smooth scroll', () => { + const { mockScrollElement, scrollToFn } = createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + + virtualizer._willUpdate() + virtualizer.scrollOffset = 1000 + virtualizer.scrollToIndex(50, { behavior: 'smooth' }) + virtualizer.cancelScroll() + scrollToFn.mockClear() + + // First measurement of an item above the fold is compensated again; while + // the smooth scrollState was active it would have been skipped. + virtualizer.resizeItem(0, 80) + + expect(scrollToFn).toHaveBeenCalledWith( + 1000, + expect.objectContaining({ adjustments: 30 }), + virtualizer, + ) +}) + +test('cancelScroll is a no-op when no scroll is in flight', () => { + const { rafCallbacks, mockScrollElement, scrollToFn } = + createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + + virtualizer._willUpdate() + scrollToFn.mockClear() + + expect(() => virtualizer.cancelScroll()).not.toThrow() + expect(scrollToFn).not.toHaveBeenCalled() + + // A later scroll command still works normally. + virtualizer.scrollToOffset(200) + expect(virtualizer['scrollState']).not.toBeNull() + virtualizer.scrollOffset = 200 + rafCallbacks.forEach((cb) => cb(0)) + expect(virtualizer['scrollState']).toBeNull() +}) + // ─── resizeItem / measurement cache invalidation ───────────────────────────── // These tests pin down the contract that resizeItem invalidates the // getMeasurements memo so subsequent reads reflect the new sizes.