Skip to content

Commit 854b403

Browse files
authored
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 -->
1 parent c264a18 commit 854b403

File tree

6 files changed

+334
-172
lines changed

6 files changed

+334
-172
lines changed

pnpm-lock.yaml

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/__test__/store/theme.test.ts

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22
* Theme store test coverage
33
*/
44

5-
import { nextTick, ref } from 'vue';
6-
import { createPinia, setActivePinia } from 'pinia';
5+
import { createApp, nextTick, ref } from 'vue';
6+
import { setActivePinia } from 'pinia';
77

88
import { defaultColors } from '~/themes/default';
99
import hexToRgba from 'hex-to-rgba';
1010
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
1111

12-
import { useThemeStore } from '~/store/theme';
12+
import type { Theme } from '~/themes/types';
13+
14+
import { globalPinia } from '~/store/globalPinia';
15+
import { THEME_STORAGE_KEY, useThemeStore } from '~/store/theme';
1316

1417
vi.mock('@vue/apollo-composable', () => ({
1518
useQuery: () => ({
@@ -25,15 +28,23 @@ vi.mock('hex-to-rgba', () => ({
2528
}));
2629

2730
describe('Theme Store', () => {
28-
let store: ReturnType<typeof useThemeStore>;
2931
const originalAddClassFn = document.body.classList.add;
3032
const originalRemoveClassFn = document.body.classList.remove;
3133
const originalStyleCssText = document.body.style.cssText;
3234
const originalDocumentElementSetProperty = document.documentElement.style.setProperty;
35+
const originalDocumentElementAddClass = document.documentElement.classList.add;
36+
const originalDocumentElementRemoveClass = document.documentElement.classList.remove;
37+
38+
let store: ReturnType<typeof useThemeStore> | undefined;
39+
let app: ReturnType<typeof createApp> | undefined;
3340

3441
beforeEach(() => {
35-
setActivePinia(createPinia());
36-
store = useThemeStore();
42+
app = createApp({ render: () => null });
43+
app.use(globalPinia);
44+
setActivePinia(globalPinia);
45+
store = undefined;
46+
window.localStorage.clear();
47+
delete (globalPinia.state.value as Record<string, unknown>).theme;
3748

3849
document.body.classList.add = vi.fn();
3950
document.body.classList.remove = vi.fn();
@@ -51,16 +62,34 @@ describe('Theme Store', () => {
5162
});
5263

5364
afterEach(() => {
54-
// Restore original methods
65+
store?.$dispose();
66+
store = undefined;
67+
app?.unmount();
68+
app = undefined;
69+
5570
document.body.classList.add = originalAddClassFn;
5671
document.body.classList.remove = originalRemoveClassFn;
5772
document.body.style.cssText = originalStyleCssText;
5873
document.documentElement.style.setProperty = originalDocumentElementSetProperty;
74+
document.documentElement.classList.add = originalDocumentElementAddClass;
75+
document.documentElement.classList.remove = originalDocumentElementRemoveClass;
5976
vi.restoreAllMocks();
6077
});
6178

79+
const createStore = () => {
80+
if (!store) {
81+
store = useThemeStore();
82+
}
83+
84+
return store;
85+
};
86+
6287
describe('State and Initialization', () => {
6388
it('should initialize with default theme', () => {
89+
const store = createStore();
90+
91+
expect(typeof store.$persist).toBe('function');
92+
6493
expect(store.theme).toEqual({
6594
name: 'white',
6695
banner: false,
@@ -74,6 +103,8 @@ describe('Theme Store', () => {
74103
});
75104

76105
it('should compute darkMode correctly', () => {
106+
const store = createStore();
107+
77108
expect(store.darkMode).toBe(false);
78109

79110
store.setTheme({ ...store.theme, name: 'black' });
@@ -87,6 +118,8 @@ describe('Theme Store', () => {
87118
});
88119

89120
it('should compute bannerGradient correctly', () => {
121+
const store = createStore();
122+
90123
expect(store.bannerGradient).toBeUndefined();
91124

92125
store.setTheme({
@@ -112,6 +145,8 @@ describe('Theme Store', () => {
112145

113146
describe('Actions', () => {
114147
it('should set theme correctly', () => {
148+
const store = createStore();
149+
115150
const newTheme = {
116151
name: 'black',
117152
banner: true,
@@ -127,6 +162,8 @@ describe('Theme Store', () => {
127162
});
128163

129164
it('should update body classes for dark mode', async () => {
165+
const store = createStore();
166+
130167
store.setTheme({ ...store.theme, name: 'black' });
131168

132169
await nextTick();
@@ -141,6 +178,8 @@ describe('Theme Store', () => {
141178
});
142179

143180
it('should update activeColorVariables when theme changes', async () => {
181+
const store = createStore();
182+
144183
store.setTheme({
145184
...store.theme,
146185
name: 'white',
@@ -170,6 +209,7 @@ describe('Theme Store', () => {
170209
});
171210

172211
it('should handle banner gradient correctly', async () => {
212+
const store = createStore();
173213
const mockHexToRgba = vi.mocked(hexToRgba);
174214

175215
mockHexToRgba.mockClear();
@@ -200,5 +240,44 @@ describe('Theme Store', () => {
200240
'linear-gradient(90deg, rgba(mock-#112233-0) 0, rgba(mock-#112233-0.7) 90%)'
201241
);
202242
});
243+
244+
it('should hydrate theme from cache when available', () => {
245+
const cachedTheme = {
246+
name: 'black',
247+
banner: true,
248+
bannerGradient: false,
249+
bgColor: '#222222',
250+
descriptionShow: true,
251+
metaColor: '#aaaaaa',
252+
textColor: '#ffffff',
253+
} satisfies Theme;
254+
255+
window.localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify({ theme: cachedTheme }));
256+
257+
const store = createStore();
258+
259+
expect(store.theme).toEqual(cachedTheme);
260+
});
261+
262+
it('should persist server theme responses to cache', async () => {
263+
const store = createStore();
264+
265+
const serverTheme = {
266+
name: 'gray',
267+
banner: false,
268+
bannerGradient: false,
269+
bgColor: '#111111',
270+
descriptionShow: false,
271+
metaColor: '#999999',
272+
textColor: '#eeeeee',
273+
} satisfies Theme;
274+
275+
store.setTheme(serverTheme, { source: 'server' });
276+
await nextTick();
277+
278+
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toEqual(
279+
JSON.stringify({ theme: serverTheme })
280+
);
281+
});
203282
});
204283
});

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@
132132
"marked": "16.2.1",
133133
"marked-base-url": "1.1.7",
134134
"pinia": "3.0.3",
135+
"pinia-plugin-persistedstate": "4.7.1",
135136
"postcss-import": "16.1.1",
136137
"semver": "7.7.2",
137138
"tailwind-merge": "2.6.0",

web/src/components/DevThemeSwitcher.standalone.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ const updateTheme = (themeName: string, skipUrlUpdate = false) => {
5151
// ignore
5252
}
5353
54-
themeStore.setTheme({ name: themeName }, true);
54+
themeStore.setTheme({ name: themeName });
5555
themeStore.setCssVars();
5656
5757
const linkId = 'dev-theme-css-link';
@@ -100,7 +100,7 @@ onMounted(() => {
100100
if (!existingLink || !existingLink.href) {
101101
updateTheme(initialTheme, true);
102102
} else {
103-
themeStore.setTheme({ name: initialTheme }, true);
103+
themeStore.setTheme({ name: initialTheme });
104104
themeStore.setCssVars();
105105
}
106106
});

web/src/store/globalPinia.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { createPinia, setActivePinia } from 'pinia';
22

3+
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
4+
35
// Create a single shared Pinia instance for all web components
46
export const globalPinia = createPinia();
7+
globalPinia.use(piniaPluginPersistedstate);
58

69
// IMPORTANT: Set it as the active pinia instance immediately
710
// This ensures stores work even when called during component setup

0 commit comments

Comments
 (0)