From fc3d82d3c3d8ada98059fba061999dd573100dee Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 3 Aug 2026 01:43:31 +0200 Subject: [PATCH] fix(client): read theme environment variables from the build-time env getThemeFromEnv read process.env, which vite-plugin-node-polyfills replaces with an empty shim in the browser, so every REACT_APP_THEME_* value was dropped and the loader always returned undefined. Read import.meta.env instead and register the REACT_APP_THEME_ prefix with Vite so the values are inlined at build time. The env source is now a parameter, which lets the tests cover the mapping without mutating globals. --- client/src/utils/getThemeFromEnv.js | 9 ++- client/src/utils/getThemeFromEnv.spec.js | 72 ++++++++++++------------ client/vite.config.ts | 2 +- packages/client/src/theme/README.md | 24 ++++---- 4 files changed, 55 insertions(+), 52 deletions(-) diff --git a/client/src/utils/getThemeFromEnv.js b/client/src/utils/getThemeFromEnv.js index d49d52da1a..39f828d3cf 100644 --- a/client/src/utils/getThemeFromEnv.js +++ b/client/src/utils/getThemeFromEnv.js @@ -79,12 +79,15 @@ const THEME_TOKENS = [ const toEnvName = (token) => `REACT_APP_THEME_${token.toUpperCase().replace(/-/g, '_')}`; /** - * Loads theme configuration from environment variables + * Loads theme configuration from build-time environment variables. Values are + * inlined by Vite, so `REACT_APP_THEME_*` must be present when the client is + * built; changing them afterwards has no effect until the next build. + * @param {Record} [env] Environment source, defaults to the build-time env * @returns {import('@librechat/client').IThemeRGB | undefined} */ -export function getThemeFromEnv() { +export function getThemeFromEnv(env = import.meta.env) { const theme = THEME_TOKENS.reduce((acc, token) => { - const value = process.env[toEnvName(token)]; + const value = env[toEnvName(token)]; if (value) { acc[`rgb-${token}`] = value; } diff --git a/client/src/utils/getThemeFromEnv.spec.js b/client/src/utils/getThemeFromEnv.spec.js index b32d366f60..0455e9325e 100644 --- a/client/src/utils/getThemeFromEnv.spec.js +++ b/client/src/utils/getThemeFromEnv.spec.js @@ -1,31 +1,16 @@ import { getThemeFromEnv } from './getThemeFromEnv'; -const originalThemeEnv = Object.fromEntries( - Object.entries(process.env).filter(([key]) => key.startsWith('REACT_APP_THEME_')), -); - -const clearThemeEnv = () => { - Object.keys(process.env) - .filter((key) => key.startsWith('REACT_APP_THEME_')) - .forEach((key) => delete process.env[key]); -}; - -beforeEach(clearThemeEnv); - -afterAll(() => { - clearThemeEnv(); - Object.assign(process.env, originalThemeEnv); -}); - describe('getThemeFromEnv', () => { it('loads link and accent colors', () => { - process.env.REACT_APP_THEME_LINK = '1 2 3'; - process.env.REACT_APP_THEME_LINK_HOVER = '4 5 6'; - process.env.REACT_APP_THEME_LINK_VISITED = '7 8 9'; - process.env.REACT_APP_THEME_ACCENT_PRIMARY = '10 11 12'; - process.env.REACT_APP_THEME_ACCENT_PRIMARY_HOVER = '13 14 15'; - - expect(getThemeFromEnv()).toEqual({ + expect( + getThemeFromEnv({ + REACT_APP_THEME_LINK: '1 2 3', + REACT_APP_THEME_LINK_HOVER: '4 5 6', + REACT_APP_THEME_LINK_VISITED: '7 8 9', + REACT_APP_THEME_ACCENT_PRIMARY: '10 11 12', + REACT_APP_THEME_ACCENT_PRIMARY_HOVER: '13 14 15', + }), + ).toEqual({ 'rgb-link': '1 2 3', 'rgb-link-hover': '4 5 6', 'rgb-link-visited': '7 8 9', @@ -35,17 +20,19 @@ describe('getThemeFromEnv', () => { }); it('loads status, inverted, fixed and destructive colors', () => { - process.env.REACT_APP_THEME_STATUS_ERROR = '1 2 3'; - process.env.REACT_APP_THEME_STATUS_SUCCESS_SUBTLE = '4 5 6'; - process.env.REACT_APP_THEME_STATUS_NEUTRAL_BORDER = '7 8 9'; - process.env.REACT_APP_THEME_TEXT_DESTRUCTIVE = '10 11 12'; - process.env.REACT_APP_THEME_BORDER_DESTRUCTIVE = '13 14 15'; - process.env.REACT_APP_THEME_SURFACE_INVERTED = '16 17 18'; - process.env.REACT_APP_THEME_TEXT_INVERTED = '19 20 21'; - process.env.REACT_APP_THEME_SURFACE_FIXED_HOVER = '22 23 24'; - process.env.REACT_APP_THEME_TEXT_FIXED = '25 26 27'; - - expect(getThemeFromEnv()).toEqual({ + expect( + getThemeFromEnv({ + REACT_APP_THEME_STATUS_ERROR: '1 2 3', + REACT_APP_THEME_STATUS_SUCCESS_SUBTLE: '4 5 6', + REACT_APP_THEME_STATUS_NEUTRAL_BORDER: '7 8 9', + REACT_APP_THEME_TEXT_DESTRUCTIVE: '10 11 12', + REACT_APP_THEME_BORDER_DESTRUCTIVE: '13 14 15', + REACT_APP_THEME_SURFACE_INVERTED: '16 17 18', + REACT_APP_THEME_TEXT_INVERTED: '19 20 21', + REACT_APP_THEME_SURFACE_FIXED_HOVER: '22 23 24', + REACT_APP_THEME_TEXT_FIXED: '25 26 27', + }), + ).toEqual({ 'rgb-status-error': '1 2 3', 'rgb-status-success-subtle': '4 5 6', 'rgb-status-neutral-border': '7 8 9', @@ -58,7 +45,22 @@ describe('getThemeFromEnv', () => { }); }); + it('ignores unrelated and empty values', () => { + expect( + getThemeFromEnv({ + MODE: 'test', + REACT_APP_THEME_TEXT_PRIMARY: '', + REACT_APP_THEME_UNKNOWN_TOKEN: '1 2 3', + REACT_APP_THEME_SURFACE_PRIMARY: '4 5 6', + }), + ).toEqual({ 'rgb-surface-primary': '4 5 6' }); + }); + it('returns undefined when no theme variables are set', () => { + expect(getThemeFromEnv({})).toBeUndefined(); + }); + + it('reads the build-time environment when no source is given', () => { expect(getThemeFromEnv()).toBeUndefined(); }); }); diff --git a/client/vite.config.ts b/client/vite.config.ts index 5edaa91bdd..8c7830dfdf 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -67,7 +67,7 @@ export default defineConfig(({ command }) => ({ }, // Set the directory where environment variables are loaded from and restrict prefixes envDir: '../', - envPrefix: ['VITE_', 'SCRIPT_', 'DOMAIN_', 'ALLOW_'], + envPrefix: ['VITE_', 'SCRIPT_', 'DOMAIN_', 'ALLOW_', 'REACT_APP_THEME_'], plugins: [ react(), { diff --git a/packages/client/src/theme/README.md b/packages/client/src/theme/README.md index 18358a6580..1fcdc57428 100644 --- a/packages/client/src/theme/README.md +++ b/packages/client/src/theme/README.md @@ -244,24 +244,22 @@ Every `IThemeRGB` key is configurable this way: drop the `rgb-` prefix and upper-snake-case the rest, so `rgb-status-error-border` becomes `REACT_APP_THEME_STATUS_ERROR_BORDER`. +The prefix must be listed in the bundler's `envPrefix` (Vite) or equivalent, and +values are inlined at build time, so the client has to be rebuilt after changing +them. + ### 2. Create a Theme Loader ```tsx -function getThemeFromEnv(): IThemeRGB | undefined { - // Check if any theme environment variables are set - const hasThemeEnvVars = Object.keys(process.env).some((key) => - key.startsWith('REACT_APP_THEME_'), - ); - - if (!hasThemeEnvVars) { - return undefined; // Use default themes - } - - return { - 'rgb-text-primary': process.env.REACT_APP_THEME_TEXT_PRIMARY || '33 33 33', - 'rgb-brand-purple': process.env.REACT_APP_THEME_BRAND_PURPLE || '171 104 255', +function getThemeFromEnv(env = import.meta.env): IThemeRGB | undefined { + const theme = { + 'rgb-text-primary': env.REACT_APP_THEME_TEXT_PRIMARY, + 'rgb-brand-purple': env.REACT_APP_THEME_BRAND_PURPLE, // ... other colors }; + + const set = Object.fromEntries(Object.entries(theme).filter(([, value]) => value)); + return Object.keys(set).length > 0 ? set : undefined; // Fall back to default themes } ```