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
@@ -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 (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
{/* Backdrop */}
<Animated.View style={backdropCombinedStyle} />
+22 -24
View File
@@ -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 (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
<Animated.View style={backdropStyle} />
<GestureDetector gesture={closeGesture} touchAction="pan-y">