Skip to content

Commit

Permalink
🍪 Use localStorage instead of cookie for static builds' theme (#445)
Browse files Browse the repository at this point in the history
* fix: respect system preference for theme

* feat: add support for reading theme from `localStorage`

* fix: hydrate client with client-side state (localStorage, matchMedia)

* fix: only use blocking loader when SSR fails

* fix: disable security for cookie

* refactor: cleanups

* fix: change theme button to support client-side state

* chore: add changeset

* refactor: move logic from providers to site

* chore: run linter

* fix: update error message

* refactor: avoid duplication

* chore: import type

* chore: appease lint

* Revert "fix: disable security for cookie"

This reverts commit 8b9b8b2.

* chore: run linter
  • Loading branch information
agoose77 authored Sep 9, 2024
1 parent 58d8756 commit a76b8f1
Show file tree
Hide file tree
Showing 14 changed files with 220 additions and 55 deletions.
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,5 +1,5 @@
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 @@ -33,7 +33,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

0 comments on commit a76b8f1

Please sign in to comment.