💡 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],
);
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);

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 { 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<string>('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<IThemeRGB | undefined>(
'theme-colors',
@ -23,8 +24,7 @@ export const themeColorsAtom = atomWithStorage<IThemeRGB | undefined>(
);
/**
* 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<string | undefined>(
'theme-name',

View file

@ -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<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
* 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<string>(getInitialTheme);
const [themeRGB, setThemeRGBState] = useState<IThemeRGB | undefined>(getInitialThemeColors);
const [themeName, setThemeNameState] = useState<string | undefined>(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 <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;