-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
🍪 Use
localStorage
instead of cookie for static builds' theme (#445)
* 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
Showing
14 changed files
with
220 additions
and
55 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './theme.js'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 })); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }} />; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './theme.js'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.