perf(renderer): centralize theme side effects
This commit is contained in:
parent
61919f5aec
commit
03106e0c24
2 changed files with 77 additions and 58 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue