From bdbe44aa905681a4db5b8469527b34b2afe9ba6c Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Fri, 5 Jun 2026 10:24:29 +0700 Subject: [PATCH] fix(theme): prevent default theme flash on hard refresh Seed the theme store from a synchronous localStorage mirror so the first render uses the saved theme before async localForage load completes. --- src/stores/__tests__/theme-store.test.ts | 36 +++++++++++++++++ src/stores/use-theme-store.ts | 50 ++++++++++++++++++++---- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/src/stores/__tests__/theme-store.test.ts b/src/stores/__tests__/theme-store.test.ts index 9d013973..c40144b5 100644 --- a/src/stores/__tests__/theme-store.test.ts +++ b/src/stores/__tests__/theme-store.test.ts @@ -26,6 +26,7 @@ const waitFor = async (predicate: () => boolean) => { describe('useThemeStore', () => { beforeEach(() => { vi.resetModules(); + localStorage.clear(); testState.entriesMock.mockReset(); testState.entriesMock.mockResolvedValue([]); testState.setItemMock.mockReset(); @@ -69,4 +70,39 @@ describe('useThemeStore', () => { expect(store.getState().getTheme('sfw')).toBe('yotsuba-b'); expect(store.getState().currentTheme).toBe('yotsuba-b'); }); + + it('initializes themes synchronously from localStorage before async load resolves', async () => { + // Keep the async localForage load pending so it cannot overwrite the synchronous init. + testState.entriesMock.mockReturnValue(new Promise(() => {})); + localStorage.setItem('5chan-themes', JSON.stringify({ nsfw: 'tomorrow', sfw: 'photon' })); + + const store = (await import('../use-theme-store')).default; + + // No await for the async load: the first render must already see the saved themes, + // otherwise the default theme flashes before the saved one loads. + expect(store.getState().themes).toEqual({ nsfw: 'tomorrow', sfw: 'photon' }); + }); + + it('falls back to defaults when localStorage is empty or invalid', async () => { + testState.entriesMock.mockReturnValue(new Promise(() => {})); + localStorage.setItem('5chan-themes', 'not-json'); + + const store = (await import('../use-theme-store')).default; + + expect(store.getState().themes).toEqual({ nsfw: 'yotsuba', sfw: 'yotsuba-b' }); + }); + + it('setTheme mirrors the saved theme into localStorage', async () => { + // Keep the async load pending so it cannot race and overwrite the localStorage mirror. + testState.entriesMock.mockReturnValue(new Promise(() => {})); + const store = (await import('../use-theme-store')).default; + + await store.getState().setTheme('sfw', 'tomorrow'); + + expect(testState.setItemMock).toHaveBeenCalledWith('sfw', 'tomorrow'); + expect(JSON.parse(localStorage.getItem('5chan-themes') ?? '{}')).toEqual({ + nsfw: 'yotsuba', + sfw: 'tomorrow', + }); + }); }); diff --git a/src/stores/use-theme-store.ts b/src/stores/use-theme-store.ts index 3358bd36..b88f91e0 100644 --- a/src/stores/use-theme-store.ts +++ b/src/stores/use-theme-store.ts @@ -12,21 +12,55 @@ interface ThemeState { loadThemes: () => Promise; } +const DEFAULT_THEMES: ThemeState['themes'] = { + nsfw: 'yotsuba', + sfw: 'yotsuba-b', +}; + +// Synchronous localStorage mirror of the persisted themes. The canonical store is +// localForage (IndexedDB) below, but reads from it are async, so on a hard refresh the +// first render would fall back to DEFAULT_THEMES and flash the default theme before the +// saved one loads. Seeding the initial state from localStorage (read synchronously) lets +// the very first render use the saved theme, eliminating that flash. +const LOCALSTORAGE_KEY = '5chan-themes'; + +const readThemesFromLocalStorage = (): ThemeState['themes'] => { + try { + const stored = localStorage.getItem(LOCALSTORAGE_KEY); + if (!stored) { + return { ...DEFAULT_THEMES }; + } + const parsed = JSON.parse(stored) as Partial; + return { + nsfw: typeof parsed?.nsfw === 'string' && parsed.nsfw ? parsed.nsfw : DEFAULT_THEMES.nsfw, + sfw: typeof parsed?.sfw === 'string' && parsed.sfw ? parsed.sfw : DEFAULT_THEMES.sfw, + }; + } catch { + return { ...DEFAULT_THEMES }; + } +}; + +const writeThemesToLocalStorage = (themes: ThemeState['themes']) => { + try { + localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(themes)); + } catch (error) { + console.warn('Failed to save themes to localStorage:', error); + } +}; + const themeStore = localForageLru.createInstance({ name: 'themeStore', size: 1000, }); const useThemeStore = create((set: StoreApi['setState'], get: StoreApi['getState']) => ({ - themes: { - nsfw: 'yotsuba', - sfw: 'yotsuba-b', - }, + themes: readThemesFromLocalStorage(), currentTheme: null, setTheme: async (category, theme) => { const currentThemes = get().themes; const updatedThemes = { ...currentThemes, [category]: theme }; await themeStore.setItem(category, theme); + writeThemesToLocalStorage(updatedThemes); set({ themes: updatedThemes, currentTheme: theme }); }, getTheme: (category, updateCurrentTheme = true) => { @@ -39,13 +73,13 @@ const useThemeStore = create((set: StoreApi['setState'], }, loadThemes: async () => { const entries: [keyof ThemeState['themes'], string][] = await themeStore.entries(); - const themes: Record = { - nsfw: 'yotsuba', - sfw: 'yotsuba-b', - }; + const themes: Record = { ...DEFAULT_THEMES }; entries.forEach(([key, value]) => { themes[key] = value; }); + // Mirror the canonical localForage values into the synchronous localStorage cache so the + // next hard refresh can read the saved theme on the first render (migrates existing users). + writeThemesToLocalStorage(themes); set({ themes, currentTheme: null }); }, }));