From dfba5e2758d12aa1bde7d008a81199ce41c97ca4 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 16 Dec 2025 12:03:09 -0600 Subject: [PATCH 1/3] more fixes --- components/overlays/overlay-container.tsx | 270 ++++++++++++++-------- components/overlays/overlay-provider.tsx | 30 ++- 2 files changed, 196 insertions(+), 104 deletions(-) diff --git a/components/overlays/overlay-container.tsx b/components/overlays/overlay-container.tsx index 4f8febde0..1de9e76fa 100644 --- a/components/overlays/overlay-container.tsx +++ b/components/overlays/overlay-container.tsx @@ -9,7 +9,7 @@ import { } from "motion/react"; import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { Drawer as DrawerPrimitive } from "vaul"; -import { Dialog, DialogOverlay, DialogPortal } from "@/components/ui/dialog"; +import { Dialog, DialogPortal } from "@/components/ui/dialog"; import { useIsMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; import { useOverlay } from "./overlay-provider"; @@ -30,8 +30,6 @@ const drawerSpring = { mass: 0.8, } as const; -const reducedMotion = { duration: 0.01 }; - /** * Variants for the dialog container (fade in/out) */ @@ -84,32 +82,20 @@ const drawerContainerVariants: Variants = { }; /** - * Slide variants that use custom prop for direction - * custom > 0: pushing (new from right, old exits left) - * custom < 0: popping (new from left, old exits right) + * Get x position for overlay item based on its position relative to current */ -const createSlideVariants = (shouldReduceMotion: boolean | null): Variants => ({ - enter: (direction: number) => ({ - x: direction > 0 ? "100%" : "-35%", - scale: direction > 0 ? 1 : 0.94, - opacity: direction > 0 ? 1 : 0, - }), - center: { - x: "0%", - scale: 1, - opacity: 1, - transition: shouldReduceMotion ? reducedMotion : iosSpring, - }, - exit: (direction: number) => ({ - x: direction > 0 ? "-35%" : "100%", - scale: direction > 0 ? 0.94 : 1, - opacity: direction > 0 ? 0 : 1, - transition: shouldReduceMotion ? reducedMotion : iosSpring, - }), -}); +function getOverlayXPosition( + isCurrent: boolean, + isPrevious: boolean +): "0%" | "-35%" | "100%" { + if (isCurrent) return "0%"; + if (isPrevious) return "-35%"; + return "100%"; +} /** - * Hook to track direction of stack changes + * Hook to track direction of stack changes (push vs pop) + * Returns 1 for push, -1 for pop */ function useStackDirection(stackLength: number) { const prevLength = useRef(stackLength); @@ -117,9 +103,9 @@ function useStackDirection(stackLength: number) { // Compute synchronously during render for immediate value if (stackLength > prevLength.current) { - direction.current = 1; + direction.current = 1; // pushing } else if (stackLength < prevLength.current) { - direction.current = -1; + direction.current = -1; // popping } // Update prevLength after render @@ -132,17 +118,38 @@ function useStackDirection(stackLength: number) { /** * Desktop dialog container with internal sliding content + * Renders all stack items persistently in the same React tree location, + * using CSS transforms to animate visibility while preserving component state */ function DesktopOverlayContainer() { const { stack, closeAll, pop } = useOverlay(); const shouldReduceMotion = useReducedMotion(); const [minHeight, setMinHeight] = useState(0); - const direction = useStackDirection(stack.length); const contentRef = useRef(null); const wasOpenRef = useRef(false); + const frozenStackRef = useRef(stack); + const direction = useStackDirection(stack.length); const isOpen = stack.length > 0; + // Freeze the stack when open so content doesn't shift during exit animation + // AnimatePresence keeps children mounted during exit, so frozenStack is used then + if (isOpen) { + frozenStackRef.current = stack; + } + + // Use frozen stack for rendering (preserves content during exit) + const renderStack = frozenStackRef.current; + const currentIndex = renderStack.length - 1; + + // DEBUG + console.log("[DesktopOverlay]", { + isOpen, + stackLength: stack.length, + frozenStackLength: frozenStackRef.current.length, + renderStackLength: renderStack.length, + }); + // Measure content height when it changes, reset on fresh open useLayoutEffect(() => { const isFreshOpen = isOpen && !wasOpenRef.current; @@ -161,8 +168,10 @@ function DesktopOverlayContainer() { } }, [stack, isOpen]); + // Use live stack for options checks (only when open) const currentItem = stack[stack.length - 1]; - const slideVariants = createSlideVariants(shouldReduceMotion); + const springTransition = shouldReduceMotion ? { duration: 0.01 } : iosSpring; + const isPushing = direction === 1; const handleBackdropClick = useCallback(() => { if (currentItem?.options.closeOnBackdropClick !== false) { @@ -186,22 +195,32 @@ function DesktopOverlayContainer() { } }, [isOpen, handleEscapeKey]); + const handleExitComplete = useCallback(() => { + console.log("[DesktopOverlay] handleExitComplete called"); + frozenStackRef.current = []; + }, []); + + console.log("[DesktopOverlay] Rendering, isOpen:", isOpen); + + // Don't render Dialog at all when closed - this ensures clean unmount + if (!isOpen && frozenStackRef.current.length === 0) { + return null; + } + return ( - - - {isOpen && ( + + {isOpen && ( + - {/* Backdrop */} - - - + {/* Backdrop - standalone clickable div */} + {/* Dialog container */} 0 ? minHeight : "auto" }} transition={iosSpring} > - {/* Content area - ref on wrapper div to avoid React 19 issues */} -
- - {currentItem && ( + {/* Content area - all items rendered persistently to preserve state */} +
+ {renderStack.map((item, index) => { + const isCurrent = index === currentIndex; + const isPrevious = index < currentIndex; + + // For push onto existing stack: new current item slides in from right + // For first overlay (fresh open): no slide, dialog container handles entrance + // For pop: returning item is already at -35%, animates to 0% + const shouldSlideIn = + isCurrent && isPushing && renderStack.length > 1; + const initialValue = shouldSlideIn + ? { x: "100%", scale: 1, opacity: 1 } + : false; + + return ( - + - )} - + ); + })}
- )} -
-
+
+ )} + ); } /** * Mobile drawer container with internal sliding content + * Renders all stack items persistently in the same React tree location, + * using CSS transforms to animate visibility while preserving component state */ function MobileOverlayContainer() { const { stack, closeAll, pop } = useOverlay(); const shouldReduceMotion = useReducedMotion(); const [minHeight, setMinHeight] = useState(0); - const direction = useStackDirection(stack.length); const contentRef = useRef(null); const wasOpenRef = useRef(false); + const frozenStackRef = useRef(stack); + const direction = useStackDirection(stack.length); const isOpen = stack.length > 0; + // Freeze the stack when open so content doesn't shift during exit animation + if (isOpen) { + frozenStackRef.current = stack; + } + + // Use frozen stack for rendering (preserves content during exit) + const renderStack = frozenStackRef.current; + const currentIndex = renderStack.length - 1; + // Measure content height when it changes, reset on fresh open useLayoutEffect(() => { const isFreshOpen = isOpen && !wasOpenRef.current; @@ -284,8 +328,11 @@ function MobileOverlayContainer() { } }, [stack, isOpen]); + // Use live stack for options checks (only when open) const currentItem = stack[stack.length - 1]; - const slideVariants = createSlideVariants(shouldReduceMotion); + const renderCurrentItem = renderStack[currentIndex]; + const springTransition = shouldReduceMotion ? { duration: 0.01 } : iosSpring; + const isPushing = direction === 1; const handleBackdropClick = useCallback(() => { if (currentItem?.options.closeOnBackdropClick !== false) { @@ -309,10 +356,19 @@ function MobileOverlayContainer() { } }, [isOpen, handleEscapeKey]); + const handleExitComplete = useCallback(() => { + frozenStackRef.current = []; + }, []); + + // Don't render Drawer at all when closed + if (!isOpen && frozenStackRef.current.length === 0) { + return null; + } + return ( - - - {isOpen && ( + + {isOpen && ( + {/* Backdrop */} @@ -340,7 +396,7 @@ function MobileOverlayContainer() { > {/* Accessible title for screen readers */} - {currentItem?.options.title || "Dialog"} + {renderCurrentItem?.options.title || "Dialog"} {/* Drag handle */} @@ -350,34 +406,50 @@ function MobileOverlayContainer() { 0 ? minHeight : "auto" }} transition={drawerSpring} > - {/* Content wrapper */} -
- - {currentItem && ( + {/* Content wrapper - all items rendered persistently to preserve state */} +
+ {renderStack.map((item, index) => { + const isCurrent = index === currentIndex; + const isPrevious = index < currentIndex; + + // For push onto existing stack: new current item slides in from right + // For first overlay (fresh open): no slide, drawer container handles entrance + // For pop: returning item is already at -35%, animates to 0% + const shouldSlideIn = + isCurrent && isPushing && renderStack.length > 1; + const initialValue = shouldSlideIn + ? { x: "100%", scale: 1, opacity: 1 } + : false; + + return ( - - )} - + ); + })}
@@ -387,9 +459,9 @@ function MobileOverlayContainer() { - )} -
- + + )} + ); } diff --git a/components/overlays/overlay-provider.tsx b/components/overlays/overlay-provider.tsx index f7c06de71..c7137d5d3 100644 --- a/components/overlays/overlay-provider.tsx +++ b/components/overlays/overlay-provider.tsx @@ -7,6 +7,7 @@ import { useCallback, useContext, useMemo, + useRef, useState, } from "react"; import type { @@ -18,6 +19,9 @@ import type { const OverlayContext = createContext(null); +// Separate context for frozen stack (used during exit animations) +const FrozenStackContext = createContext([]); + /** * Generate a unique ID for overlay instances */ @@ -35,6 +39,13 @@ type OverlayProviderProps = { */ export function OverlayProvider({ children }: OverlayProviderProps) { const [stack, setStack] = useState([]); + const frozenStackRef = useRef([]); + + // Keep frozen stack updated when stack is non-empty + // This preserves the last state for exit animations + if (stack.length > 0) { + frozenStackRef.current = stack; + } const open = useCallback( ( @@ -155,7 +166,11 @@ export function OverlayProvider({ children }: OverlayProviderProps) { ); return ( - {children} + + + {children} + + ); } @@ -173,17 +188,22 @@ export function useOverlay(): OverlayContextValue { /** * Hook to get the current overlay's position in the stack. - * Returns { index, isFirst, isLast, depth } + * Uses frozen stack during exit animations to prevent UI shifts. + * Returns { index, isFirst, isLast, depth, showBackButton } */ export function useOverlayPosition(overlayId: string) { const { stack } = useOverlay(); - const index = stack.findIndex((item) => item.id === overlayId); + const frozenStack = useContext(FrozenStackContext); + + // Use frozen stack when real stack is empty (during exit animation) + const effectiveStack = stack.length > 0 ? stack : frozenStack; + const index = effectiveStack.findIndex((item) => item.id === overlayId); return { index, isFirst: index === 0, - isLast: index === stack.length - 1, - depth: stack.length, + isLast: index === effectiveStack.length - 1, + depth: effectiveStack.length, showBackButton: index > 0, }; } From 78abba27f0ebbab32db9f8724bb35e7221989f5d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 16 Dec 2025 12:12:26 -0600 Subject: [PATCH 2/3] more fixes --- components/overlays/overlay-container.tsx | 198 ++++++++++------------ components/overlays/overlay-header.tsx | 13 +- 2 files changed, 92 insertions(+), 119 deletions(-) diff --git a/components/overlays/overlay-container.tsx b/components/overlays/overlay-container.tsx index 1de9e76fa..c0e4bd697 100644 --- a/components/overlays/overlay-container.tsx +++ b/components/overlays/overlay-container.tsx @@ -334,12 +334,6 @@ function MobileOverlayContainer() { const springTransition = shouldReduceMotion ? { duration: 0.01 } : iosSpring; const isPushing = direction === 1; - const handleBackdropClick = useCallback(() => { - if (currentItem?.options.closeOnBackdropClick !== false) { - closeAll(); - } - }, [currentItem?.options.closeOnBackdropClick, closeAll]); - const handleEscapeKey = useCallback( (e: KeyboardEvent) => { if (e.key === "Escape" && currentItem?.options.closeOnEscape !== false) { @@ -356,112 +350,96 @@ function MobileOverlayContainer() { } }, [isOpen, handleEscapeKey]); - const handleExitComplete = useCallback(() => { - frozenStackRef.current = []; - }, []); - - // Don't render Drawer at all when closed - if (!isOpen && frozenStackRef.current.length === 0) { - return null; - } + // Clear frozen stack after drawer closes + const handleAnimationEnd = useCallback(() => { + if (!isOpen) { + frozenStackRef.current = []; + } + }, [isOpen]); return ( - - {isOpen && ( - - - {/* Backdrop */} - - - - - {/* Drawer container */} - - - {/* Accessible title for screen readers */} - - {renderCurrentItem?.options.title || "Dialog"} - - - {/* Drag handle */} -
- - {/* Content area with height animation */} - - 0 ? minHeight : "auto" }} - transition={drawerSpring} - > - {/* Content wrapper - all items rendered persistently to preserve state */} -
- {renderStack.map((item, index) => { - const isCurrent = index === currentIndex; - const isPrevious = index < currentIndex; - - // For push onto existing stack: new current item slides in from right - // For first overlay (fresh open): no slide, drawer container handles entrance - // For pop: returning item is already at -35%, animates to 0% - const shouldSlideIn = - isCurrent && isPushing && renderStack.length > 1; - const initialValue = shouldSlideIn - ? { x: "100%", scale: 1, opacity: 1 } - : false; - - return ( - - - - ); - })} -
-
-
- - {/* Safe area padding for iOS */} -
- - - - - )} - + { + if (!open) { + closeAll(); + } + }} + open={isOpen} + > + + {/* Backdrop - let Vaul handle animations */} + + + {/* Drawer container - let Vaul handle open/close animations */} + + {/* Accessible title for screen readers */} + + {renderCurrentItem?.options.title || "Dialog"} + + + {/* Drag handle */} +
+ + {/* Content area with height animation */} + + 0 ? minHeight : "auto" }} + transition={drawerSpring} + > + {/* Content wrapper - all items rendered persistently to preserve state */} +
+ {renderStack.map((item, index) => { + const isCurrent = index === currentIndex; + const isPrevious = index < currentIndex; + + // For push onto existing stack: new current item slides in from right + // For first overlay (fresh open): no slide, drawer container handles entrance + // For pop: returning item is already at -35%, animates to 0% + const shouldSlideIn = + isCurrent && isPushing && renderStack.length > 1; + const initialValue = shouldSlideIn + ? { x: "100%", scale: 1, opacity: 1 } + : false; + + return ( + + + + ); + })} +
+
+
+ + {/* Safe area padding for iOS */} +
+ + + ); } diff --git a/components/overlays/overlay-header.tsx b/components/overlays/overlay-header.tsx index 16cbf575e..3fe5ede43 100644 --- a/components/overlays/overlay-header.tsx +++ b/components/overlays/overlay-header.tsx @@ -42,18 +42,13 @@ export function OverlayHeader({ }; return ( -
-
+
+ {/* Fixed min-height to prevent layout shift when back button appears */} +
{showBackButton && (