diff --git a/.changeset/rare-months-speak.md b/.changeset/rare-months-speak.md new file mode 100644 index 000000000..ae0d97b63 --- /dev/null +++ b/.changeset/rare-months-speak.md @@ -0,0 +1,6 @@ +--- +'@myst-theme/providers': minor +'@myst-theme/site': patch +--- + +Support localStorage for theme persistence diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index 6195aed8d..db1ce01b5 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -23,7 +23,7 @@ export type Heading = { }; export type SiteLoader = { - theme: Theme; + theme?: Theme; config?: SiteManifest; CONTENT_CDN_PORT?: string | number; MODE?: 'app' | 'static'; diff --git a/packages/myst-to-react/src/code.tsx b/packages/myst-to-react/src/code.tsx index 6c7c20945..31eb02427 100644 --- a/packages/myst-to-react/src/code.tsx +++ b/packages/myst-to-react/src/code.tsx @@ -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'; @@ -34,7 +34,7 @@ function normalizeLanguage(lang?: string): string | undefined { } export function CodeBlock(props: Props) { - const { isLight } = useTheme(); + const { isLight } = useThemeSwitcher(); const { value, lang, diff --git a/packages/providers/src/theme.tsx b/packages/providers/src/theme.tsx index e5196cf60..1745b4145 100644 --- a/packages/providers/src/theme.tsx +++ b/packages/providers/src/theme.tsx @@ -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?: NodeRenderersValidated; top?: number; Link?: Link; @@ -56,60 +58,38 @@ type ThemeContextType = { const ThemeContext = React.createContext(undefined); ThemeContext.displayName = 'ThemeContext'; -const prefersLightMQ = '(prefers-color-scheme: light)'; - export function ThemeProvider({ + theme, + setTheme, children, - theme: startingTheme = Theme.light, renderers, Link, top, NavLink, }: { + theme: Theme | null; + setTheme: SetThemeType; children: React.ReactNode; - theme?: Theme; renderers?: NodeRenderers; Link?: Link; top?: number; NavLink?: NavLink; }) { - const [theme, setTheme] = React.useState(() => { - if (startingTheme) { - if (isTheme(startingTheme)) return startingTheme; - else return null; - } - if (typeof document === 'undefined') return null; - return window.matchMedia(prefersLightMQ).matches ? Theme.light : Theme.dark; - }); - - const nextTheme = React.useCallback( - (next: Theme) => { - if (!next || next === theme || !isTheme(next)) return; - if (typeof document !== 'undefined') { - document.getElementsByTagName('html')[0].className = next; - } - const xmlhttp = new XMLHttpRequest(); - xmlhttp.open('POST', '/api/theme'); - xmlhttp.setRequestHeader('Content-Type', 'application/json;charset=UTF-8'); - xmlhttp.send(JSON.stringify({ theme: next })); - setTheme(next); - }, - [theme], - ); const validatedRenderers = validateRenderers(renderers); + return ( {children} ); } -export function useTheme() { +export function useThemeSwitcher() { const context = React.useContext(ThemeContext); if (context === undefined) { - const error = 'useTheme should be used within a ThemeProvider'; + const error = 'useThemeSwitcher should be used within a ThemeProvider'; const throwError = () => { throw new Error(error); }; diff --git a/packages/site/src/actions/index.ts b/packages/site/src/actions/index.ts new file mode 100644 index 000000000..39205df7c --- /dev/null +++ b/packages/site/src/actions/index.ts @@ -0,0 +1 @@ +export * from './theme.js'; diff --git a/packages/site/src/actions/theme.ts b/packages/site/src/actions/theme.ts new file mode 100644 index 000000000..f74b1d2dd --- /dev/null +++ b/packages/site/src/actions/theme.ts @@ -0,0 +1,8 @@ +import type { 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 })); +} diff --git a/packages/site/src/components/Navigation/ThemeButton.tsx b/packages/site/src/components/Navigation/ThemeButton.tsx index e5c8c4d69..2d97e3f70 100644 --- a/packages/site/src/components/Navigation/ThemeButton.tsx +++ b/packages/site/src/components/Navigation/ThemeButton.tsx @@ -1,25 +1,22 @@ -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 { isDark, nextTheme } = useTheme(); + const { nextTheme } = useThemeSwitcher(); return ( ); } diff --git a/packages/site/src/components/index.ts b/packages/site/src/components/index.ts index 5c3f71258..896b6112a 100644 --- a/packages/site/src/components/index.ts +++ b/packages/site/src/components/index.ts @@ -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'; diff --git a/packages/site/src/components/theme.tsx b/packages/site/src/components/theme.tsx new file mode 100644 index 000000000..7d9b88157 --- /dev/null +++ b/packages/site/src/components/theme.tsx @@ -0,0 +1,19 @@ +import { THEME_LOCALSTORAGE_KEY, PREFERS_LIGHT_MQ } from '../hooks/theme.js'; + +/** + * A blocking element that runs on the client before hydration to update the 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