perf(renderer): centralize theme side effects

This commit is contained in:
777genius 2026-05-31 06:35:21 +03:00
parent 61919f5aec
commit 03106e0c24
2 changed files with 77 additions and 58 deletions

View file

@ -9,7 +9,7 @@ import { ErrorBoundary } from './components/common/ErrorBoundary';
import { TabbedLayout } from './components/layout/TabbedLayout'; import { TabbedLayout } from './components/layout/TabbedLayout';
import { type SplashSceneHandle, startSplashScene } from './components/splash/splashScene'; import { type SplashSceneHandle, startSplashScene } from './components/splash/splashScene';
import { ToolApprovalSheet } from './components/team/ToolApprovalSheet'; import { ToolApprovalSheet } from './components/team/ToolApprovalSheet';
import { useTheme } from './hooks/useTheme'; import { useThemeController } from './hooks/useTheme';
import { api } from './api'; import { api } from './api';
import { useStore } from './store'; import { useStore } from './store';
@ -33,7 +33,7 @@ const SPLASH_REDUCED_AVATAR_READY_MAX_WAIT_MS = 160;
export const App = (): React.JSX.Element => { export const App = (): React.JSX.Element => {
// Initialize theme on app load // Initialize theme on app load
useTheme(); useThemeController();
const appConfig = useStore((s) => s.appConfig); const appConfig = useStore((s) => s.appConfig);
// Upgrade the static preload splash, then dismiss it after the scene is visible. // Upgrade the static preload splash, then dismiss it after the scene is visible.

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useEffect, useSyncExternalStore } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@ -14,6 +14,10 @@ function parseCachedTheme(value: string | null): ResolvedTheme | null {
return value === 'light' || value === 'dark' ? value : 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 { export function readCachedResolvedTheme(storage: Storage = localStorage): ResolvedTheme | null {
try { try {
return ( 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. * Hook to read theme state. App-level side effects live in useThemeController.
* - 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
*/ */
export function useTheme(): { export function useTheme(): {
theme: Theme; theme: Theme;
@ -49,64 +89,44 @@ export function useTheme(): {
isDark: boolean; isDark: boolean;
isLight: 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<typeof useTheme> {
const themeState = useTheme();
const { appConfig, configLoading, fetchConfig } = useStore(
useShallow((s) => ({ useShallow((s) => ({
appConfig: s.appConfig, appConfig: s.appConfig,
configLoading: s.configLoading,
fetchConfig: s.fetchConfig, fetchConfig: s.fetchConfig,
})) }))
); );
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() => { const { resolvedTheme } = themeState;
// 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';
});
// Fetch config on mount if not loaded. // 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. // in the store action prevents duplicate IPC calls.
const configLoading = useStore((s) => s.configLoading);
useEffect(() => { useEffect(() => {
if (!appConfig && !configLoading) { if (!appConfig && !configLoading) {
void fetchConfig(); void fetchConfig();
} }
}, [appConfig, configLoading, 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(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
const body = document.body; const body = document.body;
@ -129,10 +149,9 @@ export function useTheme(): {
}; };
}, [resolvedTheme]); }, [resolvedTheme]);
return { useEffect(() => {
theme: configuredTheme, writeCachedResolvedTheme(resolvedTheme);
resolvedTheme, }, [resolvedTheme]);
isDark: resolvedTheme === 'dark',
isLight: resolvedTheme === 'light', return themeState;
};
} }