From 4de4e90325395dcba3c9825e177ced1528b82c1b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 18 Apr 2026 16:25:10 +0700 Subject: [PATCH] fix(app): close theme split escape hatches --- docs/UNISTYLES.md | 40 +++++++++- .../src/components/adaptive-modal-sheet.tsx | 5 +- .../agent-form/agent-form-dropdowns.tsx | 73 +++++++++++-------- packages/app/src/components/composer.tsx | 2 +- packages/app/src/components/message.tsx | 6 +- packages/app/src/components/terminal-pane.tsx | 2 +- .../app/src/components/tool-call-sheet.tsx | 22 ++---- .../app/src/components/ui/autocomplete.tsx | 2 +- packages/app/src/components/ui/combobox.tsx | 29 +++++--- .../app/src/components/ui/context-menu.tsx | 11 ++- 10 files changed, 120 insertions(+), 72 deletions(-) diff --git a/docs/UNISTYLES.md b/docs/UNISTYLES.md index bbb00093c..8ed81e891 100644 --- a/docs/UNISTYLES.md +++ b/docs/UNISTYLES.md @@ -31,7 +31,7 @@ const styles = StyleSheet.create((theme) => ({ On first mount this can paint with the current adaptive or initial theme. If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy may report the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons. -This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, and library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`. +This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, `backgroundStyle`, `handleIndicatorStyle`, and other library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`. ## Fix Patterns @@ -105,6 +105,44 @@ const { theme } = useUnistyles(); Keep layout and typography in `StyleSheet.create`; move only the stale theme-dependent value through React. If a larger subtree shows the same behavior, consider remounting the sheet on theme changes or moving the themed paint onto a wrapper that is mounted with the visible content. +The same rule applies to bottom-sheet component props such as `backgroundStyle` and `handleIndicatorStyle`: they are library props, not the direct React Native `style` prop Unistyles registers. Prefer a custom `backgroundComponent` that calls `useUnistyles()`, or pass a small inline object from the hook theme. + +## Memoized Style Objects + +When a third-party library receives a plain style object, it is outside Unistyles' native tracking path. Make sure any memo that builds that style object depends on the actual theme values it reads. + +Avoid indirect keys like this: + +```tsx +const { theme, rt } = useUnistyles(); +const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]); +``` + +On adaptive system-theme changes, the hook can provide a light/dark theme update while an indirect runtime key is not the value that invalidates the memo. That leaves the library rendering stale colors. Assistant markdown hit this exact failure: the workspace shell switched to light, but assistant text and code spans kept the old dark-theme markdown style object. + +Prefer the hook theme itself, or explicit theme tokens, as the dependency: + +```tsx +const { theme } = useUnistyles(); +const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); +``` + +If a style factory is cheap, skipping `useMemo` entirely is also fine. + +## Static Theme Imports + +Do not import `theme` from `@/styles/theme` for live UI colors. That export is a dark-theme compatibility default, so using it in render code leaves icons, placeholders, or third-party props pinned to dark colors in light mode. + +Use `useUnistyles()` inside the component instead: + +```tsx +const { theme } = useUnistyles(); + + +``` + +Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only. + ## Adaptive Themes And Persisted Settings Unistyles [`initialTheme`](https://www.unistyl.es/v3/guides/theming#select-theme) and [`adaptiveThemes`](https://www.unistyl.es/v3/guides/theming#adaptive-themes) are mutually exclusive. `initialTheme` can be a string or a synchronous function, but it cannot wait on async storage. diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 47e39166c..d5e015d71 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -109,9 +109,6 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[4], flexGrow: 1, }, - bottomSheetHandle: { - backgroundColor: theme.colors.surface2, - }, bottomSheetHeader: { paddingHorizontal: theme.spacing[6], paddingTop: theme.spacing[4], @@ -243,7 +240,7 @@ export function AdaptiveModalSheet({ enablePanDownToClose stackBehavior={stackBehavior} backgroundComponent={SheetBackground} - handleIndicatorStyle={styles.bottomSheetHandle} + handleIndicatorStyle={{ backgroundColor: theme.colors.surface2 }} keyboardBehavior="extend" keyboardBlurBehavior="restore" accessible={false} diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index d8a12afd3..231348280 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -22,7 +22,6 @@ import { ShieldAlert, ShieldOff, } from "lucide-react-native"; -import { theme as defaultTheme } from "@/styles/theme"; import type { AgentMode, AgentModelDefinition, @@ -86,6 +85,8 @@ export function DropdownField({ renderTrigger, testID, }: DropdownFieldProps): ReactElement { + const { theme } = useUnistyles(); + if (renderTrigger) { return ( <> @@ -115,7 +116,7 @@ export function DropdownField({ {value || placeholder} - + {errorMessage ? {errorMessage} : null} {warningMessage ? {warningMessage} : null} @@ -151,6 +152,8 @@ export function SelectField({ valueEllipsizeMode, testID, }: SelectFieldProps): ReactElement { + const { theme } = useUnistyles(); + const getWebKey = useCallback((event: unknown): string | null => { if (!event || typeof event !== "object") return null; const eventWithNative = event as { nativeEvent?: unknown; key?: unknown }; @@ -210,7 +213,7 @@ export function SelectField({ {value || placeholder || "Select..."} - + {errorMessage ? {errorMessage} : null} {warningMessage ? {warningMessage} : null} @@ -229,7 +232,21 @@ interface DropdownSheetProps { } function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) { - return ; + const { theme } = useUnistyles(); + + return ( + + ); } export function DropdownSheet({ @@ -238,6 +255,7 @@ export function DropdownSheet({ onClose, children, }: DropdownSheetProps): ReactElement { + const { theme } = useUnistyles(); const bottomSheetRef = useRef(null); const snapPoints = useMemo(() => ["60%", "90%"], []); @@ -280,12 +298,12 @@ export function DropdownSheet({ backdropComponent={renderBackdrop} enablePanDownToClose backgroundComponent={DropdownSheetBackground} - handleIndicatorStyle={styles.bottomSheetHandle} + handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }} keyboardBehavior="extend" keyboardBlurBehavior="restore" > - {title} + {title} - + { if (!event || typeof event !== "object") return null; const eventWithNative = event as { nativeEvent?: unknown; key?: unknown }; @@ -469,7 +489,7 @@ export function FormSelectTrigger({ {showLabel ? {label} : null} {isLoading ? ( - + ) : ( )} - + ); } @@ -518,6 +538,8 @@ export function AgentConfigRow({ onSelectThinkingOption, disabled, }: AgentConfigRowProps): ReactElement { + const { theme } = useUnistyles(); + const providerOptions: ComboSelectOption[] = useMemo( () => providerDefinitions.map((def) => ({ @@ -576,7 +598,7 @@ export function AgentConfigRow({ placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"} disabled={disabled || providerOptions.length === 0} onSelect={onSelectProvider} - icon={} + icon={} showLabel={false} testID="draft-provider-select" /> @@ -591,9 +613,7 @@ export function AgentConfigRow({ disabled={disabled} isLoading={isModelLoading} onSelect={onSelectModel} - icon={ - - } + icon={} showLabel={false} testID="draft-model-select" /> @@ -607,7 +627,7 @@ export function AgentConfigRow({ placeholder="Default" disabled={disabled || modeOptions.length === 0} onSelect={onSelectMode} - icon={} + icon={} showLabel={false} testID="draft-mode-select" /> @@ -622,9 +642,7 @@ export function AgentConfigRow({ placeholder="Select..." disabled={disabled} onSelect={onSelectThinkingOption} - icon={ - - } + icon={} showLabel={false} /> @@ -969,6 +987,8 @@ export function GitOptionsSection({ attachWorktreeError, onSelectWorktreePath, }: GitOptionsSectionProps): ReactElement { + const { theme } = useUnistyles(); + const isLoading = status === "loading"; const isCreateMode = worktreeMode === "create"; const isAttachMode = worktreeMode === "attach"; @@ -1103,7 +1123,7 @@ export function GitOptionsSection({ autoCapitalize="none" autoCorrect={false} placeholder="branch name" - placeholderTextColor={defaultTheme.colors.foregroundMuted} + placeholderTextColor={theme.colors.foregroundMuted} onSubmitEditing={handleConfirmEdit} /> - + - + ) : ( {displayBranch} - + )} @@ -1171,14 +1188,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, }, - bottomSheetBackground: { - backgroundColor: theme.colors.surface2, - borderTopLeftRadius: theme.borderRadius["2xl"], - borderTopRightRadius: theme.borderRadius["2xl"], - }, - bottomSheetHandle: { - backgroundColor: theme.colors.palette.zinc[600], - }, bottomSheetHeader: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index 24fe17f3f..cdac24034 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -33,7 +33,7 @@ import { type MessageInputRef, type AttachmentMenuItem, } from "./message-input"; -import { Theme } from "@/styles/theme"; +import type { Theme } from "@/styles/theme"; import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; import { encodeImages } from "@/utils/encode-images"; import { focusWithRetries } from "@/utils/web-focus"; diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 71c4531f8..f57f52bf5 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -53,7 +53,6 @@ import Animated, { withTiming, } from "react-native-reanimated"; import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg"; -import { theme } from "@/styles/theme"; import { createMarkdownStyles } from "@/styles/markdown-styles"; import { Colors, Fonts } from "@/constants/theme"; import * as Clipboard from "expo-clipboard"; @@ -341,6 +340,7 @@ export const UserMessage = memo(function UserMessage({ isLastInGroup = true, disableOuterSpacing, }: UserMessageProps) { + const { theme } = useUnistyles(); const isCompact = useIsCompactFormFactor(); const [messageHovered, setMessageHovered] = useState(false); const [copyButtonHovered, setCopyButtonHovered] = useState(false); @@ -962,10 +962,10 @@ export const AssistantMessage = memo(function AssistantMessage({ client, disableOuterSpacing, }: AssistantMessageProps) { - const { theme, rt } = useUnistyles(); + const { theme } = useUnistyles(); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); - const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]); + const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); const markdownParser = useMemo(() => { const parser = MarkdownIt({ typographer: true, linkify: true }); diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index ef1089f91..bd8f9fecc 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -86,7 +86,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi const isScreenFocused = useIsFocused(); const isAppVisible = useAppVisible(); const { theme } = useUnistyles(); - const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]); + const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]); const isMobile = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const openAgentList = usePanelStore((state) => state.openAgentList); diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index 03568b35e..617b82a64 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -59,6 +59,7 @@ interface ToolCallSheetProviderProps { } export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) { + const { theme } = useUnistyles(); const bottomSheetRef = useRef(null); const [sheetData, setSheetData] = React.useState(null); @@ -103,8 +104,8 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) onChange={handleSheetChange} backdropComponent={renderBackdrop} enablePanDownToClose - backgroundStyle={styles.sheetBackground} - handleIndicatorStyle={styles.handleIndicator} + backgroundComponent={CustomSheetBackground} + handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }} > {sheetData && } @@ -120,6 +121,7 @@ interface ToolCallSheetContentProps { } function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { + const { theme } = useUnistyles(); const { toolName, displayName, detail, errorText, showLoadingSkeleton } = data; const IconComponent = resolveToolCallIcon(toolName, detail); @@ -129,13 +131,13 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { {/* Header */} - + {displayName} - + @@ -155,12 +157,6 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { // ----- Styles ----- const styles = StyleSheet.create((theme) => ({ - sheetBackground: { - backgroundColor: theme.colors.surface2, - }, - handleIndicator: { - backgroundColor: theme.colors.palette.zinc[600], - }, container: { flex: 1, backgroundColor: theme.colors.surface2, @@ -180,9 +176,6 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], flex: 1, }, - headerIcon: { - color: theme.colors.foreground, - }, headerTitle: { fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.semibold, @@ -192,9 +185,6 @@ const styles = StyleSheet.create((theme) => ({ closeButton: { padding: theme.spacing[2], }, - closeIcon: { - color: theme.colors.foregroundMuted, - }, content: { flex: 1, minHeight: 0, diff --git a/packages/app/src/components/ui/autocomplete.tsx b/packages/app/src/components/ui/autocomplete.tsx index 95ee62f29..49f920148 100644 --- a/packages/app/src/components/ui/autocomplete.tsx +++ b/packages/app/src/components/ui/autocomplete.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from "react"; import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { File, Folder } from "lucide-react-native"; -import { Theme } from "@/styles/theme"; +import type { Theme } from "@/styles/theme"; import { getAutocompleteScrollOffset } from "./autocomplete-utils"; export interface AutocompleteOption { diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 7a15e50ac..3bf33e278 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -101,7 +101,21 @@ function toNumericStyleValue(value: unknown): number | null { } function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) { - return ; + const { theme } = useUnistyles(); + + return ( + + ); } export interface SearchInputProps { @@ -268,6 +282,7 @@ export function Combobox({ anchorRef, children, }: ComboboxProps): ReactElement { + const { theme } = useUnistyles(); const isMobile = useIsCompactFormFactor(); const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition; const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search"; @@ -714,12 +729,12 @@ export function Combobox({ enableDismissOnClose={enableDismissOnClose} stackBehavior={stackBehavior} backgroundComponent={ComboboxSheetBackground} - handleIndicatorStyle={styles.bottomSheetHandle} + handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }} keyboardBehavior="extend" keyboardBlurBehavior="restore" > - {title} + {title} {stickyHeader} {!children && searchable ? searchInput : null} @@ -909,14 +924,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, - bottomSheetBackground: { - backgroundColor: theme.colors.surface0, - borderTopLeftRadius: theme.borderRadius["2xl"], - borderTopRightRadius: theme.borderRadius["2xl"], - }, - bottomSheetHandle: { - backgroundColor: theme.colors.palette.zinc[600], - }, bottomSheetHeader: { paddingHorizontal: theme.spacing[6], paddingBottom: theme.spacing[2], diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index 26e9e527c..854813214 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -349,6 +349,7 @@ export function ContextMenuContent({ testID?: string; }>): ReactElement | null { const context = useContextMenuContext("ContextMenuContent"); + const { theme } = useUnistyles(); const webScrollbarStyle = useWebScrollbarStyle(); const isMobile = useIsCompactFormFactor(); const useMobileSheet = isMobile && mobileMode === "sheet"; @@ -475,8 +476,14 @@ export function ContextMenuContent({ onChange={handleSheetChange} backdropComponent={renderSheetBackdrop} enablePanDownToClose - backgroundStyle={styles.sheetBackground} - handleIndicatorStyle={styles.sheetHandle} + backgroundStyle={[ + styles.sheetBackground, + { + backgroundColor: theme.colors.surface0, + borderColor: theme.colors.border, + }, + ]} + handleIndicatorStyle={[styles.sheetHandle, { backgroundColor: theme.colors.surface2 }]} keyboardBehavior="extend" keyboardBlurBehavior="restore" >