From 288d33ba421b41a4fe3522d5da798886bc390933 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 21 Apr 2026 22:33:25 +0200 Subject: [PATCH] fix(journey/mobile): eliminate carousel scroll stutter on mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../components/Journey/MobileMapTimeline.tsx | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/client/src/components/Journey/MobileMapTimeline.tsx b/client/src/components/Journey/MobileMapTimeline.tsx index cd543a91..27ead4b0 100644 --- a/client/src/components/Journey/MobileMapTimeline.tsx +++ b/client/src/components/Journey/MobileMapTimeline.tsx @@ -53,9 +53,6 @@ export default function MobileMapTimeline({ }) }, [entries]) const cardRefs = useRef>(new Map()) - const activeIndexRef = useRef(activeIndex) - useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex]) - // Sync map focus when carousel scrolls (with guard for uninitialized map) const syncMapToCarousel = useCallback((index: number) => { const entry = entries[index] @@ -90,29 +87,19 @@ export default function MobileMapTimeline({ }) }, [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(() => { const el = carouselRef.current if (!el || entries.length === 0) return - let rafId: number | null = null let settleTimer: number | null = null const onScroll = () => { - if (rafId != null) return - rafId = requestAnimationFrame(() => { - pickNearestCard() - rafId = null - }) if (settleTimer != null) window.clearTimeout(settleTimer) - settleTimer = window.setTimeout(() => { - // 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) + settleTimer = window.setTimeout(pickNearestCard, 150) } el.addEventListener('scroll', onScroll, { passive: true }) return () => { el.removeEventListener('scroll', onScroll) - if (rafId != null) cancelAnimationFrame(rafId) if (settleTimer != null) window.clearTimeout(settleTimer) } }, [entries.length, pickNearestCard]) @@ -210,9 +197,9 @@ export default function MobileMapTimeline({ >