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.
This commit is contained in:
Marco Beretta 2026-08-03 01:43:31 +02:00
parent 25b949bbfa
commit fc3d82d3c3
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
4 changed files with 55 additions and 52 deletions

View file

@ -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<string, string | undefined>} [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;
}

View file

@ -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();
});
});

View file

@ -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(),
{

View file

@ -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
}
```