feat: use persisted theme css to fix flashes on header (#1784)

## Summary
- install the pinia-plugin-persistedstate integration directly inside
the theme store and hydrate cached themes before applying CSS variables
- fall back to the active/global Pinia instance while ensuring persisted
state is only wired once per store instance
- update the theme store tests to reset the shared Pinia state between
runs and rely on the plugin-backed persistence

## Testing
- pnpm --filter web test __test__/store/theme.test.ts

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_69156c5e8de48323841f7dbfdadec51d)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Theme preferences now persist across sessions and are restored on
return.
* **Behavior Change**
* Theme switching may now update the URL/address bar to reflect the
selected theme.
* **Chores**
* Added a persistence integration to enable storing/restoring theme
data.
* **Tests**
* Updated/added tests covering hydration from storage and persistence of
server-provided themes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Eli Bosley
2025-11-13 16:24:30 -05:00
committed by GitHub
parent c264a1843c
commit 854b403fbd
6 changed files with 339 additions and 177 deletions

24
pnpm-lock.yaml generated
View File

@@ -1154,6 +1154,9 @@ importers:
pinia:
specifier: 3.0.3
version: 3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2))
pinia-plugin-persistedstate:
specifier: 4.7.1
version: 4.7.1(@nuxt/kit@4.0.3(magicast@0.3.5))(pinia@3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2)))
postcss-import:
specifier: 16.1.1
version: 16.1.1(postcss@8.5.6)
@@ -10116,6 +10119,20 @@ packages:
resolution: {integrity: sha512-KocF8ve28eFjjuBKKGvzOBGzG8ew2OqOOSxTTZhirkzH7h3BI1vyzqlR0qbfcDBve1Yzo3FVlWUAtCRrbVN8Fw==}
engines: {node: '>=14.16'}
pinia-plugin-persistedstate@4.7.1:
resolution: {integrity: sha512-WHOqh2esDlR3eAaknPbqXrkkj0D24h8shrDPqysgCFR6ghqP/fpFfJmMPJp0gETHsvrh9YNNg6dQfo2OEtDnIQ==}
peerDependencies:
'@nuxt/kit': '>=3.0.0'
'@pinia/nuxt': '>=0.10.0'
pinia: '>=3.0.0'
peerDependenciesMeta:
'@nuxt/kit':
optional: true
'@pinia/nuxt':
optional: true
pinia:
optional: true
pinia@3.0.3:
resolution: {integrity: sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==}
peerDependencies:
@@ -22744,6 +22761,13 @@ snapshots:
pify@6.1.0: {}
pinia-plugin-persistedstate@4.7.1(@nuxt/kit@4.0.3(magicast@0.3.5))(pinia@3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2))):
dependencies:
defu: 6.1.4
optionalDependencies:
'@nuxt/kit': 4.0.3(magicast@0.3.5)
pinia: 3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2))
pinia@3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2)):
dependencies:
'@vue/devtools-api': 7.7.2

View File

@@ -2,14 +2,17 @@
* Theme store test coverage
*/
import { nextTick, ref } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { createApp, nextTick, ref } from 'vue';
import { setActivePinia } from 'pinia';
import { defaultColors } from '~/themes/default';
import hexToRgba from 'hex-to-rgba';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useThemeStore } from '~/store/theme';
import type { Theme } from '~/themes/types';
import { globalPinia } from '~/store/globalPinia';
import { THEME_STORAGE_KEY, useThemeStore } from '~/store/theme';
vi.mock('@vue/apollo-composable', () => ({
useQuery: () => ({
@@ -25,15 +28,23 @@ vi.mock('hex-to-rgba', () => ({
}));
describe('Theme Store', () => {
let store: ReturnType<typeof useThemeStore>;
const originalAddClassFn = document.body.classList.add;
const originalRemoveClassFn = document.body.classList.remove;
const originalStyleCssText = document.body.style.cssText;
const originalDocumentElementSetProperty = document.documentElement.style.setProperty;
const originalDocumentElementAddClass = document.documentElement.classList.add;
const originalDocumentElementRemoveClass = document.documentElement.classList.remove;
let store: ReturnType<typeof useThemeStore> | undefined;
let app: ReturnType<typeof createApp> | undefined;
beforeEach(() => {
setActivePinia(createPinia());
store = useThemeStore();
app = createApp({ render: () => null });
app.use(globalPinia);
setActivePinia(globalPinia);
store = undefined;
window.localStorage.clear();
delete (globalPinia.state.value as Record<string, unknown>).theme;
document.body.classList.add = vi.fn();
document.body.classList.remove = vi.fn();
@@ -51,16 +62,34 @@ describe('Theme Store', () => {
});
afterEach(() => {
// Restore original methods
store?.$dispose();
store = undefined;
app?.unmount();
app = undefined;
document.body.classList.add = originalAddClassFn;
document.body.classList.remove = originalRemoveClassFn;
document.body.style.cssText = originalStyleCssText;
document.documentElement.style.setProperty = originalDocumentElementSetProperty;
document.documentElement.classList.add = originalDocumentElementAddClass;
document.documentElement.classList.remove = originalDocumentElementRemoveClass;
vi.restoreAllMocks();
});
const createStore = () => {
if (!store) {
store = useThemeStore();
}
return store;
};
describe('State and Initialization', () => {
it('should initialize with default theme', () => {
const store = createStore();
expect(typeof store.$persist).toBe('function');
expect(store.theme).toEqual({
name: 'white',
banner: false,
@@ -74,6 +103,8 @@ describe('Theme Store', () => {
});
it('should compute darkMode correctly', () => {
const store = createStore();
expect(store.darkMode).toBe(false);
store.setTheme({ ...store.theme, name: 'black' });
@@ -87,6 +118,8 @@ describe('Theme Store', () => {
});
it('should compute bannerGradient correctly', () => {
const store = createStore();
expect(store.bannerGradient).toBeUndefined();
store.setTheme({
@@ -112,6 +145,8 @@ describe('Theme Store', () => {
describe('Actions', () => {
it('should set theme correctly', () => {
const store = createStore();
const newTheme = {
name: 'black',
banner: true,
@@ -127,6 +162,8 @@ describe('Theme Store', () => {
});
it('should update body classes for dark mode', async () => {
const store = createStore();
store.setTheme({ ...store.theme, name: 'black' });
await nextTick();
@@ -141,6 +178,8 @@ describe('Theme Store', () => {
});
it('should update activeColorVariables when theme changes', async () => {
const store = createStore();
store.setTheme({
...store.theme,
name: 'white',
@@ -170,6 +209,7 @@ describe('Theme Store', () => {
});
it('should handle banner gradient correctly', async () => {
const store = createStore();
const mockHexToRgba = vi.mocked(hexToRgba);
mockHexToRgba.mockClear();
@@ -200,5 +240,44 @@ describe('Theme Store', () => {
'linear-gradient(90deg, rgba(mock-#112233-0) 0, rgba(mock-#112233-0.7) 90%)'
);
});
it('should hydrate theme from cache when available', () => {
const cachedTheme = {
name: 'black',
banner: true,
bannerGradient: false,
bgColor: '#222222',
descriptionShow: true,
metaColor: '#aaaaaa',
textColor: '#ffffff',
} satisfies Theme;
window.localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify({ theme: cachedTheme }));
const store = createStore();
expect(store.theme).toEqual(cachedTheme);
});
it('should persist server theme responses to cache', async () => {
const store = createStore();
const serverTheme = {
name: 'gray',
banner: false,
bannerGradient: false,
bgColor: '#111111',
descriptionShow: false,
metaColor: '#999999',
textColor: '#eeeeee',
} satisfies Theme;
store.setTheme(serverTheme, { source: 'server' });
await nextTick();
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toEqual(
JSON.stringify({ theme: serverTheme })
);
});
});
});

View File

@@ -132,6 +132,7 @@
"marked": "16.2.1",
"marked-base-url": "1.1.7",
"pinia": "3.0.3",
"pinia-plugin-persistedstate": "4.7.1",
"postcss-import": "16.1.1",
"semver": "7.7.2",
"tailwind-merge": "2.6.0",

View File

@@ -51,7 +51,7 @@ const updateTheme = (themeName: string, skipUrlUpdate = false) => {
// ignore
}
themeStore.setTheme({ name: themeName }, true);
themeStore.setTheme({ name: themeName });
themeStore.setCssVars();
const linkId = 'dev-theme-css-link';
@@ -100,7 +100,7 @@ onMounted(() => {
if (!existingLink || !existingLink.href) {
updateTheme(initialTheme, true);
} else {
themeStore.setTheme({ name: initialTheme }, true);
themeStore.setTheme({ name: initialTheme });
themeStore.setCssVars();
}
});

View File

@@ -1,7 +1,10 @@
import { createPinia, setActivePinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
// Create a single shared Pinia instance for all web components
export const globalPinia = createPinia();
globalPinia.use(piniaPluginPersistedstate);
// IMPORTANT: Set it as the active pinia instance immediately
// This ensures stores work even when called during component setup

View File

@@ -27,6 +27,8 @@ export const GET_THEME_QUERY = graphql(`
}
`);
export const THEME_STORAGE_KEY = 'unraid.theme.publicTheme';
const DEFAULT_THEME: Theme = {
name: 'white',
banner: false,
@@ -37,6 +39,26 @@ const DEFAULT_THEME: Theme = {
textColor: '',
};
type ThemeSource = 'local' | 'server';
const sanitizeTheme = (data: Partial<Theme> | null | undefined): Theme | null => {
if (!data || typeof data !== 'object') {
return null;
}
return {
name: typeof data.name === 'string' ? data.name : DEFAULT_THEME.name,
banner: typeof data.banner === 'boolean' ? data.banner : DEFAULT_THEME.banner,
bannerGradient:
typeof data.bannerGradient === 'boolean' ? data.bannerGradient : DEFAULT_THEME.bannerGradient,
bgColor: typeof data.bgColor === 'string' ? data.bgColor : DEFAULT_THEME.bgColor,
descriptionShow:
typeof data.descriptionShow === 'boolean' ? data.descriptionShow : DEFAULT_THEME.descriptionShow,
metaColor: typeof data.metaColor === 'string' ? data.metaColor : DEFAULT_THEME.metaColor,
textColor: typeof data.textColor === 'string' ? data.textColor : DEFAULT_THEME.textColor,
};
};
const DYNAMIC_VAR_KEYS = [
'--custom-header-text-primary',
'--custom-header-text-secondary',
@@ -48,201 +70,234 @@ const DYNAMIC_VAR_KEYS = [
type DynamicVarKey = (typeof DYNAMIC_VAR_KEYS)[number];
export const useThemeStore = defineStore('theme', () => {
// State
const theme = ref<Theme>({ ...DEFAULT_THEME });
export const useThemeStore = defineStore(
'theme',
() => {
// State
const theme = ref<Theme>({ ...DEFAULT_THEME });
const activeColorVariables = ref<ThemeVariables>(defaultColors.white);
const hasServerTheme = ref(false);
const devOverride = ref(false);
const activeColorVariables = ref<ThemeVariables>(defaultColors.white);
const hasServerTheme = ref(false);
const devOverride = ref(false);
const { result, onResult, onError } = useQuery<GetThemeQuery>(GET_THEME_QUERY, null, {
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-first',
});
const { result, onResult, onError } = useQuery<GetThemeQuery>(GET_THEME_QUERY, null, {
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-first',
});
const applyThemeFromQuery = (publicTheme?: GetThemeQuery['publicTheme'] | null) => {
if (!publicTheme) {
return;
}
const mapPublicTheme = (publicTheme?: GetThemeQuery['publicTheme'] | null): Theme | null => {
if (!publicTheme) {
return null;
}
hasServerTheme.value = true;
theme.value = {
name: publicTheme.name?.toLowerCase() ?? DEFAULT_THEME.name,
banner: publicTheme.showBannerImage ?? DEFAULT_THEME.banner,
bannerGradient: publicTheme.showBannerGradient ?? DEFAULT_THEME.bannerGradient,
bgColor: publicTheme.headerBackgroundColor ?? DEFAULT_THEME.bgColor,
descriptionShow: publicTheme.showHeaderDescription ?? DEFAULT_THEME.descriptionShow,
metaColor: publicTheme.headerSecondaryTextColor ?? DEFAULT_THEME.metaColor,
textColor: publicTheme.headerPrimaryTextColor ?? DEFAULT_THEME.textColor,
return sanitizeTheme({
name: publicTheme.name?.toLowerCase(),
banner: publicTheme.showBannerImage,
bannerGradient: publicTheme.showBannerGradient,
bgColor: publicTheme.headerBackgroundColor ?? undefined,
descriptionShow: publicTheme.showHeaderDescription,
metaColor: publicTheme.headerSecondaryTextColor ?? undefined,
textColor: publicTheme.headerPrimaryTextColor ?? undefined,
});
};
};
onResult(({ data }) => {
if (data?.publicTheme) {
applyThemeFromQuery(data.publicTheme);
}
});
if (result.value?.publicTheme) {
applyThemeFromQuery(result.value.publicTheme);
}
onError((err) => {
console.warn('Failed to load theme from server, keeping existing theme:', err);
});
// Getters
// Apply dark mode for gray and black themes
const darkMode = computed<boolean>(() =>
DARK_UI_THEMES.includes(theme.value?.name as (typeof DARK_UI_THEMES)[number])
);
const bannerGradient = computed(() => {
if (!theme.value?.banner || !theme.value?.bannerGradient) {
return undefined;
}
const start = theme.value?.bgColor ? 'var(--header-gradient-start)' : 'rgba(0, 0, 0, 0)';
const end = theme.value?.bgColor ? 'var(--header-gradient-end)' : 'var(--header-background-color)';
return `background-image: linear-gradient(90deg, ${start} 0, ${end} 90%);`;
});
// Actions
const setTheme = (data?: Partial<Theme>, force = false) => {
if (data) {
if (hasServerTheme.value && !force && !devOverride.value) {
const applyThemeFromQuery = (publicTheme?: GetThemeQuery['publicTheme'] | null) => {
const sanitized = mapPublicTheme(publicTheme);
if (!sanitized) {
return;
}
theme.value = {
...theme.value,
...data,
};
}
};
setTheme(sanitized, { source: 'server' });
};
const setDevOverride = (enabled: boolean) => {
devOverride.value = enabled;
};
onResult(({ data }) => {
if (data?.publicTheme) {
applyThemeFromQuery(data.publicTheme);
}
});
const setCssVars = () => {
const selectedTheme = theme.value.name;
// Prepare Tailwind v4 theme classes
const themeClasses: string[] = [];
const customClasses: string[] = [];
// Apply dark/light mode using Tailwind v4 theme switching
if (darkMode.value) {
themeClasses.push('dark');
if (result.value?.publicTheme) {
applyThemeFromQuery(result.value.publicTheme);
}
// Apply theme-specific class for Tailwind v4 theme variants
themeClasses.push(`theme-${selectedTheme}`);
onError((err) => {
console.warn('Failed to load theme from server, keeping existing theme:', err);
});
// Only set CSS variables for dynamic/user-configured values from GraphQL
// Static theme values are handled by Tailwind v4 theme classes in @tailwind-shared
const dynamicVars: Partial<Record<DynamicVarKey, string>> = {};
// Getters
// Apply dark mode for gray and black themes
const darkMode = computed<boolean>(() =>
DARK_UI_THEMES.includes(theme.value?.name as (typeof DARK_UI_THEMES)[number])
);
// User-configured colors from webGUI @ /Settings/DisplaySettings
if (theme.value.textColor) {
dynamicVars['--custom-header-text-primary'] = theme.value.textColor;
customClasses.push('has-custom-header-text');
}
if (theme.value.metaColor) {
dynamicVars['--custom-header-text-secondary'] = theme.value.metaColor;
customClasses.push('has-custom-header-meta');
}
const bannerGradient = computed(() => {
if (!theme.value?.banner || !theme.value?.bannerGradient) {
return undefined;
}
const start = theme.value?.bgColor ? 'var(--header-gradient-start)' : 'rgba(0, 0, 0, 0)';
const end = theme.value?.bgColor ? 'var(--header-gradient-end)' : 'var(--header-background-color)';
return `background-image: linear-gradient(90deg, ${start} 0, ${end} 90%);`;
});
if (theme.value.bgColor) {
dynamicVars['--custom-header-background-color'] = theme.value.bgColor;
dynamicVars['--custom-header-gradient-start'] = hexToRgba(theme.value.bgColor, 0);
dynamicVars['--custom-header-gradient-end'] = hexToRgba(theme.value.bgColor, 0.7);
customClasses.push('has-custom-header-bg');
}
// Actions
const setTheme = (data?: Partial<Theme>, options: { source?: ThemeSource } = {}) => {
if (data) {
const { source = 'local' } = options;
// Set banner gradient if needed
if (theme.value.banner && theme.value.bannerGradient) {
const start = theme.value.bgColor
? hexToRgba(theme.value.bgColor, 0)
: 'var(--header-gradient-start)';
const end = theme.value.bgColor
? hexToRgba(theme.value.bgColor, 0.7)
: 'var(--header-gradient-end)';
dynamicVars['--banner-gradient'] = `linear-gradient(90deg, ${start} 0, ${end} 90%)`;
}
requestAnimationFrame(() => {
const scopedTargets: HTMLElement[] = [
document.documentElement,
...Array.from(document.querySelectorAll<HTMLElement>('.unapi')),
];
const cleanClassList = (classList: string) =>
classList
.split(' ')
.filter((c) => !c.startsWith('theme-') && c !== 'dark' && !c.startsWith('has-custom-'))
.filter(Boolean)
.join(' ');
// Apply theme and custom classes to html element and all .unapi roots
scopedTargets.forEach((target) => {
target.className = cleanClassList(target.className);
[...themeClasses, ...customClasses].forEach((cls) => target.classList.add(cls));
if (darkMode.value) {
target.classList.add('dark');
} else {
target.classList.remove('dark');
if (source === 'server') {
hasServerTheme.value = true;
} else if (hasServerTheme.value && !devOverride.value) {
return;
}
});
// Maintain dark mode flag on body for legacy components
const sanitized = sanitizeTheme({
...theme.value,
...data,
});
if (sanitized) {
theme.value = sanitized;
}
}
};
const setDevOverride = (enabled: boolean) => {
devOverride.value = enabled;
};
const setCssVars = () => {
const selectedTheme = theme.value.name;
// Prepare Tailwind v4 theme classes
const themeClasses: string[] = [];
const customClasses: string[] = [];
// Apply dark/light mode using Tailwind v4 theme switching
if (darkMode.value) {
document.body.classList.add('dark');
} else {
document.body.classList.remove('dark');
themeClasses.push('dark');
}
// Only apply dynamic CSS variables for custom user values
// All theme defaults are handled by classes in @tailwind-shared/theme-variants.css
const activeDynamicKeys = Object.keys(dynamicVars) as DynamicVarKey[];
// Apply theme-specific class for Tailwind v4 theme variants
themeClasses.push(`theme-${selectedTheme}`);
scopedTargets.forEach((target) => {
activeDynamicKeys.forEach((key) => {
const value = dynamicVars[key];
if (value !== undefined) {
target.style.setProperty(key, value);
// Only set CSS variables for dynamic/user-configured values from GraphQL
// Static theme values are handled by Tailwind v4 theme classes in @tailwind-shared
const dynamicVars: Partial<Record<DynamicVarKey, string>> = {};
// User-configured colors from webGUI @ /Settings/DisplaySettings
if (theme.value.textColor) {
dynamicVars['--custom-header-text-primary'] = theme.value.textColor;
customClasses.push('has-custom-header-text');
}
if (theme.value.metaColor) {
dynamicVars['--custom-header-text-secondary'] = theme.value.metaColor;
customClasses.push('has-custom-header-meta');
}
if (theme.value.bgColor) {
dynamicVars['--custom-header-background-color'] = theme.value.bgColor;
dynamicVars['--custom-header-gradient-start'] = hexToRgba(theme.value.bgColor, 0);
dynamicVars['--custom-header-gradient-end'] = hexToRgba(theme.value.bgColor, 0.7);
customClasses.push('has-custom-header-bg');
}
// Set banner gradient if needed
if (theme.value.banner && theme.value.bannerGradient) {
const start = theme.value.bgColor
? hexToRgba(theme.value.bgColor, 0)
: 'var(--header-gradient-start)';
const end = theme.value.bgColor
? hexToRgba(theme.value.bgColor, 0.7)
: 'var(--header-gradient-end)';
dynamicVars['--banner-gradient'] = `linear-gradient(90deg, ${start} 0, ${end} 90%)`;
}
requestAnimationFrame(() => {
const scopedTargets: HTMLElement[] = [
document.documentElement,
...Array.from(document.querySelectorAll<HTMLElement>('.unapi')),
];
const cleanClassList = (classList: string) =>
classList
.split(' ')
.filter((c) => !c.startsWith('theme-') && c !== 'dark' && !c.startsWith('has-custom-'))
.filter(Boolean)
.join(' ');
// Apply theme and custom classes to html element and all .unapi roots
scopedTargets.forEach((target) => {
target.className = cleanClassList(target.className);
[...themeClasses, ...customClasses].forEach((cls) => target.classList.add(cls));
if (darkMode.value) {
target.classList.add('dark');
} else {
target.classList.remove('dark');
}
});
DYNAMIC_VAR_KEYS.forEach((key) => {
if (!Object.prototype.hasOwnProperty.call(dynamicVars, key)) {
target.style.removeProperty(key);
}
// Maintain dark mode flag on body for legacy components
if (darkMode.value) {
document.body.classList.add('dark');
} else {
document.body.classList.remove('dark');
}
// Only apply dynamic CSS variables for custom user values
// All theme defaults are handled by classes in @tailwind-shared/theme-variants.css
const activeDynamicKeys = Object.keys(dynamicVars) as DynamicVarKey[];
scopedTargets.forEach((target) => {
activeDynamicKeys.forEach((key) => {
const value = dynamicVars[key];
if (value !== undefined) {
target.style.setProperty(key, value);
}
});
DYNAMIC_VAR_KEYS.forEach((key) => {
if (!Object.prototype.hasOwnProperty.call(dynamicVars, key)) {
target.style.removeProperty(key);
}
});
});
// Store active variables for reference (from defaultColors for compatibility)
const customTheme = { ...defaultColors[selectedTheme] };
activeColorVariables.value = customTheme;
});
};
// Store active variables for reference (from defaultColors for compatibility)
const customTheme = { ...defaultColors[selectedTheme] };
activeColorVariables.value = customTheme;
});
};
watch(
theme,
() => {
setCssVars();
},
{ immediate: true }
);
watch(theme, () => {
setCssVars();
});
return {
// state
activeColorVariables,
bannerGradient,
darkMode,
theme,
// actions
setTheme,
setCssVars,
setDevOverride,
};
});
return {
// state
activeColorVariables,
bannerGradient,
darkMode,
theme,
// actions
setTheme,
setCssVars,
setDevOverride,
};
},
{
persist: {
key: THEME_STORAGE_KEY,
pick: ['theme'],
afterHydrate: (ctx) => {
const store = ctx.store as ReturnType<typeof useThemeStore>;
store.setTheme(store.theme);
},
},
}
);