From e0e50c9a8e1b9604a44b3cd57ac6c4ed518da061 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 18 Jul 2026 17:45:02 +0200 Subject: [PATCH] fix(app): preserve persisted theme styles after startup Module-level style composites could materialize the temporary adaptive theme before persisted settings loaded. Keep Unistyles reads in render and guard against eager module-scope access. --- .oxlintrc.json | 3 + docs/unistyles.md | 24 ++++++ packages/app/src/agent-stream/view.tsx | 6 +- packages/app/src/app/pair-scan.tsx | 12 +-- .../src/components/adaptive-modal-sheet.tsx | 5 +- packages/app/src/components/agent-list.tsx | 7 +- .../app/src/components/dictation-controls.tsx | 11 +-- .../app/src/components/explorer-sidebar.tsx | 4 +- .../app/src/components/file-explorer-pane.tsx | 4 +- .../src/components/provider-catalog-list.tsx | 4 +- .../components/provider-diagnostic-sheet.tsx | 9 +-- .../src/components/sidebar-resize-handle.tsx | 8 +- .../src/components/sidebar-workspace-list.tsx | 6 +- .../app/src/components/tool-call-details.tsx | 11 +-- .../app/src/components/tree-primitives.tsx | 6 +- packages/app/src/components/ui/combobox.tsx | 6 +- .../src/components/workspace-hover-card.tsx | 14 ++-- packages/app/src/composer/index.tsx | 4 +- .../components/desktop-updates-section.tsx | 12 ++- .../components/integrations-section.tsx | 4 +- packages/app/src/git/diff-pane.tsx | 13 ++-- .../src/provider-usage/settings-section.tsx | 6 +- packages/app/src/screens/settings-screen.tsx | 12 ++- .../appearance/appearance-section.tsx | 3 +- .../app/src/screens/settings/host-page.tsx | 7 +- .../settings/keyboard-shortcuts-section.tsx | 4 +- .../screens/settings/providers-section.tsx | 6 +- .../workspace/workspace-desktop-tabs-row.tsx | 11 +-- .../screens/workspace/workspace-screen.tsx | 7 +- .../workspace/workspace-scripts-button.tsx | 16 ++-- .../src/styles/unistyles-module-scope.test.ts | 76 +++++++++++++++++++ 31 files changed, 188 insertions(+), 133 deletions(-) create mode 100644 packages/app/src/styles/unistyles-module-scope.test.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 9e389e515..bdcab1dc5 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -87,6 +87,9 @@ { "files": ["packages/app/src/**/*.{ts,tsx}"], "rules": { + // React Native style arrays must read Unistyles proxies during render. Hoisting them to + // satisfy this allocation rule captures the temporary startup theme instead. + "react-perf/jsx-no-new-array-as-prop": "off", "no-restricted-imports": [ "error", { diff --git a/docs/unistyles.md b/docs/unistyles.md index 255d74d44..619ceed3b 100644 --- a/docs/unistyles.md +++ b/docs/unistyles.md @@ -58,6 +58,30 @@ For standard React Native components, the [Unistyles Babel plugin](https://www.u The important detail: the automatic native path tracks `props.style`. It does not generally track every prop that happens to carry style-like values. +### Do Not Materialize Styles At Module Scope + +Never read a Unistyles style property into a module-level constant. This includes cached arrays: + +```tsx +// Wrong: evaluated while the app may still be using the temporary system theme. +const ROW_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; + +// Right: each style proxy is read when this view renders. +; +``` + +Paseo starts with adaptive themes, then applies the persisted theme after async settings load. A +module-level read can therefore materialize the light style before a persisted dark theme is +active. If the view mounts after that theme change, React Native receives the stale light object; +Unistyles registers the node for future changes but does not retroactively replace its initial +props. Settings dividers once rendered light `#e4e4e7` inside a dark `#252B2A` card for exactly +this reason. + +Render-time array syntax is intentional and exempt from the app's JSX array-allocation lint rule. +Keep the entries separate so each retains its Unistyles metadata. If composition is needed outside +JSX, create the array inside the component or in a `useMemo` that first runs when the component +mounts—never at module evaluation time. + [`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant. ## Dynamic Pixel Styles On Web diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index a1354787f..c67092029 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -1234,7 +1234,9 @@ function PermissionActionButton({ onPress, }: PermissionActionButtonProps) { const handlePress = useCallback(() => onPress(action), [onPress, action]); - const optionTextStyle = isPrimary ? optionTextPrimaryStyle : permissionStyles.optionText; + const optionTextStyle = isPrimary + ? [permissionStyles.optionText, permissionStyles.optionTextPrimary] + : permissionStyles.optionText; const colorMapping = isPrimary ? primaryColorMapping : mutedColorMapping; return ( @@ -1622,8 +1624,6 @@ const permissionStyles = StyleSheet.create((theme) => ({ }, })); -const optionTextPrimaryStyle = [permissionStyles.optionText, permissionStyles.optionTextPrimary]; - interface StreamItemWrapperProps { gapBelow: number; children: ReactNode; diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx index c73dc6d93..4408ef33c 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/app/pair-scan.tsx @@ -257,10 +257,10 @@ export default function PairScanScreen() { /> - - - - + + + + {isPairing ? {t("pairing.scan.pairing")} : null} @@ -272,7 +272,3 @@ export default function PairScanScreen() { } const BARCODE_SCANNER_SETTINGS: BarcodeSettings = { barcodeTypes: ["qr"] }; -const CORNER_TL_STYLE = [styles.corner, styles.cornerTL]; -const CORNER_TR_STYLE = [styles.corner, styles.cornerTR]; -const CORNER_BL_STYLE = [styles.corner, styles.cornerBL]; -const CORNER_BR_STYLE = [styles.corner, styles.cornerBR]; diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 68814e579..9b91a375e 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -230,7 +230,6 @@ const styles = StyleSheet.create((theme) => ({ }, })); -const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }]; const WEB_EXIT_DURATION_MS = 160; function SheetBackground({ style }: BottomSheetBackgroundProps) { @@ -373,7 +372,7 @@ export function SheetHeaderView({ @@ -552,7 +552,7 @@ export function AgentList({ @@ -789,6 +789,3 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, }, })); - -const SHEET_CANCEL_BUTTON_STYLE = [styles.sheetButton, styles.sheetCancelButton]; -const SHEET_ARCHIVE_BUTTON_STYLE = [styles.sheetButton, styles.sheetArchiveButton]; diff --git a/packages/app/src/components/dictation-controls.tsx b/packages/app/src/components/dictation-controls.tsx index eb2b14a25..477717d4e 100644 --- a/packages/app/src/components/dictation-controls.tsx +++ b/packages/app/src/components/dictation-controls.tsx @@ -105,7 +105,7 @@ export function DictationControls({ @@ -115,14 +115,14 @@ export function DictationControls({ @@ -240,7 +240,7 @@ export function DictationOverlay({ onPress={onAccept} accessibilityRole="button" accessibilityLabel={t("message.dictation.insert")} - style={OVERLAY_ACCEPT_BUTTON_STYLE} + style={[overlayStyles.actionButton, OVERLAY_ACCEPT_BUTTON_BG]} > ({ }, })); -const ACTION_CONFIRM_STYLE = [styles.actionButton, styles.actionButtonConfirm]; -const ACTION_SECONDARY_STYLE = [styles.actionButton, styles.actionButtonSecondary]; const OVERLAY_ACCEPT_BUTTON_BG = { backgroundColor: "rgba(255, 255, 255, 0.25)" }; -const OVERLAY_ACCEPT_BUTTON_STYLE = [overlayStyles.actionButton, OVERLAY_ACCEPT_BUTTON_BG]; diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 8b6fa0e49..2d7209ae9 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -211,7 +211,7 @@ export function ExplorerSidebar({ return ( - + ({ minHeight: 0, }, })); - -const DESKTOP_SIDEBAR_BORDER_STYLE = [styles.desktopSidebarBorder, { flex: 1 }]; diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 6707b417c..2a6f42464 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -575,7 +575,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { } return ( - + {currentSortLabel} @@ -1253,5 +1253,3 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[4], }, })); - -const TREE_PANE_CONTAINER_STYLE = [styles.treePane, styles.treePaneFill]; diff --git a/packages/app/src/components/provider-catalog-list.tsx b/packages/app/src/components/provider-catalog-list.tsx index f3e0835d0..a31e45be0 100644 --- a/packages/app/src/components/provider-catalog-list.tsx +++ b/packages/app/src/components/provider-catalog-list.tsx @@ -155,7 +155,7 @@ export function ProviderCatalogList({ accessibilityLabel={t("providerCatalog.search")} placeholder={t("providerCatalog.search")} // @ts-expect-error - outlineStyle is web-only - style={SEARCH_INPUT_STYLE} + style={[styles.searchInput, isWeb && { outlineStyle: "none" }]} autoCapitalize="none" autoCorrect={false} /> @@ -284,5 +284,3 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, }, })); - -const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }]; diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index 9085801e0..b4831acac 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -225,7 +225,7 @@ function AddCustomModelSubSheet({ autoCorrect={false} returnKeyType="done" // @ts-expect-error - outlineStyle is web-only - style={FORM_INPUT_STYLE} + style={[sheetStyles.formInput, isWeb && { outlineStyle: "none" }]} /> {error ? {error} : null} @@ -437,7 +437,9 @@ function renderProviderSheetFooter({ const contentStyle = isCompact ? sheetStyles.compactFooterContent : sheetStyles.footerContent; const actionsStyle = isCompact ? sheetStyles.compactFooterActions : sheetStyles.footerActions; const buttonStyle = isCompact ? sheetStyles.compactFooterButton : null; - const metaStyle = isCompact ? COMPACT_FOOTER_META_STYLE : sheetStyles.footerMeta; + const metaStyle = isCompact + ? [sheetStyles.footerMeta, sheetStyles.compactFooterMeta] + : sheetStyles.footerMeta; return ( @@ -871,9 +873,6 @@ const sheetStyles = StyleSheet.create((theme) => ({ }, })); -const FORM_INPUT_STYLE = [sheetStyles.formInput, isWeb && { outlineStyle: "none" }]; -const COMPACT_FOOTER_META_STYLE = [sheetStyles.footerMeta, sheetStyles.compactFooterMeta]; - const MAIN_SNAP_POINTS = ["65%", "92%"]; const ADD_SNAP_POINTS = ["40%"]; const DIAGNOSTIC_SNAP_POINTS = ["50%", "85%"]; diff --git a/packages/app/src/components/sidebar-resize-handle.tsx b/packages/app/src/components/sidebar-resize-handle.tsx index 98f4e918b..617d9076b 100644 --- a/packages/app/src/components/sidebar-resize-handle.tsx +++ b/packages/app/src/components/sidebar-resize-handle.tsx @@ -21,7 +21,10 @@ const webResizeCursorStyle = isWeb export function SidebarResizeHandle({ edge, gesture, testID }: SidebarResizeHandleProps) { const [highlighted, setHighlighted] = useState(false); const highlightTimerRef = useRef | null>(null); - const hitAreaStyle = edge === "left" ? LEFT_HIT_AREA_STYLE : RIGHT_HIT_AREA_STYLE; + const hitAreaStyle = + edge === "left" + ? [styles.hitArea, styles.leftEdge, webResizeCursorStyle] + : [styles.hitArea, styles.rightEdge, webResizeCursorStyle]; const cancelHighlightTimer = useCallback(() => { if (highlightTimerRef.current === null) return; @@ -84,6 +87,3 @@ const styles = StyleSheet.create((theme) => ({ opacity: 0.25, }, })); - -const LEFT_HIT_AREA_STYLE = [styles.hitArea, styles.leftEdge, webResizeCursorStyle]; -const RIGHT_HIT_AREA_STYLE = [styles.hitArea, styles.rightEdge, webResizeCursorStyle]; diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index ffef8dfd0..44e545ba1 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -315,7 +315,9 @@ export function PrBadge({ hint }: { hint: PrHint }) { const handleHoverIn = useCallback(() => setIsHovered(true), []); const handleHoverOut = useCallback(() => setIsHovered(false), []); - const textStyle = isHovered ? prBadgeTextHoveredCombined : prBadgeStyles.text; + const textStyle = isHovered + ? [prBadgeStyles.text, prBadgeStyles.textHovered] + : prBadgeStyles.text; const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state); const presentation = getForgePresentation(normalizeForge(hint.forge)); @@ -395,8 +397,6 @@ const prBadgeStyles = StyleSheet.create((theme) => ({ }, })); -const prBadgeTextHoveredCombined = [prBadgeStyles.text, prBadgeStyles.textHovered]; - function StatusDotOverlay({ dotColorStyle, size, diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index 9025f6a23..72ea48cef 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -711,7 +711,7 @@ function ErrorSection({ errorText, ds }: { errorText: string; ds: DetailStyles } const { t } = useTranslation(); return ( - {t("toolCallDetails.error")} + {t("toolCallDetails.error")} - + {errorText} @@ -948,6 +952,3 @@ const styles = StyleSheet.create((theme) => { }, }; }); - -const SECTION_TITLE_ERROR_STYLE = [styles.sectionTitle, styles.errorText]; -const SCROLL_TEXT_ERROR_STYLE = [styles.scrollText, styles.errorText]; diff --git a/packages/app/src/components/tree-primitives.tsx b/packages/app/src/components/tree-primitives.tsx index 26c71f2c2..11949c140 100644 --- a/packages/app/src/components/tree-primitives.tsx +++ b/packages/app/src/components/tree-primitives.tsx @@ -51,7 +51,7 @@ export function TreeIndentGuides({ depth }: { depth: number }) { /** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */ export function TreeChevron({ expanded }: { expanded: boolean }) { return ( - + ); @@ -76,7 +76,3 @@ const styles = StyleSheet.create((theme: Theme) => ({ transform: [{ rotate: "90deg" }], }, })); - -// Stable module-level style ref so TreeChevron passes a constant array, not one created -// per render — satisfies react-perf (no inline-array prop) without a per-render useMemo. -const CHEVRON_EXPANDED_STYLE = [styles.chevron, styles.chevronExpanded]; diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 279954404..81c8f0ef0 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -199,7 +199,7 @@ export function SearchInput({ ({ justifyContent: "flex-end", }, })); - -const SEARCH_INPUT_STYLE = [styles.searchInput, IS_WEB && { outlineStyle: "none" }]; diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx index ca2930c25..37cd114fc 100644 --- a/packages/app/src/components/workspace-hover-card.tsx +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -418,7 +418,8 @@ function CopyableInfoRow({ if (copied || isHovered) { iconUniProps = foregroundColorMapping; } - const textStyle = copied || isHovered ? cardInfoTextHoveredCombined : styles.cardInfoText; + const textStyle = + copied || isHovered ? [styles.cardInfoText, styles.cardInfoTextHovered] : styles.cardInfoText; return ( ({ color: theme.colors.statusSuccess, }, })); - -const checksSummaryLabelHoveredCombined = [ - styles.checksSummaryLabel, - styles.checksSummaryLabelHovered, -]; - -const cardInfoTextHoveredCombined = [styles.cardInfoText, styles.cardInfoTextHovered]; diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 6faa7a5e5..c2e3e5dbf 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -570,7 +570,7 @@ function QueuedMessageRow({ @@ -2237,8 +2237,6 @@ const styles = StyleSheet.create((theme: Theme) => ({ }, })) as unknown as Record; -const QUEUE_SEND_BUTTON_STYLE = [styles.queueActionButton, styles.queueSendButton]; - const ThemedPencil = withUnistyles(Pencil); const ThemedArrowUp = withUnistyles(ArrowUp); const ThemedGitPullRequest = withUnistyles(GitPullRequest); diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index 9725a1848..5fa4685c9 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -246,7 +246,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) { {daemonStatusDetailText} - + {t("desktop.daemon.management.title")} {t("desktop.daemon.management.hint")} @@ -258,7 +258,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) { accessibilityLabel={t("desktop.daemon.management.title")} /> - + {t("desktop.daemon.keepRunning.title")} {t("desktop.daemon.keepRunning.hint")} @@ -270,7 +270,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) { accessibilityLabel={t("desktop.daemon.keepRunning.title")} /> - + {t("desktop.daemon.logs.title")} @@ -294,7 +294,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) { - + {t("desktop.daemon.fullStatus.title")} {t("desktop.daemon.fullStatus.hint")} @@ -420,7 +420,7 @@ export function LocalDaemonSection() { testID="host-page-daemon-lifecycle-card" > {isLoading || isLoadingSettings ? ( - + ) : ( @@ -524,7 +524,5 @@ const styles = StyleSheet.create((theme) => ({ }, })); -const LOADING_CARD_STYLE = [settingsStyles.card, styles.loadingCard]; -const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; const LOGS_MODAL_SNAP_POINTS = ["70%", "92%"]; const CLI_STATUS_MODAL_SNAP_POINTS = ["60%", "85%"]; diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx index 531172eb3..01d0f5a8b 100644 --- a/packages/app/src/desktop/components/integrations-section.tsx +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -19,8 +19,6 @@ import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-stat const CLI_DOCS_URL = "https://paseo.sh/docs/cli"; const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills"; -const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; - const OP_KIND_ORDER: Record = { add: 0, update: 1, delete: 2 }; const OP_KIND_LABEL_KEY: Record = { add: "settings.integrations.operations.add", @@ -183,7 +181,7 @@ export function IntegrationsSection() { )} - + diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 27db92cd4..5a1d2e007 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -1091,7 +1091,11 @@ export function DiffFileBody({ ); return ( - + {(() => { if (file.status === "too_large" || file.status === "binary") { return ( @@ -1118,7 +1122,7 @@ export function DiffFileBody({ if (layout === "split") { const rows = buildSplitDiffRows(file); return ( - + 0 ? scrollViewWidth : Math.max(0, bodyWidth - gutterWidth); return ( - + {computedLines.map(({ line, lineNumber, key, reviewTarget }, index) => ( @@ -3062,7 +3066,4 @@ const styles = StyleSheet.create((theme) => ({ }, })); -const FILE_SECTION_BODY_STYLE = [styles.fileSectionBodyContainer, styles.fileSectionBorder]; -const DIFF_CONTENT_SPLIT_ROW_STYLE = [styles.diffContent, styles.splitRow]; -const DIFF_CONTENT_ROW_STYLE = [styles.diffContent, styles.diffContentRow]; const DIFF_HEIGHT_CHANGE_EPSILON = 0.5; diff --git a/packages/app/src/provider-usage/settings-section.tsx b/packages/app/src/provider-usage/settings-section.tsx index 58da33fff..51da2d710 100644 --- a/packages/app/src/provider-usage/settings-section.tsx +++ b/packages/app/src/provider-usage/settings-section.tsx @@ -55,7 +55,7 @@ function ProviderUsageBody({ }) { if (view.kind === "loading") { return ( - + {providerUsageCopy.loading} ); @@ -73,7 +73,7 @@ function ProviderUsageBody({ if (view.payload.providers.length === 0) { return ( - + {providerUsageCopy.empty} ); @@ -92,5 +92,3 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, }, })); - -const EMPTY_CARD_STYLE = [settingsStyles.card, styles.emptyCard]; diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 5eefdf6fa..dcbaf1fa3 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -210,8 +210,6 @@ function selectedSidebarItemStyle({ hovered }: PressableStateCallbackType & { ho ]; } -const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; - function getSendBehaviorOptions(t: TFunction) { return [ { value: "interrupt" as const, label: t("settings.general.defaultSend.options.interrupt") }, @@ -360,7 +358,7 @@ function GeneralSection({ options={sendBehaviorOptions} /> - + {t("settings.general.language.label")} {t("settings.general.language.description")} @@ -387,7 +385,7 @@ function GeneralSection({ {isDesktopApp ? ( - + {t("settings.general.serviceUrls.label")} @@ -414,7 +412,7 @@ function GeneralSection({ ) : null} - + {t("settings.general.terminalScrollback.label")} @@ -700,7 +698,7 @@ function DesktopAppUpdateRow() { return ( <> - + {t("settings.about.releaseChannel.label")} @@ -714,7 +712,7 @@ function DesktopAppUpdateRow() { options={releaseChannelOptions} /> - + {t("settings.about.updates.label")} {statusText} diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index 4ae7b2886..3f8a41fe0 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -212,7 +212,6 @@ function AutoExpandReasoningRow({ value, onChange }: AutoExpandReasoningRowProps ); } -const TOOL_CALL_DETAIL_ROW_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [ "detailed", "overview", @@ -250,7 +249,7 @@ function ToolCallDetailRow({ value, onChange }: ToolCallDetailRowProps) { const { t } = useTranslation(); const selectedLabel = getToolCallDetailLevelLabel(t, value); return ( - + {t("settings.general.toolCallDetail.label")} diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index e2a62970f..4fde68367 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -150,7 +150,7 @@ function HostNotFound() { const { t } = useTranslation(); return ( - + {t("settings.host.notFound")} @@ -276,7 +276,7 @@ export function HostAgentsPage({ serverId }: { serverId: string }) { ) : ( - + {t("settings.host.agents.unavailable")} )} @@ -300,7 +300,7 @@ export function HostWorkspacesPage({ serverId }: { serverId: string }) { ) : ( - + {t("settings.host.workspaces.unavailable")} )} @@ -1940,4 +1940,3 @@ const styles = StyleSheet.create((theme) => ({ })); const FLEX_1_STYLE = { flex: 1 }; -const EMPTY_CARD_STYLE = [settingsStyles.card, styles.emptyCard]; diff --git a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx index de1323df5..b81dcb4ed 100644 --- a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx +++ b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx @@ -264,7 +264,7 @@ export function KeyboardShortcutsSection() { if (isNative) { return ( - + {t("settings.shortcuts.unavailableOnMobile")} @@ -362,5 +362,3 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, }, })); - -const mobileCardStyle = [settingsStyles.card, styles.mobileCard]; diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index a21ac56a8..2d951c954 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -410,12 +410,12 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { style={styles.sectionSpacing} > {!hasServer || !isConnected ? ( - + {t("settings.providers.unavailable")} ) : null} {hasServer && isConnected && isLoading ? ( - + {t("settings.providers.loading")} ) : null} @@ -543,5 +543,3 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface3, }, })); - -const EMPTY_CARD_STYLE = [settingsStyles.card, styles.emptyCard]; diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 3180bbc44..bee528e26 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -1165,7 +1165,9 @@ function ResolvedDesktopTabChip({ return ( - {showDropIndicatorBefore ? : null} + {showDropIndicatorBefore ? ( + + ) : null} - {showDropIndicatorAfter ? : null} + {showDropIndicatorAfter ? ( + + ) : null} ); }} @@ -1375,6 +1379,3 @@ const styles = StyleSheet.create((theme) => ({ height: 14, }, })); - -const TAB_DROP_INDICATOR_BEFORE_STYLE = [styles.tabDropIndicator, styles.tabDropIndicatorBefore]; -const TAB_DROP_INDICATOR_AFTER_STYLE = [styles.tabDropIndicator, styles.tabDropIndicatorAfter]; diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 16cf2b0cc..828eef184 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -3341,7 +3341,7 @@ function WorkspaceScreenContent({ [t], ); - const containerStyle = containerWithWorkspaceBackgroundStyle; + const containerStyle = [styles.container, styles.containerWorkspaceBackground]; const menuNewAgentIcon = MENU_NEW_AGENT_ICON; const menuNewTerminalIcon = MENU_NEW_TERMINAL_ICON; @@ -4090,9 +4090,4 @@ const styles = StyleSheet.create((theme) => ({ }, })); -const containerWithWorkspaceBackgroundStyle = [ - styles.container, - styles.containerWorkspaceBackground, -]; - const EXPLORER_TOGGLE_KEYS: ShortcutKey[] = ["mod", "E"]; diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.tsx b/packages/app/src/screens/workspace/workspace-scripts-button.tsx index 4d3b25ec6..d310d759c 100644 --- a/packages/app/src/screens/workspace/workspace-scripts-button.tsx +++ b/packages/app/src/screens/workspace/workspace-scripts-button.tsx @@ -88,7 +88,9 @@ function ScriptActionButtonChildren({ ) : ( ); - const labelStyle = hovered ? actionButtonLabelHoveredStyle : styles.actionButtonLabel; + const labelStyle = hovered + ? [styles.actionButtonLabel, styles.actionButtonLabelHovered] + : styles.actionButtonLabel; return ( <> {iconElement} @@ -156,7 +158,7 @@ function HostLinkChildren({ hovered, disabled, label }: HostLinkChildrenProps): const showIcon = !disabled && (hovered || isNative); const isActive = Boolean(hovered) && !disabled; const colorMapping = isActive ? foregroundColorMapping : mutedColorMapping; - const hostLabelStyle = isActive ? hostLabelActiveStyle : styles.hostLabel; + const hostLabelStyle = isActive ? [styles.hostLabel, styles.hostLabelActive] : styles.hostLabel; return ( <> @@ -207,7 +209,8 @@ function HostLinkRow({ label, url, scriptName, onOpenInBrowserTab }: HostLinkPro function ExitCodeBadge({ code }: { code: number }): ReactElement { const { t } = useTranslation(); - const exitTextStyle = code === 0 ? styles.exitBadgeText : exitBadgeTextErrorStyle; + const exitTextStyle = + code === 0 ? styles.exitBadgeText : [styles.exitBadgeText, styles.exitBadgeTextError]; return ( {t("workspace.scripts.states.exitCode", { code })} @@ -305,7 +308,7 @@ function ScriptRow({ }, [onStartScript, script.scriptName]); const scriptNameStyle = useMemo( - () => (isRunning ? scriptNameActiveStyle : styles.scriptName), + () => (isRunning ? [styles.scriptName, styles.scriptNameActive] : styles.scriptName), [isRunning], ); @@ -631,8 +634,3 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, }, })); - -const actionButtonLabelHoveredStyle = [styles.actionButtonLabel, styles.actionButtonLabelHovered]; -const hostLabelActiveStyle = [styles.hostLabel, styles.hostLabelActive]; -const scriptNameActiveStyle = [styles.scriptName, styles.scriptNameActive]; -const exitBadgeTextErrorStyle = [styles.exitBadgeText, styles.exitBadgeTextError]; diff --git a/packages/app/src/styles/unistyles-module-scope.test.ts b/packages/app/src/styles/unistyles-module-scope.test.ts new file mode 100644 index 000000000..714875d97 --- /dev/null +++ b/packages/app/src/styles/unistyles-module-scope.test.ts @@ -0,0 +1,76 @@ +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import * as ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const SOURCE_ROOT = path.resolve(__dirname, ".."); + +function listSourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + return listSourceFiles(entryPath); + } + return /\.tsx?$/.test(entry.name) ? [entryPath] : []; + }); +} + +function containsEagerStyleRead(initializer: ts.Expression): boolean { + if (ts.isFunctionLike(initializer)) { + return false; + } + + let found = false; + + function visit(node: ts.Node): void { + if (node !== initializer && ts.isFunctionLike(node)) { + return; + } + if ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text !== "StyleSheet" && + /styles$/i.test(node.expression.text) + ) { + found = true; + return; + } + ts.forEachChild(node, visit); + } + + visit(initializer); + return found; +} + +function findEagerModuleStyleReads(filePath: string): string[] { + const source = readFileSync(filePath, "utf8"); + const sourceFile = ts.createSourceFile( + filePath, + source, + ts.ScriptTarget.Latest, + true, + filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + + return sourceFile.statements.flatMap((statement) => { + if (!ts.isVariableStatement(statement)) { + return []; + } + return statement.declarationList.declarations.flatMap((declaration) => { + if (!declaration.initializer || !containsEagerStyleRead(declaration.initializer)) { + return []; + } + const line = + sourceFile.getLineAndCharacterOfPosition(declaration.getStart(sourceFile)).line + 1; + return [`${path.relative(SOURCE_ROOT, filePath)}:${line}`]; + }); + }); +} + +describe("Unistyles module scope", () => { + it("does not materialize style proxies before the persisted theme loads", () => { + const violations = listSourceFiles(SOURCE_ROOT).flatMap(findEagerModuleStyleReads); + + expect(violations).toEqual([]); + }); +});