diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 3d65d9c93bb..19934fdff42 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -3,6 +3,7 @@ "ignorePatterns": [ ".reference", ".plans", + ".cache", "dist", "dist-electron", "node_modules", diff --git a/apps/web/src/hooks/useTheme.test.ts b/apps/web/src/hooks/useTheme.test.ts new file mode 100644 index 00000000000..b003f01a1e9 --- /dev/null +++ b/apps/web/src/hooks/useTheme.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +function installThemeDom(theme: string | null, matchMedia?: Window["matchMedia"]) { + const element = { + name: "", + setAttribute: vi.fn(), + }; + const documentElement = { + classList: { + add: vi.fn(), + remove: vi.fn(), + toggle: vi.fn(), + }, + offsetHeight: 0, + style: {}, + }; + const body = { + style: {}, + }; + + vi.stubGlobal("localStorage", { + getItem: vi.fn(() => theme), + setItem: vi.fn(), + }); + vi.stubGlobal("window", { + matchMedia, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + vi.stubGlobal("document", { + body, + createElement: vi.fn(() => element), + documentElement, + head: { + append: vi.fn(), + }, + querySelector: vi.fn(() => null), + }); + vi.stubGlobal( + "getComputedStyle", + vi.fn(() => ({ backgroundColor: "rgb(1, 2, 3)" })), + ); +} + +describe("useTheme module initialization", () => { + afterEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + }); + + it("does not read matchMedia for explicit themes", async () => { + const matchMedia = vi.fn(() => ({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })) as unknown as Window["matchMedia"]; + installThemeDom("dark", matchMedia); + + await import("./useTheme"); + + expect(matchMedia).not.toHaveBeenCalled(); + }); + + it("does not require matchMedia when an explicit theme is stored", async () => { + installThemeDom("light"); + + await expect(import("./useTheme")).resolves.toBeDefined(); + }); +}); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index cd254c97548..ed76c734a41 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -18,6 +18,7 @@ const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data let listeners: Array<() => void> = []; let lastSnapshot: ThemeSnapshot | null = null; let lastDesktopTheme: Theme | null = null; +let lastAppliedTheme: ThemeSnapshot | null = null; function emitChange() { for (const listener of listeners) listener(); @@ -28,7 +29,11 @@ function hasThemeStorage() { } function getSystemDark() { - return typeof window !== "undefined" && window.matchMedia(MEDIA_QUERY).matches; + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia(MEDIA_QUERY).matches + ); } function getStored(): Theme { @@ -89,11 +94,18 @@ export function syncBrowserChromeTheme() { function applyTheme(theme: Theme, suppressTransitions = false) { if (typeof document === "undefined" || typeof window === "undefined") return; + const systemDark = theme === "system" ? getSystemDark() : false; + if (lastAppliedTheme?.theme === theme && lastAppliedTheme.systemDark === systemDark) { + syncDesktopTheme(theme); + return; + } + if (suppressTransitions) { document.documentElement.classList.add("no-transitions"); } - const isDark = theme === "dark" || (theme === "system" && getSystemDark()); + const isDark = theme === "dark" || (theme === "system" && systemDark); document.documentElement.classList.toggle("dark", isDark); + lastAppliedTheme = { theme, systemDark }; syncBrowserChromeTheme(); syncDesktopTheme(theme); if (suppressTransitions) { @@ -148,12 +160,12 @@ function subscribe(listener: () => void): () => void { listeners.push(listener); // Listen for system preference changes - const mq = window.matchMedia(MEDIA_QUERY); + const mq = typeof window.matchMedia === "function" ? window.matchMedia(MEDIA_QUERY) : null; const handleChange = () => { if (getStored() === "system") applyTheme("system", true); emitChange(); }; - mq.addEventListener("change", handleChange); + mq?.addEventListener("change", handleChange); // Listen for storage changes from other tabs const handleStorage = (e: StorageEvent) => { @@ -166,7 +178,7 @@ function subscribe(listener: () => void): () => void { return () => { listeners = listeners.filter((l) => l !== listener); - mq.removeEventListener("change", handleChange); + mq?.removeEventListener("change", handleChange); window.removeEventListener("storage", handleStorage); }; }