diff --git a/docs/src/components/header/ThemeModeToggle.tsx b/docs/src/components/header/ThemeModeToggle.tsx index ff22e076ae3797..6a7667f796d8df 100644 --- a/docs/src/components/header/ThemeModeToggle.tsx +++ b/docs/src/components/header/ThemeModeToggle.tsx @@ -6,6 +6,7 @@ import DarkModeOutlined from '@mui/icons-material/DarkModeOutlined'; import LightModeOutlined from '@mui/icons-material/LightModeOutlined'; import useMediaQuery from '@mui/material/useMediaQuery'; import { useChangeTheme } from 'docs/src/modules/components/ThemeContext'; +import useLocalStorageState from '@mui/utils/useLocalStorageState'; function CssVarsModeToggle(props: { onChange: (checked: boolean) => void }) { const [mounted, setMounted] = React.useState(false); @@ -39,30 +40,19 @@ function CssVarsModeToggle(props: { onChange: (checked: boolean) => void }) { export default function ThemeModeToggle() { const theme = useTheme(); const changeTheme = useChangeTheme(); - const [mode, setMode] = React.useState(null); + const [mode, setMode] = useLocalStorageState('mui-mode', 'system'); const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); - - React.useEffect(() => { - let initialMode = 'system'; - try { - initialMode = localStorage.getItem('mui-mode') || initialMode; - } catch (error) { - // do nothing - } - setMode(initialMode); - }, []); + const preferredMode = prefersDarkMode ? 'dark' : 'light'; const handleChangeThemeMode = (checked: boolean) => { const paletteMode = checked ? 'dark' : 'light'; setMode(paletteMode); + }; - try { - localStorage.setItem('mui-mode', paletteMode); // syncing with homepage, can be removed once all pages are migrated to CSS variables - } catch (error) { - // do nothing - } + React.useEffect(() => { + const paletteMode = mode === 'system' ? preferredMode : mode; changeTheme({ paletteMode }); - }; + }, [changeTheme, mode, preferredMode]); if (mode === null) { return ; diff --git a/docs/src/modules/components/AppSettingsDrawer.js b/docs/src/modules/components/AppSettingsDrawer.js index 3ddad7cb06a71b..58d2a830cd787e 100644 --- a/docs/src/modules/components/AppSettingsDrawer.js +++ b/docs/src/modules/components/AppSettingsDrawer.js @@ -18,6 +18,7 @@ import FormatTextdirectionLToRIcon from '@mui/icons-material/FormatTextdirection import FormatTextdirectionRToLIcon from '@mui/icons-material/FormatTextdirectionRToL'; import { useChangeTheme } from 'docs/src/modules/components/ThemeContext'; import { useTranslate } from '@mui/docs/i18n'; +import useLocalStorageState from '@mui/utils/useLocalStorageState'; const Heading = styled(Typography)(({ theme }) => ({ margin: '20px 0 10px', @@ -42,45 +43,23 @@ function AppSettingsDrawer(props) { const t = useTranslate(); const upperTheme = useTheme(); const changeTheme = useChangeTheme(); - const [mode, setMode] = React.useState(null); + const [mode, setMode] = useLocalStorageState('mui-mode', 'system'); const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); const preferredMode = prefersDarkMode ? 'dark' : 'light'; - React.useEffect(() => { - // syncing with homepage, can be removed once all pages are migrated to CSS variables - let initialMode = 'system'; - try { - initialMode = localStorage.getItem('mui-mode') || initialMode; - } catch (error) { - // do nothing - } - setMode(initialMode); - }, [preferredMode]); - const handleChangeThemeMode = (event, paletteMode) => { if (paletteMode === null) { return; } setMode(paletteMode); - - if (paletteMode === 'system') { - try { - localStorage.setItem('mui-mode', 'system'); // syncing with homepage, can be removed once all pages are migrated to CSS variables - } catch (error) { - // thrown when cookies are disabled. - } - changeTheme({ paletteMode: preferredMode }); - } else { - try { - localStorage.setItem('mui-mode', paletteMode); // syncing with homepage, can be removed once all pages are migrated to CSS variables - } catch (error) { - // thrown when cookies are disabled. - } - changeTheme({ paletteMode }); - } }; + React.useEffect(() => { + const paletteMode = mode === 'system' ? preferredMode : mode; + changeTheme({ paletteMode }); + }, [changeTheme, mode, preferredMode]); + const handleChangeDirection = (event, direction) => { if (direction === null) { direction = upperTheme.direction; diff --git a/docs/src/modules/components/HighlightedCodeWithTabs.tsx b/docs/src/modules/components/HighlightedCodeWithTabs.tsx index f31a70348e42df..bedd57ba5882f6 100644 --- a/docs/src/modules/components/HighlightedCodeWithTabs.tsx +++ b/docs/src/modules/components/HighlightedCodeWithTabs.tsx @@ -4,6 +4,7 @@ import { Tabs, TabsOwnProps } from '@mui/base/Tabs'; import { TabsList as TabsListBase } from '@mui/base/TabsList'; import { TabPanel as TabPanelBase } from '@mui/base/TabPanel'; import { Tab as TabBase } from '@mui/base/Tab'; +import useLocalStorageState from '@mui/utils/useLocalStorageState'; import HighlightedCode from './HighlightedCode'; const TabList = styled(TabsListBase)(({ theme }) => ({ @@ -85,36 +86,16 @@ export default function HighlightedCodeWithTabs({ storageKey?: string; } & Record) { const availableTabs = React.useMemo(() => tabs.map(({ tab }) => tab), [tabs]); - const [activeTab, setActiveTab] = React.useState(availableTabs[0]); + const [activeTab, setActiveTab] = useLocalStorageState(storageKey ?? null, availableTabs[0]); const [mounted, setMounted] = React.useState(false); React.useEffect(() => { - try { - setActiveTab((prev) => { - if (storageKey === undefined) { - return prev; - } - const storedValues = localStorage.getItem(storageKey); - - return storedValues && availableTabs.includes(storedValues) ? storedValues : prev; - }); - } catch (error) { - // ignore error - } setMounted(true); - }, [availableTabs, storageKey]); + }, []); const handleChange: TabsOwnProps['onChange'] = (event, newValue) => { setActiveTab(newValue as string); - if (storageKey === undefined) { - return; - } - try { - localStorage.setItem(storageKey, newValue as string); - } catch (error) { - // ignore error - } }; const ownerState = { mounted }; diff --git a/packages/mui-utils/src/useLocalStorageState/index.ts b/packages/mui-utils/src/useLocalStorageState/index.ts new file mode 100644 index 00000000000000..33ff661f99ed20 --- /dev/null +++ b/packages/mui-utils/src/useLocalStorageState/index.ts @@ -0,0 +1 @@ +export { default } from './useLocalStorageState'; diff --git a/packages/mui-utils/src/useLocalStorageState/useLocalStorageState.ts b/packages/mui-utils/src/useLocalStorageState/useLocalStorageState.ts new file mode 100644 index 00000000000000..d8ce3c4b836071 --- /dev/null +++ b/packages/mui-utils/src/useLocalStorageState/useLocalStorageState.ts @@ -0,0 +1,155 @@ +'use client'; + +import * as React from 'react'; + +const NOOP = () => {}; + +// storage events only work across tabs, we'll use an event emitter to announce within the current tab +const currentTabChangeListeners = new Map void>>(); + +function onCurrentTabStorageChange(key: string, handler: () => void) { + let listeners = currentTabChangeListeners.get(key); + + if (!listeners) { + listeners = new Set(); + currentTabChangeListeners.set(key, listeners); + } + + listeners.add(handler); +} + +function offCurrentTabStorageChange(key: string, handler: () => void) { + const listeners = currentTabChangeListeners.get(key); + if (!listeners) { + return; + } + + listeners.delete(handler); + + if (listeners.size === 0) { + currentTabChangeListeners.delete(key); + } +} + +function emitCurrentTabStorageChange(key: string) { + const listeners = currentTabChangeListeners.get(key); + if (listeners) { + listeners.forEach((listener) => listener()); + } +} + +function subscribe(area: Storage, key: string, cb: () => void): () => void { + const storageHandler = (event: StorageEvent) => { + if (event.storageArea === area && event.key === key) { + cb(); + } + }; + window.addEventListener('storage', storageHandler); + onCurrentTabStorageChange(key, cb); + return () => { + window.removeEventListener('storage', storageHandler); + offCurrentTabStorageChange(key, cb); + }; +} + +function getSnapshot(area: Storage, key: string): string | null { + return area.getItem(key); +} + +function setValue(area: Storage, key: string, value: string | null) { + if (typeof window !== 'undefined') { + if (value === null) { + area.removeItem(key); + } else { + area.setItem(key, String(value)); + } + emitCurrentTabStorageChange(key); + } +} + +type Initializer = () => T; + +type UseStorageStateHookResult = [T, React.Dispatch>]; + +function useLocalStorageStateServer( + key: string | null, + initializer: string | Initializer, +): UseStorageStateHookResult; +function useLocalStorageStateServer( + key: string | null, + initializer?: string | null | Initializer, +): UseStorageStateHookResult; +function useLocalStorageStateServer( + key: string | null, + initializer: string | null | Initializer = null, +): UseStorageStateHookResult | UseStorageStateHookResult { + const [initialValue] = React.useState(initializer); + return [initialValue, () => {}]; +} + +/** + * Sync state to local storage so that it persists through a page refresh. Usage is + * similar to useState except we pass in a storage key so that we can default + * to that value on page load instead of the specified initial value. + * + * Since the storage API isn't available in server-rendering environments, we + * return initialValue during SSR and hydration. + * + * Things this hook does different from existing solutions: + * - SSR-capable: it shows initial value during SSR and hydration, but immediately + * initializes when clientside mounted. + * - Sync state across tabs: When another tab changes the value in the storage area, the + * current tab follows suit. + */ +function useLocalStorageStateBrowser( + key: string | null, + initializer: string | Initializer, +): UseStorageStateHookResult; +function useLocalStorageStateBrowser( + key: string | null, + initializer?: string | null | Initializer, +): UseStorageStateHookResult; +function useLocalStorageStateBrowser( + key: string | null, + initializer: string | null | Initializer = null, +): UseStorageStateHookResult | UseStorageStateHookResult { + const [initialValue] = React.useState(initializer); + const area = window.localStorage; + const subscribeKey = React.useCallback( + (cb: () => void) => (key ? subscribe(area, key, cb) : NOOP), + [area, key], + ); + const getKeySnapshot = React.useCallback( + () => (key && getSnapshot(area, key)) ?? initialValue, + [area, initialValue, key], + ); + const getKeyServerSnapshot = React.useCallback(() => initialValue, [initialValue]); + + const storedValue = React.useSyncExternalStore( + subscribeKey, + getKeySnapshot, + getKeyServerSnapshot, + ); + + const setStoredValue = React.useCallback( + (value: React.SetStateAction) => { + if (key) { + const valueToStore = value instanceof Function ? value(storedValue) : value; + setValue(area, key, valueToStore); + } + }, + [area, key, storedValue], + ); + + const [nonStoredValue, setNonStoredValue] = React.useState(initialValue); + + if (!key) { + return [nonStoredValue, setNonStoredValue]; + } + + return [storedValue, setStoredValue]; +} + +export default typeof window === 'undefined' + ? useLocalStorageStateServer + : useLocalStorageStateBrowser;