fix(grid): smooth mobile scroll in huge grids#73
Merged
Conversation
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.
…teren Scroll" This reverts commit aaa21a8.
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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