feat(FN-1277): hydrate dashboard theme settings from backend

- Hydrate useTheme from global settings while seeding initial state from localStorage cache to prevent flash
- Add write-through theme persistence so setter updates local cache immediately and asynchronously saves via updateGlobalSettings
- Clarify Settings modal save behavior for intentional idempotent global theme writes
- Expand useTheme and App tests to cover backend hydration, fallback handling, and new global settings fetch expectations
This commit is contained in:
gsxdsm
2026-04-08 11:58:18 -07:00
parent 9ac5b19646
commit 9219323bc0
4 changed files with 293 additions and 75 deletions

View File

@@ -1,8 +1,20 @@
import { readFileSync } from "node:fs";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { COLOR_THEMES } from "@fusion/core";
import { renderHook, act, waitFor } from "@testing-library/react";
import { COLOR_THEMES, type Settings } from "@fusion/core";
import { useTheme, getThemeInitScript } from "../useTheme";
import { fetchGlobalSettings, updateGlobalSettings } from "../../api";
vi.mock("../../api", () => ({
fetchGlobalSettings: vi.fn(),
updateGlobalSettings: vi.fn(),
}));
const THEME_MODE_STORAGE_KEY = "kb-dashboard-theme-mode";
const COLOR_THEME_STORAGE_KEY = "kb-dashboard-color-theme";
const mockFetchGlobalSettings = vi.mocked(fetchGlobalSettings);
const mockUpdateGlobalSettings = vi.mocked(updateGlobalSettings);
describe("useTheme", () => {
// Mock localStorage
@@ -11,6 +23,7 @@ describe("useTheme", () => {
// Mock matchMedia
let matchMediaListeners: Array<(e: { matches: boolean }) => void> = [];
let currentSystemDark = true;
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Reset mocks
@@ -18,6 +31,14 @@ describe("useTheme", () => {
matchMediaListeners = [];
currentSystemDark = true;
mockFetchGlobalSettings.mockReset();
mockUpdateGlobalSettings.mockReset();
// Default: keep hydration pending unless a test opts into explicit backend behavior.
mockFetchGlobalSettings.mockImplementation(() => new Promise(() => {}));
mockUpdateGlobalSettings.mockResolvedValue({} as Settings);
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
// Mock localStorage
vi.stubGlobal("localStorage", {
getItem: (key: string) => localStorageMock[key] || null,
@@ -53,6 +74,7 @@ describe("useTheme", () => {
});
afterEach(() => {
consoleWarnSpy.mockRestore();
vi.unstubAllGlobals();
});
@@ -64,8 +86,8 @@ describe("useTheme", () => {
});
it("initializes from localStorage", () => {
localStorageMock["kb-dashboard-theme-mode"] = "light";
localStorageMock["kb-dashboard-color-theme"] = "ocean";
localStorageMock[THEME_MODE_STORAGE_KEY] = "light";
localStorageMock[COLOR_THEME_STORAGE_KEY] = "ocean";
const { result } = renderHook(() => useTheme());
@@ -73,6 +95,120 @@ describe("useTheme", () => {
expect(result.current.colorTheme).toBe("ocean");
});
it("hydrates themeMode from backend on mount", async () => {
mockFetchGlobalSettings.mockResolvedValue({ themeMode: "light" });
const { result } = renderHook(() => useTheme());
expect(result.current.themeMode).toBe("dark");
await waitFor(() => {
expect(result.current.themeMode).toBe("light");
});
expect(localStorageMock[THEME_MODE_STORAGE_KEY]).toBe("light");
});
it("hydrates colorTheme from backend on mount", async () => {
mockFetchGlobalSettings.mockResolvedValue({ colorTheme: "ocean" });
const { result } = renderHook(() => useTheme());
expect(result.current.colorTheme).toBe("default");
await waitFor(() => {
expect(result.current.colorTheme).toBe("ocean");
});
expect(localStorageMock[COLOR_THEME_STORAGE_KEY]).toBe("ocean");
});
it("prefers backend over localStorage on hydration", async () => {
localStorageMock[THEME_MODE_STORAGE_KEY] = "light";
mockFetchGlobalSettings.mockResolvedValue({ themeMode: "dark" });
const { result } = renderHook(() => useTheme());
expect(result.current.themeMode).toBe("light");
await waitFor(() => {
expect(result.current.themeMode).toBe("dark");
});
expect(localStorageMock[THEME_MODE_STORAGE_KEY]).toBe("dark");
});
it("keeps localStorage value when backend matches", async () => {
localStorageMock[THEME_MODE_STORAGE_KEY] = "dark";
mockFetchGlobalSettings.mockResolvedValue({ themeMode: "dark" });
const { result } = renderHook(() => useTheme());
expect(result.current.themeMode).toBe("dark");
await waitFor(() => {
expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1);
});
expect(result.current.themeMode).toBe("dark");
expect(localStorageMock[THEME_MODE_STORAGE_KEY]).toBe("dark");
});
it("write-through calls updateGlobalSettings on setThemeMode", () => {
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setThemeMode("light");
});
expect(result.current.themeMode).toBe("light");
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ themeMode: "light" });
});
it("write-through calls updateGlobalSettings on setColorTheme", () => {
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setColorTheme("forest");
});
expect(result.current.colorTheme).toBe("forest");
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ colorTheme: "forest" });
});
it("write-through updates localStorage immediately", () => {
let resolveUpdate: (value: Settings) => void;
const pendingUpdate = new Promise<Settings>((resolve) => {
resolveUpdate = resolve;
});
mockUpdateGlobalSettings.mockReturnValue(pendingUpdate);
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setThemeMode("system");
});
expect(localStorageMock[THEME_MODE_STORAGE_KEY]).toBe("system");
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ themeMode: "system" });
resolveUpdate!({} as Settings);
});
it("backend hydration failure falls back to localStorage", async () => {
localStorageMock[THEME_MODE_STORAGE_KEY] = "light";
mockFetchGlobalSettings.mockRejectedValue(new Error("network unavailable"));
const { result } = renderHook(() => useTheme());
await waitFor(() => {
expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1);
});
expect(result.current.themeMode).toBe("light");
expect(consoleWarnSpy).toHaveBeenCalledWith(
"[useTheme] Failed to hydrate theme from global settings",
expect.any(Error),
);
});
it("updates theme mode", () => {
const { result } = renderHook(() => useTheme());
@@ -81,7 +217,7 @@ describe("useTheme", () => {
});
expect(result.current.themeMode).toBe("light");
expect(localStorageMock["kb-dashboard-theme-mode"]).toBe("light");
expect(localStorageMock[THEME_MODE_STORAGE_KEY]).toBe("light");
});
it("updates color theme", () => {
@@ -92,7 +228,7 @@ describe("useTheme", () => {
});
expect(result.current.colorTheme).toBe("forest");
expect(localStorageMock["kb-dashboard-color-theme"]).toBe("forest");
expect(localStorageMock[COLOR_THEME_STORAGE_KEY]).toBe("forest");
});
it("sets data-theme attribute on document", () => {
@@ -102,7 +238,7 @@ describe("useTheme", () => {
});
it("sets data-color-theme attribute on document", () => {
localStorageMock["kb-dashboard-color-theme"] = "sunset";
localStorageMock[COLOR_THEME_STORAGE_KEY] = "sunset";
renderHook(() => useTheme());
@@ -111,7 +247,7 @@ describe("useTheme", () => {
it("handles system theme mode by setting effective theme", () => {
currentSystemDark = false;
localStorageMock["kb-dashboard-theme-mode"] = "system";
localStorageMock[THEME_MODE_STORAGE_KEY] = "system";
renderHook(() => useTheme());
@@ -151,9 +287,9 @@ describe("useTheme", () => {
});
it("updates effective theme when system changes in system mode", () => {
localStorageMock["kb-dashboard-theme-mode"] = "system";
localStorageMock[THEME_MODE_STORAGE_KEY] = "system";
const { result } = renderHook(() => useTheme());
renderHook(() => useTheme());
// Initially dark
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
@@ -169,7 +305,7 @@ describe("useTheme", () => {
});
it("applies factory theme attributes", () => {
localStorageMock["kb-dashboard-color-theme"] = "factory";
localStorageMock[COLOR_THEME_STORAGE_KEY] = "factory";
renderHook(() => useTheme());
@@ -178,7 +314,7 @@ describe("useTheme", () => {
});
it("applies nord theme attributes", () => {
localStorageMock["kb-dashboard-color-theme"] = "nord";
localStorageMock[COLOR_THEME_STORAGE_KEY] = "nord";
renderHook(() => useTheme());
@@ -187,7 +323,7 @@ describe("useTheme", () => {
});
it("applies dracula theme attributes", () => {
localStorageMock["kb-dashboard-color-theme"] = "dracula";
localStorageMock[COLOR_THEME_STORAGE_KEY] = "dracula";
renderHook(() => useTheme());
@@ -196,7 +332,7 @@ describe("useTheme", () => {
});
it("applies gruvbox theme attributes", () => {
localStorageMock["kb-dashboard-color-theme"] = "gruvbox";
localStorageMock[COLOR_THEME_STORAGE_KEY] = "gruvbox";
renderHook(() => useTheme());
@@ -205,7 +341,7 @@ describe("useTheme", () => {
});
it("applies tokyo-night theme attributes", () => {
localStorageMock["kb-dashboard-color-theme"] = "tokyo-night";
localStorageMock[COLOR_THEME_STORAGE_KEY] = "tokyo-night";
renderHook(() => useTheme());
@@ -218,7 +354,7 @@ describe("useTheme", () => {
style.textContent = readFileSync("app/styles.css", "utf8");
document.head.appendChild(style);
localStorageMock["kb-dashboard-color-theme"] = "factory";
localStorageMock[COLOR_THEME_STORAGE_KEY] = "factory";
renderHook(() => useTheme());
@@ -253,7 +389,7 @@ describe("useTheme", () => {
});
it("ignores invalid theme mode in localStorage", () => {
localStorageMock["kb-dashboard-theme-mode"] = "invalid";
localStorageMock[THEME_MODE_STORAGE_KEY] = "invalid";
const { result } = renderHook(() => useTheme());
@@ -261,7 +397,7 @@ describe("useTheme", () => {
});
it("ignores invalid color theme in localStorage", () => {
localStorageMock["kb-dashboard-color-theme"] = "invalid-theme";
localStorageMock[COLOR_THEME_STORAGE_KEY] = "invalid-theme";
const { result } = renderHook(() => useTheme());
@@ -301,8 +437,8 @@ describe("getThemeInitScript", () => {
it("includes the correct localStorage keys", () => {
const script = getThemeInitScript();
expect(script).toContain("kb-dashboard-theme-mode");
expect(script).toContain("kb-dashboard-color-theme");
expect(script).toContain(THEME_MODE_STORAGE_KEY);
expect(script).toContain(COLOR_THEME_STORAGE_KEY);
});
it("includes every supported theme in the validated theme list", () => {

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useLayoutEffect } from "react";
import { useState, useEffect, useCallback, useLayoutEffect, useRef } from "react";
import { COLOR_THEMES, type ThemeMode, type ColorTheme } from "@fusion/core";
import { fetchGlobalSettings, updateGlobalSettings } from "../api";
const THEME_MODE_STORAGE_KEY = "kb-dashboard-theme-mode";
const COLOR_THEME_STORAGE_KEY = "kb-dashboard-color-theme";
@@ -19,6 +20,54 @@ interface UseThemeReturn {
isSystemDark: boolean;
}
function isValidThemeMode(value: unknown): value is ThemeMode {
return value === "dark" || value === "light" || value === "system";
}
function readCachedThemeMode(): ThemeMode {
if (!isBrowser) return "dark";
try {
const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY);
if (isValidThemeMode(saved)) {
return saved;
}
} catch {
// localStorage not available, use default
}
return "dark";
}
function readCachedColorTheme(): ColorTheme {
if (!isBrowser) return "default";
try {
const saved = localStorage.getItem(COLOR_THEME_STORAGE_KEY);
if (saved && VALID_COLOR_THEMES.includes(saved as ColorTheme)) {
return saved as ColorTheme;
}
} catch {
// localStorage not available, use default
}
return "default";
}
function writeCachedThemeMode(mode: ThemeMode): void {
if (!isBrowser) return;
try {
localStorage.setItem(THEME_MODE_STORAGE_KEY, mode);
} catch {
// localStorage not available, skip cache write
}
}
function writeCachedColorTheme(theme: ColorTheme): void {
if (!isBrowser) return;
try {
localStorage.setItem(COLOR_THEME_STORAGE_KEY, theme);
} catch {
// localStorage not available, skip cache write
}
}
/**
* Get the effective theme mode (resolves "system" to actual dark/light value)
*/
@@ -42,36 +91,20 @@ function applyThemeAttributes(themeMode: ThemeMode, colorTheme: ColorTheme, syst
}
/**
* Custom hook for theme management
* Handles localStorage persistence, system preference detection, and theme application
* Custom hook for theme management.
*
* Source of truth: backend global settings (`~/.pi/fusion/settings.json`).
*
* Behavior:
* - Initializes from localStorage cache to avoid pre-hydration theme flash
* - Hydrates from backend global settings on mount and reconciles cache
* - Writes through on updates (state + localStorage cache + async backend update)
*/
export function useTheme(): UseThemeReturn {
// Initialize from localStorage or defaults
const [themeMode, setThemeModeState] = useState<ThemeMode>(() => {
if (!isBrowser) return "dark";
try {
const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY);
if (saved === "dark" || saved === "light" || saved === "system") {
return saved;
}
} catch {
// localStorage not available, use default
}
return "dark";
});
const [colorTheme, setColorThemeState] = useState<ColorTheme>(() => {
if (!isBrowser) return "default";
try {
const saved = localStorage.getItem(COLOR_THEME_STORAGE_KEY);
if (saved && VALID_COLOR_THEMES.includes(saved as ColorTheme)) {
return saved as ColorTheme;
}
} catch {
// localStorage not available, use default
}
return "default";
});
// Initialize from localStorage cache or defaults to avoid flash before hydration.
const [themeMode, setThemeModeState] = useState<ThemeMode>(() => readCachedThemeMode());
const [colorTheme, setColorThemeState] = useState<ColorTheme>(() => readCachedColorTheme());
const [isHydrating, setIsHydrating] = useState(true);
// Track system color scheme preference
const [isSystemDark, setIsSystemDark] = useState<boolean>(() => {
@@ -79,6 +112,59 @@ export function useTheme(): UseThemeReturn {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
});
const themeModeRef = useRef(themeMode);
const colorThemeRef = useRef(colorTheme);
useEffect(() => {
themeModeRef.current = themeMode;
}, [themeMode]);
useEffect(() => {
colorThemeRef.current = colorTheme;
}, [colorTheme]);
// Hydrate canonical theme values from backend global settings.
useEffect(() => {
if (!isBrowser || !isHydrating) return;
let cancelled = false;
void fetchGlobalSettings()
.then((globalSettings) => {
if (cancelled) return;
if (isValidThemeMode(globalSettings.themeMode)) {
if (themeModeRef.current !== globalSettings.themeMode) {
setThemeModeState(globalSettings.themeMode);
}
if (readCachedThemeMode() !== globalSettings.themeMode) {
writeCachedThemeMode(globalSettings.themeMode);
}
}
if (globalSettings.colorTheme && VALID_COLOR_THEMES.includes(globalSettings.colorTheme)) {
if (colorThemeRef.current !== globalSettings.colorTheme) {
setColorThemeState(globalSettings.colorTheme);
}
if (readCachedColorTheme() !== globalSettings.colorTheme) {
writeCachedColorTheme(globalSettings.colorTheme);
}
}
})
.catch((error) => {
console.warn("[useTheme] Failed to hydrate theme from global settings", error);
})
.finally(() => {
if (!cancelled) {
setIsHydrating(false);
}
});
return () => {
cancelled = true;
};
}, [isHydrating]);
// Listen to system color scheme changes
useEffect(() => {
if (!isBrowser) return;
@@ -97,32 +183,23 @@ export function useTheme(): UseThemeReturn {
applyThemeAttributes(themeMode, colorTheme, isSystemDark);
}, [themeMode, colorTheme, isSystemDark]);
// Persist theme to localStorage
useEffect(() => {
if (!isBrowser) return;
try {
localStorage.setItem(THEME_MODE_STORAGE_KEY, themeMode);
} catch {
// localStorage not available, skip persistence
}
}, [themeMode]);
useEffect(() => {
if (!isBrowser) return;
try {
localStorage.setItem(COLOR_THEME_STORAGE_KEY, colorTheme);
} catch {
// localStorage not available, skip persistence
}
}, [colorTheme]);
// Wrapper setters
// Wrapper setters with write-through persistence.
const setThemeMode = useCallback((mode: ThemeMode) => {
setThemeModeState(mode);
writeCachedThemeMode(mode);
void updateGlobalSettings({ themeMode: mode }).catch((error) => {
console.warn("[useTheme] Failed to persist themeMode to global settings", error);
});
}, []);
const setColorTheme = useCallback((theme: ColorTheme) => {
setColorThemeState(theme);
writeCachedColorTheme(theme);
void updateGlobalSettings({ colorTheme: theme }).catch((error) => {
console.warn("[useTheme] Failed to persist colorTheme to global settings", error);
});
}, []);
return {
@@ -135,8 +212,10 @@ export function useTheme(): UseThemeReturn {
}
/**
* Utility to apply theme before React hydration
* Call this in a script tag in index.html to prevent theme flash
* Utility to apply theme before React hydration.
*
* This script intentionally reads from localStorage because it runs synchronously
* before React boots; localStorage is treated as a backend-synced cache.
*/
export function getThemeInitScript(): string {
return `