diff --git a/docs/src/components/header/ThemeModeToggle.tsx b/docs/src/components/header/ThemeModeToggle.tsx index ff22e076ae3797..fda2d61676d99d 100644 --- a/docs/src/components/header/ThemeModeToggle.tsx +++ b/docs/src/components/header/ThemeModeToggle.tsx @@ -4,16 +4,12 @@ import IconButton from '@mui/material/IconButton'; import Tooltip from '@mui/material/Tooltip'; 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 { useColorSchemeShim } from 'docs/src/modules/components/ThemeContext'; -function CssVarsModeToggle(props: { onChange: (checked: boolean) => void }) { - const [mounted, setMounted] = React.useState(false); +function CssVarsModeToggle(props: { onChange: (newMode: string) => void }) { const { mode, systemMode, setMode } = useColorScheme(); - React.useEffect(() => { - setMounted(true); - }, []); const calculatedMode = mode === 'system' ? systemMode : mode; + return ( void }) { disableTouchRipple disabled={!calculatedMode} onClick={() => { - props.onChange(calculatedMode === 'light'); - setMode(calculatedMode === 'dark' ? 'light' : 'dark'); + const newMode = calculatedMode === 'dark' ? 'light' : 'dark'; + props.onChange(newMode); + setMode(newMode); }} > - {!calculatedMode || !mounted + {!calculatedMode ? null : { light: , @@ -37,55 +34,37 @@ function CssVarsModeToggle(props: { onChange: (checked: boolean) => void }) { } export default function ThemeModeToggle() { - const theme = useTheme(); - const changeTheme = useChangeTheme(); - const [mode, setMode] = React.useState(null); - 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 handleChangeThemeMode = (checked: boolean) => { - const paletteMode = checked ? 'dark' : 'light'; - setMode(paletteMode); + // TODO replace with useColorScheme once all pages support css vars + const { mode, systemMode, setMode } = useColorSchemeShim(); + const calculatedMode = mode === 'system' ? systemMode : mode; - try { - localStorage.setItem('mui-mode', paletteMode); // syncing with homepage, can be removed once all pages are migrated to CSS variables - } catch (error) { - // do nothing - } - changeTheme({ paletteMode }); - }; + const theme = useTheme(); + // Server-side hydration if (mode === null) { return ; } - if (theme.vars) { - // Temporarily renders conditionally because `useColorScheme` could not be used in the pages that haven't migrated to CSS theme variables. - return ; + // TODO remove this code branch, all pages should be migrated to use CssVarsProvider + if (!theme.vars) { + return ( + + { + setMode(calculatedMode === 'dark' ? 'light' : 'dark'); + }} + > + {calculatedMode === 'dark' ? ( + + ) : ( + + )} + + + ); } - const checked = mode === 'system' ? prefersDarkMode : mode === 'dark'; - - return ( - - { - handleChangeThemeMode(!checked); - }} - > - {checked ? : } - - - ); + return ; } diff --git a/docs/src/layouts/AppHeader.tsx b/docs/src/layouts/AppHeader.tsx index 6e467d01819e7d..7c8d9de1f13614 100644 --- a/docs/src/layouts/AppHeader.tsx +++ b/docs/src/layouts/AppHeader.tsx @@ -39,7 +39,6 @@ interface AppHeaderProps { export default function AppHeader(props: AppHeaderProps) { const { gitHubRepository = 'https://github.com/mui' } = props; - const t = useTranslate(); return ( diff --git a/docs/src/modules/components/AppSettingsDrawer.js b/docs/src/modules/components/AppSettingsDrawer.js index d79c5d4ef97018..8b04f7d36e3dd6 100644 --- a/docs/src/modules/components/AppSettingsDrawer.js +++ b/docs/src/modules/components/AppSettingsDrawer.js @@ -1,7 +1,6 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { styled, useTheme } from '@mui/material/styles'; -import useMediaQuery from '@mui/material/useMediaQuery'; import Drawer from '@mui/material/Drawer'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; @@ -16,7 +15,7 @@ import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined'; import SettingsBrightnessIcon from '@mui/icons-material/SettingsBrightness'; import FormatTextdirectionLToRIcon from '@mui/icons-material/FormatTextdirectionLToR'; import FormatTextdirectionRToLIcon from '@mui/icons-material/FormatTextdirectionRToL'; -import { useChangeTheme } from 'docs/src/modules/components/ThemeContext'; +import { useColorSchemeShim, useChangeTheme } from 'docs/src/modules/components/ThemeContext'; import { useTranslate } from '@mui/docs/i18n'; const Heading = styled(Typography)(({ theme }) => ({ @@ -37,55 +36,26 @@ const IconToggleButton = styled(ToggleButton)({ }, }); -function AppSettingsDrawer(props) { +export default function AppSettingsDrawer(props) { const { onClose, open = false, ...other } = props; const t = useTranslate(); const upperTheme = useTheme(); const changeTheme = useChangeTheme(); - const [mode, setMode] = React.useState(null); - 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]); + // TODO replace with useColorScheme once all pages support css vars + const { mode, setMode } = useColorSchemeShim(); 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 }); - } }; const handleChangeDirection = (event, direction) => { if (direction === null) { direction = upperTheme.direction; } - changeTheme({ direction }); }; @@ -202,5 +172,3 @@ AppSettingsDrawer.propTypes = { onClose: PropTypes.func.isRequired, open: PropTypes.bool, }; - -export default AppSettingsDrawer; diff --git a/docs/src/modules/components/HighlightedCodeWithTabs.tsx b/docs/src/modules/components/HighlightedCodeWithTabs.tsx index f31a70348e42df..7372011ff47f06 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 }) => ({ @@ -77,44 +78,25 @@ type TabsConfig = { language: string; tab: string; }; -export default function HighlightedCodeWithTabs({ - tabs, - storageKey, -}: { - tabs: Array; - storageKey?: string; -} & Record) { + +export default function HighlightedCodeWithTabs( + props: { + tabs: Array; + storageKey?: string; + } & Record, +) { + const { tabs, storageKey } = props; 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/docs/src/modules/components/MaterialYouUsageDemo.tsx b/docs/src/modules/components/MaterialYouUsageDemo.tsx index 6f42e405dfcc02..69cb6fba05bdf3 100644 --- a/docs/src/modules/components/MaterialYouUsageDemo.tsx +++ b/docs/src/modules/components/MaterialYouUsageDemo.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useTheme as md2UseTheme, alpha } from '@mui/material/styles'; +import { alpha } from '@mui/material/styles'; import ReplayRoundedIcon from '@mui/icons-material/ReplayRounded'; import Box from '@mui/material/Box'; import Divider from '@mui/material/Divider'; @@ -13,13 +13,10 @@ import Typography from '@mui/material/Typography'; import { extendTheme, CssVarsProvider as MaterialYouCssVarsProvider, - useColorScheme, } from '@mui/material-next/styles'; import BrandingProvider from 'docs/src/BrandingProvider'; import HighlightedCode from 'docs/src/modules/components/HighlightedCode'; -type Mode = 'light' | 'dark' | 'system'; - const materialYouTheme = extendTheme(); const shallowEqual = (item1: { [k: string]: any }, item2: { [k: string]: any }) => { let equal = true; @@ -93,14 +90,6 @@ export const prependLinesSpace = (code: string, size: number = 2) => { return newCode.join('\n'); }; -function ModeSwitcher({ md2Mode }: { md2Mode: Mode }) { - const { setMode } = useColorScheme(); - React.useEffect(() => { - setMode(md2Mode); - }, [md2Mode, setMode]); - return null; -} - interface MaterialYouUsageDemoProps { /** * Name of the component to show in the code block. @@ -195,7 +184,6 @@ export default function MaterialYouUsageDemo - {renderDemo(demoProps)} diff --git a/docs/src/modules/components/ThemeContext.js b/docs/src/modules/components/ThemeContext.js index 50fe41c5a7adf5..46713fcf715e8b 100644 --- a/docs/src/modules/components/ThemeContext.js +++ b/docs/src/modules/components/ThemeContext.js @@ -5,7 +5,6 @@ import { createTheme as createMdTheme, } from '@mui/material/styles'; import { deepmerge } from '@mui/utils'; -import useMediaQuery from '@mui/material/useMediaQuery'; import { enUS, zhCN, ptBR } from '@mui/material/locale'; import { unstable_useEnhancedEffect as useEnhancedEffect } from '@mui/material/utils'; import { getCookie } from 'docs/src/modules/utils/helpers'; @@ -16,6 +15,8 @@ import { getThemedComponents, getMetaThemeColor, } from 'docs/src/modules/brandingTheme'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import useLocalStorageState from '@mui/utils/useLocalStorageState'; const languageMap = { en: enUS, @@ -111,82 +112,87 @@ if (process.env.NODE_ENV !== 'production') { export function ThemeProvider(props) { const { children } = props; - const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)', { noSsr: true }); - const preferredMode = prefersDarkMode ? 'dark' : 'light'; - const [themeOptions, dispatch] = React.useReducer( - (state, action) => { - switch (action.type) { - case 'SET_SPACING': - return { - ...state, - spacing: action.payload, - }; - case 'INCREASE_SPACING': { - return { - ...state, - spacing: state.spacing + 1, - }; - } - case 'DECREASE_SPACING': { - return { - ...state, - spacing: state.spacing - 1, - }; - } - case 'SET_DENSE': - return { - ...state, - dense: action.payload, - }; - case 'RESET_DENSITY': - return { - ...state, - dense: themeInitialOptions.dense, - spacing: themeInitialOptions.spacing, - }; - case 'RESET_COLORS': - return { - ...state, - paletteColors: themeInitialOptions.paletteColors, - }; - case 'CHANGE': - return { - ...state, - paletteMode: action.payload.paletteMode || state.paletteMode, - direction: action.payload.direction || state.direction, - paletteColors: action.payload.paletteColors || state.paletteColors, - }; - default: - throw new Error(`Unrecognized type ${action.type}`); + const [themeOptions, dispatch] = React.useReducer((state, action) => { + switch (action.type) { + case 'SET_SPACING': + return { + ...state, + spacing: action.payload, + }; + case 'INCREASE_SPACING': { + return { + ...state, + spacing: state.spacing + 1, + }; } - }, - { ...themeInitialOptions, paletteMode: 'light' }, - ); + case 'DECREASE_SPACING': { + return { + ...state, + spacing: state.spacing - 1, + }; + } + case 'SET_DENSE': + return { + ...state, + dense: action.payload, + }; + case 'RESET_DENSITY': + return { + ...state, + dense: themeInitialOptions.dense, + spacing: themeInitialOptions.spacing, + }; + case 'RESET_COLORS': + return { + ...state, + paletteColors: themeInitialOptions.paletteColors, + }; + case 'CHANGE': + // No value changed + if ( + (!action.payload.paletteMode || action.payload.paletteMode === state.paletteMode) && + (!action.payload.direction || action.payload.direction === state.direction) && + (!action.payload.paletteColors || action.payload.paletteColors === state.paletteColors) + ) { + return state; + } + + return { + ...state, + paletteMode: action.payload.paletteMode || state.paletteMode, + direction: action.payload.direction || state.direction, + paletteColors: action.payload.paletteColors || state.paletteColors, + }; + default: + throw new Error(`Unrecognized type ${action.type}`); + } + }, themeInitialOptions); const userLanguage = useUserLanguage(); const { dense, direction, paletteColors, paletteMode, spacing } = themeOptions; useLazyCSS('/static/styles/prism-okaidia.css', '#prismjs'); - useEnhancedEffect(() => { - const nextPaletteColors = JSON.parse(getCookie('paletteColors') || 'null'); - let nextPaletteMode = preferredMode; // syncing with homepage, can be removed once all pages are migrated to CSS variables - try { - nextPaletteMode = localStorage.getItem('mui-mode') ?? preferredMode; - } catch (error) { - // mainly thrown when cookies are disabled. - } + // TODO replace with useColorScheme once all pages support css vars + const { mode, systemMode } = useColorSchemeShim(); + const calculatedMode = mode === 'system' ? systemMode : mode; - if (nextPaletteMode === 'system') { - nextPaletteMode = preferredMode; + useEnhancedEffect(() => { + let nextPaletteColors = JSON.parse(getCookie('paletteColors') || 'null'); + // Set default value if no value is found in cookie + if (nextPaletteColors === null) { + nextPaletteColors = themeInitialOptions.paletteColors; } dispatch({ type: 'CHANGE', - payload: { paletteColors: nextPaletteColors, paletteMode: nextPaletteMode }, + payload: { + paletteColors: nextPaletteColors, + paletteMode: calculatedMode, + }, }); - }, [preferredMode]); + }, [calculatedMode]); useEnhancedEffect(() => { document.body.dir = direction; @@ -271,3 +277,16 @@ export function useChangeTheme() { const dispatch = React.useContext(DispatchContext); return React.useCallback((options) => dispatch({ type: 'CHANGE', payload: options }), [dispatch]); } + +// TODO: remove once all pages support css vars and replace call sites with useColorScheme() +export function useColorSchemeShim() { + const [mode, setMode] = useLocalStorageState('mui-mode', 'system'); + const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)', { noSsr: true }); + const systemMode = prefersDarkMode ? 'dark' : 'light'; + + return { + mode, + systemMode, + setMode, + }; +} diff --git a/packages/mui-material-next/src/index.ts b/packages/mui-material-next/src/index.ts index c8726df41789c2..06124e3d20d038 100644 --- a/packages/mui-material-next/src/index.ts +++ b/packages/mui-material-next/src/index.ts @@ -1,5 +1,4 @@ 'use client'; - export { default as Badge } from './Badge'; export * from './Badge'; diff --git a/packages/mui-material/src/styles/CssVarsProvider.tsx b/packages/mui-material/src/styles/CssVarsProvider.tsx index 90cd0733dd72a7..75f5c6e6f18154 100644 --- a/packages/mui-material/src/styles/CssVarsProvider.tsx +++ b/packages/mui-material/src/styles/CssVarsProvider.tsx @@ -35,10 +35,7 @@ const { CssVarsProvider, useColorScheme, getInitColorSchemeScript } = createCssV }; newTheme.unstable_sx = function sx(props: SxProps) { - return styleFunctionSx({ - sx: props, - theme: this, - }); + return styleFunctionSx({ sx: props, theme: this }); }; return newTheme; diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.js b/packages/mui-system/src/cssVars/createCssVarsProvider.js index dc6882ab204cb3..e4969c4ad5b44e 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.js +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.js @@ -60,22 +60,23 @@ export default function createCssVarsProvider(options) { return value; }; - function CssVarsProvider({ - children, - theme: themeProp = defaultTheme, - modeStorageKey = defaultModeStorageKey, - colorSchemeStorageKey = defaultColorSchemeStorageKey, - attribute = defaultAttribute, - defaultMode = designSystemMode, - defaultColorScheme = designSystemColorScheme, - disableTransitionOnChange = designSystemTransitionOnChange, - storageWindow = typeof window === 'undefined' ? undefined : window, - documentNode = typeof document === 'undefined' ? undefined : document, - colorSchemeNode = typeof document === 'undefined' ? undefined : document.documentElement, - colorSchemeSelector = ':root', - disableNestedContext = false, - disableStyleSheetGeneration = false, - }) { + function CssVarsProvider(props) { + const { + children, + theme: themeProp = defaultTheme, + modeStorageKey = defaultModeStorageKey, + colorSchemeStorageKey = defaultColorSchemeStorageKey, + attribute = defaultAttribute, + defaultMode = designSystemMode, + defaultColorScheme = designSystemColorScheme, + disableTransitionOnChange = designSystemTransitionOnChange, + storageWindow = typeof window === 'undefined' ? undefined : window, + documentNode = typeof document === 'undefined' ? undefined : document, + colorSchemeNode = typeof document === 'undefined' ? undefined : document.documentElement, + colorSchemeSelector = ':root', + disableNestedContext = false, + disableStyleSheetGeneration = false, + } = props; const hasMounted = React.useRef(false); const upperTheme = muiUseTheme(); const ctx = React.useContext(ColorSchemeContext); @@ -248,14 +249,14 @@ export default function createCssVarsProvider(options) { const contextValue = React.useMemo( () => ({ - mode, - systemMode, - setMode, - lightColorScheme, - darkColorScheme, + allColorSchemes, colorScheme, + darkColorScheme, + lightColorScheme, + mode, setColorScheme, - allColorSchemes, + setMode, + systemMode, }), [ allColorSchemes, diff --git a/packages/mui-system/src/cssVars/useCurrentColorScheme.ts b/packages/mui-system/src/cssVars/useCurrentColorScheme.ts index a44c4790419d77..f9f46bbfdc9dbf 100644 --- a/packages/mui-system/src/cssVars/useCurrentColorScheme.ts +++ b/packages/mui-system/src/cssVars/useCurrentColorScheme.ts @@ -160,7 +160,7 @@ export default function useCurrentColorScheme { + (event?: MediaQueryListEvent) => { if (state.mode === 'system') { - setState((currentState) => ({ - ...currentState, - systemMode: e?.matches ? 'dark' : 'light', - })); + setState((currentState) => { + const systemMode = event?.matches ? 'dark' : 'light'; + + // Early exit, nothing changed. + if (currentState.systemMode === systemMode) { + return currentState; + } + return { ...currentState, systemMode }; + }); } }, [state.mode], @@ -278,35 +283,41 @@ export default function useCurrentColorScheme media.removeListener(handler); + return () => { + media.removeListener(handler); + }; }, []); // Handle when localStorage has changed React.useEffect(() => { - const handleStorage = (event: StorageEvent) => { - const value = event.newValue; - if ( - typeof event.key === 'string' && - event.key.startsWith(colorSchemeStorageKey) && - (!value || joinedColorSchemes.match(value)) - ) { - // If the key is deleted, value will be null then reset color scheme to the default one. - if (event.key.endsWith('light')) { - setColorScheme({ light: value as SupportedColorScheme | null }); + if (storageWindow) { + const handleStorage = (event: StorageEvent) => { + const value = event.newValue; + if ( + typeof event.key === 'string' && + event.key.startsWith(colorSchemeStorageKey) && + (!value || joinedColorSchemes.match(value)) + ) { + // If the key is deleted, value will be null then reset color scheme to the default one. + if (event.key.endsWith('light')) { + setColorScheme({ light: value as SupportedColorScheme | null }); + } + if (event.key.endsWith('dark')) { + setColorScheme({ dark: value as SupportedColorScheme | null }); + } } - if (event.key.endsWith('dark')) { - setColorScheme({ dark: value as SupportedColorScheme | null }); + if ( + event.key === modeStorageKey && + (!value || ['light', 'dark', 'system'].includes(value)) + ) { + setMode((value as Mode) || defaultMode); } - } - if (event.key === modeStorageKey && (!value || ['light', 'dark', 'system'].includes(value))) { - setMode((value as Mode) || defaultMode); - } - }; - if (storageWindow) { + }; // For syncing color-scheme changes between iframes storageWindow.addEventListener('storage', handleStorage); - return () => storageWindow.removeEventListener('storage', handleStorage); + return () => { + storageWindow.removeEventListener('storage', handleStorage); + }; } return undefined; }, [ 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..2d785fc5512f05 --- /dev/null +++ b/packages/mui-utils/src/useLocalStorageState/useLocalStorageState.ts @@ -0,0 +1,150 @@ +'use client'; +import * as React from 'react'; + +// 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 | null, callbark: () => void): () => void { + if (!key) { + return () => {}; + } + const storageHandler = (event: StorageEvent) => { + if (event.storageArea === area && event.key === key) { + callbark(); + } + }; + window.addEventListener('storage', storageHandler); + onCurrentTabStorageChange(key, callbark); + return () => { + window.removeEventListener('storage', storageHandler); + offCurrentTabStorageChange(key, callbark); + }; +} + +function getSnapshot(area: Storage, key: string | null): string | null { + if (!key) { + return null; + } + try { + return area.getItem(key); + } catch { + // ignore + // See https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API#feature-detecting_localstorage + return null; + } +} + +function setValue(area: Storage, key: string | null, value: string | null) { + if (!key) { + return; + } + try { + if (value === null) { + area.removeItem(key); + } else { + area.setItem(key, String(value)); + } + } catch { + // ignore + // See https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API#feature-detecting_localstorage + return; + } + emitCurrentTabStorageChange(key); +} + +type Initializer = () => string | null; + +type UseStorageStateHookResult = [ + string | null, + React.Dispatch>, +]; + +const serverValue: UseStorageStateHookResult = [null, () => {}]; + +function useLocalStorageStateServer(): UseStorageStateHookResult { + return serverValue; +} + +/** + * 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 null during SSR and hydration. + */ +function useLocalStorageStateBrowser( + key: string | null, + initializer: string | null | Initializer = null, +): UseStorageStateHookResult { + const [initialValue] = React.useState(initializer); + const area = window.localStorage; + const subscribeKey = React.useCallback( + (callbark: () => void) => subscribe(area, key, callbark), + [area, key], + ); + const getKeySnapshot = React.useCallback( + () => getSnapshot(area, key) ?? initialValue, + [area, initialValue, key], + ); + + // Start with null for the hydration, and then switch to the actual value. + const getKeyServerSnapshot = () => null; + + const storedValue = React.useSyncExternalStore( + subscribeKey, + getKeySnapshot, + getKeyServerSnapshot, + ); + + const setStoredValue = React.useCallback( + (value: React.SetStateAction) => { + 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;