💡 fix: System Theme Picker Selection (#11220)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions

* 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
This commit is contained in:
Dustin Healy 2026-02-11 19:46:41 -08:00 committed by GitHub
parent 5b67e48fe1
commit cc7f61096b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 160 additions and 75 deletions

View file

@ -77,13 +77,6 @@ const ThemeSelector = ({ returnThemeOnly }: { returnThemeOnly?: boolean }) => {
[setTheme, localize], [setTheme, localize],
); );
useEffect(() => {
if (theme === 'system') {
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
setTheme(prefersDarkScheme ? 'dark' : 'light');
}
}, [theme, setTheme]);
useEffect(() => { useEffect(() => {
if (announcement) { if (announcement) {
const timeout = setTimeout(() => setAnnouncement(''), 1000); const timeout = setTimeout(() => setAnnouncement(''), 1000);

View file

@ -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 { atomWithStorage } from 'jotai/utils';
import { IThemeRGB } from '../types'; import { IThemeRGB } from '../types';
/** /**
* Atom for storing the theme mode (light/dark/system) in localStorage * @deprecated Use ThemeContext instead. This atom is no longer used internally.
* Key: 'color-theme'
*/ */
export const themeModeAtom = atomWithStorage<string>('color-theme', 'system', undefined, { export const themeModeAtom = atomWithStorage<string>('color-theme', 'system', undefined, {
getOnInit: true, getOnInit: true,
}); });
/** /**
* Atom for storing custom theme colors in localStorage * @deprecated Use ThemeContext instead. This atom is no longer used internally.
* Key: 'theme-colors'
*/ */
export const themeColorsAtom = atomWithStorage<IThemeRGB | undefined>( export const themeColorsAtom = atomWithStorage<IThemeRGB | undefined>(
'theme-colors', 'theme-colors',
@ -23,8 +24,7 @@ export const themeColorsAtom = atomWithStorage<IThemeRGB | undefined>(
); );
/** /**
* Atom for storing the theme name in localStorage * @deprecated Use ThemeContext instead. This atom is no longer used internally.
* Key: 'theme-name'
*/ */
export const themeNameAtom = atomWithStorage<string | undefined>( export const themeNameAtom = atomWithStorage<string | undefined>(
'theme-name', 'theme-name',

View file

@ -1,8 +1,10 @@
import React, { createContext, useContext, useEffect, useMemo, useCallback, useRef } from 'react'; import React, { createContext, useContext, useEffect, useMemo, useCallback, useState, useRef } from 'react';
import { useAtom } from 'jotai';
import { IThemeRGB } from '../types'; import { IThemeRGB } from '../types';
import applyTheme from '../utils/applyTheme'; 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 = { type ThemeContextType = {
theme: string; // 'light' | 'dark' | 'system' theme: string; // 'light' | 'dark' | 'system'
@ -40,6 +42,70 @@ export const isDark = (theme: string): boolean => {
return theme === 'dark'; 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<string, unknown>)[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 * ThemeProvider component that handles both dark/light mode switching
* and dynamic color themes via CSS variables with localStorage persistence * and dynamic color themes via CSS variables with localStorage persistence
@ -50,102 +116,128 @@ export function ThemeProvider({
themeName: propThemeName, themeName: propThemeName,
initialTheme, initialTheme,
}: ThemeProviderProps) { }: ThemeProviderProps) {
// Use jotai atoms for persistent state const [theme, setThemeState] = useState<string>(getInitialTheme);
const [theme, setTheme] = useAtom(themeModeAtom); const [themeRGB, setThemeRGBState] = useState<IThemeRGB | undefined>(getInitialThemeColors);
const [storedThemeRGB, setStoredThemeRGB] = useAtom(themeColorsAtom); const [themeName, setThemeNameState] = useState<string | undefined>(getInitialThemeName);
const [storedThemeName, setStoredThemeName] = useAtom(themeNameAtom);
// Track if props have been initialized // 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 // Initialize from props only once on mount
useEffect(() => { useEffect(() => {
if (!propsInitialized.current) { if (initialized.current) return;
propsInitialized.current = true; initialized.current = true;
// Set initial theme if provided // Set initial theme if provided
if (initialTheme) { if (initialTheme) {
setTheme(initialTheme); setTheme(initialTheme);
}
// Set initial theme colors if provided
if (propThemeRGB) {
setStoredThemeRGB(propThemeRGB);
}
// Set initial theme name if provided
if (propThemeName) {
setStoredThemeName(propThemeName);
}
} }
}, [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 // Apply class-based dark mode
const applyThemeMode = useCallback((rawTheme: string) => { const applyThemeMode = useCallback((currentTheme: string) => {
const root = window.document.documentElement; const root = window.document.documentElement;
const darkMode = isDark(rawTheme); const darkMode = isDark(currentTheme);
root.classList.remove(darkMode ? 'light' : 'dark'); root.classList.remove(darkMode ? 'light' : 'dark');
root.classList.add(darkMode ? 'dark' : 'light'); root.classList.add(darkMode ? 'dark' : 'light');
}, []); }, []);
// Handle system theme changes // Apply theme mode whenever 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
useEffect(() => { useEffect(() => {
applyThemeMode(theme); applyThemeMode(theme);
}, [theme, applyThemeMode]); }, [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 // Apply dynamic color theme
useEffect(() => { useEffect(() => {
if (storedThemeRGB) { if (themeRGB) {
applyTheme(storedThemeRGB); applyTheme(themeRGB);
} }
}, [storedThemeRGB]); }, [themeRGB]);
// Reset theme function // Reset theme function
const resetTheme = useCallback(() => { const resetTheme = useCallback(() => {
setTheme('system'); setTheme('system');
setStoredThemeRGB(undefined); setThemeRGB(undefined);
setStoredThemeName(undefined); setThemeName(undefined);
// Remove any custom CSS variables // Remove any custom CSS variables
const root = document.documentElement; const root = document.documentElement;
const customProps = Array.from(root.style).filter((prop) => prop.startsWith('--')); const customProps = Array.from(root.style).filter((prop) => prop.startsWith('--'));
customProps.forEach((prop) => root.style.removeProperty(prop)); customProps.forEach((prop) => root.style.removeProperty(prop));
}, [setTheme, setStoredThemeRGB, setStoredThemeName]); }, [setTheme, setThemeRGB, setThemeName]);
const value = useMemo( const value = useMemo(
() => ({ () => ({
theme, theme,
setTheme, setTheme,
themeRGB: storedThemeRGB, themeRGB,
setThemeRGB: setStoredThemeRGB, setThemeRGB,
themeName: storedThemeName, themeName,
setThemeName: setStoredThemeName, setThemeName,
resetTheme, resetTheme,
}), }),
[ [theme, setTheme, themeRGB, setThemeRGB, themeName, setThemeName, resetTheme],
theme,
setTheme,
storedThemeRGB,
setStoredThemeRGB,
storedThemeName,
setStoredThemeName,
resetTheme,
],
); );
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;