From 03106e0c247ea73538b7f76e5c437ee96c74fa95 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 31 May 2026 06:35:21 +0300 Subject: [PATCH] perf(renderer): centralize theme side effects --- src/renderer/App.tsx | 4 +- src/renderer/hooks/useTheme.ts | 131 +++++++++++++++++++-------------- 2 files changed, 77 insertions(+), 58 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a3089b46..f22f23a7 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -9,7 +9,7 @@ import { ErrorBoundary } from './components/common/ErrorBoundary'; import { TabbedLayout } from './components/layout/TabbedLayout'; import { type SplashSceneHandle, startSplashScene } from './components/splash/splashScene'; import { ToolApprovalSheet } from './components/team/ToolApprovalSheet'; -import { useTheme } from './hooks/useTheme'; +import { useThemeController } from './hooks/useTheme'; import { api } from './api'; import { useStore } from './store'; @@ -33,7 +33,7 @@ const SPLASH_REDUCED_AVATAR_READY_MAX_WAIT_MS = 160; export const App = (): React.JSX.Element => { // Initialize theme on app load - useTheme(); + useThemeController(); const appConfig = useStore((s) => s.appConfig); // Upgrade the static preload splash, then dismiss it after the scene is visible. diff --git a/src/renderer/hooks/useTheme.ts b/src/renderer/hooks/useTheme.ts index 7a44b4f0..53321950 100644 --- a/src/renderer/hooks/useTheme.ts +++ b/src/renderer/hooks/useTheme.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useSyncExternalStore } from 'react'; import { useShallow } from 'zustand/react/shallow'; @@ -14,6 +14,10 @@ function parseCachedTheme(value: string | null): ResolvedTheme | null { return value === 'light' || value === 'dark' ? value : null; } +function readSystemResolvedTheme(): ResolvedTheme { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + export function readCachedResolvedTheme(storage: Storage = localStorage): ResolvedTheme | null { try { return ( @@ -36,12 +40,48 @@ export function writeCachedResolvedTheme( } } +let systemThemeSnapshot: ResolvedTheme | null = null; +let systemThemeQuery: MediaQueryList | null = null; +const systemThemeListeners = new Set<() => void>(); + +function getSystemThemeSnapshot(): ResolvedTheme { + systemThemeSnapshot ??= readCachedResolvedTheme() ?? readSystemResolvedTheme(); + return systemThemeSnapshot; +} + +function updateSystemThemeSnapshot(): void { + const next = readSystemResolvedTheme(); + if (systemThemeSnapshot === next) { + return; + } + systemThemeSnapshot = next; + for (const listener of systemThemeListeners) { + listener(); + } +} + +function subscribeSystemTheme(listener: () => void): () => void { + systemThemeListeners.add(listener); + if (!systemThemeQuery) { + systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)'); + systemThemeQuery.addEventListener('change', updateSystemThemeSnapshot); + window.queueMicrotask(updateSystemThemeSnapshot); + } + return () => { + systemThemeListeners.delete(listener); + if (systemThemeListeners.size === 0 && systemThemeQuery) { + systemThemeQuery.removeEventListener('change', updateSystemThemeSnapshot); + systemThemeQuery = null; + } + }; +} + +function useSystemTheme(): ResolvedTheme { + return useSyncExternalStore(subscribeSystemTheme, getSystemThemeSnapshot, getSystemThemeSnapshot); +} + /** - * Hook to manage theme state and application. - * - Fetches theme preference from config on mount - * - Listens to system theme changes when set to 'system' - * - Applies theme class to document root - * - Caches theme in localStorage for flash prevention + * Hook to read theme state. App-level side effects live in useThemeController. */ export function useTheme(): { theme: Theme; @@ -49,64 +89,44 @@ export function useTheme(): { isDark: boolean; isLight: boolean; } { - const { appConfig, fetchConfig } = useStore( + const appConfig = useStore((s) => s.appConfig); + + // Get configured theme + const configuredTheme: Theme = appConfig?.general?.theme ?? 'system'; + const systemTheme = useSystemTheme(); + const resolvedTheme = configuredTheme === 'system' ? systemTheme : configuredTheme; + + return { + theme: configuredTheme, + resolvedTheme, + isDark: resolvedTheme === 'dark', + isLight: resolvedTheme === 'light', + }; +} + +/** + * App-level theme side effects. Keep this mounted once at the root. + */ +export function useThemeController(): ReturnType { + const themeState = useTheme(); + const { appConfig, configLoading, fetchConfig } = useStore( useShallow((s) => ({ appConfig: s.appConfig, + configLoading: s.configLoading, fetchConfig: s.fetchConfig, })) ); - const [resolvedTheme, setResolvedTheme] = useState(() => { - // Initialize from cache to prevent flash - const cached = readCachedResolvedTheme(); - if (cached) return cached; - - // No cache — detect system preference for flash-free first launch - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - }); + const { resolvedTheme } = themeState; // Fetch config on mount if not loaded. - // The centralized init chain also calls fetchConfig — configLoading guard + // The centralized init chain also calls fetchConfig - configLoading guard // in the store action prevents duplicate IPC calls. - const configLoading = useStore((s) => s.configLoading); useEffect(() => { if (!appConfig && !configLoading) { void fetchConfig(); } }, [appConfig, configLoading, fetchConfig]); - // Get configured theme - const configuredTheme: Theme = appConfig?.general?.theme ?? 'system'; - - // Get system theme preference - const getSystemTheme = useCallback((): ResolvedTheme => { - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - }, []); - - // Resolve 'system' theme and listen for changes - useEffect(() => { - const updateTheme = (): void => { - const resolved = configuredTheme === 'system' ? getSystemTheme() : configuredTheme; - setResolvedTheme(resolved); - - // Cache for flash prevention - writeCachedResolvedTheme(resolved); - }; - - updateTheme(); - - // Listen to system theme changes when in 'system' mode - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - const handleChange = (): void => { - if (configuredTheme === 'system') { - updateTheme(); - } - }; - - mediaQuery.addEventListener('change', handleChange); - return () => mediaQuery.removeEventListener('change', handleChange); - }, [configuredTheme, getSystemTheme]); - - // Apply theme class to document root useEffect(() => { const root = document.documentElement; const body = document.body; @@ -129,10 +149,9 @@ export function useTheme(): { }; }, [resolvedTheme]); - return { - theme: configuredTheme, - resolvedTheme, - isDark: resolvedTheme === 'dark', - isLight: resolvedTheme === 'light', - }; + useEffect(() => { + writeCachedResolvedTheme(resolvedTheme); + }, [resolvedTheme]); + + return themeState; }