Skip to content

Commit 0afbb2a

Browse files
[fix] Prevent theme flickering on page load (#1395)
* [fix] Prevent theme flickering on page load * Simplify * Simplify
1 parent 968b9f0 commit 0afbb2a

File tree

3 files changed

+124
-52
lines changed

3 files changed

+124
-52
lines changed

lib/next/ThemeContext.jsx

Lines changed: 33 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import {
55
useEffect,
66
useCallback,
77
} from "react";
8+
import {
9+
applyThemeAndPersist,
10+
getPreferredTheme,
11+
getThemeFromDOM,
12+
} from "./theme";
813

914
const 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-
4029
export 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";

lib/next/theme.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
const THEME_STORAGE_KEY = "theme";
2+
const THEME_LIGHT = "light";
3+
const THEME_DARK = "dark";
4+
5+
function isValidTheme(theme) {
6+
return theme === THEME_LIGHT || theme === THEME_DARK;
7+
}
8+
9+
/**
10+
* Returns the preferred theme based on explicit user choice (localStorage) or
11+
* OS preference (prefers-color-scheme). Defaults to "light" when window is not
12+
* available (SSR).
13+
*/
14+
export function getPreferredTheme() {
15+
if (typeof window === "undefined") {
16+
return THEME_LIGHT;
17+
}
18+
19+
try {
20+
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
21+
if (isValidTheme(stored)) return stored;
22+
} catch {
23+
// localStorage can throw in some environments; ignore and fall back.
24+
}
25+
26+
try {
27+
return window.matchMedia &&
28+
window.matchMedia("(prefers-color-scheme: dark)").matches
29+
? THEME_DARK
30+
: THEME_LIGHT;
31+
} catch {
32+
return THEME_LIGHT;
33+
}
34+
}
35+
36+
/**
37+
* Applies theme to the root element (compatible with Tailwind's `dark:` class
38+
* strategy) AND persists the selection to localStorage.
39+
*/
40+
export function applyThemeAndPersist(theme) {
41+
if (typeof document === "undefined") {
42+
return;
43+
}
44+
if (!isValidTheme(theme)) {
45+
return;
46+
}
47+
48+
const inactiveTheme = theme === THEME_LIGHT ? THEME_DARK : THEME_LIGHT;
49+
const root = document.documentElement;
50+
root.classList.add(theme);
51+
root.classList.remove(inactiveTheme);
52+
53+
// Hint to the browser for built-in UI (form controls, scrollbars, etc.)
54+
root.style.colorScheme = theme;
55+
56+
try {
57+
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
58+
} catch {
59+
// Ignore storage failures.
60+
}
61+
}
62+
63+
export function getThemeFromDOM() {
64+
if (typeof document === "undefined") {
65+
return THEME_LIGHT;
66+
}
67+
68+
return document.documentElement.classList.contains(THEME_DARK)
69+
? THEME_DARK
70+
: THEME_LIGHT;
71+
}
72+
73+
/**
74+
* Inline script string to apply theme before first paint (prevents flash).
75+
*
76+
* NOTE: Keep this aligned with `getPreferredTheme()` + `applyThemeAndPersist()`.
77+
* We intentionally centralize this generator in the same module to reduce drift.
78+
*/
79+
export function getThemeBootstrapScript() {
80+
// Keep this compact: it runs on every page view before hydration.
81+
return `!function(){try{var e=localStorage.getItem(${JSON.stringify(
82+
THEME_STORAGE_KEY,
83+
)}),t=e==="${THEME_DARK}"||e==="${THEME_LIGHT}"?e:(matchMedia&&matchMedia("(prefers-color-scheme: dark)").matches?"${THEME_DARK}":"${THEME_LIGHT}"),o=document.documentElement;o.classList.add(t),o.classList.remove(t==="${THEME_DARK}"?"${THEME_LIGHT}":"${THEME_DARK}"),o.style.colorScheme=t}catch(e){}}();`;
84+
}

pages/_document.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import Document, { Html, Head, Main, NextScript } from "next/document";
2+
import { getThemeBootstrapScript } from "../lib/next/theme";
23

34
export default function StreamlitDocument() {
45
return (
56
<Html>
67
<Head>
8+
{/* Prevent light->dark flash by applying theme before first paint */}
9+
<script
10+
dangerouslySetInnerHTML={{
11+
__html: getThemeBootstrapScript(),
12+
}}
13+
/>
714
{/* OneTrust Consent SDK */}
815
<script
916
src="https://cdn.cookielaw.org/scripttemplates/otSDKStub.js"

0 commit comments

Comments
 (0)