diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 4f566ecd3..37f2ba50f 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -97,6 +97,7 @@ export function ExplorerSidebar({ windowWidth, animateToOpen, animateToClose, + settledGeneration, isGesturing, gestureAnimatingRef, closeGestureRef, @@ -259,22 +260,37 @@ export function ExplorerSidebar({ [isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth], ); - const sidebarAnimatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: translateX.value }], - })); + // 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 backdropAnimatedStyle = useAnimatedStyle(() => ({ - opacity: backdropOpacity.value, - pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", - })); + // 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 resizeAnimatedStyle = useAnimatedStyle(() => ({ width: resizeWidth.value, })); const backdropCombinedStyle = useMemo( - () => [explorerStaticStyles.backdrop, backdropAnimatedStyle], - [backdropAnimatedStyle], + () => [ + explorerStaticStyles.backdrop, + backdropAnimatedStyle, + { pointerEvents: isOpen ? ("auto" as const) : ("none" as const) }, + ], + [backdropAnimatedStyle, isOpen], ); const mobileSidebarStyle = useMemo( () => [ diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 4b08a507a..3bfbd7c73 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -615,6 +615,7 @@ function MobileSidebar({ windowWidth, animateToOpen, animateToClose, + settledGeneration, isGesturing, mobilePanelState, gestureAnimatingRef, @@ -748,14 +749,25 @@ function MobileSidebar({ [activeHostStatusColor], ); - const sidebarAnimatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: translateX.value }], - })); + // 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 backdropAnimatedStyle = useAnimatedStyle(() => ({ - opacity: backdropOpacity.value, - pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", - })); + // 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], + ); let overlayPointerEvents: "auto" | "none" | "box-none"; if (!isWeb) overlayPointerEvents = "box-none"; @@ -763,8 +775,12 @@ function MobileSidebar({ else overlayPointerEvents = "none"; const backdropStyle = useMemo( - () => [staticStyles.backdrop, backdropAnimatedStyle], - [backdropAnimatedStyle], + () => [ + staticStyles.backdrop, + backdropAnimatedStyle, + { pointerEvents: isOpen ? ("auto" as const) : ("none" as const) }, + ], + [backdropAnimatedStyle, isOpen], ); const mobileSidebarStyle = useMemo( () => [ diff --git a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx index d5e3c08ac..8ddc69d73 100644 --- a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx +++ b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx @@ -5,10 +5,17 @@ import { useEffect, useMemo, useRef, + useState, type ReactNode, } from "react"; import { useWindowDimensions } from "react-native"; -import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated"; +import { + runOnJS, + 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"; @@ -26,6 +33,7 @@ interface ExplorerSidebarAnimationContextValue { windowWidth: number; animateToOpen: () => void; animateToClose: () => void; + settledGeneration: number; isGesturing: SharedValue; gestureAnimatingRef: React.MutableRefObject; openGestureRef: React.MutableRefObject; @@ -54,6 +62,13 @@ 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); + }, []); + // Track previous isOpen to detect changes const prevIsOpen = useRef(isOpen); const prevMobileView = useRef(mobileView); @@ -108,6 +123,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React if (isCompactLayout) { settleMobilePanel("file-explorer"); } + runOnJS(bumpSettledGeneration)(); }, ); backdropOpacity.value = withTiming(targets.backdropOpacity, { @@ -131,6 +147,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React if (isCompactLayout && mobileView === "agent") { settleMobilePanel("agent"); } + runOnJS(bumpSettledGeneration)(); }, ); backdropOpacity.value = withTiming(targets.backdropOpacity, { @@ -145,6 +162,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React if (isCompactLayout && ownsMobileViewChange) { settleMobilePanel(mobileView); } + bumpSettledGeneration(); }, [ isOpen, mobileView, @@ -155,6 +173,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React isCompactLayout, startMobilePanelTransition, settleMobilePanel, + bumpSettledGeneration, ]); const animateToOpen = useCallback(() => { @@ -169,13 +188,20 @@ 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]); + }, [ + translateX, + backdropOpacity, + startMobilePanelTransition, + settleMobilePanel, + bumpSettledGeneration, + ]); const animateToClose = useCallback(() => { "worklet"; @@ -189,13 +215,21 @@ 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]); + }, [ + translateX, + backdropOpacity, + windowWidth, + startMobilePanelTransition, + settleMobilePanel, + bumpSettledGeneration, + ]); const value = useMemo( () => ({ @@ -204,12 +238,21 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React windowWidth, animateToOpen, animateToClose, + settledGeneration, isGesturing, gestureAnimatingRef, openGestureRef, closeGestureRef, }), - [translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing], + [ + translateX, + backdropOpacity, + windowWidth, + animateToOpen, + animateToClose, + settledGeneration, + isGesturing, + ], ); return ( diff --git a/packages/app/src/contexts/sidebar-animation-context.tsx b/packages/app/src/contexts/sidebar-animation-context.tsx index caeced2d9..e869d2451 100644 --- a/packages/app/src/contexts/sidebar-animation-context.tsx +++ b/packages/app/src/contexts/sidebar-animation-context.tsx @@ -5,10 +5,17 @@ import { useEffect, useMemo, useRef, + useState, type ReactNode, } from "react"; import { Keyboard, useWindowDimensions } from "react-native"; -import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated"; +import { + runOnJS, + 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"; @@ -43,6 +50,7 @@ interface SidebarAnimationContextValue { animateToClose: () => void; startMobilePanelTransition: (mobileView: "agent" | "agent-list" | "file-explorer") => void; settleMobilePanel: (mobileView: "agent" | "agent-list" | "file-explorer") => void; + settledGeneration: number; isGesturing: SharedValue; mobileVisualPanel: SharedValue; mobilePanelState: SharedValue; @@ -93,6 +101,17 @@ 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); + }, []); + // Track previous isOpen to detect changes const prevIsOpen = useRef(isOpen); const prevMobileView = useRef(mobileView); @@ -219,6 +238,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) if (isCompactLayout) { settleMobilePanel("agent-list"); } + runOnJS(bumpSettledGeneration)(); }, ); backdropOpacity.value = withTiming(targets.backdropOpacity, { @@ -242,6 +262,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) if (isCompactLayout && mobileView === "agent") { settleMobilePanel("agent"); } + runOnJS(bumpSettledGeneration)(); }, ); backdropOpacity.value = withTiming(targets.backdropOpacity, { @@ -256,6 +277,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) if (isCompactLayout && ownsMobileViewChange) { settleMobilePanel(mobileView); } + bumpSettledGeneration(); }, [ isOpen, mobileView, @@ -268,6 +290,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) mobilePanelState, startMobilePanelTransition, settleMobilePanel, + bumpSettledGeneration, ]); const animateToOpen = useCallback(() => { @@ -282,13 +305,20 @@ 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]); + }, [ + translateX, + backdropOpacity, + startMobilePanelTransition, + settleMobilePanel, + bumpSettledGeneration, + ]); const animateToClose = useCallback(() => { "worklet"; @@ -302,13 +332,21 @@ 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]); + }, [ + translateX, + backdropOpacity, + windowWidth, + startMobilePanelTransition, + settleMobilePanel, + bumpSettledGeneration, + ]); const value = useMemo( () => ({ @@ -319,6 +357,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) animateToClose, startMobilePanelTransition, settleMobilePanel, + settledGeneration, isGesturing, mobileVisualPanel, mobilePanelState, @@ -334,6 +373,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) animateToClose, startMobilePanelTransition, settleMobilePanel, + settledGeneration, isGesturing, mobileVisualPanel, mobilePanelState,