diff --git a/__tests__/contexts/theme-context.test.tsx b/__tests__/contexts/theme-context.test.tsx new file mode 100644 index 000000000..f2acf3b3b --- /dev/null +++ b/__tests__/contexts/theme-context.test.tsx @@ -0,0 +1,148 @@ +import { act, render, renderHook } from "@testing-library/react"; +import { ThemeProvider, useTheme } from "@/contexts/theme-context"; + +const STORAGE_KEY = "vwc-theme"; +const DARK_QUERY = "(prefers-color-scheme: dark)"; + +type MediaListener = (event: MediaQueryListEvent) => void; + +interface MockMediaQueryList { + matches: boolean; + media: string; + addEventListener: (type: "change", listener: MediaListener) => void; + removeEventListener: (type: "change", listener: MediaListener) => void; + dispatchOSChange: (matches: boolean) => void; +} + +function installMatchMediaMock(initialMatches: boolean): MockMediaQueryList { + const listeners = new Set(); + + const mql: MockMediaQueryList = { + matches: initialMatches, + media: DARK_QUERY, + addEventListener: (_type, listener) => { + listeners.add(listener); + }, + removeEventListener: (_type, listener) => { + listeners.delete(listener); + }, + dispatchOSChange: (matches: boolean) => { + mql.matches = matches; + for (const listener of listeners) { + listener({ matches } as MediaQueryListEvent); + } + }, + }; + + window.matchMedia = ((query: string) => { + if (query !== DARK_QUERY) { + return { + matches: false, + media: query, + addEventListener: () => undefined, + removeEventListener: () => undefined, + } as unknown as MediaQueryList; + } + return mql as unknown as MediaQueryList; + }) as typeof window.matchMedia; + + return mql; +} + +beforeEach(() => { + window.localStorage.clear(); + document.documentElement.classList.remove("dark"); + document.documentElement.style.colorScheme = ""; +}); + +describe("ThemeProvider + useTheme", () => { + it("defaults to system mode and resolves to OS preference", () => { + installMatchMediaMock(true); // OS prefers dark + + const { result } = renderHook(() => useTheme(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.theme).toBe("system"); + expect(result.current.resolvedTheme).toBe("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); + + it("reads stored preference on mount", () => { + installMatchMediaMock(false); + window.localStorage.setItem(STORAGE_KEY, "dark"); + + const { result } = renderHook(() => useTheme(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.theme).toBe("dark"); + expect(result.current.resolvedTheme).toBe("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); + + it("setTheme persists to localStorage and updates the html class", () => { + installMatchMediaMock(false); + + const { result } = renderHook(() => useTheme(), { + wrapper: ({ children }) => {children}, + }); + + act(() => result.current.setTheme("dark")); + + expect(result.current.theme).toBe("dark"); + expect(result.current.resolvedTheme).toBe("dark"); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + + act(() => result.current.setTheme("light")); + + expect(result.current.resolvedTheme).toBe("light"); + expect(document.documentElement.classList.contains("dark")).toBe(false); + }); + + it("system mode flips live when OS theme changes", () => { + const mql = installMatchMediaMock(false); + + const { result } = renderHook(() => useTheme(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.resolvedTheme).toBe("light"); + + act(() => mql.dispatchOSChange(true)); + + expect(result.current.resolvedTheme).toBe("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); + + it("setTheme to explicit value stops live OS sync", () => { + const mql = installMatchMediaMock(false); + + const { result } = renderHook(() => useTheme(), { + wrapper: ({ children }) => {children}, + }); + + act(() => result.current.setTheme("light")); + act(() => mql.dispatchOSChange(true)); + + expect(result.current.theme).toBe("light"); + expect(result.current.resolvedTheme).toBe("light"); + }); + + it("throws when useTheme is called outside the provider", () => { + const Consumer = () => { + useTheme(); + return null; + }; + const original = console.error; + console.error = () => undefined; // silence React's error boundary log + try { + expect(() => render()).toThrow( + /useTheme must be used within a / + ); + } finally { + console.error = original; + } + }); +}); diff --git a/src/contexts/theme-context.tsx b/src/contexts/theme-context.tsx new file mode 100644 index 000000000..d709f8a1d --- /dev/null +++ b/src/contexts/theme-context.tsx @@ -0,0 +1,153 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +export type Theme = "light" | "dark" | "system"; +export type ResolvedTheme = "light" | "dark"; + +interface ThemeContextValue { + theme: Theme; + resolvedTheme: ResolvedTheme; + setTheme: (theme: Theme) => void; +} + +const STORAGE_KEY = "vwc-theme"; +const DARK_QUERY = "(prefers-color-scheme: dark)"; + +const ThemeContext = createContext(undefined); + +/** + * Returns the OS-level preference. Safe to call on the server (returns + * `"light"` since `window` isn't available — the pre-paint inline script + * in _document.tsx resolves the *real* value before first paint, so this + * SSR default never reaches the screen). + */ +function getSystemPreference(): ResolvedTheme { + if (typeof window === "undefined") return "light"; + return window.matchMedia(DARK_QUERY).matches ? "dark" : "light"; +} + +function readStoredTheme(): Theme { + if (typeof window === "undefined") return "system"; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (raw === "light" || raw === "dark" || raw === "system") return raw; + } catch { + // Storage can throw under privacy modes / disabled cookies — fall through. + } + return "system"; +} + +function applyResolved(resolved: ResolvedTheme): void { + if (typeof document === "undefined") return; + const root = document.documentElement; + if (resolved === "dark") root.classList.add("dark"); + else root.classList.remove("dark"); + // For native UI like form controls + scrollbars. + root.style.colorScheme = resolved; +} + +interface ThemeProviderProps { + children: ReactNode; + /** Default theme when nothing has been stored yet. Defaults to `"system"`. */ + defaultTheme?: Theme; +} + +export function ThemeProvider({ children, defaultTheme = "system" }: ThemeProviderProps) { + // SSR-safe initial values — the inline pre-paint script in _document.tsx + // applies the *real* resolved theme to before React hydrates, so + // we don't need to compute it during render and risk a hydration mismatch. + const [theme, setThemeState] = useState(defaultTheme); + const [resolvedTheme, setResolvedTheme] = useState("light"); + + // Read stored preference on mount and reconcile with what the inline + // script already applied. + useEffect(() => { + const stored = readStoredTheme(); + setThemeState(stored); + }, []); + + // Resolve `theme` → `resolvedTheme`, write to , and subscribe to + // OS theme changes when in "system" mode. + useEffect(() => { + if (typeof window === "undefined") return undefined; + + const apply = () => { + const next = theme === "system" ? getSystemPreference() : theme; + setResolvedTheme(next); + applyResolved(next); + }; + + apply(); + + if (theme !== "system") return undefined; + + const mql = window.matchMedia(DARK_QUERY); + const onChange = () => apply(); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); + }, [theme]); + + const setTheme = useCallback((next: Theme) => { + setThemeState(next); + try { + window.localStorage.setItem(STORAGE_KEY, next); + } catch { + // Same reasoning as readStoredTheme — fall through silently. + } + }, []); + + const value = useMemo( + () => ({ theme, resolvedTheme, setTheme }), + [theme, resolvedTheme, setTheme] + ); + + return {children}; +} + +/** + * Read and update the active theme. + * + * const { theme, resolvedTheme, setTheme } = useTheme(); + * + * - `theme` is the user's stored preference (may be `"system"`). + * - `resolvedTheme` is what's currently applied (`"light"` or `"dark"`). + */ +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) { + throw new Error("useTheme must be used within a "); + } + return ctx; +} + +/** + * IIFE injected into _document.tsx so the resolved theme is applied to + * before first paint. Without this, a dark-mode user sees a flash + * of light content while React hydrates. Kept as a string so it can be + * fed to `dangerouslySetInnerHTML`. + * + * The script is intentionally compact and side-effect-only — no + * dependencies, no React, runs before anything else. + */ +export const THEME_PREPAINT_SCRIPT = ` +(function () { + try { + var stored = localStorage.getItem('${STORAGE_KEY}'); + var theme = (stored === 'light' || stored === 'dark' || stored === 'system') ? stored : 'system'; + var resolved = theme === 'system' + ? (window.matchMedia('${DARK_QUERY}').matches ? 'dark' : 'light') + : theme; + var root = document.documentElement; + if (resolved === 'dark') root.classList.add('dark'); + else root.classList.remove('dark'); + root.style.colorScheme = resolved; + } catch (e) {} +})(); +`; diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx index cea8f9bc7..80b4451f7 100644 --- a/src/pages/_document.tsx +++ b/src/pages/_document.tsx @@ -1,4 +1,5 @@ import Document, { Head, Html, Main, NextScript } from "next/document"; +import { THEME_PREPAINT_SCRIPT } from "@/contexts/theme-context"; export default class MyDocument extends Document { render() { @@ -8,6 +9,14 @@ export default class MyDocument extends Document { return ( + {/* Apply theme before first paint so dark-mode users + don't see a flash of light content while hydrating. + Source: src/contexts/theme-context.tsx */} +