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 { 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.

View file

@ -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<typeof useTheme> {
const themeState = useTheme();
const { appConfig, configLoading, fetchConfig } = useStore(
useShallow((s) => ({
appConfig: s.appConfig,
configLoading: s.configLoading,
fetchConfig: s.fetchConfig,
}))
);
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() => {
// 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;
}