Keep settled mobile sidebars hidden under UI load

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.
This commit is contained in:
Mohamed Boudra
2026-06-13 08:34:52 +07:00
parent 04a985bf23
commit e0463ad17f
6 changed files with 112 additions and 132 deletions
@@ -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<number>;
backdropOpacity: SharedValue<number>;
windowWidth: number;
animateToOpen: () => void;
animateToClose: () => void;
settledGeneration: number;
overlayVisible: boolean;
setOverlayPeek: (peek: boolean) => void;
isGesturing: SharedValue<boolean>;
gestureAnimatingRef: React.MutableRefObject<boolean>;
openGestureRef: React.MutableRefObject<GestureType | undefined>;
@@ -62,12 +60,21 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
const openGestureRef = 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);
}, []);
// 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<ExplorerSidebarAnimationContextValue>(
() => ({
@@ -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,
],
);