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
This commit is contained in:
Mohamed Boudra
2026-03-20 18:26:36 +07:00
parent 866aeb8ad9
commit 614c085310
4 changed files with 137 additions and 94 deletions
+11 -3
View File
@@ -252,6 +252,9 @@ function QueryProvider({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
} }
const rowStyle = { flex: 1, flexDirection: "row" } as const;
const flexStyle = { flex: 1 } as const;
interface AppContainerProps { interface AppContainerProps {
children: ReactNode; children: ReactNode;
selectedAgentId?: string; selectedAgentId?: string;
@@ -377,11 +380,16 @@ function AppContainer({
] ]
); );
const containerStyle = useMemo(
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
[theme.colors.surface0]
);
const content = ( const content = (
<View style={{ flex: 1, backgroundColor: theme.colors.surface0 }}> <View style={containerStyle}>
<View style={{ flex: 1, flexDirection: "row" }}> <View style={rowStyle}>
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />} {!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<View style={{ flex: 1 }}> <View style={flexStyle}>
{children} {children}
</View> </View>
</View> </View>
+99 -72
View File
@@ -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 { View, Pressable, Text, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from 'react-native-safe-area-context'
import Animated, { import Animated, {
@@ -39,7 +39,7 @@ interface LeftSidebarProps {
selectedAgentId?: string selectedAgentId?: string
} }
export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) { export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) {
const { theme } = useUnistyles() const { theme } = useUnistyles()
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' 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) // Close gesture (swipe left to close when sidebar is open)
const closeGesture = Gesture.Pan() const closeGesture = useMemo(
.withRef(closeGestureRef) () =>
.enabled(isOpen) Gesture.Pan()
// Use manual activation so child views keep touch streams unless we detect .withRef(closeGestureRef)
// an intentional left-swipe close (mirrors explorer-sidebar pattern). .enabled(isOpen)
.manualActivation(true) // Use manual activation so child views keep touch streams unless we detect
.onTouchesDown((event) => { // an intentional left-swipe close (mirrors explorer-sidebar pattern).
const touch = event.changedTouches[0] .manualActivation(true)
if (!touch) { .onTouchesDown((event) => {
return const touch = event.changedTouches[0]
} if (!touch) {
closeTouchStartX.value = touch.absoluteX return
closeTouchStartY.value = touch.absoluteY }
}) closeTouchStartX.value = touch.absoluteX
.onTouchesMove((event, stateManager) => { closeTouchStartY.value = touch.absoluteY
const touch = event.changedTouches[0] })
if (!touch || event.numberOfTouches !== 1) { .onTouchesMove((event, stateManager) => {
stateManager.fail() const touch = event.changedTouches[0]
return if (!touch || event.numberOfTouches !== 1) {
} stateManager.fail()
return
}
const deltaX = touch.absoluteX - closeTouchStartX.value const deltaX = touch.absoluteX - closeTouchStartX.value
const deltaY = touch.absoluteY - closeTouchStartY.value const deltaY = touch.absoluteY - closeTouchStartY.value
const absDeltaX = Math.abs(deltaX) const absDeltaX = Math.abs(deltaX)
const absDeltaY = Math.abs(deltaY) const absDeltaY = Math.abs(deltaY)
// Fail quickly on clear rightward or vertical intent so child views keep control. // Fail quickly on clear rightward or vertical intent so child views keep control.
if (deltaX >= 10) { if (deltaX >= 10) {
stateManager.fail() stateManager.fail()
return return
} }
if (absDeltaY > 10 && absDeltaY > absDeltaX) { if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail() stateManager.fail()
return return
} }
// Activate only on intentional leftward movement. // Activate only on intentional leftward movement.
if (deltaX <= -15 && absDeltaX > absDeltaY) { if (deltaX <= -15 && absDeltaX > absDeltaY) {
stateManager.activate() stateManager.activate()
} }
}) })
.onStart(() => { .onStart(() => {
isGesturing.value = true isGesturing.value = true
}) })
.onUpdate((event) => { .onUpdate((event) => {
if (!isMobile) return if (!isMobile) return
// Only allow swiping left (closing) // Only allow swiping left (closing)
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX)) const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX))
translateX.value = newTranslateX translateX.value = newTranslateX
backdropOpacity.value = interpolate( backdropOpacity.value = interpolate(
newTranslateX, newTranslateX,
[-windowWidth, 0], [-windowWidth, 0],
[0, 1], [0, 1],
Extrapolation.CLAMP Extrapolation.CLAMP
) )
}) })
.onEnd((event) => { .onEnd((event) => {
isGesturing.value = false isGesturing.value = false
if (!isMobile) return if (!isMobile) return
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500 const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
if (shouldClose) { if (shouldClose) {
animateToClose() animateToClose()
runOnJS(handleClose)() runOnJS(handleClose)()
} else { } else {
animateToOpen() animateToOpen()
} }
}) })
.onFinalize(() => { .onFinalize(() => {
isGesturing.value = false 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(() => ({ const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }], transform: [{ translateX: translateX.value }],
@@ -303,7 +330,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
<Animated.View <Animated.View
style={[ style={[
styles.mobileSidebar, styles.mobileSidebar,
{ width: windowWidth, paddingTop: insets.top, paddingBottom: insets.bottom }, mobileSidebarInsetStyle,
sidebarAnimatedStyle, sidebarAnimatedStyle,
]} ]}
pointerEvents="auto" pointerEvents="auto"
@@ -502,7 +529,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
onPress={() => setIsHostPickerOpen(true)} onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0} disabled={hostOptions.length === 0}
> >
<View style={[styles.hostStatusDot, { backgroundColor: activeHostStatusColor }]} /> <View style={hostStatusDotStyle} />
<Text style={styles.hostTriggerText} numberOfLines={1}> <Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel} {activeHostLabel}
</Text> </Text>
@@ -568,7 +595,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
</View> </View>
</View> </View>
) )
} })
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
backdrop: { backdrop: {
@@ -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 { useWindowDimensions } from "react-native";
import { import {
useSharedValue, useSharedValue,
@@ -78,7 +78,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
} }
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]); }, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
const animateToOpen = () => { const animateToOpen = useCallback(() => {
"worklet"; "worklet";
translateX.value = withTiming(0, { translateX.value = withTiming(0, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
@@ -88,9 +88,9 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
}; }, [translateX, backdropOpacity]);
const animateToClose = () => { const animateToClose = useCallback(() => {
"worklet"; "worklet";
translateX.value = withTiming(-windowWidth, { translateX.value = withTiming(-windowWidth, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
@@ -100,20 +100,23 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
}; }, [translateX, backdropOpacity, windowWidth]);
const value = useMemo<SidebarAnimationContextValue>(
() => ({
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
closeGestureRef,
}),
[translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing, closeGestureRef]
);
return ( return (
<SidebarAnimationContext.Provider <SidebarAnimationContext.Provider value={value}>
value={{
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
closeGestureRef,
}}
>
{children} {children}
</SidebarAnimationContext.Provider> </SidebarAnimationContext.Provider>
); );
@@ -519,6 +519,12 @@ export function WorkspaceScreen({
workspaceId, workspaceId,
openIntent, openIntent,
}: WorkspaceScreenProps) { }: WorkspaceScreenProps) {
const isFocused = useIsFocused();
if (!isFocused) {
return <View style={{ flex: 1 }} />;
}
return ( return (
<ExplorerSidebarAnimationProvider> <ExplorerSidebarAnimationProvider>
<WorkspaceScreenContent <WorkspaceScreenContent
@@ -539,7 +545,6 @@ function WorkspaceScreenContent({
const isDarkMode = useColorScheme() === "dark"; const isDarkMode = useColorScheme() === "dark";
const mainBackgroundColor = isDarkMode ? theme.colors.surface1 : theme.colors.surface0; const mainBackgroundColor = isDarkMode ? theme.colors.surface1 : theme.colors.surface0;
const toast = useToast(); const toast = useToast();
const isScreenFocused = useIsFocused();
const isMobile = const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -1773,7 +1778,7 @@ function WorkspaceScreenContent({
] as const, ] as const,
enabled: Boolean(normalizedServerId && normalizedWorkspaceId), enabled: Boolean(normalizedServerId && normalizedWorkspaceId),
priority: 100, priority: 100,
isActive: () => isScreenFocused, isActive: () => true,
handle: handleWorkspaceTabAction, handle: handleWorkspaceTabAction,
}); });
@@ -1794,7 +1799,7 @@ function WorkspaceScreenContent({
] as const, ] as const,
enabled: Boolean(normalizedServerId && normalizedWorkspaceId), enabled: Boolean(normalizedServerId && normalizedWorkspaceId),
priority: 100, priority: 100,
isActive: () => isScreenFocused, isActive: () => true,
handle: handleWorkspacePaneAction, handle: handleWorkspacePaneAction,
}); });