shift anchoring can get stuck in a requestAnimationFrame loop and freeze the scroller

Author: aqu1lesCreated Aug 28, 2026Updated Aug 28, 2026

Describe the bug

With shift on, alignShiftAnchor() can keep rescheduling itself every frame and never stop. When that happens the scroller stops responding to scrolling at all.

setScrollTop() writes the target to el.scrollTop without clamping it:

typescript
if (!scrollerEl || Math.abs(scrollerEl.scrollTop - target) < 0.5) {
  return false
}
_applyingShiftAnchor = true
scrollerEl.scrollTop = target

Browsers clamp out of range writes silently. So if the target is bigger than scrollHeight - clientHeight, scrollTop never gets there. The < 0.5 check stays false, setScrollTop keeps returning true, stableFrames never hits 2, and the loop never ends.

The bad part is _applyingShiftAnchor. It gets set on every iteration, and onNativeScroll throws away every scroll event while it's true, including the user's own wheel. So clearShiftAnchor() never runs and you can't scroll your way out. Only a reload fixes it.

I ran into this in a chat feed that prepends older messages. While it was stuck:

  • the container was still scrollable: scrollTop 3982, scrollHeight 5007, clientHeight 1025
  • el.scrollTop = 0 worked for a moment, then it was back at 3982 (the max)
  • I patched the scrollTop setter and logged it: 600+ writes of 4478, same stack every time (rAF -> alignShiftAnchor -> setScrollTop), still counting up. 4478 is past the max of 3982.

3.0.5 has the same code.

Looks like the same kind of loop as #221, but on the shift path.

I have a fix ready (clamp the target before comparing and writing) plus a test. Happy to open a PR.

I used an AI agent to help dig into this. The numbers above are from real runs, not guesses.

Reproduction

StackBlitz: https://stackblitz.com/github/aqu1les/vue-virtual-scroller/tree/repro-shift-anchor-freeze

Click "Reproduce the freeze", then try to scroll the list with the wheel. Nothing moves.

The button does what my chat feed does when you open a conversation that has no scroll and pagination fires right away:

  1. scroll to the bottom of a 40 message conversation (200px rows)
  2. swap the items for a 3 message conversation
  3. next tick, prepend 20 older messages (40px rows)

The counter above the list tracks writes to scrollTop. On 3.0.5 it goes up about 60 times a second and never stops, and the last written value (800) is above the max (320). With the clamp it stops after 12 writes and the wheel works again. Same result on 5 runs.

One thing that surprised me: a plain prepend can't cause this. target and scrollHeight - clientHeight grow by the same amount. You need the anchor captured while scrollTop still belongs to the previous, taller list, so logicalOffset comes out way too big. That's probably why it felt random in my app.

System Info

bash
System:
    OS: Linux 5.15 Ubuntu 22.04.5 LTS 22.04.5 LTS (Jammy Jellyfish)
    CPU: (12) x64 11th Gen Intel(R) Core(TM) i5-11400H @ 2.70GHz
    Memory: 7.05 GB / 23.47 GB
    Container: Yes
    Shell: 5.8.1 - /usr/bin/zsh
  Binaries:
    Node: 22.19.0
    Yarn: 1.22.22
    npm: 10.9.3
    pnpm: 10.6.5
    bun: 1.4.0
  Browsers:
    Chrome: 139.0.7258.127
  npmPackages:
    vue: 3.5.13
    vue-virtual-scroller: 3.0.4 (same code in 3.0.5)

Used Package Manager

pnpm

Validations

Source: Akryum/vue-virtual-scroller