Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🍪 Use localStorage instead of cookie for static builds' theme #445

Merged
merged 16 commits into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/rare-months-speak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@myst-theme/providers': minor
'@myst-theme/site': patch
---

Support localStorage for theme persistence
2 changes: 1 addition & 1 deletion packages/common/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export type Heading = {
};

export type SiteLoader = {
theme: Theme;
theme?: Theme;
config?: SiteManifest;
CONTENT_CDN_PORT?: string | number;
MODE?: 'app' | 'static';
Expand Down
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
42 changes: 11 additions & 31 deletions packages/providers/src/theme.tsx
Original file line number Diff line number Diff line change
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?: NodeRenderersValidated;
top?: number;
Link?: Link;
Expand All @@ -56,60 +58,38 @@ type ThemeContextType = {
const ThemeContext = React.createContext<ThemeContextType | undefined>(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<Theme | null>(() => {
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 (
<ThemeContext.Provider
value={{ theme, setTheme: nextTheme, renderers: validatedRenderers, Link, NavLink, top }}
value={{ theme, setTheme, renderers: validatedRenderers, 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';
const error = 'useThemeSwitcher should be used within a ThemeProvider';
const throwError = () => {
throw new Error(error);
};
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 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 }));
}
15 changes: 6 additions & 9 deletions packages/site/src/components/Navigation/ThemeButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
className={classNames(
'theme rounded-full border border-stone-700 dark:border-white hover:bg-neutral-100 border-solid overflow-hidden text-stone-700 dark:text-white hover:text-stone-500 dark:hover:text-neutral-800',
className,
)}
title={`Change theme to ${isDark ? 'light' : 'dark'} mode.`}
aria-label={`Change theme to ${isDark ? 'light' : 'dark'} mode.`}
title={`Toggle theme between light and dark mode.`}
aria-label={`Toggle theme between light and dark mode.`}
onClick={nextTheme}
>
{isDark ? (
<MoonIcon className="h-full w-full p-0.5" />
) : (
<SunIcon className="h-full w-full p-0.5" />
)}
<MoonIcon className="h-full w-full p-0.5 hidden dark:block" />
<SunIcon className="h-full w-full p-0.5 dark:hidden" />
</button>
);
}
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';
19 changes: 19 additions & 0 deletions packages/site/src/components/theme.tsx
Original file line number Diff line number Diff line change
@@ -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 <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';
import { postThemeToAPI } from '../actions/theme.js';

export const PREFERS_LIGHT_MQ = '(prefers-color-scheme: light)';
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;
useLocalStorage?: boolean;
}): [Theme | null, (theme: Theme) => void] {
// 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';
5 changes: 3 additions & 2 deletions packages/site/src/loaders/theme.server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createCookieSessionStorage, json } from '@remix-run/node';
import { isTheme, Theme } from '@myst-theme/providers';
import { isTheme } from '@myst-theme/providers';
import type { Theme } from '@myst-theme/providers';
import type { ActionFunction } from '@remix-run/node';

export const themeStorage = createCookieSessionStorage({
Expand All @@ -18,7 +19,7 @@ async function getThemeSession(request: Request) {
return {
getTheme: () => {
const themeValue = session.get('theme');
return isTheme(themeValue) ? themeValue : Theme.light;
return isTheme(themeValue) ? themeValue : undefined;
},
setTheme: (theme: Theme) => session.set('theme', theme),
commit: () => themeStorage.commitSession(session, { expires: new Date('2100-01-01') }),
Expand Down
Loading
Loading