Skip to content

Commit

Permalink
refactor: move logic from providers to site
Browse files Browse the repository at this point in the history
  • Loading branch information
agoose77 committed Aug 29, 2024
1 parent 52a3db7 commit db92e3b
Show file tree
Hide file tree
Showing 11 changed files with 154 additions and 101 deletions.
4 changes: 2 additions & 2 deletions packages/myst-to-react/src/code.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Code, InlineCode } from 'myst-spec';
import type { NodeRenderer } from '@myst-theme/providers';
import { useTheme } from '@myst-theme/providers';
import { useThemeSwitcher } from '@myst-theme/providers';
import { LightAsync as SyntaxHighlighter } from 'react-syntax-highlighter';
import light from 'react-syntax-highlighter/dist/esm/styles/hljs/xcode.js';
import dark from 'react-syntax-highlighter/dist/esm/styles/hljs/vs2015.js';
Expand Down Expand Up @@ -34,7 +34,7 @@ function normalizeLanguage(lang?: string): string | undefined {
}

export function CodeBlock(props: Props) {
const { isLight } = useTheme();
const { isLight } = useThemeSwitcher();
const {
value,
lang,
Expand Down
94 changes: 10 additions & 84 deletions packages/providers/src/theme.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react';
import React from 'react';
import type { NodeRenderer } from './types.js';
import { Theme } from '@myst-theme/common';

Expand Down Expand Up @@ -44,9 +44,11 @@ export function isTheme(value: unknown): value is Theme {
return typeof value === 'string' && Object.values(Theme).includes(value as Theme);
}

type SetThemeType = (theme: Theme) => void;

type ThemeContextType = {
theme: Theme | null;
setTheme: (theme: Theme) => void;
setTheme: SetThemeType;
renderers?: Record<string, NodeRenderer>;
top?: number;
Link?: Link;
Expand All @@ -56,108 +58,32 @@ type ThemeContextType = {
const ThemeContext = React.createContext<ThemeContextType | undefined>(undefined);
ThemeContext.displayName = 'ThemeContext';

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

const THEME_KEY = 'myst:theme';

/**
* 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);
`;

return <script dangerouslySetInnerHTML={{ __html: CLIENT_THEME_SOURCE }} />;
}

export function ThemeProvider({
theme,
setTheme,
children,
theme: ssrTheme,
renderers,
Link,
top,
NavLink,
useLocalStorageForDarkMode,
}: {
theme: Theme | null;
setTheme: SetThemeType;
children: React.ReactNode;
theme?: Theme;
renderers?: Record<string, NodeRenderer>;
Link?: Link;
top?: number;
NavLink?: NavLink;
useLocalStorageForDarkMode?: boolean;
}) {
// Here, the initial state on the server without any set cookies will be null.
// The client will then load the initial state as non-null.
// Thus, we must mutate the DOM *pre-hydration* to ensure that the initial state is
// identical to that of the hydrated state, i.e. perform out-of-react DOM updates
// This is handled by the BlockingThemeLoader component.
const [theme, setTheme] = React.useState<Theme | 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;

// 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 = () => {
setTheme(mediaQuery.matches ? Theme.light : Theme.dark);
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);

// Listen for changes to theme, and propagate to server
// This should be unidirectional; updates to the cookie do not trigger document rerenders
const mountRun = useRef(false);
useEffect(() => {
// Only update after the component is mounted (i.e. don't send initial state)
if (!mountRun.current) {
mountRun.current = true;
return;
}
if (!isTheme(theme)) {
return;
}
if (useLocalStorageForDarkMode) {
localStorage.setItem(THEME_KEY, theme);
} else {
const xmlhttp = new XMLHttpRequest();
xmlhttp.open('POST', '/api/theme');
xmlhttp.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
xmlhttp.send(JSON.stringify({ theme }));
}
}, [theme]);


return (
<ThemeContext.Provider value={{ theme, setTheme, renderers, Link, NavLink, top }}>
{children}
</ThemeContext.Provider>
);
}

export function useTheme() {
export function useThemeSwitcher() {
const context = React.useContext(ThemeContext);
if (context === undefined) {
const error = 'useTheme should be used within a ThemeProvider';
Expand Down
1 change: 1 addition & 0 deletions packages/site/src/actions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './theme.js';
8 changes: 8 additions & 0 deletions packages/site/src/actions/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Theme } from '@myst-theme/common';

export function postThemeToAPI(theme: Theme) {
const xmlhttp = new XMLHttpRequest();
xmlhttp.open('POST', '/api/theme');
xmlhttp.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
xmlhttp.send(JSON.stringify({ theme }));
}
4 changes: 2 additions & 2 deletions packages/site/src/components/Navigation/ThemeButton.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useTheme } from '@myst-theme/providers';
import { useThemeSwitcher } from '@myst-theme/providers';
import { MoonIcon } from '@heroicons/react/24/solid';
import { SunIcon } from '@heroicons/react/24/outline';
import classNames from 'classnames';

export function ThemeButton({ className = 'w-8 h-8 mx-3' }: { className?: string }) {
const { nextTheme } = useTheme();
const { nextTheme } = useThemeSwitcher();
return (
<button
className={classNames(
Expand Down
1 change: 1 addition & 0 deletions packages/site/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export { ExternalOrInternalLink } from './ExternalOrInternalLink.js';
export * from './Navigation/index.js';
export { renderers } from './renderers.js';
export { SkipToArticle, SkipTo } from './SkipToArticle.js';
export { BlockingThemeLoader } from './theme.js';
20 changes: 20 additions & 0 deletions packages/site/src/components/theme.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { THEME_LOCALSTORAGE_KEY } from '../hooks/theme.js';
const PREFERS_LIGHT_MQ = '(prefers-color-scheme: light)';

/**
* 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_LOCALSTORAGE_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);
`;

return <script dangerouslySetInnerHTML={{ __html: CLIENT_THEME_SOURCE }} />;
}
1 change: 1 addition & 0 deletions packages/site/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './theme.js';
84 changes: 84 additions & 0 deletions packages/site/src/hooks/theme.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React, { useEffect, useRef } from 'react';
import { Theme } from '@myst-theme/common';
import { isTheme } from '@myst-theme/providers';
const PREFERS_LIGHT_MQ = '(prefers-color-scheme: light)';
import { postThemeToAPI } from '../actions/theme.js';

export const THEME_LOCALSTORAGE_KEY = 'myst:theme';

export function getPreferredTheme() {
if (typeof window !== 'object') {
return null;
}
const mediaQuery = window.matchMedia(PREFERS_LIGHT_MQ);
return mediaQuery.matches ? Theme.light : Theme.dark;
}

/**
* Hook that changes theme to follow changes to system preference.
*/
export function usePreferredTheme({ setTheme }: { setTheme: (theme: Theme | null) => void }) {
// 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 = () => {
setTheme(mediaQuery.matches ? Theme.light : Theme.dark);
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
}

export function useTheme({
ssrTheme,
useLocalStorage,
}: {
ssrTheme: Theme | null;
useLocalStorage?: boolean;
}) {
// Here, the initial state on the server without any set cookies will be null.
// The client will then load the initial state as non-null.
// Thus, we must mutate the DOM *pre-hydration* to ensure that the initial state is
// identical to that of the hydrated state, i.e. perform out-of-react DOM updates
// This is handled by the BlockingThemeLoader component.
const [theme, setTheme] = React.useState<Theme | 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 preferredTheme = getPreferredTheme();

// Local storage preferred theme
const savedTheme = localStorage.getItem(THEME_LOCALSTORAGE_KEY);
return useLocalStorage && isTheme(savedTheme) ? savedTheme : preferredTheme;
});

// Listen for system-updates that change the preferred theme
usePreferredTheme({ setTheme });

// Listen for changes to theme, and propagate to server
// This should be unidirectional; updates to the cookie do not trigger document rerenders
const mountRun = useRef(false);
useEffect(() => {
// Only update after the component is mounted (i.e. don't send initial state)
if (!mountRun.current) {
mountRun.current = true;
return;
}
if (!isTheme(theme)) {
return;
}
if (useLocalStorage) {
localStorage.setItem(THEME_LOCALSTORAGE_KEY, theme);
} else {
postThemeToAPI(theme);
}
}, [theme]);

return [theme, setTheme];
}
2 changes: 2 additions & 0 deletions packages/site/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export * from './utils.js';
export * from './loaders/index.js';
export * from './components/index.js';
export * from './hooks/index.js';
export * from './pages/index.js';
export * from './seo/index.js';
export * from './themeCSS.js';
export * from './actions/index.js';
36 changes: 23 additions & 13 deletions packages/site/src/pages/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import {
SiteProvider,
Theme,
ThemeProvider,
BlockingThemeLoader,
useTheme,
useThemeSwitcher,
} from '@myst-theme/providers';
import {
Links,
Expand All @@ -20,7 +19,12 @@ import {
Link,
NavLink,
} from '@remix-run/react';
import { DEFAULT_NAV_HEIGHT, renderers as defaultRenderers } from '../components/index.js';
import {
DEFAULT_NAV_HEIGHT,
renderers as defaultRenderers,
BlockingThemeLoader,
} from '../components/index.js';
import { useTheme } from '../hooks/index.js';
import { Analytics } from '../seo/index.js';
import { Error404 } from './Error404.js';
import classNames from 'classnames';
Expand Down Expand Up @@ -55,22 +59,28 @@ export function Document({
Link: Link as any,
NavLink: NavLink as any,
};

// (Local) theme state driven by SSR and cookie/localStorage
const [theme, setTheme] = useTheme({ ssrTheme: ssrTheme, useLocalStorage: staticBuild });

// Inject blocking element to set proper pre-hydration state
const head = ssrTheme ? undefined : <BlockingThemeLoader useLocalStorage={!!staticBuild} />

return (
<ThemeProvider
theme={ssrTheme}
theme={theme}
setTheme={setTheme}
renderers={renderers}
useLocalStorageForDarkMode={staticBuild}
{...links}
top={top}
>
<DocumentWithoutProviders
children={children}
scripts={scripts}
head={head}
config={config}
title={title}
theme={ssrTheme}
liveReloadListener={!staticBuild}
useLocalStorageForDarkMode={staticBuild}
baseurl={baseurl}
top={top}
/>
Expand All @@ -81,16 +91,16 @@ export function Document({
export function DocumentWithoutProviders({
children,
scripts,
head,
config,
title,
baseurl,
theme: ssrTheme,
useLocalStorageForDarkMode,
top = DEFAULT_NAV_HEIGHT,
liveReloadListener,
}: {
children: React.ReactNode;
scripts?: React.ReactNode;
head?: React.ReactNode;
config?: SiteManifest;
title?: string;
baseurl?: string;
Expand All @@ -104,13 +114,13 @@ export function DocumentWithoutProviders({
// 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
// In live-server contexts, setting the theme or changing the system preferred theme will modify the ssrTheme upon next request _and_ update the useThemeSwitcher 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.
// 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 useThemeSwitcher.
//
// In static sites, ssrTheme is forever null.
// if (ssrTheme) { assert(theme === ssrTheme) }
const { theme } = useTheme();
const { theme } = useThemeSwitcher();
return (
// Set the theme during SSR if possible, otherwise leave it up to the BlockingThemeLoader
<html lang="en" className={classNames(theme)} style={{ scrollPadding: top }}>
Expand All @@ -124,7 +134,7 @@ export function DocumentWithoutProviders({
analytics_google={config?.options?.analytics_google}
analytics_plausible={config?.options?.analytics_plausible}
/>
{!ssrTheme && <BlockingThemeLoader useLocalStorage={useLocalStorageForDarkMode ?? true} />}
{ head }
</head>
<body className="m-0 transition-colors duration-500 bg-white dark:bg-stone-900">
<BaseUrlProvider baseurl={baseurl}>
Expand Down

0 comments on commit db92e3b

Please sign in to comment.