diff --git a/examples/openui-chat/src/hooks/use-system-theme.tsx b/examples/openui-chat/src/hooks/use-system-theme.tsx index 7c110c21d..f306f66ae 100644 --- a/examples/openui-chat/src/hooks/use-system-theme.tsx +++ b/examples/openui-chat/src/hooks/use-system-theme.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useContext, useLayoutEffect, useState } from "react"; +import { createContext, useContext, useLayoutEffect, useSyncExternalStore } from "react"; type ThemeMode = "light" | "dark"; @@ -10,20 +10,27 @@ interface ThemeContextType { const ThemeContext = createContext(undefined); +const colorSchemeQuery = "(prefers-color-scheme: dark)"; + +function subscribeToSystemMode(onStoreChange: () => void) { + const mq = window.matchMedia(colorSchemeQuery); + const handler = () => onStoreChange(); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); +} + function getSystemMode(): ThemeMode { - if (typeof window === "undefined") return "light"; - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + return window.matchMedia(colorSchemeQuery).matches ? "dark" : "light"; } -export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [mode, setMode] = useState(getSystemMode); +function getServerMode(): ThemeMode { + // The server cannot know the browser's color scheme. React uses this same + // snapshot for the first client render, keeping hydration deterministic. + return "light"; +} - useLayoutEffect(() => { - const mq = window.matchMedia("(prefers-color-scheme: dark)"); - const handler = (e: MediaQueryListEvent) => setMode(e.matches ? "dark" : "light"); - mq.addEventListener("change", handler); - return () => mq.removeEventListener("change", handler); - }, []); +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const mode = useSyncExternalStore(subscribeToSystemMode, getSystemMode, getServerMode); useLayoutEffect(() => { document.body.setAttribute("data-theme", mode); diff --git a/examples/openui-cloud/src/hooks/use-system-theme.tsx b/examples/openui-cloud/src/hooks/use-system-theme.tsx index 7c110c21d..f306f66ae 100644 --- a/examples/openui-cloud/src/hooks/use-system-theme.tsx +++ b/examples/openui-cloud/src/hooks/use-system-theme.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useContext, useLayoutEffect, useState } from "react"; +import { createContext, useContext, useLayoutEffect, useSyncExternalStore } from "react"; type ThemeMode = "light" | "dark"; @@ -10,20 +10,27 @@ interface ThemeContextType { const ThemeContext = createContext(undefined); +const colorSchemeQuery = "(prefers-color-scheme: dark)"; + +function subscribeToSystemMode(onStoreChange: () => void) { + const mq = window.matchMedia(colorSchemeQuery); + const handler = () => onStoreChange(); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); +} + function getSystemMode(): ThemeMode { - if (typeof window === "undefined") return "light"; - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + return window.matchMedia(colorSchemeQuery).matches ? "dark" : "light"; } -export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [mode, setMode] = useState(getSystemMode); +function getServerMode(): ThemeMode { + // The server cannot know the browser's color scheme. React uses this same + // snapshot for the first client render, keeping hydration deterministic. + return "light"; +} - useLayoutEffect(() => { - const mq = window.matchMedia("(prefers-color-scheme: dark)"); - const handler = (e: MediaQueryListEvent) => setMode(e.matches ? "dark" : "light"); - mq.addEventListener("change", handler); - return () => mq.removeEventListener("change", handler); - }, []); +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const mode = useSyncExternalStore(subscribeToSystemMode, getSystemMode, getServerMode); useLayoutEffect(() => { document.body.setAttribute("data-theme", mode); diff --git a/packages/openui-cli/src/templates/openui-cloud/src/hooks/use-system-theme.tsx b/packages/openui-cli/src/templates/openui-cloud/src/hooks/use-system-theme.tsx index 7c110c21d..f306f66ae 100644 --- a/packages/openui-cli/src/templates/openui-cloud/src/hooks/use-system-theme.tsx +++ b/packages/openui-cli/src/templates/openui-cloud/src/hooks/use-system-theme.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useContext, useLayoutEffect, useState } from "react"; +import { createContext, useContext, useLayoutEffect, useSyncExternalStore } from "react"; type ThemeMode = "light" | "dark"; @@ -10,20 +10,27 @@ interface ThemeContextType { const ThemeContext = createContext(undefined); +const colorSchemeQuery = "(prefers-color-scheme: dark)"; + +function subscribeToSystemMode(onStoreChange: () => void) { + const mq = window.matchMedia(colorSchemeQuery); + const handler = () => onStoreChange(); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); +} + function getSystemMode(): ThemeMode { - if (typeof window === "undefined") return "light"; - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + return window.matchMedia(colorSchemeQuery).matches ? "dark" : "light"; } -export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [mode, setMode] = useState(getSystemMode); +function getServerMode(): ThemeMode { + // The server cannot know the browser's color scheme. React uses this same + // snapshot for the first client render, keeping hydration deterministic. + return "light"; +} - useLayoutEffect(() => { - const mq = window.matchMedia("(prefers-color-scheme: dark)"); - const handler = (e: MediaQueryListEvent) => setMode(e.matches ? "dark" : "light"); - mq.addEventListener("change", handler); - return () => mq.removeEventListener("change", handler); - }, []); +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const mode = useSyncExternalStore(subscribeToSystemMode, getSystemMode, getServerMode); useLayoutEffect(() => { document.body.setAttribute("data-theme", mode); diff --git a/packages/openui-cli/src/templates/openui-self-hosted/src/app/layout.tsx b/packages/openui-cli/src/templates/openui-self-hosted/src/app/layout.tsx index 099a706e3..322bb0738 100644 --- a/packages/openui-cli/src/templates/openui-self-hosted/src/app/layout.tsx +++ b/packages/openui-cli/src/templates/openui-self-hosted/src/app/layout.tsx @@ -1,3 +1,4 @@ +import { ThemeProvider } from "@/hooks/use-system-theme"; import type { Metadata } from "next"; import "./globals.css"; @@ -13,7 +14,9 @@ export default function RootLayout({ }>) { return ( - {children} + + {children} + ); } diff --git a/packages/openui-cli/src/templates/openui-self-hosted/src/app/page.tsx b/packages/openui-cli/src/templates/openui-self-hosted/src/app/page.tsx index bc1a078d3..ccbd71f1c 100644 --- a/packages/openui-cli/src/templates/openui-self-hosted/src/app/page.tsx +++ b/packages/openui-cli/src/templates/openui-self-hosted/src/app/page.tsx @@ -2,6 +2,7 @@ import "@openuidev/react-ui/components.css"; import "@openuidev/react-ui/styles/index.css"; +import { useTheme } from "@/hooks/use-system-theme"; import { openAIMessageFormat, openAIReadableStreamAdapter, @@ -24,9 +25,16 @@ const llm: ChatLLM = { }; export default function Home() { + const mode = useTheme(); + return (
- +
); } diff --git a/packages/openui-cli/src/templates/openui-self-hosted/src/hooks/use-system-theme.tsx b/packages/openui-cli/src/templates/openui-self-hosted/src/hooks/use-system-theme.tsx new file mode 100644 index 000000000..f306f66ae --- /dev/null +++ b/packages/openui-cli/src/templates/openui-self-hosted/src/hooks/use-system-theme.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { createContext, useContext, useLayoutEffect, useSyncExternalStore } from "react"; + +type ThemeMode = "light" | "dark"; + +interface ThemeContextType { + mode: ThemeMode; +} + +const ThemeContext = createContext(undefined); + +const colorSchemeQuery = "(prefers-color-scheme: dark)"; + +function subscribeToSystemMode(onStoreChange: () => void) { + const mq = window.matchMedia(colorSchemeQuery); + const handler = () => onStoreChange(); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); +} + +function getSystemMode(): ThemeMode { + return window.matchMedia(colorSchemeQuery).matches ? "dark" : "light"; +} + +function getServerMode(): ThemeMode { + // The server cannot know the browser's color scheme. React uses this same + // snapshot for the first client render, keeping hydration deterministic. + return "light"; +} + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const mode = useSyncExternalStore(subscribeToSystemMode, getSystemMode, getServerMode); + + useLayoutEffect(() => { + document.body.setAttribute("data-theme", mode); + }, [mode]); + + return {children}; +} + +export function useTheme(): ThemeMode { + const ctx = useContext(ThemeContext); + if (!ctx) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return ctx.mode; +} diff --git a/packages/react-ui/src/components/AgentInterface/__tests__/AgentInterface.theme.ssr.test.tsx b/packages/react-ui/src/components/AgentInterface/__tests__/AgentInterface.theme.ssr.test.tsx new file mode 100644 index 000000000..ed4d684f1 --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/__tests__/AgentInterface.theme.ssr.test.tsx @@ -0,0 +1,50 @@ +import { renderToString } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { makeMockLLM } from "../../../__test-helpers/mockChat"; +import { useTheme } from "../../ThemeProvider"; +import { AgentInterface } from "../AgentInterface"; + +const ThemeProbe = () => { + const { mode } = useTheme(); + return ; +}; + +const renderAgent = ({ mode, logoUrl }: { mode: "light" | "dark"; logoUrl: string }) => + renderToString( + + + , + ); + +describe("AgentInterface controlled theme and logo SSR contract", () => { + it.each([ + { mode: "light" as const, logoUrl: "/logo-light.svg" }, + { mode: "dark" as const, logoUrl: "/logo-dark.svg" }, + ])("renders the caller-provided $mode logo URL", ({ mode, logoUrl }) => { + const html = renderAgent({ mode, logoUrl }); + + expect(html).toContain(`src="${logoUrl}"`); + expect(html).toContain(`data-theme-mode="${mode}"`); + expect(html).toContain('class="openui-agent-sidebar-header__logo"'); + }); + + it("reproduces different first-render markup when the caller changes controlled inputs", () => { + const serverHtml = renderAgent({ + mode: "light", + logoUrl: "/logo-light.svg", + }); + const firstClientHtml = renderAgent({ + mode: "dark", + logoUrl: "/logo-dark.svg", + }); + + expect(serverHtml).toContain('src="/logo-light.svg"'); + expect(firstClientHtml).toContain('src="/logo-dark.svg"'); + expect(firstClientHtml).not.toBe(serverHtml); + }); +});