Stop mobile sidebars getting stuck out of sync (#1953)

* fix(app): keep mobile sidebars synced with store

Gesture-owned skip flags could swallow the next store sync after a no-op or overlapping gesture, leaving React state and Reanimated shared values split. Always reconcile both mobile sidebars from the panel store after gesture animations.

* test(app): keep sidebar sync coverage pure

* fix(app): keep sidebar close transitions idempotent

* fix(app): keep mobile sidebars stable through interruptions

Independent React and worklet state let stale gesture or animation callbacks
overwrite newer panel commands. Use one revisioned target and one transient
motion runtime so outdated callbacks cannot restore an earlier panel.
This commit is contained in:
Mohamed Boudra
2026-07-09 23:08:56 +02:00
committed by GitHub
parent 15d3091104
commit 6af1379357
21 changed files with 1434 additions and 1651 deletions
+22 -194
View File
@@ -29,10 +29,9 @@ import {
MAX_EXPLORER_SIDEBAR_WIDTH,
type ExplorerTab,
} from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useToast } from "@/contexts/toast-context";
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state";
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { GitDiffPane } from "@/git/diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
@@ -91,25 +90,11 @@ export function CompactExplorerSidebar({
workspaceRoot,
isGit,
});
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
useSidebarAnimation();
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
mode: "padding",
enabled: true,
});
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
overlayVisible,
isGesturing,
gestureAnimatingRef,
closeGestureRef,
} = useExplorerSidebarAnimation();
const { gesture: closeGesture } = useCloseFileExplorerGesture();
const handleClose = useCallback(
(reason: string) => {
@@ -122,184 +107,38 @@ export function CompactExplorerSidebar({
[isOpen, showMobileAgent],
);
const handleCloseFromGesture = useCallback(() => {
gestureAnimatingRef.current = true;
mobilePanelGestureAnimatingRef.current = true;
showMobileAgent();
}, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, showMobileAgent]);
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
// Swipe gesture to close (swipe right on mobile)
const closeGesture = useMemo(
() =>
Gesture.Pan()
.withRef(closeGestureRef)
.enabled(true)
// Use manual activation so child views keep touch streams
// unless we detect an intentional right-swipe close.
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (!touch) {
return;
}
closeTouchStartX.value = touch.absoluteX;
closeTouchStartY.value = touch.absoluteY;
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - closeTouchStartX.value;
const deltaY = touch.absoluteY - closeTouchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (!canCloseRightSidebarGesture(mobilePanelState.value)) {
stateManager.fail();
return;
}
// Fail quickly on clear leftward or vertical intent so child views keep control.
if (deltaX <= -10) {
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail();
return;
}
// Activate only on intentional rightward movement.
if (deltaX >= 15 && absDeltaX > absDeltaY) {
stateManager.activate();
}
})
.onStart(() => {
isGesturing.value = true;
})
.onUpdate((event) => {
// Right sidebar: swipe right to close (positive translationX)
const newTranslateX = Math.max(0, Math.min(windowWidth, event.translationX));
translateX.value = newTranslateX;
const progress = 1 - newTranslateX / windowWidth;
backdropOpacity.value = Math.max(0, Math.min(1, progress));
})
.onEnd((event) => {
isGesturing.value = false;
const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500;
runOnJS(logExplorerSidebar)("closeGestureEnd", {
translationX: event.translationX,
velocityX: event.velocityX,
shouldClose,
windowWidth,
});
if (shouldClose) {
animateToClose();
runOnJS(handleCloseFromGesture)();
} else {
animateToOpen();
}
})
.onFinalize(() => {
isGesturing.value = false;
}),
[
windowWidth,
translateX,
backdropOpacity,
mobilePanelState,
animateToOpen,
animateToClose,
handleCloseFromGesture,
isGesturing,
closeGestureRef,
closeTouchStartX,
closeTouchStartY,
],
);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
const backdropAnimatedStyle = useAnimatedStyle(() => ({
opacity: backdropOpacity.value,
}));
const backdropCombinedStyle = useMemo(
() => [
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],
);
const mobileSidebarStyle = useMemo(
() => [
explorerStaticStyles.mobileSidebar,
{
width: windowWidth,
paddingTop: insets.top,
backgroundColor: theme.colors.surfaceSidebar,
},
sidebarAnimatedStyle,
mobileKeyboardInsetStyle,
],
[
windowWidth,
insets.top,
theme.colors.surfaceSidebar,
sidebarAnimatedStyle,
mobileKeyboardInsetStyle,
],
[insets.top, theme.colors.surfaceSidebar, 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],
);
// Mobile: full-screen overlay with gesture.
// On web, keep it interactive only while open so closed sidebars don't eat taps.
let overlayPointerEvents: "auto" | "none" | "box-none";
if (!isWeb) overlayPointerEvents = "box-none";
else if (isOpen) overlayPointerEvents = "auto";
else overlayPointerEvents = "none";
return (
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
<Animated.View style={backdropCombinedStyle} />
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<ExplorerSidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleHeaderClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</Animated.View>
</GestureDetector>
</View>
<MobilePanelOverlay
panel="file-explorer"
closeGesture={closeGesture}
panelStyle={mobileSidebarStyle}
>
<ExplorerSidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleHeaderClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</MobilePanelOverlay>
);
}
@@ -604,17 +443,6 @@ function PrTabContent({
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages.
const explorerStaticStyles = RNStyleSheet.create({
backdrop: {
...RNStyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
mobileSidebar: {
position: "absolute" as const,
top: 0,
right: 0,
bottom: 0,
overflow: "hidden" as const,
},
desktopSidebar: {
position: "relative" as const,
},