Fix mobile sidebars reappearing ghost-open after heavy commits

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.
This commit is contained in:
Mohamed Boudra
2026-06-12 16:50:32 +07:00
parent 9af18ef2f5
commit 00a6e636fb
4 changed files with 140 additions and 25 deletions
@@ -97,6 +97,7 @@ export function ExplorerSidebar({
windowWidth, windowWidth,
animateToOpen, animateToOpen,
animateToClose, animateToClose,
settledGeneration,
isGesturing, isGesturing,
gestureAnimatingRef, gestureAnimatingRef,
closeGestureRef, closeGestureRef,
@@ -259,22 +260,37 @@ export function ExplorerSidebar({
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth], [isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
); );
const sidebarAnimatedStyle = useAnimatedStyle(() => ({ // settledGeneration as deps: after each settle the updater is rebuilt so the
transform: [{ translateX: translateX.value }], // 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(() => ({ // pointerEvents comes from React state, not the worklet: the Fabric revert
opacity: backdropOpacity.value, // protection above does not cover pointerEvents, so a worklet-driven value
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", // can wedge an invisible tap-eating backdrop after a heavy commit.
})); const backdropAnimatedStyle = useAnimatedStyle(
() => ({
opacity: backdropOpacity.value,
}),
[settledGeneration],
);
const resizeAnimatedStyle = useAnimatedStyle(() => ({ const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value, width: resizeWidth.value,
})); }));
const backdropCombinedStyle = useMemo( const backdropCombinedStyle = useMemo(
() => [explorerStaticStyles.backdrop, backdropAnimatedStyle], () => [
[backdropAnimatedStyle], explorerStaticStyles.backdrop,
backdropAnimatedStyle,
{ pointerEvents: isOpen ? ("auto" as const) : ("none" as const) },
],
[backdropAnimatedStyle, isOpen],
); );
const mobileSidebarStyle = useMemo( const mobileSidebarStyle = useMemo(
() => [ () => [
+25 -9
View File
@@ -615,6 +615,7 @@ function MobileSidebar({
windowWidth, windowWidth,
animateToOpen, animateToOpen,
animateToClose, animateToClose,
settledGeneration,
isGesturing, isGesturing,
mobilePanelState, mobilePanelState,
gestureAnimatingRef, gestureAnimatingRef,
@@ -748,14 +749,25 @@ function MobileSidebar({
[activeHostStatusColor], [activeHostStatusColor],
); );
const sidebarAnimatedStyle = useAnimatedStyle(() => ({ // settledGeneration as deps: after each settle the updater is rebuilt so the
transform: [{ translateX: translateX.value }], // 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(() => ({ // pointerEvents comes from React state, not the worklet: the Fabric revert
opacity: backdropOpacity.value, // protection above does not cover pointerEvents, so a worklet-driven value
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", // can wedge an invisible tap-eating backdrop after a heavy commit.
})); const backdropAnimatedStyle = useAnimatedStyle(
() => ({
opacity: backdropOpacity.value,
}),
[settledGeneration],
);
let overlayPointerEvents: "auto" | "none" | "box-none"; let overlayPointerEvents: "auto" | "none" | "box-none";
if (!isWeb) overlayPointerEvents = "box-none"; if (!isWeb) overlayPointerEvents = "box-none";
@@ -763,8 +775,12 @@ function MobileSidebar({
else overlayPointerEvents = "none"; else overlayPointerEvents = "none";
const backdropStyle = useMemo( const backdropStyle = useMemo(
() => [staticStyles.backdrop, backdropAnimatedStyle], () => [
[backdropAnimatedStyle], staticStyles.backdrop,
backdropAnimatedStyle,
{ pointerEvents: isOpen ? ("auto" as const) : ("none" as const) },
],
[backdropAnimatedStyle, isOpen],
); );
const mobileSidebarStyle = useMemo( const mobileSidebarStyle = useMemo(
() => [ () => [
@@ -5,10 +5,17 @@ import {
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { useWindowDimensions } from "react-native"; 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 { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
@@ -26,6 +33,7 @@ interface ExplorerSidebarAnimationContextValue {
windowWidth: number; windowWidth: number;
animateToOpen: () => void; animateToOpen: () => void;
animateToClose: () => void; animateToClose: () => void;
settledGeneration: number;
isGesturing: SharedValue<boolean>; isGesturing: SharedValue<boolean>;
gestureAnimatingRef: React.MutableRefObject<boolean>; gestureAnimatingRef: React.MutableRefObject<boolean>;
openGestureRef: React.MutableRefObject<GestureType | undefined>; openGestureRef: React.MutableRefObject<GestureType | undefined>;
@@ -54,6 +62,13 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
const openGestureRef = useRef<GestureType | undefined>(undefined); const openGestureRef = useRef<GestureType | undefined>(undefined);
const closeGestureRef = useRef<GestureType | undefined>(undefined); const closeGestureRef = useRef<GestureType | undefined>(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 // Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen); const prevIsOpen = useRef(isOpen);
const prevMobileView = useRef(mobileView); const prevMobileView = useRef(mobileView);
@@ -108,6 +123,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
if (isCompactLayout) { if (isCompactLayout) {
settleMobilePanel("file-explorer"); settleMobilePanel("file-explorer");
} }
runOnJS(bumpSettledGeneration)();
}, },
); );
backdropOpacity.value = withTiming(targets.backdropOpacity, { backdropOpacity.value = withTiming(targets.backdropOpacity, {
@@ -131,6 +147,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
if (isCompactLayout && mobileView === "agent") { if (isCompactLayout && mobileView === "agent") {
settleMobilePanel("agent"); settleMobilePanel("agent");
} }
runOnJS(bumpSettledGeneration)();
}, },
); );
backdropOpacity.value = withTiming(targets.backdropOpacity, { backdropOpacity.value = withTiming(targets.backdropOpacity, {
@@ -145,6 +162,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
if (isCompactLayout && ownsMobileViewChange) { if (isCompactLayout && ownsMobileViewChange) {
settleMobilePanel(mobileView); settleMobilePanel(mobileView);
} }
bumpSettledGeneration();
}, [ }, [
isOpen, isOpen,
mobileView, mobileView,
@@ -155,6 +173,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
isCompactLayout, isCompactLayout,
startMobilePanelTransition, startMobilePanelTransition,
settleMobilePanel, settleMobilePanel,
bumpSettledGeneration,
]); ]);
const animateToOpen = useCallback(() => { const animateToOpen = useCallback(() => {
@@ -169,13 +188,20 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
(finished) => { (finished) => {
if (!finished) return; if (!finished) return;
settleMobilePanel("file-explorer"); settleMobilePanel("file-explorer");
runOnJS(bumpSettledGeneration)();
}, },
); );
backdropOpacity.value = withTiming(1, { backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
}, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]); }, [
translateX,
backdropOpacity,
startMobilePanelTransition,
settleMobilePanel,
bumpSettledGeneration,
]);
const animateToClose = useCallback(() => { const animateToClose = useCallback(() => {
"worklet"; "worklet";
@@ -189,13 +215,21 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
(finished) => { (finished) => {
if (!finished) return; if (!finished) return;
settleMobilePanel("agent"); settleMobilePanel("agent");
runOnJS(bumpSettledGeneration)();
}, },
); );
backdropOpacity.value = withTiming(0, { backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
}, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]); }, [
translateX,
backdropOpacity,
windowWidth,
startMobilePanelTransition,
settleMobilePanel,
bumpSettledGeneration,
]);
const value = useMemo<ExplorerSidebarAnimationContextValue>( const value = useMemo<ExplorerSidebarAnimationContextValue>(
() => ({ () => ({
@@ -204,12 +238,21 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
windowWidth, windowWidth,
animateToOpen, animateToOpen,
animateToClose, animateToClose,
settledGeneration,
isGesturing, isGesturing,
gestureAnimatingRef, gestureAnimatingRef,
openGestureRef, openGestureRef,
closeGestureRef, closeGestureRef,
}), }),
[translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing], [
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
settledGeneration,
isGesturing,
],
); );
return ( return (
@@ -5,10 +5,17 @@ import {
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { Keyboard, useWindowDimensions } from "react-native"; 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 { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
@@ -43,6 +50,7 @@ interface SidebarAnimationContextValue {
animateToClose: () => void; animateToClose: () => void;
startMobilePanelTransition: (mobileView: "agent" | "agent-list" | "file-explorer") => void; startMobilePanelTransition: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
settleMobilePanel: (mobileView: "agent" | "agent-list" | "file-explorer") => void; settleMobilePanel: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
settledGeneration: number;
isGesturing: SharedValue<boolean>; isGesturing: SharedValue<boolean>;
mobileVisualPanel: SharedValue<number>; mobileVisualPanel: SharedValue<number>;
mobilePanelState: SharedValue<number>; mobilePanelState: SharedValue<number>;
@@ -93,6 +101,17 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
const openGestureRef = useRef<GestureType | undefined>(undefined); const openGestureRef = useRef<GestureType | undefined>(undefined);
const closeGestureRef = useRef<GestureType | undefined>(undefined); const closeGestureRef = useRef<GestureType | undefined>(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 // Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen); const prevIsOpen = useRef(isOpen);
const prevMobileView = useRef(mobileView); const prevMobileView = useRef(mobileView);
@@ -219,6 +238,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
if (isCompactLayout) { if (isCompactLayout) {
settleMobilePanel("agent-list"); settleMobilePanel("agent-list");
} }
runOnJS(bumpSettledGeneration)();
}, },
); );
backdropOpacity.value = withTiming(targets.backdropOpacity, { backdropOpacity.value = withTiming(targets.backdropOpacity, {
@@ -242,6 +262,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
if (isCompactLayout && mobileView === "agent") { if (isCompactLayout && mobileView === "agent") {
settleMobilePanel("agent"); settleMobilePanel("agent");
} }
runOnJS(bumpSettledGeneration)();
}, },
); );
backdropOpacity.value = withTiming(targets.backdropOpacity, { backdropOpacity.value = withTiming(targets.backdropOpacity, {
@@ -256,6 +277,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
if (isCompactLayout && ownsMobileViewChange) { if (isCompactLayout && ownsMobileViewChange) {
settleMobilePanel(mobileView); settleMobilePanel(mobileView);
} }
bumpSettledGeneration();
}, [ }, [
isOpen, isOpen,
mobileView, mobileView,
@@ -268,6 +290,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
mobilePanelState, mobilePanelState,
startMobilePanelTransition, startMobilePanelTransition,
settleMobilePanel, settleMobilePanel,
bumpSettledGeneration,
]); ]);
const animateToOpen = useCallback(() => { const animateToOpen = useCallback(() => {
@@ -282,13 +305,20 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
(finished) => { (finished) => {
if (!finished) return; if (!finished) return;
settleMobilePanel("agent-list"); settleMobilePanel("agent-list");
runOnJS(bumpSettledGeneration)();
}, },
); );
backdropOpacity.value = withTiming(1, { backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
}, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]); }, [
translateX,
backdropOpacity,
startMobilePanelTransition,
settleMobilePanel,
bumpSettledGeneration,
]);
const animateToClose = useCallback(() => { const animateToClose = useCallback(() => {
"worklet"; "worklet";
@@ -302,13 +332,21 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
(finished) => { (finished) => {
if (!finished) return; if (!finished) return;
settleMobilePanel("agent"); settleMobilePanel("agent");
runOnJS(bumpSettledGeneration)();
}, },
); );
backdropOpacity.value = withTiming(0, { backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
}, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]); }, [
translateX,
backdropOpacity,
windowWidth,
startMobilePanelTransition,
settleMobilePanel,
bumpSettledGeneration,
]);
const value = useMemo<SidebarAnimationContextValue>( const value = useMemo<SidebarAnimationContextValue>(
() => ({ () => ({
@@ -319,6 +357,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
animateToClose, animateToClose,
startMobilePanelTransition, startMobilePanelTransition,
settleMobilePanel, settleMobilePanel,
settledGeneration,
isGesturing, isGesturing,
mobileVisualPanel, mobileVisualPanel,
mobilePanelState, mobilePanelState,
@@ -334,6 +373,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
animateToClose, animateToClose,
startMobilePanelTransition, startMobilePanelTransition,
settleMobilePanel, settleMobilePanel,
settledGeneration,
isGesturing, isGesturing,
mobileVisualPanel, mobileVisualPanel,
mobilePanelState, mobilePanelState,