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:
@@ -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", () => {
|
||||
|
||||
@@ -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 `
|
||||
|
||||
Reference in New Issue
Block a user