'use client'; /** * IOSStack — iOS-style navigation with "Move with me" drag + frozen previous page. * * FIX: hasPrevious uses a ref (not state) to avoid race conditions between * the drag-back callback's setHasPrevious(false) and the render-time * setHasPrevious(true). Refs update synchronously — no batching issues. */ import { motion, AnimatePresence, useMotionValue, useTransform, animate } from 'framer-motion'; import { usePathname, useRouter } from 'next/navigation'; import { useEffect, useRef, useState, type ReactNode, type TouchEvent as ReactTouchEvent } from 'react'; const SLIDE_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1]; const PUSH_DURATION = 0.45; const BACK_DURATION = 0.4; const EDGE_ZONE_PX = 28; const COMMIT_THRESHOLD_PX = 120; export function IOSStack({ children }: { children: ReactNode }) { const pathname = usePathname(); const router = useRouter(); const windowWidth = typeof window !== 'undefined' ? window.innerWidth : 375; // --- Frozen previous page --- const previousChildren = useRef(null); const lastChildren = useRef(children); const lastPath = useRef(pathname); // FIX: Use a ref for hasPrevious to avoid race conditions. // State batching can cause setHasPrevious(false) from drag-back to // override setHasPrevious(true) from the render-time check. const hasPreviousRef = useRef(false); const [hasPreviousState, setHasPreviousState] = useState(false); const [direction, setDirection] = useState<'forward' | 'back'>('forward'); // Helper to update both ref and state const setHasPrevious = (val: boolean) => { hasPreviousRef.current = val; setHasPreviousState(val); }; // --- Render-time capture of previous children --- if (lastPath.current !== pathname) { previousChildren.current = lastChildren.current; setHasPrevious(true); setDirection(lastPath.current.length > pathname.length ? 'back' : 'forward'); lastPath.current = pathname; } lastChildren.current = children; // --- Motion values --- const x = useMotionValue(0); const prevPageX = useTransform(x, [0, windowWidth], [0, windowWidth * 0.2]); const shadowOpacity = useTransform(x, [0, windowWidth], [0.35, 0]); const prevPageScale = useTransform(x, [0, windowWidth], [0.96, 1]); // --- Manual touch handlers --- const touchState = useRef<{ startX: number; startY: number; active: boolean; horizontal: boolean | null; }>({ startX: 0, startY: 0, active: false, horizontal: null }); const [isDragging, setIsDragging] = useState(false); function onTouchStart(e: ReactTouchEvent) { // FIX: Use the ref (synchronous) instead of state (may be stale due to batching) if (!hasPreviousRef.current) return; const t = e.touches[0]; if (!t || t.clientX > EDGE_ZONE_PX) { touchState.current = { startX: 0, startY: 0, active: false, horizontal: null }; return; } touchState.current = { startX: t.clientX, startY: t.clientY, active: true, horizontal: null, }; } function onTouchMove(e: ReactTouchEvent) { const state = touchState.current; if (!state.active) return; const t = e.touches[0]; if (!t) return; const dx = t.clientX - state.startX; const dy = t.clientY - state.startY; if (state.horizontal === null) { const absDx = Math.abs(dx); const absDy = Math.abs(dy); if (absDx < 8 && absDy < 8) return; if (absDy > absDx) { state.active = false; return; } state.horizontal = true; setIsDragging(true); } if (!state.horizontal) return; if (e.cancelable) e.preventDefault(); const clamped = Math.max(0, Math.min(dx, windowWidth)); x.set(clamped); } function onTouchEnd() { const state = touchState.current; touchState.current = { startX: 0, startY: 0, active: false, horizontal: null }; if (!state.active || !state.horizontal) { setIsDragging(false); return; } const current = x.get(); if (current >= COMMIT_THRESHOLD_PX) { // Commit: animate off-screen, then navigate back animate(x, windowWidth, { duration: 0.3, ease: SLIDE_EASE, onComplete: () => { // FIX: Don't clear hasPrevious here — let the render-time check // handle it when the pathname changes after router.back(). // This prevents the race condition where setHasPrevious(false) // overrides the render-time setHasPrevious(true). router.back(); requestAnimationFrame(() => { x.set(0); setIsDragging(false); }); }, }); } else { animate(x, 0, { duration: 0.3, ease: SLIDE_EASE, onComplete: () => setIsDragging(false), }); } } // --- Animation variants --- const variants = { enter: (dir: string) => ({ x: dir === 'forward' ? '100%' : '0%', zIndex: 10, }), center: { x: 0, zIndex: 10, transition: { duration: PUSH_DURATION, ease: SLIDE_EASE }, }, exit: (dir: string) => ({ x: dir === 'forward' ? '-20%' : '100%', zIndex: dir === 'forward' ? 1 : 10, transition: { duration: BACK_DURATION, ease: SLIDE_EASE }, }), }; return (
{/* Layer 0: Frozen Previous Page */} {hasPreviousState && previousChildren.current && (
); }