Skip to content

Commit 3113d2c

Browse files
creilly11235claude
authored andcommitted
feat(apollo-vertex): add resolvedTheme and disableTransitionOnChange to ThemeProvider
Upstream improvements from vertical-medical-mrs shell-theme-provider: - resolvedTheme in context so consumers can read the actual light/dark value - disableTransitionOnChange prop to prevent CSS transition flash on theme switch - defaultTheme prop to allow overriding the default "system" fallback - Memoized context value to prevent unnecessary re-renders Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3e09c1a commit 3113d2c

1 file changed

Lines changed: 61 additions & 18 deletions

File tree

apps/apollo-vertex/registry/shell/shell-theme-provider.tsx

Lines changed: 61 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import type { ReactNode } from "react";
2-
import { createContext, useContext, useEffect, useState } from "react";
2+
import {
3+
createContext,
4+
useCallback,
5+
useContext,
6+
useEffect,
7+
useMemo,
8+
useState,
9+
} from "react";
310
import { THEME_STORAGE_KEY } from "./shell-constants";
411

512
export type ThemeConfig = {
@@ -10,19 +17,21 @@ export type Theme = "light" | "dark" | "system";
1017

1118
interface ThemeProviderProps {
1219
children: ReactNode;
20+
defaultTheme?: Theme;
1321
themeConfig?: ThemeConfig;
1422
storageKey?: string;
23+
disableTransitionOnChange?: boolean;
1524
}
1625

1726
interface ThemeProviderState {
1827
theme: Theme;
28+
resolvedTheme: "light" | "dark";
1929
setTheme: (theme: Theme) => void;
2030
}
2131

22-
const ThemeProviderContext = createContext<ThemeProviderState>({
23-
theme: "system",
24-
setTheme: () => null,
25-
});
32+
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(
33+
undefined,
34+
);
2635

2736
const cssVarMap: Record<string, string> = {
2837
background: "--background",
@@ -74,10 +83,26 @@ function getEffectiveTheme(theme: Theme): "light" | "dark" {
7483
return theme;
7584
}
7685

77-
function applyThemeClass(theme: Theme) {
86+
function applyThemeClass(theme: Theme, disableTransitions?: boolean) {
7887
const root = window.document.documentElement;
88+
const resolved = getEffectiveTheme(theme);
89+
90+
let styleEl: HTMLStyleElement | null = null;
91+
if (disableTransitions) {
92+
styleEl = document.createElement("style");
93+
styleEl.textContent =
94+
"*, *::before, *::after { transition: none !important }";
95+
document.head.append(styleEl);
96+
}
97+
7998
root.classList.remove("light", "dark");
80-
root.classList.add(getEffectiveTheme(theme));
99+
root.classList.add(resolved);
100+
101+
if (styleEl) {
102+
// Force reflow, then re-enable transitions
103+
void root.offsetHeight;
104+
styleEl.remove();
105+
}
81106
}
82107

83108
function applyThemeConfig(config: ThemeConfig, theme: Theme) {
@@ -108,35 +133,48 @@ function clearThemeConfig() {
108133

109134
export function ThemeProvider({
110135
children,
136+
defaultTheme = "system",
111137
themeConfig,
112138
storageKey = THEME_STORAGE_KEY,
139+
disableTransitionOnChange,
113140
}: ThemeProviderProps) {
114141
const [theme, setThemeState] = useState<Theme>(() => {
115-
if (typeof window === "undefined") return "system";
142+
if (typeof window === "undefined") return defaultTheme;
116143
const stored = localStorage.getItem(storageKey);
117-
return isValidTheme(stored) ? stored : "system";
144+
return isValidTheme(stored) ? stored : defaultTheme;
118145
});
119146

120-
const setTheme = (newTheme: Theme) => {
121-
localStorage.setItem(storageKey, newTheme);
122-
setThemeState(newTheme);
123-
};
147+
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">(() =>
148+
typeof window === "undefined" ? "light" : getEffectiveTheme(theme),
149+
);
150+
151+
const setTheme = useCallback(
152+
(newTheme: Theme) => {
153+
localStorage.setItem(storageKey, newTheme);
154+
setThemeState(newTheme);
155+
},
156+
[storageKey],
157+
);
124158

125159
// Apply light/dark class to document root
126160
useEffect(() => {
127-
applyThemeClass(theme);
128-
}, [theme]);
161+
applyThemeClass(theme, disableTransitionOnChange);
162+
setResolvedTheme(getEffectiveTheme(theme));
163+
}, [theme, disableTransitionOnChange]);
129164

130165
// Listen for system theme changes when in system mode
131166
useEffect(() => {
132167
if (theme !== "system") return;
133168

134169
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
135-
const handleChange = () => applyThemeClass(theme);
170+
const handleChange = () => {
171+
applyThemeClass(theme, disableTransitionOnChange);
172+
setResolvedTheme(getEffectiveTheme(theme));
173+
};
136174

137175
mediaQuery.addEventListener("change", handleChange);
138176
return () => mediaQuery.removeEventListener("change", handleChange);
139-
}, [theme]);
177+
}, [theme, disableTransitionOnChange]);
140178

141179
// Cross-tab sync: update React state when theme changes in another tab
142180
useEffect(() => {
@@ -158,8 +196,13 @@ export function ThemeProvider({
158196
return () => clearThemeConfig();
159197
}, [themeConfig, theme]);
160198

199+
const value = useMemo(
200+
() => ({ theme, resolvedTheme, setTheme }),
201+
[theme, resolvedTheme, setTheme],
202+
);
203+
161204
return (
162-
<ThemeProviderContext.Provider value={{ theme, setTheme }}>
205+
<ThemeProviderContext.Provider value={value}>
163206
{children}
164207
</ThemeProviderContext.Provider>
165208
);

0 commit comments

Comments
 (0)