From cc7f61096be47ab2cd4e155bd44c18350f027f05 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:46:41 -0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=A1=20fix:=20System=20Theme=20Picker?= =?UTF-8?q?=20Selection=20(#11220)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: theme picker selection * refactor: remove problematic Jotai use and replace with React state and localStorage implementation * chore: address comments from Copilot + LibreChat Agent assisted reviewers * chore: remove unnecessary edit * chore: remove space --- .../client/src/components/ThemeSelector.tsx | 7 - packages/client/src/theme/atoms/themeAtoms.ts | 12 +- .../src/theme/context/ThemeProvider.tsx | 216 +++++++++++++----- 3 files changed, 160 insertions(+), 75 deletions(-) diff --git a/packages/client/src/components/ThemeSelector.tsx b/packages/client/src/components/ThemeSelector.tsx index c1e20358df..b817c41d7e 100644 --- a/packages/client/src/components/ThemeSelector.tsx +++ b/packages/client/src/components/ThemeSelector.tsx @@ -77,13 +77,6 @@ const ThemeSelector = ({ returnThemeOnly }: { returnThemeOnly?: boolean }) => { [setTheme, localize], ); - useEffect(() => { - if (theme === 'system') { - const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)').matches; - setTheme(prefersDarkScheme ? 'dark' : 'light'); - } - }, [theme, setTheme]); - useEffect(() => { if (announcement) { const timeout = setTimeout(() => setAnnouncement(''), 1000); diff --git a/packages/client/src/theme/atoms/themeAtoms.ts b/packages/client/src/theme/atoms/themeAtoms.ts index 316d092fb0..0babc4036b 100644 --- a/packages/client/src/theme/atoms/themeAtoms.ts +++ b/packages/client/src/theme/atoms/themeAtoms.ts @@ -1,17 +1,18 @@ +// This file is kept for backward compatibility but is no longer used internally. +// Theme state is now managed via React useState + localStorage in ThemeProvider. + import { atomWithStorage } from 'jotai/utils'; import { IThemeRGB } from '../types'; /** - * Atom for storing the theme mode (light/dark/system) in localStorage - * Key: 'color-theme' + * @deprecated Use ThemeContext instead. This atom is no longer used internally. */ export const themeModeAtom = atomWithStorage('color-theme', 'system', undefined, { getOnInit: true, }); /** - * Atom for storing custom theme colors in localStorage - * Key: 'theme-colors' + * @deprecated Use ThemeContext instead. This atom is no longer used internally. */ export const themeColorsAtom = atomWithStorage( 'theme-colors', @@ -23,8 +24,7 @@ export const themeColorsAtom = atomWithStorage( ); /** - * Atom for storing the theme name in localStorage - * Key: 'theme-name' + * @deprecated Use ThemeContext instead. This atom is no longer used internally. */ export const themeNameAtom = atomWithStorage( 'theme-name', diff --git a/packages/client/src/theme/context/ThemeProvider.tsx b/packages/client/src/theme/context/ThemeProvider.tsx index c803796164..30773d222a 100644 --- a/packages/client/src/theme/context/ThemeProvider.tsx +++ b/packages/client/src/theme/context/ThemeProvider.tsx @@ -1,8 +1,10 @@ -import React, { createContext, useContext, useEffect, useMemo, useCallback, useRef } from 'react'; -import { useAtom } from 'jotai'; +import React, { createContext, useContext, useEffect, useMemo, useCallback, useState, useRef } from 'react'; import { IThemeRGB } from '../types'; import applyTheme from '../utils/applyTheme'; -import { themeModeAtom, themeColorsAtom, themeNameAtom } from '../atoms/themeAtoms'; + +const THEME_KEY = 'color-theme'; +const THEME_COLORS_KEY = 'theme-colors'; +const THEME_NAME_KEY = 'theme-name'; type ThemeContextType = { theme: string; // 'light' | 'dark' | 'system' @@ -40,6 +42,70 @@ export const isDark = (theme: string): boolean => { return theme === 'dark'; }; +/** + * Validate that a parsed value looks like an IThemeRGB object + */ +const isValidThemeColors = (value: unknown): value is IThemeRGB => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + for (const key of Object.keys(value)) { + const val = (value as Record)[key]; + if (val !== undefined && typeof val !== 'string') { + return false; + } + } + return true; +}; + +/** + * Get initial theme from localStorage or default to 'system' + */ +const getInitialTheme = (): string => { + if (typeof window === 'undefined') return 'system'; + try { + const stored = localStorage.getItem(THEME_KEY); + if (stored && ['light', 'dark', 'system'].includes(stored)) { + return stored; + } + } catch { + // localStorage not available + } + return 'system'; +}; + +/** + * Get initial theme colors from localStorage + */ +const getInitialThemeColors = (): IThemeRGB | undefined => { + if (typeof window === 'undefined') return undefined; + try { + const stored = localStorage.getItem(THEME_COLORS_KEY); + if (stored) { + const parsed = JSON.parse(stored); + if (isValidThemeColors(parsed)) { + return parsed; + } + } + } catch { + // localStorage not available or invalid JSON + } + return undefined; +}; + +/** + * Get initial theme name from localStorage + */ +const getInitialThemeName = (): string | undefined => { + if (typeof window === 'undefined') return undefined; + try { + return localStorage.getItem(THEME_NAME_KEY) || undefined; + } catch { + // localStorage not available + } + return undefined; +}; + /** * ThemeProvider component that handles both dark/light mode switching * and dynamic color themes via CSS variables with localStorage persistence @@ -50,102 +116,128 @@ export function ThemeProvider({ themeName: propThemeName, initialTheme, }: ThemeProviderProps) { - // Use jotai atoms for persistent state - const [theme, setTheme] = useAtom(themeModeAtom); - const [storedThemeRGB, setStoredThemeRGB] = useAtom(themeColorsAtom); - const [storedThemeName, setStoredThemeName] = useAtom(themeNameAtom); + const [theme, setThemeState] = useState(getInitialTheme); + const [themeRGB, setThemeRGBState] = useState(getInitialThemeColors); + const [themeName, setThemeNameState] = useState(getInitialThemeName); // Track if props have been initialized - const propsInitialized = useRef(false); + const initialized = useRef(false); + + const setTheme = useCallback((newTheme: string) => { + setThemeState(newTheme); + if (typeof window === 'undefined') return; + try { + localStorage.setItem(THEME_KEY, newTheme); + } catch { + // localStorage not available + } + }, []); + + const setThemeRGB = useCallback((colors?: IThemeRGB) => { + setThemeRGBState(colors); + if (typeof window === 'undefined') return; + try { + if (colors) { + localStorage.setItem(THEME_COLORS_KEY, JSON.stringify(colors)); + } else { + localStorage.removeItem(THEME_COLORS_KEY); + } + } catch { + // localStorage not available + } + }, []); + + const setThemeName = useCallback((name?: string) => { + setThemeNameState(name); + if (typeof window === 'undefined') return; + try { + if (name) { + localStorage.setItem(THEME_NAME_KEY, name); + } else { + localStorage.removeItem(THEME_NAME_KEY); + } + } catch { + // localStorage not available + } + }, []); // Initialize from props only once on mount useEffect(() => { - if (!propsInitialized.current) { - propsInitialized.current = true; + if (initialized.current) return; + initialized.current = true; - // Set initial theme if provided - if (initialTheme) { - setTheme(initialTheme); - } - - // Set initial theme colors if provided - if (propThemeRGB) { - setStoredThemeRGB(propThemeRGB); - } - - // Set initial theme name if provided - if (propThemeName) { - setStoredThemeName(propThemeName); - } + // Set initial theme if provided + if (initialTheme) { + setTheme(initialTheme); } - }, [initialTheme, propThemeRGB, propThemeName, setTheme, setStoredThemeRGB, setStoredThemeName]); + + // Set initial theme colors if provided + if (propThemeRGB) { + setThemeRGB(propThemeRGB); + } + + // Set initial theme name if provided + if (propThemeName) { + setThemeName(propThemeName); + } + }, [initialTheme, propThemeRGB, propThemeName, setTheme, setThemeRGB, setThemeName]); // Apply class-based dark mode - const applyThemeMode = useCallback((rawTheme: string) => { + const applyThemeMode = useCallback((currentTheme: string) => { const root = window.document.documentElement; - const darkMode = isDark(rawTheme); + const darkMode = isDark(currentTheme); root.classList.remove(darkMode ? 'light' : 'dark'); root.classList.add(darkMode ? 'dark' : 'light'); }, []); - // Handle system theme changes - useEffect(() => { - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - const changeThemeOnSystemChange = () => { - if (theme === 'system') { - applyThemeMode('system'); - } - }; - - mediaQuery.addEventListener('change', changeThemeOnSystemChange); - return () => { - mediaQuery.removeEventListener('change', changeThemeOnSystemChange); - }; - }, [theme, applyThemeMode]); - - // Apply dark/light mode class + // Apply theme mode whenever theme changes useEffect(() => { applyThemeMode(theme); }, [theme, applyThemeMode]); + // Listen for system theme changes when theme is 'system' + useEffect(() => { + if (theme !== 'system') return; + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = () => { + applyThemeMode('system'); + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, [theme, applyThemeMode]); + // Apply dynamic color theme useEffect(() => { - if (storedThemeRGB) { - applyTheme(storedThemeRGB); + if (themeRGB) { + applyTheme(themeRGB); } - }, [storedThemeRGB]); + }, [themeRGB]); // Reset theme function const resetTheme = useCallback(() => { setTheme('system'); - setStoredThemeRGB(undefined); - setStoredThemeName(undefined); + setThemeRGB(undefined); + setThemeName(undefined); // Remove any custom CSS variables const root = document.documentElement; const customProps = Array.from(root.style).filter((prop) => prop.startsWith('--')); customProps.forEach((prop) => root.style.removeProperty(prop)); - }, [setTheme, setStoredThemeRGB, setStoredThemeName]); + }, [setTheme, setThemeRGB, setThemeName]); const value = useMemo( () => ({ theme, setTheme, - themeRGB: storedThemeRGB, - setThemeRGB: setStoredThemeRGB, - themeName: storedThemeName, - setThemeName: setStoredThemeName, + themeRGB, + setThemeRGB, + themeName, + setThemeName, resetTheme, }), - [ - theme, - setTheme, - storedThemeRGB, - setStoredThemeRGB, - storedThemeName, - setStoredThemeName, - resetTheme, - ], + [theme, setTheme, themeRGB, setThemeRGB, themeName, setThemeName, resetTheme], ); return {children};