From e0463ad17f31be5ac6154db147505477400d66ad Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 13 Jun 2026 08:34:52 +0700 Subject: [PATCH] Keep settled mobile sidebars hidden under UI load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier ghost-open fix gated re-commits on a settle counter, but on Fabric that doesn't hold: Reanimated re-applies its own (stale) animated props over React's committed props on every commit, so a settled-closed sidebar still reappears after a heavy commit (e.g. a long thread mounting or a message send) — reanimated#9635. No committed transform/opacity value can win while a useAnimatedStyle is attached. Split ownership instead: React owns whether the overlay exists, the worklet owns its motion. display lives on the plain wrapper View that Reanimated never touches, so a hidden overlay stays hidden regardless of what the animated props revert to. An open gesture flips an overlayPeek flag to reveal the panel before the store updates; a short close grace keeps it displayed until the close animation finishes. Residual: a heavy commit landing mid-close-animation can still flash the panel until the animation settles — transient, not the previous permanent wedge. --- packages/app/src/app/_layout.tsx | 5 ++ .../app/src/components/explorer-sidebar.tsx | 41 ++++----- packages/app/src/components/left-sidebar.tsx | 46 +++++----- .../explorer-sidebar-animation-context.tsx | 63 ++++++-------- .../contexts/sidebar-animation-context.tsx | 84 ++++++++----------- .../src/hooks/use-explorer-open-gesture.ts | 5 ++ 6 files changed, 112 insertions(+), 132 deletions(-) diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 46dfc9cb1..e3757c205 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -511,6 +511,7 @@ function MobileGestureWrapper({ windowWidth, animateToOpen, animateToClose, + setOverlayPeek, isGesturing, mobilePanelState, gestureAnimatingRef, @@ -579,6 +580,8 @@ function MobileGestureWrapper({ }) .onStart(() => { isGesturing.value = true; + // The overlay is display:none while closed; reveal it for the drag. + runOnJS(setOverlayPeek)(true); }) .onUpdate((event) => { const newTranslateX = Math.min(0, -windowWidth + event.translationX); @@ -602,6 +605,7 @@ function MobileGestureWrapper({ }) .onFinalize(() => { isGesturing.value = false; + runOnJS(setOverlayPeek)(false); }), [ openGestureEnabled, @@ -611,6 +615,7 @@ function MobileGestureWrapper({ mobilePanelState, animateToOpen, animateToClose, + setOverlayPeek, handleGestureOpen, isGesturing, openGestureRef, diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 37f2ba50f..a03708e44 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -97,7 +97,7 @@ export function ExplorerSidebar({ windowWidth, animateToOpen, animateToClose, - settledGeneration, + overlayVisible, isGesturing, gestureAnimatingRef, closeGestureRef, @@ -260,25 +260,13 @@ export function ExplorerSidebar({ [isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth], ); - // settledGeneration as deps: after each settle the updater is rebuilt so the - // shared values are re-applied to the view, protecting against a heavy Fabric - // commit reverting the transform to stale React-committed props (#9635). - const sidebarAnimatedStyle = useAnimatedStyle( - () => ({ - transform: [{ translateX: translateX.value }], - }), - [settledGeneration], - ); + const sidebarAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }], + })); - // pointerEvents comes from React state, not the worklet: the Fabric revert - // protection above does not cover pointerEvents, so a worklet-driven value - // can wedge an invisible tap-eating backdrop after a heavy commit. - const backdropAnimatedStyle = useAnimatedStyle( - () => ({ - opacity: backdropOpacity.value, - }), - [settledGeneration], - ); + const backdropAnimatedStyle = useAnimatedStyle(() => ({ + opacity: backdropOpacity.value, + })); const resizeAnimatedStyle = useAnimatedStyle(() => ({ width: resizeWidth.value, @@ -288,6 +276,9 @@ export function ExplorerSidebar({ () => [ explorerStaticStyles.backdrop, backdropAnimatedStyle, + // pointerEvents is React-owned, not worklet-owned: Reanimated never + // touches it, so a stale animated-prop revert can't wedge an invisible + // tap-eating backdrop. { pointerEvents: isOpen ? ("auto" as const) : ("none" as const) }, ], [backdropAnimatedStyle, isOpen], @@ -311,6 +302,16 @@ export function ExplorerSidebar({ mobileKeyboardInsetStyle, ], ); + // display is React-owned on the plain wrapper View (no animated styles), so + // a hidden overlay stays hidden no matter what Reanimated's Fabric overlay + // reverts the panel transform to after a heavy commit (reanimated#9635). + const overlayStyle = useMemo( + () => [ + StyleSheet.absoluteFillObject, + { display: overlayVisible ? ("flex" as const) : ("none" as const) }, + ], + [overlayVisible], + ); const desktopSidebarStyle = useMemo( () => [explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }], [resizeAnimatedStyle, insets.top], @@ -331,7 +332,7 @@ export function ExplorerSidebar({ if (isMobile) { return ( - + {/* Backdrop */} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index a15fa7dc8..4ab82ae9b 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -41,10 +41,7 @@ import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; -import { - useSidebarAnimation, - useSidebarSettledGeneration, -} from "@/contexts/sidebar-animation-context"; +import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model"; @@ -618,12 +615,12 @@ function MobileSidebar({ windowWidth, animateToOpen, animateToClose, + overlayVisible, isGesturing, mobilePanelState, gestureAnimatingRef, closeGestureRef, } = useSidebarAnimation(); - const settledGeneration = useSidebarSettledGeneration(); const closeTouchStartX = useSharedValue(0); const closeTouchStartY = useSharedValue(0); @@ -752,25 +749,13 @@ function MobileSidebar({ [activeHostStatusColor], ); - // settledGeneration as deps: after each settle the updater is rebuilt so the - // shared values are re-applied to the view, protecting against a heavy Fabric - // commit reverting the transform to stale React-committed props (#9635). - const sidebarAnimatedStyle = useAnimatedStyle( - () => ({ - transform: [{ translateX: translateX.value }], - }), - [settledGeneration], - ); + const sidebarAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }], + })); - // pointerEvents comes from React state, not the worklet: the Fabric revert - // protection above does not cover pointerEvents, so a worklet-driven value - // can wedge an invisible tap-eating backdrop after a heavy commit. - const backdropAnimatedStyle = useAnimatedStyle( - () => ({ - opacity: backdropOpacity.value, - }), - [settledGeneration], - ); + const backdropAnimatedStyle = useAnimatedStyle(() => ({ + opacity: backdropOpacity.value, + })); let overlayPointerEvents: "auto" | "none" | "box-none"; if (!isWeb) overlayPointerEvents = "box-none"; @@ -781,6 +766,9 @@ function MobileSidebar({ () => [ staticStyles.backdrop, backdropAnimatedStyle, + // pointerEvents is React-owned, not worklet-owned: Reanimated never + // touches it, so a stale animated-prop revert can't wedge an invisible + // tap-eating backdrop. { pointerEvents: isOpen ? ("auto" as const) : ("none" as const) }, ], [backdropAnimatedStyle, isOpen], @@ -794,9 +782,19 @@ function MobileSidebar({ ], [mobileSidebarInsetStyle, sidebarAnimatedStyle, theme.colors.surfaceSidebar], ); + // display is React-owned on the plain wrapper View (no animated styles), so + // a hidden overlay stays hidden no matter what Reanimated's Fabric overlay + // reverts the panel transform to after a heavy commit (reanimated#9635). + const overlayStyle = useMemo( + () => [ + StyleSheet.absoluteFillObject, + { display: overlayVisible ? ("flex" as const) : ("none" as const) }, + ], + [overlayVisible], + ); return ( - + diff --git a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx index 8ddc69d73..987dfb6ac 100644 --- a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx +++ b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx @@ -9,13 +9,7 @@ import { type ReactNode, } from "react"; import { useWindowDimensions } from "react-native"; -import { - runOnJS, - useSharedValue, - withTiming, - Easing, - type SharedValue, -} from "react-native-reanimated"; +import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated"; import { type GestureType } from "react-native-gesture-handler"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; @@ -27,13 +21,17 @@ import { const ANIMATION_DURATION = 220; const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); +// Keeps the overlay displayed long enough for the close animation to finish +// before React hides it. +const OVERLAY_CLOSE_GRACE_MS = 300; interface ExplorerSidebarAnimationContextValue { translateX: SharedValue; backdropOpacity: SharedValue; windowWidth: number; animateToOpen: () => void; animateToClose: () => void; - settledGeneration: number; + overlayVisible: boolean; + setOverlayPeek: (peek: boolean) => void; isGesturing: SharedValue; gestureAnimatingRef: React.MutableRefObject; openGestureRef: React.MutableRefObject; @@ -62,12 +60,21 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React const openGestureRef = useRef(undefined); const closeGestureRef = useRef(undefined); - // Same Fabric stale-props revert protection as in sidebar-animation-context: - // bump after every settle so consumers re-commit the settled shared values. - const [settledGeneration, setSettledGeneration] = useState(0); - const bumpSettledGeneration = useCallback(() => { - setSettledGeneration((generation) => generation + 1); - }, []); + // React owns whether the overlay is displayed at all; the worklet owns its + // motion. See the matching block in sidebar-animation-context.tsx for why + // (Fabric re-applies stale animated props over committed props, + // reanimated#9635). + const [overlayPeek, setOverlayPeek] = useState(false); + const overlayTarget = isOpen || overlayPeek; + const [overlayVisible, setOverlayVisible] = useState(overlayTarget); + useEffect(() => { + if (overlayTarget) { + setOverlayVisible(true); + return; + } + const timer = setTimeout(() => setOverlayVisible(false), OVERLAY_CLOSE_GRACE_MS); + return () => clearTimeout(timer); + }, [overlayTarget]); // Track previous isOpen to detect changes const prevIsOpen = useRef(isOpen); @@ -123,7 +130,6 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React if (isCompactLayout) { settleMobilePanel("file-explorer"); } - runOnJS(bumpSettledGeneration)(); }, ); backdropOpacity.value = withTiming(targets.backdropOpacity, { @@ -147,7 +153,6 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React if (isCompactLayout && mobileView === "agent") { settleMobilePanel("agent"); } - runOnJS(bumpSettledGeneration)(); }, ); backdropOpacity.value = withTiming(targets.backdropOpacity, { @@ -162,7 +167,6 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React if (isCompactLayout && ownsMobileViewChange) { settleMobilePanel(mobileView); } - bumpSettledGeneration(); }, [ isOpen, mobileView, @@ -173,7 +177,6 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React isCompactLayout, startMobilePanelTransition, settleMobilePanel, - bumpSettledGeneration, ]); const animateToOpen = useCallback(() => { @@ -188,20 +191,13 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React (finished) => { if (!finished) return; settleMobilePanel("file-explorer"); - runOnJS(bumpSettledGeneration)(); }, ); backdropOpacity.value = withTiming(1, { duration: ANIMATION_DURATION, easing: ANIMATION_EASING, }); - }, [ - translateX, - backdropOpacity, - startMobilePanelTransition, - settleMobilePanel, - bumpSettledGeneration, - ]); + }, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]); const animateToClose = useCallback(() => { "worklet"; @@ -215,21 +211,13 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React (finished) => { if (!finished) return; settleMobilePanel("agent"); - runOnJS(bumpSettledGeneration)(); }, ); backdropOpacity.value = withTiming(0, { duration: ANIMATION_DURATION, easing: ANIMATION_EASING, }); - }, [ - translateX, - backdropOpacity, - windowWidth, - startMobilePanelTransition, - settleMobilePanel, - bumpSettledGeneration, - ]); + }, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]); const value = useMemo( () => ({ @@ -238,7 +226,8 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React windowWidth, animateToOpen, animateToClose, - settledGeneration, + overlayVisible, + setOverlayPeek, isGesturing, gestureAnimatingRef, openGestureRef, @@ -250,7 +239,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React windowWidth, animateToOpen, animateToClose, - settledGeneration, + overlayVisible, isGesturing, ], ); diff --git a/packages/app/src/contexts/sidebar-animation-context.tsx b/packages/app/src/contexts/sidebar-animation-context.tsx index b4e4d25fe..dae026bdc 100644 --- a/packages/app/src/contexts/sidebar-animation-context.tsx +++ b/packages/app/src/contexts/sidebar-animation-context.tsx @@ -9,13 +9,7 @@ import { type ReactNode, } from "react"; import { Keyboard, useWindowDimensions } from "react-native"; -import { - runOnJS, - useSharedValue, - withTiming, - Easing, - type SharedValue, -} from "react-native-reanimated"; +import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated"; import { type GestureType } from "react-native-gesture-handler"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative } from "@/constants/platform"; @@ -38,6 +32,9 @@ import { const ANIMATION_DURATION = 220; const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); +// Keeps the overlay displayed long enough for the close animation to finish +// before React hides it. +const OVERLAY_CLOSE_GRACE_MS = 300; export const MOBILE_VISUAL_PANEL_AGENT = 0; export const MOBILE_VISUAL_PANEL_AGENT_LIST = 1; export const MOBILE_VISUAL_PANEL_FILE_EXPLORER = 2; @@ -50,6 +47,8 @@ interface SidebarAnimationContextValue { animateToClose: () => void; startMobilePanelTransition: (mobileView: "agent" | "agent-list" | "file-explorer") => void; settleMobilePanel: (mobileView: "agent" | "agent-list" | "file-explorer") => void; + overlayVisible: boolean; + setOverlayPeek: (peek: boolean) => void; isGesturing: SharedValue; mobileVisualPanel: SharedValue; mobilePanelState: SharedValue; @@ -60,10 +59,6 @@ interface SidebarAnimationContextValue { const SidebarAnimationContext = createContext(null); -// Separate context so that settle-driven re-renders only reach MobileSidebar, -// not every other useSidebarAnimation() consumer. -const SidebarSettledGenerationContext = createContext(0); - function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explorer"): number { if (mobileView === "agent-list") { return MOBILE_VISUAL_PANEL_AGENT_LIST; @@ -104,16 +99,27 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) const openGestureRef = useRef(undefined); const closeGestureRef = useRef(undefined); - // After an open/close settles, a heavy Fabric commit can re-apply React's - // stale committed props onto the native view, reverting the UI-thread - // transform (reanimated#9635 — sidebar reappears "ghost-open"). Bumping this - // counter after every settle re-renders the consumers so the animated styles - // refresh React's committed props from the settled shared values. It must - // never write a shared value — it only triggers a React re-commit. - const [settledGeneration, setSettledGeneration] = useState(0); - const bumpSettledGeneration = useCallback(() => { - setSettledGeneration((generation) => generation + 1); - }, []); + // React owns whether the overlay is displayed at all; the worklet owns its + // motion. On Fabric, Reanimated re-applies its own (possibly stale) animated + // props over React's committed props on every commit, so a settled-closed + // overlay can ghost back on screen after a heavy commit (reanimated#9635) — + // no committed transform/opacity value can prevent that. display lives on + // the plain wrapper View that Reanimated never touches, so React's value is + // authoritative: a hidden overlay stays hidden no matter what the animated + // props revert to. overlayPeek shows the overlay during an open gesture + // before the store flips; the close grace keeps it displayed while the + // close animation plays. + const [overlayPeek, setOverlayPeek] = useState(false); + const overlayTarget = isOpen || overlayPeek; + const [overlayVisible, setOverlayVisible] = useState(overlayTarget); + useEffect(() => { + if (overlayTarget) { + setOverlayVisible(true); + return; + } + const timer = setTimeout(() => setOverlayVisible(false), OVERLAY_CLOSE_GRACE_MS); + return () => clearTimeout(timer); + }, [overlayTarget]); // Track previous isOpen to detect changes const prevIsOpen = useRef(isOpen); @@ -241,7 +247,6 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) if (isCompactLayout) { settleMobilePanel("agent-list"); } - runOnJS(bumpSettledGeneration)(); }, ); backdropOpacity.value = withTiming(targets.backdropOpacity, { @@ -265,7 +270,6 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) if (isCompactLayout && mobileView === "agent") { settleMobilePanel("agent"); } - runOnJS(bumpSettledGeneration)(); }, ); backdropOpacity.value = withTiming(targets.backdropOpacity, { @@ -280,7 +284,6 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) if (isCompactLayout && ownsMobileViewChange) { settleMobilePanel(mobileView); } - bumpSettledGeneration(); }, [ isOpen, mobileView, @@ -293,7 +296,6 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) mobilePanelState, startMobilePanelTransition, settleMobilePanel, - bumpSettledGeneration, ]); const animateToOpen = useCallback(() => { @@ -308,20 +310,13 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) (finished) => { if (!finished) return; settleMobilePanel("agent-list"); - runOnJS(bumpSettledGeneration)(); }, ); backdropOpacity.value = withTiming(1, { duration: ANIMATION_DURATION, easing: ANIMATION_EASING, }); - }, [ - translateX, - backdropOpacity, - startMobilePanelTransition, - settleMobilePanel, - bumpSettledGeneration, - ]); + }, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]); const animateToClose = useCallback(() => { "worklet"; @@ -335,21 +330,13 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) (finished) => { if (!finished) return; settleMobilePanel("agent"); - runOnJS(bumpSettledGeneration)(); }, ); backdropOpacity.value = withTiming(0, { duration: ANIMATION_DURATION, easing: ANIMATION_EASING, }); - }, [ - translateX, - backdropOpacity, - windowWidth, - startMobilePanelTransition, - settleMobilePanel, - bumpSettledGeneration, - ]); + }, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]); const value = useMemo( () => ({ @@ -360,6 +347,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) animateToClose, startMobilePanelTransition, settleMobilePanel, + overlayVisible, + setOverlayPeek, isGesturing, mobileVisualPanel, mobilePanelState, @@ -375,6 +364,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) animateToClose, startMobilePanelTransition, settleMobilePanel, + overlayVisible, isGesturing, mobileVisualPanel, mobilePanelState, @@ -385,11 +375,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) ); return ( - - - {children} - - + {children} ); } @@ -400,7 +386,3 @@ export function useSidebarAnimation() { } return context; } - -export function useSidebarSettledGeneration() { - return useContext(SidebarSettledGenerationContext); -} diff --git a/packages/app/src/hooks/use-explorer-open-gesture.ts b/packages/app/src/hooks/use-explorer-open-gesture.ts index 811232833..bddbe6e59 100644 --- a/packages/app/src/hooks/use-explorer-open-gesture.ts +++ b/packages/app/src/hooks/use-explorer-open-gesture.ts @@ -20,6 +20,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu windowWidth, animateToOpen, animateToClose, + setOverlayPeek, isGesturing, gestureAnimatingRef, openGestureRef, @@ -93,6 +94,8 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu }) .onStart(() => { isGesturing.value = true; + // The overlay is display:none while closed; reveal it for the drag. + runOnJS(setOverlayPeek)(true); }) .onUpdate((event) => { // Right sidebar: start from closed position (+windowWidth) and move towards 0. @@ -122,6 +125,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu }) .onFinalize(() => { isGesturing.value = false; + runOnJS(setOverlayPeek)(false); }), [ enabled, @@ -131,6 +135,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu mobilePanelState, animateToOpen, animateToClose, + setOverlayPeek, isGesturing, openGestureRef, leftOpenGestureRef,