feat: support query theme bootstrap for embeds

This commit is contained in:
2026-04-02 20:41:18 +08:00
parent 83071a4b76
commit 6e55bc02b7
4 changed files with 87 additions and 9 deletions

View File

@@ -1,5 +1,9 @@
import { describe, expect, test } from "bun:test";
import { resolveIncomingThemeMode, resolveStoredTheme } from "./theme-sync";
import {
parseThemeQuery,
resolveIncomingThemeMode,
resolveStoredTheme,
} from "./theme-sync";
describe("theme sync helpers", () => {
test("resolves stored analytics theme values", () => {
@@ -17,4 +21,14 @@ describe("theme sync helpers", () => {
expect(resolveIncomingThemeMode("system", true)).toBe("dark");
expect(resolveIncomingThemeMode("unknown", true)).toBeNull();
});
test("parses theme override from query params", () => {
expect(parseThemeQuery("light")).toBe("light");
expect(parseThemeQuery("dark")).toBe("dark");
expect(parseThemeQuery("auto")).toBe("system");
expect(parseThemeQuery("system")).toBe("system");
expect(parseThemeQuery("LIGHT")).toBe("light");
expect(parseThemeQuery("unknown")).toBeNull();
expect(parseThemeQuery(null)).toBeNull();
});
});

View File

@@ -1,6 +1,25 @@
import type { Theme } from "./theme";
export type ResolvedTheme = "light" | "dark";
export type ThemeQueryMode = "light" | "dark" | "system";
export function parseThemeQuery(value: string | null | undefined): ThemeQueryMode | null {
if (!value) {
return null;
}
const normalized = value.toLowerCase();
if (normalized === "light" || normalized === "dark") {
return normalized;
}
if (normalized === "auto" || normalized === "system") {
return "system";
}
return null;
}
export function resolveStoredTheme(
theme: Theme,

View File

@@ -1,7 +1,12 @@
"use client";
import { createContext, useContext, useState, useEffect, startTransition, type ReactNode } from "react";
import { resolveIncomingThemeMode, resolveStoredTheme, type ResolvedTheme } from "./theme-sync";
import {
parseThemeQuery,
resolveIncomingThemeMode,
resolveStoredTheme,
type ResolvedTheme,
} from "./theme-sync";
export type Theme = "light" | "dark" | "system";
@@ -51,6 +56,14 @@ function getParentResolvedTheme(): ResolvedTheme | null {
}
}
function getThemeQuery(): Theme | null {
if (typeof window === "undefined") {
return null;
}
return parseThemeQuery(new URLSearchParams(window.location.search).get("theme"));
}
const ThemeContext = createContext<ThemeContextType>({
theme: "system",
setTheme: () => {},
@@ -61,9 +74,18 @@ const ThemeContext = createContext<ThemeContextType>({
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>("system");
const [isEmbedded, setIsEmbedded] = useState(getIsEmbedded);
const [queryTheme, setQueryTheme] = useState<Theme | null>(getThemeQuery);
const [parentResolved, setParentResolved] = useState<ResolvedTheme | null>(getParentResolvedTheme);
const [resolved, setResolved] = useState<ResolvedTheme>(
() => getParentResolvedTheme() ?? resolveStoredTheme("system", getPrefersDark()),
() => {
const nextQueryTheme = getThemeQuery();
return (
getParentResolvedTheme()
?? (nextQueryTheme ? resolveStoredTheme(nextQueryTheme, getPrefersDark()) : null)
?? resolveStoredTheme("system", getPrefersDark())
);
},
);
useEffect(() => {
@@ -77,10 +99,12 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const nextIsEmbedded = getIsEmbedded();
const nextQueryTheme = getThemeQuery();
const nextParentResolved = getParentResolvedTheme();
startTransition(() => {
setIsEmbedded(nextIsEmbedded);
setQueryTheme(nextQueryTheme);
setParentResolved((current) => current ?? nextParentResolved);
});
}, []);
@@ -111,6 +135,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const root = document.documentElement;
const activeTheme = queryTheme ?? theme;
const queryResolved = queryTheme
? resolveStoredTheme(queryTheme, mq.matches)
: null;
const applyResolved = (nextResolved: ResolvedTheme) => {
setResolved(nextResolved);
@@ -119,19 +147,23 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
root.setAttribute("data-theme", nextResolved);
};
applyResolved(parentResolved ?? resolveStoredTheme(theme, mq.matches));
applyResolved(
parentResolved
?? queryResolved
?? resolveStoredTheme(theme, mq.matches),
);
if (parentResolved !== null || theme !== "system") {
if (parentResolved !== null || activeTheme !== "system") {
return;
}
const handleSystemThemeChange = (event: MediaQueryListEvent) => {
applyResolved(resolveStoredTheme(theme, event.matches));
applyResolved(resolveStoredTheme(activeTheme, event.matches));
};
mq.addEventListener("change", handleSystemThemeChange);
return () => mq.removeEventListener("change", handleSystemThemeChange);
}, [parentResolved, theme]);
}, [parentResolved, queryTheme, theme]);
const setTheme = (t: Theme) => {
setThemeState(t);