Skip to content

Commit

Permalink
fix: static-export theme control
Browse files Browse the repository at this point in the history
  • Loading branch information
agoose77 committed Aug 27, 2024
1 parent 80d9671 commit 634a8e7
Show file tree
Hide file tree
Showing 4 changed files with 46 additions and 35 deletions.
51 changes: 22 additions & 29 deletions packages/providers/src/theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,34 +58,29 @@ ThemeContext.displayName = 'ThemeContext';

const PREFERS_LIGHT_MQ = '(prefers-color-scheme: light)';

/**
* Return the theme preference indicated by the system
*/
function getPreferredTheme() {
return window.matchMedia(PREFERS_LIGHT_MQ).matches ? Theme.light : Theme.dark;
}

const THEME_KEY = 'myst:theme';

const CLIENT_THEME_SOURCE = `
const savedTheme = localStorage.getItem(${JSON.stringify(THEME_KEY)});
/**
* A blocking element that runs on the client before hydration to update the <html> preferred class
* This ensures that the hydrated state matches the non-hydrated state (by updating the DOM on the
* client between SSR on the server and hydration on the client)
*/
export function BlockingThemeLoader({ useLocalStorage }: { useLocalStorage: boolean }) {
const LOCAL_STORAGE_SOURCE = `localStorage.getItem(${JSON.stringify(THEME_KEY)})`;
const CLIENT_THEME_SOURCE = `
const savedTheme = ${useLocalStorage ? LOCAL_STORAGE_SOURCE : 'null'};
const theme = window.matchMedia(${JSON.stringify(PREFERS_LIGHT_MQ)}).matches ? 'light' : 'dark';
const classes = document.documentElement.classList;
const hasAnyTheme = classes.contains('light') || classes.contains('dark');
if (!hasAnyTheme) classes.add(savedTheme ?? theme);
`;

/**
* A blocking element that runs before hydration to update the <html> preferred class
*/
export function BlockingThemeLoader() {
return <script dangerouslySetInnerHTML={{ __html: CLIENT_THEME_SOURCE }} />;
}


export function ThemeProvider({
children,
theme: startingTheme,
theme: ssrTheme,
renderers,
Link,
top,
Expand All @@ -101,27 +96,24 @@ export function ThemeProvider({
useLocalStorageForDarkMode?: boolean;
}) {
const [theme, setTheme] = React.useState<Theme | null>(() => {
// Allow hard-coded theme ignoring system preferences (not recommended)
if (startingTheme) {
return isTheme(startingTheme) ? startingTheme : null;
if (isTheme(ssrTheme)) {
return ssrTheme;
}
// On the server we can't know what the preferred theme is, so leave it up to client
if (typeof window !== 'object') {
return null;
}
// System preferred theme
const mediaQuery = window.matchMedia(PREFERS_LIGHT_MQ);
const preferredTheme = mediaQuery.matches ? Theme.light : Theme.dark;

// Prefer local storage if set
if (useLocalStorageForDarkMode) {
const savedTheme = localStorage.getItem(THEME_KEY);
if (savedTheme && isTheme(savedTheme)) {
return savedTheme;
}
}

// Interrogate the system for a preferred theme
return getPreferredTheme();
// Local storage preferred theme
const savedTheme = localStorage.getItem(THEME_KEY);
return useLocalStorageForDarkMode && isTheme(savedTheme) ? savedTheme : preferredTheme;
});

// Listen for system-updates that change the preferred theme
// This will modify the saved theme
useEffect(() => {
const mediaQuery = window.matchMedia(PREFERS_LIGHT_MQ);
const handleChange = () => {
Expand All @@ -135,6 +127,7 @@ export function ThemeProvider({
// This should be unidirectional; updates to the cookie do not trigger document rerenders
const mountRun = useRef(false);
useEffect(() => {
console.log('Theme changed', theme);
// Only update after the component is mounted (i.e. don't send initial state)
if (!mountRun.current) {
mountRun.current = true;
Expand All @@ -149,7 +142,7 @@ export function ThemeProvider({
const xmlhttp = new XMLHttpRequest();
xmlhttp.open('POST', '/api/theme');
xmlhttp.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
xmlhttp.send(JSON.stringify({ theme }));
xmlhttp.send(JSON.stringify({ theme: theme }));
}
}, [theme]);

Expand Down
3 changes: 2 additions & 1 deletion packages/site/src/components/Navigation/ThemeButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { SunIcon } from '@heroicons/react/24/outline';
import classNames from 'classnames';

export function ThemeButton({ className = 'w-8 h-8 mx-3' }: { className?: string }) {
const { isDark, nextTheme } = useTheme();
const { isDark, nextTheme, theme } = useTheme();
console.log({ isDark, theme })
return (
<button
className={classNames(
Expand Down
2 changes: 1 addition & 1 deletion packages/site/src/loaders/theme.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { ActionFunction } from '@remix-run/node';
export const themeStorage = createCookieSessionStorage({
cookie: {
name: 'theme',
secure: true,
secure: false,
secrets: ['secret'],
sameSite: 'lax',
path: '/',
Expand Down
25 changes: 21 additions & 4 deletions packages/site/src/pages/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import classNames from 'classnames';
export function Document({
children,
scripts,
theme,
theme: ssrTheme,
config,
title,
staticBuild,
Expand Down Expand Up @@ -57,9 +57,9 @@ export function Document({
};
return (
<ThemeProvider
theme={theme}
theme={ssrTheme}
renderers={renderers}
useLocalStorageForDarkMode={true}
useLocalStorageForDarkMode={staticBuild}
{...links}
top={top}
>
Expand All @@ -68,7 +68,9 @@ export function Document({
scripts={scripts}
config={config}
title={title}
theme={ssrTheme}
liveReloadListener={!staticBuild}
useLocalStorageForDarkMode={staticBuild}
baseurl={baseurl}
top={top}
/>
Expand All @@ -82,6 +84,8 @@ export function DocumentWithoutProviders({
config,
title,
baseurl,
theme: ssrTheme,
useLocalStorageForDarkMode,
top = DEFAULT_NAV_HEIGHT,
liveReloadListener,
}: {
Expand All @@ -90,12 +94,25 @@ export function DocumentWithoutProviders({
config?: SiteManifest;
title?: string;
baseurl?: string;
useLocalStorageForDarkMode?: boolean;
top?: number;
theme?: Theme;
renderers?: Record<string, NodeRenderer>;
liveReloadListener?: boolean;
}) {
// Theme value from theme context. For a clean page load (no cookies), both ssrTheme and theme are null
// And thus the BlockingThemeLoader is used to inject the client-preferred theme (localStorage or media query)
// without a FOUC.
//
// In live-server contexts, setting the theme or changing the system preferred theme will modify the ssrTheme upon next request _and_ update the useTheme context state, leading to a re-render
// Upon re-render, the state-theme value is set on `html` and the client-side BlockingThemeLoader discovers that it has no additional work to do, exiting the script tag early
// Upon a new request to the server, the theme preference is received from the set cookie, and therefore we don't inject a BlockingThemeLoader AND we have the theme value in useTheme.
//
// In static sites, ssrTheme is forever null.
// if (ssrTheme) { assert(theme === ssrTheme) }
const { theme } = useTheme();
return (
// Set the theme during SSR if possible, otherwise leave it up to the BlockingThemeLoader
<html lang="en" className={classNames(theme)} style={{ scrollPadding: top }}>
<head>
<meta charSet="utf-8" />
Expand All @@ -107,7 +124,7 @@ export function DocumentWithoutProviders({
analytics_google={config?.options?.analytics_google}
analytics_plausible={config?.options?.analytics_plausible}
/>
<BlockingThemeLoader />
{!ssrTheme && <BlockingThemeLoader useLocalStorage={useLocalStorageForDarkMode ?? true} />}
</head>
<body className="m-0 transition-colors duration-500 bg-white dark:bg-stone-900">
<BaseUrlProvider baseurl={baseurl}>
Expand Down

0 comments on commit 634a8e7

Please sign in to comment.