From 00a6e636fb1f6abda45190009fd510efb17d1f3c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 12 Jun 2026 16:50:32 +0700 Subject: [PATCH] Fix mobile sidebars reappearing ghost-open after heavy commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Android (Fabric), a large React commit landing after a sidebar open/close settles — e.g. a long agent thread finishing its first mount — re-applies React's stale committed props onto the native view, reverting the UI-thread transform so the sidebar reappears with no transition while store and worklet state correctly say "closed" (software-mansion/react-native-reanimated#9635). Work around it by bumping a settled-generation counter after every settle so the animated styles refresh React's committed props from the settled shared values. The backdrop's pointerEvents moves from the worklet to React state for the same reason: Reanimated's prop re-application doesn't cover it, and a reverted "auto" wedges an invisible tap-eating backdrop. Animations still run entirely on the UI thread; the bump only fires after settle and writes no shared values. --- .../app/src/components/explorer-sidebar.tsx | 34 +++++++++---- packages/app/src/components/left-sidebar.tsx | 34 +++++++++---- .../explorer-sidebar-animation-context.tsx | 51 +++++++++++++++++-- .../contexts/sidebar-animation-context.tsx | 46 +++++++++++++++-- 4 files changed, 140 insertions(+), 25 deletions(-) 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,