Skip to content

fix(grid): smooth mobile scroll in huge grids#73

Merged
merlin1de merged 11 commits intomasterfrom
fix-mobile-scroll
May 7, 2026
Merged

fix(grid): smooth mobile scroll in huge grids#73
merlin1de merged 11 commits intomasterfrom
fix-mobile-scroll

Conversation

@merlin1de
Copy link
Copy Markdown
Owner

Summary

Fixes mobile scroll stutter in huge grids (7 000+ images) on Chrome for Android: from page 2 onwards the bitmap advanced row-by-row instead of moving smoothly with the finger.

Root cause

On a mobile 2-column layout at 7 k images the scrollable container becomes ~570 k px tall — past Chrome on Android's GPU tile cache. Past page 1 the user dragged into a region that wasn't pre-rasterized; Chrome had to rasterize tiles on demand, the compositor scroll fell back to the main thread, and the bitmap advanced in row-sized jumps. At 1 200 images (~100 k container) the same code is smooth; the issue is purely container-height vs. tile-cache.

Fix

Re-engaged the existing compression map at a 350 k px cap so the container fits the cache, and rewrote the top-spacer math with a continuous sub-row offset:

```
topSpacer = scrollTop − (logicalScrollTop mod rowStride) − BUFFER × rowStride
```

The old formula `scrollTop − BUFFER × rowStride` (the original 1.2.x compression code) bound topSpacer 1:1 to scrollTop, which made content stick visually during sub-row scrolls and jump a whole row at row-tick boundaries — the "Festbeißen + Sprung" pattern. The new formula adds a sub-row correction so content advances at `compressionRatio` px per finger pixel, smoothly.

At 7 k images on mobile this gives a ratio of ~1.6 — content travels 1.6 px per finger pixel, feels like normal flick momentum. Below the 350 k threshold (≤ ~5 k images on mobile, ≤ ~15 k on desktop) compression doesn't engage at all and scroll stays exactly 1:1.

Bonus

  • `overflow-anchor: none` on grid + direct children as defense-in-depth against mobile-browser anchor quirks
  • Code-review pass on `GridView.vue`: fixed a mobile keyboard-navigation regression (`columnsEstimate()` ignored the 2-column mobile logic and stepped by 1 instead of 1 row), pulled the 400 px preload margin out as a shared constant, refreshed obsolete comments

Commit history

History intentionally kept unsquashed so the diagnostic path is visible. The pair `aaa21a8` (Buffer/concurrency bump) + `948e567` (revert) is a no-op that targeted the wrong symptom — happy to squash on merge if preferred.

Test plan

  • Vitest: 418/418 green
  • Deployed to production (cloud.mischler.info / sixpack), confirmed mobile-scroll fix on Chrome for Android at 7 000 images
  • Verify desktop (mouse wheel + scrollbar drag) on small lists stays 1:1 (no compression engaged)
  • Optional: re-test on a different Android device

merlin1de added 11 commits May 6, 2026 11:46
Bei 7000+ Items im virtualisierten Grid greift die Compression-Map
(MAX_PHYSICAL_HEIGHT=350k px). In dem Modus ist topSpacerHeight
direkt an scrollTop gebunden und ändert sich mit jedem Scroll-Pixel.

Browser interpretiert das als Layout-Shift im sichtbaren Bereich
und korrigiert scrollTop zurück (Scroll-Anchoring), um visuelle
Stabilität zu wahren — auf Mobile sichtbar als ruckartiges
Zurückspringen während sanftem Touch-Flick.

Fix: overflow-anchor: none auf .sr-grid und seine Kinder.
Damit deaktiviert der Browser das Anchoring-Verhalten in diesem
Scroll-Container.
Bei 7000+ Bildern auf Mobile (~3500 Reihen × 163 px ≈ 570k logisch)
schlug die Compression-Map zu (350k Cap → Ratio ~1.63). In dem Modus
ist topSpacer direkt an scrollTop gekoppelt: Inhalt bleibt während
Sub-Row-Scrolls visuell stehen und springt am Row-Tick eine ganze
Zeile auf einmal — auf Mobile als Festbeißen + Zurückzucken sichtbar.

Cap von 350k auf 2M angehoben — bei realistischen Bildmengen (bis
~25k auf Mobile, alle Desktop-Spalten-Layouts) greift die Compression
gar nicht mehr. Browser handlen 2M-Container ohne Scroll-Probleme;
die alte 350k-Grenze stammte aus einer Scrollbar-Drag-Präzisionsfrage
auf Windows-Desktop (Drag-Thumb stoppte unterhalb des Endes), nicht
aus einem echten Scroll-Limit.

Der overflow-anchor:none-Fix vom vorherigen Commit bleibt drin als
Defense-in-Depth, falls die Compression bei sehr großen Listen doch
mal greift.
…roll

Bei langsamem Touch-Scroll auf Mobile: Buffer war zu knapp (2 Reihen
≈ 326 px Vorlauf), Thumbs starteten erst, wenn die Zeile fast im
Viewport war — Placeholder-Shimmer pro neu eintauchender Reihe.

- VIRTUAL_BUFFER_ROWS 2 → 4: ~650 px Vorlauf, Thumbs sind meist
  dekodiert bevor die Zeile sichtbar wird.
- THUMB_CONCURRENCY 5 → 8: HTTP/2 multiplext sauber; reduziert die
  spürbare Lag in unbekannten Bereichen ohne Connection-Stau.
- IO rootMargin 400 → 800 px: synchron zum größeren Buffer, IO
  triggert weiterhin innerhalb des DOM-Range.
…roll

Auch ohne Compression riss jeder Row-Tick (Vue-Render bei Reihen-Grenze)
den Browser aus dem Compositor-Scroll → Bitmap rückte in Reihen-Schritten
statt pixelweise.

Snap auf Vielfache von 4 Reihen: zwischen Snaps ist die DOM komplett
stabil, GPU-Pfad bleibt aktiv. Pro Snap ein einzelner größerer Render
(4 rein/4 raus statt 1+1), 4× seltener — verbirgt sich im Touch-
Momentum besser.

DOM-Größe wächst um ~SNAP/2 Reihen im Mittel (vernachlässigbar bei
2-12 Spalten).
…obile

Bei 7000+ Bildern auf Mobile wurde der Scroll-Container ~570k px hoch —
das übersteigt Chrome on Androids GPU-Tile-Cache. Ab Page 2 muss Chrome
Tiles on-demand rasterisieren, der Compositor-Scroll fällt zurück auf
den Main-Thread, die Bitmap rückt in Zeilen-Sprüngen statt pixelweise.

- MAX_PHYSICAL_HEIGHT zurück auf 350k → Container passt in den Cache
- topSpacer-Formel: kontinuierliches Sub-Row-Offset
  topSpacer = scrollTop − (logicalScrollTop mod rowStride) − BUFFER×rowStride
  Inhalt bewegt sich linear mit compressionRatio × scrollTop, kein
  Festbeißen + Sprung mehr (alte Formel topSpacer = scrollTop − BUFFER×rowStride
  bewegte den Inhalt 1:1 mit Scroll → Items pinned visuell, Sprung am Row-Tick).
- SNAP-Code rausgeworfen — bei aktiver Compression nicht mehr nötig
- overflow-anchor:none bleibt drin (Defense-in-Depth)

Trade-off bei Compression-aktiv: Inhalt scrollt ratio× schneller als der
Finger (bei 7k mobile: 1,6×). Fühlt sich an wie normales Touch-Flick-
Momentum, nicht wie ein Bug. Unter 350k px greift Compression nicht,
Scroll bleibt 1:1.
A) Bug: columnsEstimate() nutzt jetzt columnsCount.value statt eigener
   offsetWidth/THUMB_SIZE-Schätzung. Auf Mobile (390 px / 280 ≈ 1.4 → 1)
   navigierten Pfeil ↑/↓ um eine Spalte statt eine Reihe.

B) Konsistenz: Magic Number 400 als THUMB_PRELOAD_MARGIN_PX-Konstante
   herausgezogen, gemeinsam genutzt von IO rootMargin und observeAllItems
   Safety-Net.

C) Comment-Drift: CSS overflow-anchor:none — Begründung umgeschrieben,
   beschrieb das alte buggy Verhalten (topSpacer 1:1 an scrollTop). Mit der
   neuen kontinuierlichen Formel ist die per-Pixel-Bewegung gewollt; die
   Anchor-Disable bleibt als Defense-in-Depth gegen Mobile-Browser-Quirks.

D) Comment-Drift: Strategie-Block der Virtualisierung beschrieb nur die
   Spacer-Strategie ohne die Compression-Map zu erwähnen. Beide Modi
   (ratio=1 klassisch, ratio>1 kontinuierlich) jetzt explizit dokumentiert.
Bisher landete in der Watch- und Resync-Path-Logik alles aus dem Render-
Range pauschal als priority=true in der Queue, mit Top-Down-Order durch
das unshift-Reverse-Pattern. Folge: bei kalter Cache (Shooting frisch
geöffnet, ab 500 Bildern spürbar) belegten die BUFFER Reihen oberhalb
des Viewports die ersten Concurrency-Slots, bevor die echten Viewport-
Bilder anfingen zu laden — User starrte sichtbar länger auf Placeholder.

Neue enqueueRenderedRange()-Helper:
  1. Viewport-Reihen mit priority=true (unshift, reverse-iter für
     Top-of-Viewport zuerst)
  2. Buffer-unten mit priority=false (push, was als nächstes bei
     Scroll-Down kommt)
  3. Buffer-oben mit priority=false (least likely)

Cached und bereits ladende Items werden gleich übersprungen, kein
unnötiger Queue-Roundtrip. Kein Resync, kein Abbruch — nur Reihenfolge.

Watch + resyncRenderedThumbs nutzen jetzt beide den gleichen Helper.
Auf Chrome Android wurde beim Pinch-Zoom mit zwei Fingern häufig der
untere Finger zuerst (oder allein) registriert, der Browser interpretiert
das als Pull-to-Refresh statt als Pinch-Start und lädt die ganze Seite
neu — der Loupe-Zoom funktioniert dann nicht.

Loupe-Container kriegt touch-action:none. Damit deaktiviert der Browser
alle Default-Touch-Gesten (Scroll, Pinch, Pull-to-Refresh, Double-Tap-
Zoom); die Loupe macht ihr Pinch-Zoom, Pan und Swipe-Navigation ohnehin
schon komplett selbst in den touch{start,move,end}-Handlern, also
kein Funktionsverlust.

overscroll-behavior:contain als zweiter Gürtel falls touch-action mal
ignoriert wird (alte WebViews/Browser-Bugs).
Mein vorheriger enqueueRenderedRange()-Patch hatte einen Timing-Bug:
observeAllItems() lief direkt vor enqueueRenderedRange() im Watch und
enqueuete per DOM-Order-forEach mit dem alten Safety-Net Items in die
Queue, BEVOR enqueueRenderedRange dran war. Wenn enqueueRenderedRange
dann seine sortierten enqueueThumb()-Calls absetzte, war jedes Item
bereits über den loadQueue.some()-Guard in der Queue → Priority-Argument
unwirksam. Faktisch bestimmte weiterhin observeAllItems' DOM-Order die
Lade-Reihenfolge.

Fix: observeAllItems setzt jetzt nur noch den IO auf, plus Cache-Hit-
Direktzuweisung. Kein Enqueueing mehr. enqueueRenderedRange() ist die
einzige Quelle für Queue-Reihenfolge.

Zusätzlich images-Watch um expliziten enqueueRenderedRange()-Aufruf
ergänzt: falls renderStartIdx/EndIdx bei einem Bilder-Wechsel numerisch
identisch bleiben (Edge-Case), feuert deren Watch nicht — dann müssen
wir hier selbst nachholen.
A) Loupe-Reaktivierungs-Watcher (props.active) nutzt jetzt enqueueRenderedRange
   statt eigener Reverse-Iter mit pauschalem priority=true. Errored Thumbs
   werden in einer Forward-Iter zurückgesetzt (thumbError, thumbRetries,
   thumbUrl), dann übernimmt enqueueRenderedRange das Re-Queueing mit
   Viewport-Priorität. Konsistent zum restlichen Loading-Flow.

B) Kommentar über dem renderStartIdx/EndIdx-Watch aktualisiert: beschrieb noch
   die alte „observeAllItems hält Cache-Pfade konsistent + Backup für Fallback
@merlin1de merlin1de merged commit 2d9d5e4 into master May 7, 2026
3 checks passed
@merlin1de merlin1de deleted the fix-mobile-scroll branch May 7, 2026 08:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant