From 7f968135b54062292a13c78e864b266865887d70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 00:00:36 +0200 Subject: [PATCH] refactor(theme): back the provider with useSyncExternalStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading localStorage/matchMedia in a mount effect tripped the react-hooks/set-state-in-effect rule. Model the stored preference and the OS preference as external stores read via useSyncExternalStore with an SSR-stable 'dark' server snapshot, and apply the class from a DOM-only effect — no setState-in-effect, same hydration-seam fix. --- src/components/providers.tsx | 124 +++++++++++++++++++++-------------- 1 file changed, 75 insertions(+), 49 deletions(-) diff --git a/src/components/providers.tsx b/src/components/providers.tsx index db1f37cd7..73365431f 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -12,6 +12,7 @@ import { useContext, useEffect, useState, + useSyncExternalStore, type ReactNode, } from "react"; import { I18nProvider } from "@/lib/i18n/context"; @@ -61,66 +62,91 @@ function applyTheme(resolved: "dark" | "light") { document.documentElement.classList.add(resolved); } -function readStoredTheme(): Theme { - if (typeof window === "undefined") return "dark"; - const saved = localStorage.getItem("healthlog-theme") as Theme | null; - // A stored "light"/"dark"/"system" choice wins. With no stored - // preference the app defaults to dark rather than tracking the OS. +// ── Theme as an external store ─────────────────────── +// +// The stored preference lives in localStorage, and the OS preference in +// matchMedia — both browser-only values that must NOT be read in a `useState` +// initializer (server renders "dark", a client with a stored "light"/"system" +// initializes differently → the React #418 hydration seam this repo already +// hit with the sidebar). `useSyncExternalStore` is the sanctioned fix: it reads +// the value with an SSR-stable server snapshot ("dark") and reconciles to the +// real value after hydration without a setState-in-effect. The nonce-bound +// inline script in layout.tsx stamps the correct class pre-paint, so there is +// no visual FOUC while React reconciles. + +const THEME_STORAGE_KEY = "healthlog-theme"; +const themeListeners = new Set<() => void>(); + +function notifyThemeListeners() { + for (const cb of themeListeners) cb(); +} + +function subscribeStoredTheme(onChange: () => void): () => void { + themeListeners.add(onChange); + const onStorage = (e: StorageEvent) => { + if (e.key === THEME_STORAGE_KEY) onChange(); + }; + window.addEventListener("storage", onStorage); + return () => { + themeListeners.delete(onChange); + window.removeEventListener("storage", onStorage); + }; +} + +function getStoredThemeSnapshot(): Theme { + const saved = localStorage.getItem(THEME_STORAGE_KEY) as Theme | null; + // A stored "light"/"dark"/"system" choice wins. With no stored preference the + // app defaults to dark rather than tracking the OS. if (saved === "light" || saved === "dark" || saved === "system") return saved; return "dark"; } +function getServerThemeSnapshot(): Theme { + return "dark"; +} + +function subscribeSystemTheme(onChange: () => void): () => void { + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); +} + +function getSystemThemeSnapshot(): "dark" | "light" { + return getSystemTheme(); +} + +function getServerSystemSnapshot(): "dark" | "light" { + return "dark"; +} + function ThemeProvider({ children }: { children: ReactNode }) { - // Initialize to the SSR-stable default on BOTH the server and the client's - // first render so the hydrated React tree matches; the persisted preference - // is reconciled in a mount effect below. Reading localStorage in the - // `useState` initializer instead diverges server ("dark") from a client that - // stored "light"/"system", which is the exact React #418 hydration seam this - // repo has hit before (the sidebar matchMedia/localStorage-in-initializer). - // The nonce-bound inline script in layout.tsx has already stamped the correct - // class on pre-hydration, so there is no visual FOUC from deferring - // the reconcile to an effect. - const [theme, setThemeState] = useState("dark"); - const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">("dark"); + const theme = useSyncExternalStore( + subscribeStoredTheme, + getStoredThemeSnapshot, + getServerThemeSnapshot, + ); + const systemTheme = useSyncExternalStore( + subscribeSystemTheme, + getSystemThemeSnapshot, + getServerSystemSnapshot, + ); + const resolvedTheme = theme === "system" ? systemTheme : theme; - // Reconcile the stored preference after mount + keep the class in sync (the - // inline script handles the pre-paint class; this keeps React state honest). + // Keep the class in sync with the resolved theme. This is a DOM write, + // not a setState — it runs on mount (reconciling the class the inline script + // set to any stored preference) and whenever the resolved theme changes. useEffect(() => { - const stored = readStoredTheme(); - const resolved = stored === "system" ? getSystemTheme() : stored; - setThemeState(stored); - setResolvedTheme(resolved); - applyTheme(resolved); - }, []); - - // Listen for OS preference changes when in system mode - useEffect(() => { - if (theme !== "system") return; - const mq = window.matchMedia("(prefers-color-scheme: dark)"); - const handler = (e: MediaQueryListEvent) => { - const resolved = e.matches ? "dark" : "light"; - setResolvedTheme(resolved); - applyTheme(resolved); - }; - mq.addEventListener("change", handler); - return () => mq.removeEventListener("change", handler); - }, [theme]); + applyTheme(resolvedTheme); + }, [resolvedTheme]); const setTheme = useCallback((next: Theme) => { - setThemeState(next); // Persist every explicit choice, including "system" — the absence of a - // stored value is reserved for a fresh visitor and now defaults to dark. + // stored value is reserved for a fresh visitor and defaults to dark. // Storing "system" verbatim keeps an explicit OS-tracking choice - // distinguishable from "never chose", so a reload honours it. - localStorage.setItem("healthlog-theme", next); - if (next === "system") { - const resolved = getSystemTheme(); - setResolvedTheme(resolved); - applyTheme(resolved); - } else { - setResolvedTheme(next); - applyTheme(next); - } + // distinguishable from "never chose", so a reload honours it. The write + + // notify re-reads the external store; the effect above applies the class. + localStorage.setItem(THEME_STORAGE_KEY, next); + notifyThemeListeners(); }, []); return (