From 756fbaf23b9114031b1f42095404e622b5fc76c3 Mon Sep 17 00:00:00 2001 From: Karol Chudzik Date: Fri, 17 Apr 2026 15:20:17 +0200 Subject: [PATCH 1/2] fix(studio): avoid shell theme hydration mismatch on SSR useResolvedShellTheme initialized state by reading window.localStorage and window.matchMedia, so SSR emitted data-mdcms-theme="light" while the first client render resolved to the user's actual preference, triggering a React hydration mismatch on the studio shell root. Start with a stable "light" value on both server and first client render and defer resolution to useEffect so the real theme is applied post-mount. --- packages/studio/src/lib/studio-component.tsx | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/packages/studio/src/lib/studio-component.tsx b/packages/studio/src/lib/studio-component.tsx index b16cf59..c8eac78 100644 --- a/packages/studio/src/lib/studio-component.tsx +++ b/packages/studio/src/lib/studio-component.tsx @@ -641,20 +641,7 @@ export function resolveShellAppliedTheme(input: { } function useResolvedShellTheme(): ShellAppliedTheme { - const [applied, setApplied] = useState(() => { - if (typeof window === "undefined") { - return "light"; - } - - const storage = window.localStorage ?? null; - const stored = readStoredThemePreference(storage); - const systemPrefersDark = - typeof window.matchMedia === "function" - ? window.matchMedia("(prefers-color-scheme: dark)").matches - : false; - - return resolveAppliedTheme(stored ?? "system", systemPrefersDark); - }); + const [applied, setApplied] = useState("light"); useEffect(() => { if ( From c260c7d78d142897778fd45cded4a4120e621a89 Mon Sep 17 00:00:00 2001 From: Karol Chudzik Date: Fri, 17 Apr 2026 15:25:09 +0200 Subject: [PATCH 2/2] fix(studio): prevent dark-theme flash by resolving shell theme pre-hydration Initializing useResolvedShellTheme to a stable "light" on SSR avoided the hydration mismatch but left dark-mode users with a brief light-theme flash until useEffect ran post-mount. Inject an inline script as the first child of the shell root that reads the stored preference and system media query synchronously during HTML parsing, mutating data-mdcms-theme before the browser paints. suppressHydrationWarning lets React tolerate the pre-hydration mutation, and the hook now uses an isomorphic layout effect so client-only mounts also resolve the theme before paint instead of after. Adds a test asserting the inline script is emitted in SSR markup. --- packages/studio/src/lib/studio-component.tsx | 28 +++++++++++++++++--- packages/studio/src/lib/studio.test.ts | 19 +++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/lib/studio-component.tsx b/packages/studio/src/lib/studio-component.tsx index c8eac78..b22dfd3 100644 --- a/packages/studio/src/lib/studio-component.tsx +++ b/packages/studio/src/lib/studio-component.tsx @@ -1,6 +1,12 @@ "use client"; -import { useEffect, useRef, useState, type RefObject } from "react"; +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type RefObject, +} from "react"; import { isRuntimeErrorLike, @@ -16,8 +22,14 @@ import { import { readStoredThemePreference, resolveAppliedTheme, + STUDIO_THEME_STORAGE_KEY, } from "./runtime-ui/adapters/next-themes.js"; +const useIsomorphicLayoutEffect = + typeof window === "undefined" ? useEffect : useLayoutEffect; + +export const SHELL_THEME_INLINE_SCRIPT = `(function(){try{var el=document.currentScript&&document.currentScript.parentElement;if(!el)return;var s=null;try{s=window.localStorage&&window.localStorage.getItem(${JSON.stringify(STUDIO_THEME_STORAGE_KEY)});}catch(_){}var p=s==="light"||s==="dark"||s==="system"?s:"system";var r=p==="light"||p==="dark"?p:(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light");if(el.getAttribute("data-mdcms-theme")!==r){el.setAttribute("data-mdcms-theme",r);}}catch(_){}})();`; + export type { MdcmsConfig } from "./studio-loader.js"; export type StudioStartupState = "loading" | "ready" | "error"; @@ -643,7 +655,7 @@ export function resolveShellAppliedTheme(input: { function useResolvedShellTheme(): ShellAppliedTheme { const [applied, setApplied] = useState("light"); - useEffect(() => { + useIsomorphicLayoutEffect(() => { if ( typeof window === "undefined" || typeof window.matchMedia !== "function" @@ -656,7 +668,13 @@ function useResolvedShellTheme(): ShellAppliedTheme { const recompute = () => { const stored = readStoredThemePreference(storage); - setApplied(resolveAppliedTheme(stored ?? "system", mediaQuery.matches)); + setApplied((prev) => { + const next = resolveAppliedTheme( + stored ?? "system", + mediaQuery.matches, + ); + return prev === next ? prev : next; + }); }; recompute(); @@ -700,7 +718,11 @@ export function StudioShellFrame({ data-mdcms-brand="MDCMS" data-mdcms-theme={shellTheme} className={startupState === "ready" ? undefined : "mdcms-studio-shell"} + suppressHydrationWarning > +