From c3cd1de64752ab8adb94352abac1ff1b6b4bfc11 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 19 Apr 2026 09:44:23 +0200 Subject: [PATCH] Fix theme initialization and hydration issues --- app/layout.tsx | 3 +- components/navigation/theme-menu.tsx | 16 +-- components/theme-provider.tsx | 203 ++++++++++++++++++++++++++- components/ui/sonner.tsx | 4 +- 4 files changed, 204 insertions(+), 22 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 20bef26..cfa32ae 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -32,11 +32,10 @@ export default function RootLayout({ ; - } - - if (theme === "dark") { - return ; - } - - return ; -} - export function ThemeMenu() { const { theme, setTheme } = useTheme(); return ( - {getThemeIcon(theme)} + Theme diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx index 2e0ad9b..831f265 100644 --- a/components/theme-provider.tsx +++ b/components/theme-provider.tsx @@ -1,10 +1,205 @@ "use client"; import * as React from "react"; -import { ThemeProvider as NextThemesProvider } from "next-themes"; -type ThemeProviderProps = React.ComponentProps; +const STORAGE_KEY = "inspannings-monitor-theme"; -export function ThemeProvider({ children, ...props }: ThemeProviderProps) { - return {children}; +export type ThemeName = "light" | "dark" | "system"; +export type ResolvedThemeName = "light" | "dark"; + +type ThemeContextValue = { + theme: ThemeName; + setTheme: (theme: ThemeName) => void; + resolvedTheme: ResolvedThemeName; + systemTheme: ResolvedThemeName; + themes: ThemeName[]; +}; + +type ThemeProviderProps = { + children: React.ReactNode; + defaultTheme?: ThemeName; + enableSystem?: boolean; + disableTransitionOnChange?: boolean; +}; + +const ThemeContext = React.createContext(null); + +const AVAILABLE_THEMES: ThemeName[] = ["light", "dark", "system"]; + +function getSystemTheme(): ResolvedThemeName { + if (typeof window === "undefined") { + return "dark"; + } + + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +function readStoredTheme( + defaultTheme: ThemeName, + enableSystem: boolean, +): ThemeName { + if (typeof window === "undefined") { + return defaultTheme; + } + + const storedTheme = window.localStorage.getItem(STORAGE_KEY); + + if (storedTheme === "light" || storedTheme === "dark") { + return storedTheme; + } + + if (storedTheme === "system" && enableSystem) { + return storedTheme; + } + + return defaultTheme; +} + +function applyResolvedTheme(resolvedTheme: ResolvedThemeName) { + const root = document.documentElement; + root.classList.remove("light", "dark"); + root.classList.add(resolvedTheme); + root.dataset.theme = resolvedTheme; + root.style.colorScheme = resolvedTheme; +} + +function withDisabledTransitions(action: () => void) { + const style = document.createElement("style"); + style.appendChild( + document.createTextNode( + "*{-webkit-transition:none!important;transition:none!important}", + ), + ); + + document.head.appendChild(style); + action(); + + window.getComputedStyle(document.body); + + requestAnimationFrame(() => { + document.head.removeChild(style); + }); +} + +export function ThemeProvider({ + children, + defaultTheme = "dark", + enableSystem = true, + disableTransitionOnChange = false, +}: ThemeProviderProps) { + const initialSystemTheme = React.useMemo(() => getSystemTheme(), []); + const [theme, setThemeState] = React.useState(() => + typeof window === "undefined" + ? defaultTheme + : readStoredTheme(defaultTheme, enableSystem), + ); + const [systemTheme, setSystemTheme] = + React.useState(initialSystemTheme); + const [resolvedTheme, setResolvedTheme] = + React.useState(() => { + if (typeof window === "undefined") { + return defaultTheme === "light" ? "light" : "dark"; + } + + const currentTheme = readStoredTheme(defaultTheme, enableSystem); + return currentTheme === "system" ? getSystemTheme() : currentTheme; + }); + + React.useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + + function syncTheme(nextTheme: ThemeName) { + const nextSystemTheme = getSystemTheme(); + const nextResolvedTheme = + nextTheme === "system" ? nextSystemTheme : nextTheme; + + const apply = () => applyResolvedTheme(nextResolvedTheme); + + if (disableTransitionOnChange) { + withDisabledTransitions(apply); + } else { + apply(); + } + + document.documentElement.dataset.themePreference = nextTheme; + setSystemTheme(nextSystemTheme); + setResolvedTheme(nextResolvedTheme); + } + + syncTheme(theme); + + function handleStorage(event: StorageEvent) { + if (event.key !== STORAGE_KEY) { + return; + } + + const nextTheme = + event.newValue === "light" || + event.newValue === "dark" || + (event.newValue === "system" && enableSystem) + ? (event.newValue as ThemeName) + : defaultTheme; + + setThemeState(nextTheme); + syncTheme(nextTheme); + } + + function handleSystemThemeChange() { + const nextSystemTheme = getSystemTheme(); + setSystemTheme(nextSystemTheme); + + if (theme === "system") { + const apply = () => applyResolvedTheme(nextSystemTheme); + + if (disableTransitionOnChange) { + withDisabledTransitions(apply); + } else { + apply(); + } + + setResolvedTheme(nextSystemTheme); + } + } + + window.addEventListener("storage", handleStorage); + mediaQuery.addEventListener("change", handleSystemThemeChange); + + return () => { + window.removeEventListener("storage", handleStorage); + mediaQuery.removeEventListener("change", handleSystemThemeChange); + }; + }, [defaultTheme, disableTransitionOnChange, enableSystem, theme]); + + const setTheme = React.useCallback( + (nextTheme: ThemeName) => { + setThemeState(nextTheme); + window.localStorage.setItem(STORAGE_KEY, nextTheme); + }, + [], + ); + + const contextValue = React.useMemo( + () => ({ + theme, + setTheme, + resolvedTheme, + systemTheme, + themes: enableSystem ? AVAILABLE_THEMES : ["light", "dark"], + }), + [enableSystem, resolvedTheme, setTheme, systemTheme, theme], + ); + + return ( + {children} + ); +} + +export function useTheme() { + const context = React.useContext(ThemeContext); + + if (!context) { + throw new Error("useTheme must be used within ThemeProvider"); + } + + return context; } diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx index f0ebcd7..30bf937 100644 --- a/components/ui/sonner.tsx +++ b/components/ui/sonner.tsx @@ -7,7 +7,7 @@ import { OctagonXIcon, TriangleAlertIcon, } from "lucide-react"; -import { useTheme } from "next-themes"; +import { useTheme } from "@/components/theme-provider"; import { Toaster as Sonner, type ToasterProps } from "sonner"; const Toaster = ({ ...props }: ToasterProps) => { @@ -15,7 +15,7 @@ const Toaster = ({ ...props }: ToasterProps) => { return ( ,