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 { 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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue