From 614c085310b75a829c4eeb33769cc138a5c4ff2e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 20 Mar 2026 18:26:36 +0700 Subject: [PATCH] fix(app): reduce unnecessary re-renders in sidebar and workspace screens - Gate WorkspaceScreen on useIsFocused to prevent background rendering - Stabilize SidebarAnimationProvider context with useCallback/useMemo - Wrap LeftSidebar in memo and memoize gesture/styles - Extract inline style objects in AppContainer to stable references --- packages/app/src/app/_layout.tsx | 14 +- packages/app/src/components/left-sidebar.tsx | 171 ++++++++++-------- .../contexts/sidebar-animation-context.tsx | 35 ++-- .../screens/workspace/workspace-screen.tsx | 11 +- 4 files changed, 137 insertions(+), 94 deletions(-) diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index bd72d47f5..c9a0a7010 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -252,6 +252,9 @@ function QueryProvider({ children }: { children: ReactNode }) { return {children}; } +const rowStyle = { flex: 1, flexDirection: "row" } as const; +const flexStyle = { flex: 1 } as const; + interface AppContainerProps { children: ReactNode; selectedAgentId?: string; @@ -377,11 +380,16 @@ function AppContainer({ ] ); + const containerStyle = useMemo( + () => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }), + [theme.colors.surface0] + ); + const content = ( - - + + {!isMobile && chromeEnabled && } - + {children} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 172959e9b..8256b58c5 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState, useEffect, useRef, useSyncExternalStore } from 'react' +import { memo, useCallback, useMemo, useState, useEffect, useRef, useSyncExternalStore } from 'react' import { View, Pressable, Text, Platform } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import Animated, { @@ -39,7 +39,7 @@ interface LeftSidebarProps { selectedAgentId?: string } -export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) { +export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) { const { theme } = useUnistyles() const insets = useSafeAreaInsets() const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' @@ -207,76 +207,103 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr ) // Close gesture (swipe left to close when sidebar is open) - const closeGesture = Gesture.Pan() - .withRef(closeGestureRef) - .enabled(isOpen) - // Use manual activation so child views keep touch streams unless we detect - // an intentional left-swipe close (mirrors explorer-sidebar pattern). - .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 closeGesture = useMemo( + () => + Gesture.Pan() + .withRef(closeGestureRef) + .enabled(isOpen) + // Use manual activation so child views keep touch streams unless we detect + // an intentional left-swipe close (mirrors explorer-sidebar pattern). + .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) + const deltaX = touch.absoluteX - closeTouchStartX.value + const deltaY = touch.absoluteY - closeTouchStartY.value + const absDeltaX = Math.abs(deltaX) + const absDeltaY = Math.abs(deltaY) - // Fail quickly on clear rightward or vertical intent so child views keep control. - if (deltaX >= 10) { - stateManager.fail() - return - } - if (absDeltaY > 10 && absDeltaY > absDeltaX) { - stateManager.fail() - return - } + // Fail quickly on clear rightward 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 leftward movement. - if (deltaX <= -15 && absDeltaX > absDeltaY) { - stateManager.activate() - } - }) - .onStart(() => { - isGesturing.value = true - }) - .onUpdate((event) => { - if (!isMobile) return - // Only allow swiping left (closing) - const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX)) - translateX.value = newTranslateX - backdropOpacity.value = interpolate( - newTranslateX, - [-windowWidth, 0], - [0, 1], - Extrapolation.CLAMP - ) - }) - .onEnd((event) => { - isGesturing.value = false - if (!isMobile) return - const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500 - if (shouldClose) { - animateToClose() - runOnJS(handleClose)() - } else { - animateToOpen() - } - }) - .onFinalize(() => { - isGesturing.value = false - }) + // Activate only on intentional leftward movement. + if (deltaX <= -15 && absDeltaX > absDeltaY) { + stateManager.activate() + } + }) + .onStart(() => { + isGesturing.value = true + }) + .onUpdate((event) => { + if (!isMobile) return + // Only allow swiping left (closing) + const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX)) + translateX.value = newTranslateX + backdropOpacity.value = interpolate( + newTranslateX, + [-windowWidth, 0], + [0, 1], + Extrapolation.CLAMP + ) + }) + .onEnd((event) => { + isGesturing.value = false + if (!isMobile) return + const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500 + if (shouldClose) { + animateToClose() + runOnJS(handleClose)() + } else { + animateToOpen() + } + }) + .onFinalize(() => { + isGesturing.value = false + }), + [ + isOpen, + closeGestureRef, + closeTouchStartX, + closeTouchStartY, + isGesturing, + isMobile, + windowWidth, + translateX, + backdropOpacity, + animateToClose, + animateToOpen, + handleClose, + ] + ) + + const mobileSidebarInsetStyle = useMemo( + () => ({ width: windowWidth, paddingTop: insets.top, paddingBottom: insets.bottom }), + [windowWidth, insets.top, insets.bottom] + ) + + const hostStatusDotStyle = useMemo( + () => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }], + [activeHostStatusColor] + ) const sidebarAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value }], @@ -303,7 +330,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr setIsHostPickerOpen(true)} disabled={hostOptions.length === 0} > - + {activeHostLabel} @@ -568,7 +595,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr ) -} +}) const styles = StyleSheet.create((theme) => ({ backdrop: { diff --git a/packages/app/src/contexts/sidebar-animation-context.tsx b/packages/app/src/contexts/sidebar-animation-context.tsx index 59203e697..eed2a16a0 100644 --- a/packages/app/src/contexts/sidebar-animation-context.tsx +++ b/packages/app/src/contexts/sidebar-animation-context.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useEffect, useRef, type ReactNode } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, type ReactNode } from "react"; import { useWindowDimensions } from "react-native"; import { useSharedValue, @@ -78,7 +78,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) } }, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]); - const animateToOpen = () => { + const animateToOpen = useCallback(() => { "worklet"; translateX.value = withTiming(0, { duration: ANIMATION_DURATION, @@ -88,9 +88,9 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) duration: ANIMATION_DURATION, easing: ANIMATION_EASING, }); - }; + }, [translateX, backdropOpacity]); - const animateToClose = () => { + const animateToClose = useCallback(() => { "worklet"; translateX.value = withTiming(-windowWidth, { duration: ANIMATION_DURATION, @@ -100,20 +100,23 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) duration: ANIMATION_DURATION, easing: ANIMATION_EASING, }); - }; + }, [translateX, backdropOpacity, windowWidth]); + + const value = useMemo( + () => ({ + translateX, + backdropOpacity, + windowWidth, + animateToOpen, + animateToClose, + isGesturing, + closeGestureRef, + }), + [translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing, closeGestureRef] + ); return ( - + {children} ); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 274536eec..e9fefa0b2 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -519,6 +519,12 @@ export function WorkspaceScreen({ workspaceId, openIntent, }: WorkspaceScreenProps) { + const isFocused = useIsFocused(); + + if (!isFocused) { + return ; + } + return ( isScreenFocused, + isActive: () => true, handle: handleWorkspaceTabAction, }); @@ -1794,7 +1799,7 @@ function WorkspaceScreenContent({ ] as const, enabled: Boolean(normalizedServerId && normalizedWorkspaceId), priority: 100, - isActive: () => isScreenFocused, + isActive: () => true, handle: handleWorkspacePaneAction, });