diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx index a6b0815..182a5b6 100644 --- a/src/contexts/ThemeContext.tsx +++ b/src/contexts/ThemeContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, useEffect } from 'react'; +import { createContext, useContext, useState, useLayoutEffect, useMemo, useCallback, useSyncExternalStore } from 'react'; import type { ReactNode } from 'react'; type Theme = 'light' | 'dark' | 'system'; @@ -31,64 +31,61 @@ function getStoredTheme(): Theme { return 'system'; } +// Subscribe to system theme changes +function subscribeToSystemTheme(callback: () => void) { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + mediaQuery.addEventListener('change', callback); + return () => mediaQuery.removeEventListener('change', callback); +} + +function getSystemThemeSnapshot(): 'light' | 'dark' { + return getSystemTheme(); +} + export function ThemeProvider({ children }: { children: ReactNode }) { const [theme, setThemeState] = useState(getStoredTheme); - const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light'); - // Resolve the actual theme (handles 'system' preference) - useEffect(() => { - const resolved = theme === 'system' ? getSystemTheme() : theme; - setResolvedTheme(resolved); + // Use useSyncExternalStore to track system theme changes + const systemTheme = useSyncExternalStore( + subscribeToSystemTheme, + getSystemThemeSnapshot, + () => 'light' as const // Server snapshot + ); - // Apply class to document + // Compute resolved theme from theme preference and system theme + const resolvedTheme = useMemo<'light' | 'dark'>(() => { + return theme === 'system' ? systemTheme : theme; + }, [theme, systemTheme]); + + // Apply class to document - useLayoutEffect is appropriate for DOM mutations + useLayoutEffect(() => { const root = document.documentElement; - if (resolved === 'dark') { + if (resolvedTheme === 'dark') { root.classList.add('dark'); } else { root.classList.remove('dark'); } - }, [theme]); + }, [resolvedTheme]); - // Listen for system theme changes - useEffect(() => { - if (theme !== 'system') return; - - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - const handleChange = (e: MediaQueryListEvent) => { - const newTheme = e.matches ? 'dark' : 'light'; - setResolvedTheme(newTheme); - - const root = document.documentElement; - if (newTheme === 'dark') { - root.classList.add('dark'); - } else { - root.classList.remove('dark'); - } - }; - - mediaQuery.addEventListener('change', handleChange); - return () => mediaQuery.removeEventListener('change', handleChange); - }, [theme]); - - const setTheme = (newTheme: Theme) => { + const setTheme = useCallback((newTheme: Theme) => { setThemeState(newTheme); localStorage.setItem(STORAGE_KEY, newTheme); - }; + }, []); - const toggleTheme = () => { + const toggleTheme = useCallback(() => { const newTheme = resolvedTheme === 'light' ? 'dark' : 'light'; setTheme(newTheme); - }; + }, [resolvedTheme, setTheme]); + + const value = useMemo(() => ({ + theme, + resolvedTheme, + setTheme, + toggleTheme, + }), [theme, resolvedTheme, setTheme, toggleTheme]); return ( - + {children} );