mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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.
This commit is contained in:
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,21 +12,55 @@ interface ThemeState {
|
||||
loadThemes: () => Promise<void>;
|
||||
}
|
||||
|
||||
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<ThemeState['themes']>;
|
||||
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<ThemeState>((set: StoreApi<ThemeState>['setState'], get: StoreApi<ThemeState>['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<ThemeState>((set: StoreApi<ThemeState>['setState'],
|
||||
},
|
||||
loadThemes: async () => {
|
||||
const entries: [keyof ThemeState['themes'], string][] = await themeStore.entries();
|
||||
const themes: Record<keyof ThemeState['themes'], string> = {
|
||||
nsfw: 'yotsuba',
|
||||
sfw: 'yotsuba-b',
|
||||
};
|
||||
const themes: Record<keyof ThemeState['themes'], string> = { ...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 });
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user