Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions examples/openui-chat/src/hooks/use-system-theme.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { createContext, useContext, useLayoutEffect, useState } from "react";
import { createContext, useContext, useLayoutEffect, useSyncExternalStore } from "react";

type ThemeMode = "light" | "dark";

Expand All @@ -10,20 +10,27 @@ interface ThemeContextType {

const ThemeContext = createContext<ThemeContextType | undefined>(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<ThemeMode>(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);
Expand Down
29 changes: 18 additions & 11 deletions examples/openui-cloud/src/hooks/use-system-theme.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { createContext, useContext, useLayoutEffect, useState } from "react";
import { createContext, useContext, useLayoutEffect, useSyncExternalStore } from "react";

type ThemeMode = "light" | "dark";

Expand All @@ -10,20 +10,27 @@ interface ThemeContextType {

const ThemeContext = createContext<ThemeContextType | undefined>(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<ThemeMode>(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);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { createContext, useContext, useLayoutEffect, useState } from "react";
import { createContext, useContext, useLayoutEffect, useSyncExternalStore } from "react";

type ThemeMode = "light" | "dark";

Expand All @@ -10,20 +10,27 @@ interface ThemeContextType {

const ThemeContext = createContext<ThemeContextType | undefined>(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<ThemeMode>(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);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ThemeProvider } from "@/hooks/use-system-theme";
import type { Metadata } from "next";
import "./globals.css";

Expand All @@ -13,7 +14,9 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body>{children}</body>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -24,9 +25,16 @@ const llm: ChatLLM = {
};

export default function Home() {
const mode = useTheme();

return (
<div className="h-screen w-screen overflow-hidden">
<AgentInterface llm={llm} componentLibrary={openuiLibrary} agentName="OpenUI Self Hosted" />
<AgentInterface
llm={llm}
componentLibrary={openuiLibrary}
agentName="OpenUI Self Hosted"
theme={{ mode }}
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"use client";

import { createContext, useContext, useLayoutEffect, useSyncExternalStore } from "react";

type ThemeMode = "light" | "dark";

interface ThemeContextType {
mode: ThemeMode;
}

const ThemeContext = createContext<ThemeContextType | undefined>(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 <ThemeContext.Provider value={{ mode }}>{children}</ThemeContext.Provider>;
}

export function useTheme(): ThemeMode {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return ctx.mode;
}
Original file line number Diff line number Diff line change
@@ -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 <span data-theme-mode={mode} />;
};

const renderAgent = ({ mode, logoUrl }: { mode: "light" | "dark"; logoUrl: string }) =>
renderToString(
<AgentInterface
llm={makeMockLLM()}
agentName="Hydration repro"
theme={{ mode }}
logoUrl={logoUrl}
>
<ThemeProbe />
</AgentInterface>,
);

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);
});
});
Loading