Skip to content

Commit

Permalink
[utils] Port useLocalStorageState hook from Toolpad (mui#41096)
Browse files Browse the repository at this point in the history
Signed-off-by: Jan Potoms <[email protected]>
  • Loading branch information
Janpot authored Feb 21, 2024
1 parent 6d6bdec commit b923a63
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 67 deletions.
24 changes: 7 additions & 17 deletions docs/src/components/header/ThemeModeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<string | null>(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 <IconButton color="primary" disableTouchRipple />;
Expand Down
35 changes: 7 additions & 28 deletions docs/src/modules/components/AppSettingsDrawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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;
Expand Down
25 changes: 3 additions & 22 deletions docs/src/modules/components/HighlightedCodeWithTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => ({
Expand Down Expand Up @@ -85,36 +86,16 @@ export default function HighlightedCodeWithTabs({
storageKey?: string;
} & Record<string, any>) {
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 };
Expand Down
1 change: 1 addition & 0 deletions packages/mui-utils/src/useLocalStorageState/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './useLocalStorageState';
155 changes: 155 additions & 0 deletions packages/mui-utils/src/useLocalStorageState/useLocalStorageState.ts
Original file line number Diff line number Diff line change
@@ -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<string, Set<() => 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> = () => T;

type UseStorageStateHookResult<T> = [T, React.Dispatch<React.SetStateAction<T>>];

function useLocalStorageStateServer(
key: string | null,
initializer: string | Initializer<string>,
): UseStorageStateHookResult<string>;
function useLocalStorageStateServer(
key: string | null,
initializer?: string | null | Initializer<string | null>,
): UseStorageStateHookResult<string | null>;
function useLocalStorageStateServer(
key: string | null,
initializer: string | null | Initializer<string | null> = null,
): UseStorageStateHookResult<string | null> | UseStorageStateHookResult<string> {
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<string>,
): UseStorageStateHookResult<string>;
function useLocalStorageStateBrowser(
key: string | null,
initializer?: string | null | Initializer<string | null>,
): UseStorageStateHookResult<string | null>;
function useLocalStorageStateBrowser(
key: string | null,
initializer: string | null | Initializer<string | null> = null,
): UseStorageStateHookResult<string | null> | UseStorageStateHookResult<string> {
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<string | null>) => {
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;

0 comments on commit b923a63

Please sign in to comment.