fix(journey/mobile): eliminate carousel scroll stutter on mobile

- Defer activeIndex updates until scrolling settles (150ms debounce)
  instead of updating every RAF — mid-swipe card resize (240→320px)
  caused layout reflow on every frame, which is the main stutter source
- Switch scrollSnapType from 'proximity' to 'mandatory' for reliable
  browser-native snapping without needing a JS re-center pass
- Remove scroll-smooth CSS class (conflicts with mandatory snap)
- Remove the post-settle scrollIntoView call (mandatory snap handles it)
- Drop the now-unused activeIndexRef

Closes #818
This commit is contained in:
jubnl
2026-04-21 22:33:25 +02:00
parent e7fb78dc1e
commit 288d33ba42
@@ -53,9 +53,6 @@ export default function MobileMapTimeline({
}) })
}, [entries]) }, [entries])
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map()) const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
const activeIndexRef = useRef(activeIndex)
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
// Sync map focus when carousel scrolls (with guard for uninitialized map) // Sync map focus when carousel scrolls (with guard for uninitialized map)
const syncMapToCarousel = useCallback((index: number) => { const syncMapToCarousel = useCallback((index: number) => {
const entry = entries[index] const entry = entries[index]
@@ -90,29 +87,19 @@ export default function MobileMapTimeline({
}) })
}, [syncMapToCarousel]) }, [syncMapToCarousel])
// Track scroll; debounce to re-center the active card when the user stops. // Defer all state updates until scrolling settles — updating activeIndex
// mid-swipe resizes cards (240→320px), causing layout reflow every frame.
useEffect(() => { useEffect(() => {
const el = carouselRef.current const el = carouselRef.current
if (!el || entries.length === 0) return if (!el || entries.length === 0) return
let rafId: number | null = null
let settleTimer: number | null = null let settleTimer: number | null = null
const onScroll = () => { const onScroll = () => {
if (rafId != null) return
rafId = requestAnimationFrame(() => {
pickNearestCard()
rafId = null
})
if (settleTimer != null) window.clearTimeout(settleTimer) if (settleTimer != null) window.clearTimeout(settleTimer)
settleTimer = window.setTimeout(() => { settleTimer = window.setTimeout(pickNearestCard, 150)
// Ensure the active card sits at the center once the user settles.
const card = cardRefs.current.get(activeIndexRef.current)
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
}, 180)
} }
el.addEventListener('scroll', onScroll, { passive: true }) el.addEventListener('scroll', onScroll, { passive: true })
return () => { return () => {
el.removeEventListener('scroll', onScroll) el.removeEventListener('scroll', onScroll)
if (rafId != null) cancelAnimationFrame(rafId)
if (settleTimer != null) window.clearTimeout(settleTimer) if (settleTimer != null) window.clearTimeout(settleTimer)
} }
}, [entries.length, pickNearestCard]) }, [entries.length, pickNearestCard])
@@ -210,9 +197,9 @@ export default function MobileMapTimeline({
> >
<div <div
ref={carouselRef} ref={carouselRef}
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth" className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1"
style={{ style={{
scrollSnapType: 'x proximity', scrollSnapType: 'x mandatory',
WebkitOverflowScrolling: 'touch', WebkitOverflowScrolling: 'touch',
scrollbarWidth: 'none', scrollbarWidth: 'none',
msOverflowStyle: 'none', msOverflowStyle: 'none',