@@ -5,6 +5,11 @@ import {
55 useEffect ,
66 useCallback ,
77} from "react" ;
8+ import {
9+ applyThemeAndPersist ,
10+ getPreferredTheme ,
11+ getThemeFromDOM ,
12+ } from "./theme" ;
813
914const ThemeContext = createContext ( ) ;
1015
@@ -21,54 +26,41 @@ export function updateIframeThemes(theme) {
2126 } ) ;
2227}
2328
24- /**
25- * Gets the user's theme preference from localStorage or system preference.
26- * Returns "light" as default for SSR.
27- */
28- function getUserPreference ( ) {
29- if ( typeof window === "undefined" ) {
30- return "light" ;
31- }
32- if ( window . localStorage . getItem ( "theme" ) ) {
33- return window . localStorage . getItem ( "theme" ) ;
34- }
35- return window . matchMedia ( "(prefers-color-scheme: dark)" ) . matches
36- ? "dark"
37- : "light" ;
38- }
39-
4029export function ThemeContextProvider ( { children } ) {
41- // Initialize with "light" for SSR, will be updated on mount
42- const [ theme , setThemeState ] = useState ( "light" ) ;
30+ // Initialize from DOM if available (so we match the pre-hydration bootstrap),
31+ // otherwise default to "light" for SSR.
32+ const [ theme , setThemeState ] = useState ( ( ) =>
33+ typeof document === "undefined" ? "light" : getThemeFromDOM ( ) ,
34+ ) ;
4335 const [ isInitialized , setIsInitialized ] = useState ( false ) ;
4436
45- // Apply theme to DOM and localStorage
46- const applyTheme = useCallback ( ( newTheme ) => {
47- if ( typeof document === "undefined" ) return ;
48-
49- const inactiveTheme = newTheme === "light" ? "dark" : "light" ;
50- document . documentElement . classList . add ( newTheme ) ;
51- document . documentElement . classList . remove ( inactiveTheme ) ;
52- localStorage . setItem ( "theme" , newTheme ) ;
53- } , [ ] ) ;
54-
5537 // Set theme and update everything
56- const setTheme = useCallback (
57- ( newTheme ) => {
58- setThemeState ( newTheme ) ;
59- applyTheme ( newTheme ) ;
60- updateIframeThemes ( newTheme ) ;
61- } ,
62- [ applyTheme ] ,
63- ) ;
38+ const setTheme = useCallback ( ( newTheme ) => {
39+ setThemeState ( newTheme ) ;
40+ applyThemeAndPersist ( newTheme ) ;
41+ updateIframeThemes ( newTheme ) ;
42+ } , [ ] ) ;
6443
6544 // Initialize theme on mount (client-side only)
6645 useEffect ( ( ) => {
67- const preferredTheme = getUserPreference ( ) ;
46+ const preferredTheme = getPreferredTheme ( ) ;
47+
48+ // If the pre-hydration bootstrap ever drifts from our runtime logic,
49+ // fail loudly in dev so we don't reintroduce flashes.
50+ if ( process . env . NODE_ENV !== "production" ) {
51+ const domTheme = getThemeFromDOM ( ) ;
52+ if ( domTheme !== preferredTheme ) {
53+ // eslint-disable-next-line no-console
54+ console . warn (
55+ `[theme] DOM theme (${ domTheme } ) differs from preferredTheme (${ preferredTheme } ). This indicates theme bootstrap drift.` ,
56+ ) ;
57+ }
58+ }
59+
6860 setThemeState ( preferredTheme ) ;
69- applyTheme ( preferredTheme ) ;
61+ applyThemeAndPersist ( preferredTheme ) ;
7062 setIsInitialized ( true ) ;
71- } , [ applyTheme ] ) ;
63+ } , [ ] ) ;
7264
7365 return (
7466 < ThemeContext . Provider value = { { theme, setTheme, isInitialized } } >
@@ -95,19 +87,6 @@ export function useThemeContextSafe() {
9587 return useContext ( ThemeContext ) ;
9688}
9789
98- /**
99- * Gets the current theme from DOM (for use in non-React contexts or SSR fallback).
100- * Returns "light" as default if document is not available.
101- */
102- export function getThemeFromDOM ( ) {
103- if ( typeof document !== "undefined" ) {
104- return document . documentElement . classList . contains ( "dark" )
105- ? "dark"
106- : "light" ;
107- }
108- return "light" ;
109- }
110-
11190/**
11291 * Adds a "light" or "dark" theme to a given Streamlit Cloud URL.
11392 */
@@ -132,3 +111,5 @@ export function addThemeToSearchParams(searchParams, theme) {
132111
133112 searchParams . append ( "embed_options" , `${ theme } _theme` ) ;
134113}
114+
115+ export { getThemeFromDOM } from "./theme" ;
0 commit comments