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:
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user