From 46642864de48f8d6035f929a81dfc7f98abfe2be Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Wed, 29 Jan 2025 15:17:17 +0700 Subject: [PATCH 01/10] add support for custom storage --- .../mui-material/src/styles/ThemeProvider.tsx | 6 + .../src/styles/ThemeProviderWithVars.spec.tsx | 25 ++- packages/mui-material/src/styles/index.d.ts | 1 + .../src/cssVars/createCssVarsProvider.d.ts | 6 + .../src/cssVars/createCssVarsProvider.js | 7 + packages/mui-system/src/cssVars/index.ts | 1 + .../src/cssVars/localStorageManager.ts | 82 +++++++++ .../src/cssVars/useCurrentColorScheme.test.js | 165 ++++++++++++++++-- .../src/cssVars/useCurrentColorScheme.ts | 162 +++++++++-------- 9 files changed, 358 insertions(+), 97 deletions(-) create mode 100644 packages/mui-system/src/cssVars/localStorageManager.ts diff --git a/packages/mui-material/src/styles/ThemeProvider.tsx b/packages/mui-material/src/styles/ThemeProvider.tsx index e9e0dc5fb94c82..25a7f013f43308 100644 --- a/packages/mui-material/src/styles/ThemeProvider.tsx +++ b/packages/mui-material/src/styles/ThemeProvider.tsx @@ -1,6 +1,7 @@ 'use client'; import * as React from 'react'; import { DefaultTheme } from '@mui/system'; +import { StorageManager } from '@mui/system/cssVars'; import ThemeProviderNoVars from './ThemeProviderNoVars'; import { CssThemeVariables } from './createThemeNoVars'; import { CssVarsProvider } from './ThemeProviderWithVars'; @@ -47,6 +48,11 @@ export interface ThemeProviderProps extends ThemeProviderC * @default window */ storageWindow?: Window | null; + /** + * The storage manager to be used for storing the mode and color scheme + * @default using `window.localStorage` + */ + storageManager?: StorageManager | null; /** * localStorage key used to store application `mode` * @default 'mui-mode' diff --git a/packages/mui-material/src/styles/ThemeProviderWithVars.spec.tsx b/packages/mui-material/src/styles/ThemeProviderWithVars.spec.tsx index ebd779e2085610..0f879463a8c0ca 100644 --- a/packages/mui-material/src/styles/ThemeProviderWithVars.spec.tsx +++ b/packages/mui-material/src/styles/ThemeProviderWithVars.spec.tsx @@ -1,5 +1,12 @@ import * as React from 'react'; -import { extendTheme, CssVarsProvider, styled, useTheme, Overlays } from '@mui/material/styles'; +import { + extendTheme, + ThemeProvider, + styled, + useTheme, + Overlays, + StorageManager, +} from '@mui/material/styles'; import type {} from '@mui/material/themeCssVarsAugmentation'; const customTheme = extendTheme({ @@ -53,7 +60,7 @@ function TestUseTheme() { return
test
; } - + ({ // test that `theme` in sx has access to CSS vars @@ -63,4 +70,16 @@ function TestUseTheme() { }, })} /> -; +; + +; + +const storageManager: StorageManager = () => { + return { + get: () => 'light', + set: () => {}, + subscribe: () => () => {}, + }; +}; + +; diff --git a/packages/mui-material/src/styles/index.d.ts b/packages/mui-material/src/styles/index.d.ts index f029178794472a..7fda84978bf832 100644 --- a/packages/mui-material/src/styles/index.d.ts +++ b/packages/mui-material/src/styles/index.d.ts @@ -103,6 +103,7 @@ export { default as withStyles } from './withStyles'; export { default as withTheme } from './withTheme'; export * from './ThemeProviderWithVars'; +export type { StorageManager } from '@mui/system/cssVars'; export { default as extendTheme } from './createThemeWithVars'; diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.d.ts b/packages/mui-system/src/cssVars/createCssVarsProvider.d.ts index 5dee405542060e..0ee7de145f76fa 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.d.ts +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.d.ts @@ -1,6 +1,7 @@ import * as React from 'react'; import InitColorSchemeScript from '../InitColorSchemeScript'; import { Result } from './useCurrentColorScheme'; +import type { StorageManager } from './localStorageManager'; export interface ColorSchemeContextValue extends Result { @@ -70,6 +71,11 @@ export interface CreateCssVarsProviderResult< * @default document */ colorSchemeNode?: Element | null; + /** + * The storage manager to be used for storing the mode and color scheme + * @default using `window.localStorage` + */ + storageManager?: StorageManager | null; /** * The window that attaches the 'storage' event listener * @default window diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.js b/packages/mui-system/src/cssVars/createCssVarsProvider.js index 3946722bd4574d..fa9246284c414d 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.js +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.js @@ -60,6 +60,7 @@ export default function createCssVarsProvider(options) { modeStorageKey = defaultModeStorageKey, colorSchemeStorageKey = defaultColorSchemeStorageKey, disableTransitionOnChange = designSystemTransitionOnChange, + storageManager, storageWindow = typeof window === 'undefined' ? undefined : window, documentNode = typeof document === 'undefined' ? undefined : document, colorSchemeNode = typeof document === 'undefined' ? undefined : document.documentElement, @@ -119,6 +120,7 @@ export default function createCssVarsProvider(options) { modeStorageKey, colorSchemeStorageKey, defaultMode, + storageManager, storageWindow, noSsr, }); @@ -357,6 +359,11 @@ export default function createCssVarsProvider(options) { * You should use this option in conjuction with `InitColorSchemeScript` component. */ noSsr: PropTypes.bool, + /** + * The storage manager to be used for storing the mode and color scheme + * @default using `window.localStorage` + */ + storageManager: PropTypes.func, /** * The window that attaches the 'storage' event listener. * @default window diff --git a/packages/mui-system/src/cssVars/index.ts b/packages/mui-system/src/cssVars/index.ts index e20a5bec491de3..63df8b222a9817 100644 --- a/packages/mui-system/src/cssVars/index.ts +++ b/packages/mui-system/src/cssVars/index.ts @@ -10,3 +10,4 @@ export { default as prepareTypographyVars } from './prepareTypographyVars'; export type { ExtractTypographyTokens } from './prepareTypographyVars'; export { default as createCssVarsTheme } from './createCssVarsTheme'; export { createGetColorSchemeSelector } from './getColorSchemeSelector'; +export type { StorageManager } from './localStorageManager'; diff --git a/packages/mui-system/src/cssVars/localStorageManager.ts b/packages/mui-system/src/cssVars/localStorageManager.ts new file mode 100644 index 00000000000000..8c4dff5fa7e89b --- /dev/null +++ b/packages/mui-system/src/cssVars/localStorageManager.ts @@ -0,0 +1,82 @@ +export interface StorageManager { + (options: { key: string; storageWindow?: Window | null }): { + /** + * Function to get the value from the storage + * @param defaultValue The default value to be returned if the key is not found + * @returns The value from the storage or the default value + * @example + */ + get(defaultValue: any): any; + /** + * Function to set the value in the storage + * @param value The value to be set + * @returns void + */ + set(value: any): void; + /** + * Function to subscribe to the value of the specified key triggered by external events + * @param handler The function to be called when the value changes + * @returns A function to unsubscribe the handler + * @example + * React.useEffect(() => { + * const unsubscribe = storageManager.subscribe((value) => { + * console.log(value); + * }); + * return unsubscribe; + * }, []); + */ + subscribe(handler: (value: any) => void): () => void; + }; +} + +function noop() {} + +const localStorageManager: StorageManager = ({ key, storageWindow = window }) => { + return { + get(defaultValue) { + if (typeof window === 'undefined') { + return undefined; + } + if (!storageWindow) { + return defaultValue; + } + let value; + try { + value = storageWindow.localStorage.getItem(key) || undefined; + if (!value) { + // the first time that user enters the site. + storageWindow.localStorage.setItem(key, defaultValue); + } + } catch { + // Unsupported + } + return value || defaultValue; + }, + set: (value) => { + if (storageWindow) { + try { + storageWindow.localStorage.setItem(key, value); + } catch { + // Unsupported + } + } + }, + subscribe: (handler) => { + if (!storageWindow) { + return noop; + } + const listener = (event: StorageEvent) => { + const value = event.newValue; + if (event.key === key) { + handler(value); + } + }; + storageWindow.addEventListener('storage', listener); + return () => { + storageWindow.removeEventListener('storage', listener); + }; + }, + }; +}; + +export default localStorageManager; diff --git a/packages/mui-system/src/cssVars/useCurrentColorScheme.test.js b/packages/mui-system/src/cssVars/useCurrentColorScheme.test.js index 7143167618502f..bd39a226b60f50 100644 --- a/packages/mui-system/src/cssVars/useCurrentColorScheme.test.js +++ b/packages/mui-system/src/cssVars/useCurrentColorScheme.test.js @@ -13,7 +13,7 @@ describe('useCurrentColorScheme', () => { let originalMatchmedia; let originalAddEventListener; let storage = {}; - let storageHandler = {}; + const eventHandlers = new Map(); let trigger; const createMatchMedia = (matches) => () => ({ @@ -32,7 +32,18 @@ describe('useCurrentColorScheme', () => { before(() => { originalAddEventListener = window.addEventListener; window.addEventListener = (key, handler) => { - storageHandler[key] = handler; + if (eventHandlers.has(key)) { + eventHandlers.get(key).listeners.push(handler); + } else { + eventHandlers.set(key, { + listeners: [handler], + broadcastEvent(event) { + this.listeners.forEach((listener) => { + listener(event); + }); + }, + }); + } }; }); @@ -45,7 +56,7 @@ describe('useCurrentColorScheme', () => { // clear the localstorage storage = {}; // Create mocks of localStorage getItem and setItem functions - Object.defineProperty(global, 'localStorage', { + Object.defineProperty(window, 'localStorage', { value: { getItem: spy((key) => storage[key]), setItem: spy((key, value) => { @@ -55,7 +66,6 @@ describe('useCurrentColorScheme', () => { configurable: true, }); - storageHandler = {}; window.matchMedia = createMatchMedia(false); }); @@ -550,7 +560,7 @@ describe('useCurrentColorScheme', () => { fireEvent.click(container.firstChild); expect( - global.localStorage.setItem.lastCall.calledWith(DEFAULT_MODE_STORAGE_KEY, 'dark'), + window.localStorage.setItem.lastCall.calledWith(DEFAULT_MODE_STORAGE_KEY, 'dark'), ).to.equal(true); }); @@ -565,7 +575,7 @@ describe('useCurrentColorScheme', () => { return null; } render(); - expect(global.localStorage.setItem.calledWith(DEFAULT_MODE_STORAGE_KEY, 'system')).to.equal( + expect(window.localStorage.setItem.calledWith(DEFAULT_MODE_STORAGE_KEY, 'system')).to.equal( true, ); }); @@ -593,11 +603,11 @@ describe('useCurrentColorScheme', () => { fireEvent.click(container.firstChild); - expect(global.localStorage.setItem.calledWith(DEFAULT_MODE_STORAGE_KEY, 'dark')).to.equal( + expect(window.localStorage.setItem.calledWith(DEFAULT_MODE_STORAGE_KEY, 'dark')).to.equal( true, ); expect( - global.localStorage.setItem.calledWith(`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-dark`, 'dim'), + window.localStorage.setItem.calledWith(`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-dark`, 'dim'), ).to.equal(true); }); @@ -654,7 +664,9 @@ describe('useCurrentColorScheme', () => { const { container } = render(); act(() => { - storageHandler.storage?.({ key: DEFAULT_MODE_STORAGE_KEY, newValue: 'dark' }); + eventHandlers + .get('storage') + .broadcastEvent?.({ key: DEFAULT_MODE_STORAGE_KEY, newValue: 'dark' }); }); expect(JSON.parse(container.firstChild.textContent)).to.deep.equal({ @@ -678,7 +690,9 @@ describe('useCurrentColorScheme', () => { const { container } = render(); act(() => { - storageHandler.storage?.({ key: DEFAULT_MODE_STORAGE_KEY, newValue: 'system' }); + eventHandlers + .get('storage') + .broadcastEvent?.({ key: DEFAULT_MODE_STORAGE_KEY, newValue: 'system' }); }); expect(JSON.parse(container.firstChild.textContent)).to.deep.equal({ @@ -704,7 +718,9 @@ describe('useCurrentColorScheme', () => { const { container } = render(); act(() => { - storageHandler.storage?.({ key: DEFAULT_MODE_STORAGE_KEY, newValue: null }); + eventHandlers + .get('storage') + .broadcastEvent?.({ key: DEFAULT_MODE_STORAGE_KEY, newValue: null }); }); expect(JSON.parse(container.firstChild.textContent)).to.deep.equal({ @@ -729,7 +745,7 @@ describe('useCurrentColorScheme', () => { const { container } = render(); act(() => { - storageHandler.storage?.({ + eventHandlers.get('storage').broadcastEvent?.({ key: `${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-light`, newValue: 'light-dim', }); @@ -744,7 +760,7 @@ describe('useCurrentColorScheme', () => { }); act(() => { - storageHandler.storage?.({ + eventHandlers.get('storage').broadcastEvent?.({ key: `${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-dark`, newValue: 'dark-dim', }); @@ -785,7 +801,7 @@ describe('useCurrentColorScheme', () => { fireEvent.click(screen.getByTestId('reset')); expect( - global.localStorage.setItem.lastCall.calledWith(DEFAULT_MODE_STORAGE_KEY, 'system'), + window.localStorage.setItem.lastCall.calledWith(DEFAULT_MODE_STORAGE_KEY, 'system'), ).to.equal(true); }); @@ -808,20 +824,133 @@ describe('useCurrentColorScheme', () => { fireEvent.click(screen.getByTestId('dark')); - global.localStorage.setItem.resetHistory(); - expect(global.localStorage.setItem.callCount).to.equal(0); // reset the calls to neglect initial setItem in the assertion below + window.localStorage.setItem.resetHistory(); + expect(window.localStorage.setItem.callCount).to.equal(0); // reset the calls to neglect initial setItem in the assertion below fireEvent.click(screen.getByTestId('reset')); expect( - global.localStorage.setItem.calledWith( + window.localStorage.setItem.calledWith( `${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-light`, 'light', ), ).to.equal(true); expect( - global.localStorage.setItem.calledWith(`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-dark`, 'dark'), + window.localStorage.setItem.calledWith(`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-dark`, 'dark'), ).to.equal(true); }); }); + + describe('Custom storage', () => { + let cache = {}; + + beforeEach(() => { + cache = {}; + }); + + const storageManager = ({ key }) => ({ + get(defaultValue) { + return cache[key] || defaultValue; + }, + set(value) { + cache[key] = value; + }, + subscribe: (handler) => { + const listener = (event) => { + const value = event.newValue; + if (event.key === key) { + handler(value); + } + }; + window.addEventListener('storage', listener); + return () => { + window.removeEventListener('storage', listener); + }; + }, + }); + + it('use custom storage', () => { + function Data() { + const { setMode, ...data } = useCurrentColorScheme({ + storageManager, + defaultMode: 'light', + defaultLightColorScheme: 'light', + defaultDarkColorScheme: 'dark', + supportedColorSchemes: ['light', 'dark'], + }); + return ( + + ); + } + const { container } = render(); + + fireEvent.click(container.firstChild); + + expect(storageManager({ key: DEFAULT_MODE_STORAGE_KEY }).get()).to.equal('dark'); + }); + + it('handle subscription', () => { + function Data() { + const { setMode, ...data } = useCurrentColorScheme({ + storageManager, + defaultMode: 'light', + defaultLightColorScheme: 'light', + defaultDarkColorScheme: 'dark', + supportedColorSchemes: ['light', 'dark'], + }); + return ( + + ); + } + const { container } = render(); + + act(() => { + eventHandlers.get('storage').broadcastEvent?.({ + key: DEFAULT_MODE_STORAGE_KEY, + newValue: 'dark', + }); + }); + + expect(JSON.parse(container.firstChild.textContent)).to.deep.equal({ + mode: 'dark', + lightColorScheme: 'light', + darkColorScheme: 'dark', + colorScheme: 'dark', + }); + }); + + it('able to disable storage manager', () => { + function Data() { + const { setMode, ...data } = useCurrentColorScheme({ + storageManager: null, + defaultMode: 'light', + defaultLightColorScheme: 'light', + defaultDarkColorScheme: 'dark', + supportedColorSchemes: ['light', 'dark'], + }); + return ( + + ); + } + expect(() => render()).not.to.throw(); + }); + }); }); diff --git a/packages/mui-system/src/cssVars/useCurrentColorScheme.ts b/packages/mui-system/src/cssVars/useCurrentColorScheme.ts index f504172a6e2807..dbccbc950b660e 100644 --- a/packages/mui-system/src/cssVars/useCurrentColorScheme.ts +++ b/packages/mui-system/src/cssVars/useCurrentColorScheme.ts @@ -4,6 +4,8 @@ import { DEFAULT_MODE_STORAGE_KEY, DEFAULT_COLOR_SCHEME_STORAGE_KEY, } from '../InitColorSchemeScript/InitColorSchemeScript'; +import type { StorageManager } from './localStorageManager'; +import localStorageManager from './localStorageManager'; export type Mode = 'light' | 'dark' | 'system'; export type SystemMode = Exclude; @@ -53,6 +55,8 @@ export type Result = State void; }; +function noop() {} + export function getSystemMode(mode: undefined | string): SystemMode | undefined { if ( typeof window !== 'undefined' && @@ -95,23 +99,6 @@ export function getColorScheme( }); } -function initializeValue(key: string, defaultValue: string) { - if (typeof window === 'undefined') { - return undefined; - } - let value; - try { - value = localStorage.getItem(key) || undefined; - if (!value) { - // the first time that user enters the site. - localStorage.setItem(key, defaultValue); - } - } catch { - // Unsupported - } - return value || defaultValue; -} - interface UseCurrentColoSchemeOptions { defaultLightColorScheme: SupportedColorScheme; defaultDarkColorScheme: SupportedColorScheme; @@ -120,6 +107,7 @@ interface UseCurrentColoSchemeOptions { modeStorageKey?: string; colorSchemeStorageKey?: string; storageWindow?: Window | null; + storageManager?: StorageManager; noSsr?: boolean; } @@ -134,22 +122,39 @@ export default function useCurrentColorScheme 1; + const modeStorage = React.useMemo( + () => (storageManager ? storageManager({ key: modeStorageKey, storageWindow }) : undefined), + [storageManager, modeStorageKey, storageWindow], + ); + const lightStorage = React.useMemo( + () => + storageManager + ? storageManager({ key: `${colorSchemeStorageKey}-light`, storageWindow }) + : undefined, + [storageManager, colorSchemeStorageKey, storageWindow], + ); + const darkStorage = React.useMemo( + () => + storageManager + ? storageManager({ key: `${colorSchemeStorageKey}-dark`, storageWindow }) + : undefined, + [storageManager, colorSchemeStorageKey, storageWindow], + ); const [state, setState] = React.useState(() => { - const initialMode = initializeValue(modeStorageKey, defaultMode); - const lightColorScheme = initializeValue( - `${colorSchemeStorageKey}-light`, - defaultLightColorScheme, - ); - const darkColorScheme = initializeValue( - `${colorSchemeStorageKey}-dark`, - defaultDarkColorScheme, - ); + const initialMode = modeStorage ? modeStorage.get(defaultMode) : defaultMode; + const lightColorScheme = lightStorage + ? lightStorage.get(defaultLightColorScheme) + : defaultLightColorScheme; + const darkColorScheme = darkStorage + ? darkStorage.get(defaultDarkColorScheme) + : defaultDarkColorScheme; return { mode: initialMode, systemMode: getSystemMode(initialMode), @@ -173,8 +178,10 @@ export default function useCurrentColorScheme['setColorScheme'] = React.useCallback( (value) => { if (!value) { setState((currentState) => { - try { - localStorage.setItem(`${colorSchemeStorageKey}-light`, defaultLightColorScheme); - localStorage.setItem(`${colorSchemeStorageKey}-dark`, defaultDarkColorScheme); - } catch { - // Unsupported + if (lightStorage) { + lightStorage.set(defaultLightColorScheme); + } + if (darkStorage) { + darkStorage.set(defaultDarkColorScheme); } return { ...currentState, @@ -210,15 +217,16 @@ export default function useCurrentColorScheme { const newState = { ...currentState }; processState(currentState, (mode) => { - try { - localStorage.setItem(`${colorSchemeStorageKey}-${mode}`, value); - } catch { - // Unsupported - } if (mode === 'light') { + if (lightStorage) { + lightStorage.set(value); + } newState.lightColorScheme = value; } if (mode === 'dark') { + if (darkStorage) { + darkStorage.set(value); + } newState.darkColorScheme = value; } }); @@ -236,10 +244,8 @@ export default function useCurrentColorScheme { - if (storageWindow && isMultiSchemes) { - 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 === modeStorageKey && - (!value || ['light', 'dark', 'system'].includes(value)) - ) { - setMode((value as Mode) || defaultMode); - } - }; - // For syncing color-scheme changes between iframes - storageWindow.addEventListener('storage', handleStorage); + if (isMultiSchemes) { + const unsubscribeMode = modeStorage + ? modeStorage.subscribe((value: Mode) => { + if (!value || ['light', 'dark', 'system'].includes(value)) { + setMode((value as Mode) || defaultMode); + } + }) + : noop; + const unsubscribeLight = lightStorage + ? lightStorage.subscribe((value: SupportedColorScheme) => { + if (!value || joinedColorSchemes.match(value)) { + setColorScheme({ light: value as SupportedColorScheme | null }); + } + }) + : noop; + const unsubscribeDark = darkStorage + ? darkStorage.subscribe((value: SupportedColorScheme) => { + if (!value || joinedColorSchemes.match(value)) { + setColorScheme({ dark: value as SupportedColorScheme | null }); + } + }) + : noop; return () => { - storageWindow.removeEventListener('storage', handleStorage); + unsubscribeMode(); + unsubscribeLight(); + unsubscribeDark(); }; } return undefined; }, [ setColorScheme, setMode, - modeStorageKey, - colorSchemeStorageKey, joinedColorSchemes, defaultMode, storageWindow, isMultiSchemes, + modeStorage, + lightStorage, + darkStorage, ]); return { From 74a7236521e5459ead52c0597996408b11a735af Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Thu, 30 Jan 2025 10:16:59 +0700 Subject: [PATCH 02/10] add docs --- .../customization/dark-mode/dark-mode.md | 54 +++++++++++++++++++ .../src/cssVars/localStorageManager.ts | 6 ++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/docs/data/material/customization/dark-mode/dark-mode.md b/docs/data/material/customization/dark-mode/dark-mode.md index 804769e1d6a02a..016713c66cf989 100644 --- a/docs/data/material/customization/dark-mode/dark-mode.md +++ b/docs/data/material/customization/dark-mode/dark-mode.md @@ -342,3 +342,57 @@ For applications that need to support light and dark mode using CSS media `prefe But if you want to be able to toggle between modes manually, avoiding the flicker requires a combination of CSS variables and the `InitColorSchemeScript` component. Check out the [Preventing SSR flicker](/material-ui/customization/css-theme-variables/configuration/#preventing-ssr-flickering) section for more details. + +## Storage manager + +The default approach of the [built-in support](#built-in-support) uses the browser's `localStorage` API to store the mode and color scheme preference. + +To use a different storage manager, create a custom function with this signature: + +```ts +type Unsubscribe = () => void; + +function storageManager(params: { key: string }): { + get: (defaultValue: any) => any; + set: (value: any) => void; + subscribe: (handler: (value: any) => void) => Unsubscribe; +}; +``` + +Then pass it to the `storageManager` prop of the `ThemeProvider` component: + +```tsx +import { ThemeProvider, createTheme } from '@mui/material/styles'; +import type { StorageManager } from '@mui/material/styles'; + +const theme = createTheme({ + colorSchemes: { + dark: true, + }, +}); + +function storageManager(): StorageManager { + return { + get: (defaultValue) => { + // Your implementation + }, + set: (value) => { + // Your implementation + }, + subscribe: (handler) => { + // Your implementation + return () => { + // cleanup + }; + }, + }; +} + +function App() { + return ( + + ... + + ); +} +``` diff --git a/packages/mui-system/src/cssVars/localStorageManager.ts b/packages/mui-system/src/cssVars/localStorageManager.ts index 8c4dff5fa7e89b..2df7370f65ab2f 100644 --- a/packages/mui-system/src/cssVars/localStorageManager.ts +++ b/packages/mui-system/src/cssVars/localStorageManager.ts @@ -4,7 +4,6 @@ export interface StorageManager { * Function to get the value from the storage * @param defaultValue The default value to be returned if the key is not found * @returns The value from the storage or the default value - * @example */ get(defaultValue: any): any; /** @@ -31,7 +30,10 @@ export interface StorageManager { function noop() {} -const localStorageManager: StorageManager = ({ key, storageWindow = window }) => { +const localStorageManager: StorageManager = ({ key, storageWindow }) => { + if (!storageWindow && typeof window !== 'undefined') { + storageWindow = window; + } return { get(defaultValue) { if (typeof window === 'undefined') { From 8a22951cea1dfc346810bcc82d2359c0a9c86836 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Fri, 31 Jan 2025 14:58:18 +0700 Subject: [PATCH 03/10] replace global with window --- packages/mui-material/src/styles/ThemeProvider.test.tsx | 2 +- .../mui-system/src/cssVars/createCssVarsProvider.test.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/mui-material/src/styles/ThemeProvider.test.tsx b/packages/mui-material/src/styles/ThemeProvider.test.tsx index b5828595030b49..33a77b7e3c2642 100644 --- a/packages/mui-material/src/styles/ThemeProvider.test.tsx +++ b/packages/mui-material/src/styles/ThemeProvider.test.tsx @@ -12,7 +12,7 @@ describe('ThemeProvider', () => { originalMatchmedia = window.matchMedia; // Create mocks of localStorage getItem and setItem functions storage = {}; - Object.defineProperty(global, 'localStorage', { + Object.defineProperty(window, 'localStorage', { value: { getItem: (key: string) => storage[key], setItem: (key: string, value: string) => { diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.test.js b/packages/mui-system/src/cssVars/createCssVarsProvider.test.js index 4accde568ba8ca..43daf0fd8225ae 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.test.js +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.test.js @@ -28,7 +28,7 @@ describe('createCssVarsProvider', () => { originalMatchmedia = window.matchMedia; // Create mocks of localStorage getItem and setItem functions - Object.defineProperty(global, 'localStorage', { + Object.defineProperty(window, 'localStorage', { value: { getItem: spy((key) => storage[key]), setItem: spy((key, value) => { @@ -584,13 +584,13 @@ describe('createCssVarsProvider', () => { , ); - expect(global.localStorage.setItem.calledWith(DEFAULT_MODE_STORAGE_KEY, 'system')).to.equal( + expect(window.localStorage.setItem.calledWith(DEFAULT_MODE_STORAGE_KEY, 'system')).to.equal( true, ); fireEvent.click(screen.getByRole('button', { name: 'change to dark' })); - expect(global.localStorage.setItem.calledWith(DEFAULT_MODE_STORAGE_KEY, 'dark')).to.equal( + expect(window.localStorage.setItem.calledWith(DEFAULT_MODE_STORAGE_KEY, 'dark')).to.equal( true, ); }); From b7edd826fcdd58a2764001ae70be4750aab27017 Mon Sep 17 00:00:00 2001 From: Siriwat K Date: Fri, 31 Jan 2025 15:00:29 +0700 Subject: [PATCH 04/10] Apply suggestions from code review Signed-off-by: Siriwat K --- docs/data/material/customization/dark-mode/dark-mode.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/data/material/customization/dark-mode/dark-mode.md b/docs/data/material/customization/dark-mode/dark-mode.md index 016713c66cf989..d48afc535891ed 100644 --- a/docs/data/material/customization/dark-mode/dark-mode.md +++ b/docs/data/material/customization/dark-mode/dark-mode.md @@ -371,7 +371,7 @@ const theme = createTheme({ }, }); -function storageManager(): StorageManager { +function storageManager(params): StorageManager { return { get: (defaultValue) => { // Your implementation From bff11233c224c1b9e4be73dbec69270c747cb2e0 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Wed, 26 Feb 2025 10:24:11 +0700 Subject: [PATCH 05/10] update doc --- .../customization/dark-mode/dark-mode.md | 126 ++++++++++-------- 1 file changed, 72 insertions(+), 54 deletions(-) diff --git a/docs/data/material/customization/dark-mode/dark-mode.md b/docs/data/material/customization/dark-mode/dark-mode.md index 4f1f13dce9ae2d..51d55503b6e336 100644 --- a/docs/data/material/customization/dark-mode/dark-mode.md +++ b/docs/data/material/customization/dark-mode/dark-mode.md @@ -122,6 +122,78 @@ The `mode` is always `undefined` on first render, so make sure to handle this ca {{"demo": "ToggleColorMode.js", "defaultCodeOpen": false}} +## Storage manager + +By default, the [built-in support](#built-in-support) for color schemes uses the browser's `localStorage` API to store the user's mode and scheme preference. + +To use a different storage manager, create a custom function with this signature: + +```ts +type Unsubscribe = () => void; + +function storageManager(params: { key: string }): { + get: (defaultValue: any) => any; + set: (value: any) => void; + subscribe: (handler: (value: any) => void) => Unsubscribe; +}; +``` + +Then pass it to the `storageManager` prop of the `ThemeProvider` component: + +```tsx +import { ThemeProvider, createTheme } from '@mui/material/styles'; +import type { StorageManager } from '@mui/material/styles'; + +const theme = createTheme({ + colorSchemes: { + dark: true, + }, +}); + +function storageManager(params): StorageManager { + return { + get: (defaultValue) => { + // Your implementation + }, + set: (value) => { + // Your implementation + }, + subscribe: (handler) => { + // Your implementation + return () => { + // cleanup + }; + }, + }; +} + +function App() { + return ( + + ... + + ); +} +``` + +:::warning +If you are using the `InitColorSchemeScript` component to [prevent SSR flicker](/material-ui/customization/css-theme-variables/configuration/#preventing-ssr-flickering), you have to include the `localStorage` implementation in your custom storage manager. +::: + +### Disable storage + +To disable the storage manager, pass `null` to the `storageManager` prop: + +```tsx + + ... + +``` + +:::warning +If you are building a SSR application, disabling the storage manager will cause [dark mode flicker](#dark-mode-flicker) when users refresh the page. +::: + ## Disable transitions To instantly switch between color schemes with no transition, apply the `disableTransitionOnChange` prop to the `ThemeProvider` component: @@ -344,57 +416,3 @@ For applications that need to support light and dark mode using CSS media `prefe But if you want to be able to toggle between modes manually, avoiding the flicker requires a combination of CSS variables and the `InitColorSchemeScript` component. Check out the [Preventing SSR flicker](/material-ui/customization/css-theme-variables/configuration/#preventing-ssr-flickering) section for more details. - -## Storage manager - -The default approach of the [built-in support](#built-in-support) uses the browser's `localStorage` API to store the mode and color scheme preference. - -To use a different storage manager, create a custom function with this signature: - -```ts -type Unsubscribe = () => void; - -function storageManager(params: { key: string }): { - get: (defaultValue: any) => any; - set: (value: any) => void; - subscribe: (handler: (value: any) => void) => Unsubscribe; -}; -``` - -Then pass it to the `storageManager` prop of the `ThemeProvider` component: - -```tsx -import { ThemeProvider, createTheme } from '@mui/material/styles'; -import type { StorageManager } from '@mui/material/styles'; - -const theme = createTheme({ - colorSchemes: { - dark: true, - }, -}); - -function storageManager(params): StorageManager { - return { - get: (defaultValue) => { - // Your implementation - }, - set: (value) => { - // Your implementation - }, - subscribe: (handler) => { - // Your implementation - return () => { - // cleanup - }; - }, - }; -} - -function App() { - return ( - - ... - - ); -} -``` From 1a653ccf4320a86867e908ab369fc4ca41053f83 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Wed, 26 Feb 2025 10:25:30 +0700 Subject: [PATCH 06/10] apply suggestion --- packages/mui-system/src/cssVars/createCssVarsProvider.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.d.ts b/packages/mui-system/src/cssVars/createCssVarsProvider.d.ts index 0ee7de145f76fa..0b78935613dce0 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.d.ts +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.d.ts @@ -72,7 +72,7 @@ export interface CreateCssVarsProviderResult< */ colorSchemeNode?: Element | null; /** - * The storage manager to be used for storing the mode and color scheme + * The storage manager to be used for storing the mode and color scheme. * @default using `window.localStorage` */ storageManager?: StorageManager | null; From 06cc05403b40f784724693950ff9293623157015 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Wed, 26 Feb 2025 10:34:55 +0700 Subject: [PATCH 07/10] simplify code --- .../src/cssVars/useCurrentColorScheme.ts | 95 +++++++------------ 1 file changed, 32 insertions(+), 63 deletions(-) diff --git a/packages/mui-system/src/cssVars/useCurrentColorScheme.ts b/packages/mui-system/src/cssVars/useCurrentColorScheme.ts index dbccbc950b660e..4a214cd092c43f 100644 --- a/packages/mui-system/src/cssVars/useCurrentColorScheme.ts +++ b/packages/mui-system/src/cssVars/useCurrentColorScheme.ts @@ -107,7 +107,7 @@ interface UseCurrentColoSchemeOptions { modeStorageKey?: string; colorSchemeStorageKey?: string; storageWindow?: Window | null; - storageManager?: StorageManager; + storageManager?: StorageManager | null; noSsr?: boolean; } @@ -129,32 +129,22 @@ export default function useCurrentColorScheme 1; const modeStorage = React.useMemo( - () => (storageManager ? storageManager({ key: modeStorageKey, storageWindow }) : undefined), + () => storageManager?.({ key: modeStorageKey, storageWindow }), [storageManager, modeStorageKey, storageWindow], ); const lightStorage = React.useMemo( - () => - storageManager - ? storageManager({ key: `${colorSchemeStorageKey}-light`, storageWindow }) - : undefined, + () => storageManager?.({ key: `${colorSchemeStorageKey}-light`, storageWindow }), [storageManager, colorSchemeStorageKey, storageWindow], ); const darkStorage = React.useMemo( - () => - storageManager - ? storageManager({ key: `${colorSchemeStorageKey}-dark`, storageWindow }) - : undefined, + () => storageManager?.({ key: `${colorSchemeStorageKey}-dark`, storageWindow }), [storageManager, colorSchemeStorageKey, storageWindow], ); const [state, setState] = React.useState(() => { - const initialMode = modeStorage ? modeStorage.get(defaultMode) : defaultMode; - const lightColorScheme = lightStorage - ? lightStorage.get(defaultLightColorScheme) - : defaultLightColorScheme; - const darkColorScheme = darkStorage - ? darkStorage.get(defaultDarkColorScheme) - : defaultDarkColorScheme; + const initialMode = modeStorage?.get(defaultMode) || defaultMode; + const lightColorScheme = lightStorage?.get(defaultLightColorScheme) || defaultLightColorScheme; + const darkColorScheme = darkStorage?.get(defaultDarkColorScheme) || defaultDarkColorScheme; return { mode: initialMode, systemMode: getSystemMode(initialMode), @@ -177,13 +167,7 @@ export default function useCurrentColorScheme { if (!value) { setState((currentState) => { - if (lightStorage) { - lightStorage.set(defaultLightColorScheme); - } - if (darkStorage) { - darkStorage.set(defaultDarkColorScheme); - } + lightStorage?.set(defaultLightColorScheme); + darkStorage?.set(defaultDarkColorScheme); return { ...currentState, lightColorScheme: defaultLightColorScheme, @@ -218,15 +198,11 @@ export default function useCurrentColorScheme { if (mode === 'light') { - if (lightStorage) { - lightStorage.set(value); - } + lightStorage?.set(value); newState.lightColorScheme = value; } if (mode === 'dark') { - if (darkStorage) { - darkStorage.set(value); - } + darkStorage?.set(value); newState.darkColorScheme = value; } }); @@ -244,9 +220,7 @@ export default function useCurrentColorScheme { if (isMultiSchemes) { - const unsubscribeMode = modeStorage - ? modeStorage.subscribe((value: Mode) => { - if (!value || ['light', 'dark', 'system'].includes(value)) { - setMode((value as Mode) || defaultMode); - } - }) - : noop; - const unsubscribeLight = lightStorage - ? lightStorage.subscribe((value: SupportedColorScheme) => { - if (!value || joinedColorSchemes.match(value)) { - setColorScheme({ light: value as SupportedColorScheme | null }); - } - }) - : noop; - const unsubscribeDark = darkStorage - ? darkStorage.subscribe((value: SupportedColorScheme) => { - if (!value || joinedColorSchemes.match(value)) { - setColorScheme({ dark: value as SupportedColorScheme | null }); - } - }) - : noop; + const unsubscribeMode = + modeStorage?.subscribe((value: Mode) => { + if (!value || ['light', 'dark', 'system'].includes(value)) { + setMode((value as Mode) || defaultMode); + } + }) || noop; + const unsubscribeLight = + lightStorage?.subscribe((value: SupportedColorScheme) => { + if (!value || joinedColorSchemes.match(value)) { + setColorScheme({ light: value as SupportedColorScheme | null }); + } + }) || noop; + const unsubscribeDark = + darkStorage?.subscribe((value: SupportedColorScheme) => { + if (!value || joinedColorSchemes.match(value)) { + setColorScheme({ dark: value as SupportedColorScheme | null }); + } + }) || noop; return () => { unsubscribeMode(); unsubscribeLight(); From 86ea63612039f182e98a6ea47dc7e6514ad6149a Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Wed, 26 Feb 2025 15:19:11 +0700 Subject: [PATCH 08/10] update doc --- docs/data/material/customization/dark-mode/dark-mode.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/data/material/customization/dark-mode/dark-mode.md b/docs/data/material/customization/dark-mode/dark-mode.md index 51d55503b6e336..07c28b815b2cf8 100644 --- a/docs/data/material/customization/dark-mode/dark-mode.md +++ b/docs/data/material/customization/dark-mode/dark-mode.md @@ -191,7 +191,7 @@ To disable the storage manager, pass `null` to the `storageManager` prop: ``` :::warning -If you are building a SSR application, disabling the storage manager will cause [dark mode flicker](#dark-mode-flicker) when users refresh the page. +Disabling the storage manager will reset the app to its default mode when users refresh the page. ::: ## Disable transitions From b0200375a0c652ab663cb72d97d131769cd0f24b Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Wed, 26 Feb 2025 16:34:35 +0700 Subject: [PATCH 09/10] remove unnecessary auto store to storage --- .../src/cssVars/createCssVarsProvider.test.js | 4 ---- .../src/cssVars/localStorageManager.ts | 6 +----- .../src/cssVars/useCurrentColorScheme.test.js | 16 ---------------- 3 files changed, 1 insertion(+), 25 deletions(-) diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.test.js b/packages/mui-system/src/cssVars/createCssVarsProvider.test.js index 43daf0fd8225ae..9c699b7f1ff7ee 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.test.js +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.test.js @@ -584,10 +584,6 @@ describe('createCssVarsProvider', () => { , ); - expect(window.localStorage.setItem.calledWith(DEFAULT_MODE_STORAGE_KEY, 'system')).to.equal( - true, - ); - fireEvent.click(screen.getByRole('button', { name: 'change to dark' })); expect(window.localStorage.setItem.calledWith(DEFAULT_MODE_STORAGE_KEY, 'dark')).to.equal( diff --git a/packages/mui-system/src/cssVars/localStorageManager.ts b/packages/mui-system/src/cssVars/localStorageManager.ts index 2df7370f65ab2f..c1e793c53179f8 100644 --- a/packages/mui-system/src/cssVars/localStorageManager.ts +++ b/packages/mui-system/src/cssVars/localStorageManager.ts @@ -44,11 +44,7 @@ const localStorageManager: StorageManager = ({ key, storageWindow }) => { } let value; try { - value = storageWindow.localStorage.getItem(key) || undefined; - if (!value) { - // the first time that user enters the site. - storageWindow.localStorage.setItem(key, defaultValue); - } + value = storageWindow.localStorage.getItem(key); } catch { // Unsupported } diff --git a/packages/mui-system/src/cssVars/useCurrentColorScheme.test.js b/packages/mui-system/src/cssVars/useCurrentColorScheme.test.js index bd39a226b60f50..a6846765e0babb 100644 --- a/packages/mui-system/src/cssVars/useCurrentColorScheme.test.js +++ b/packages/mui-system/src/cssVars/useCurrentColorScheme.test.js @@ -564,22 +564,6 @@ describe('useCurrentColorScheme', () => { ).to.equal(true); }); - it('save system mode', () => { - function Data() { - useCurrentColorScheme({ - defaultMode: 'system', - defaultLightColorScheme: 'light', - defaultDarkColorScheme: 'dark', - supportedColorSchemes: ['light', 'dark'], - }); - return null; - } - render(); - expect(window.localStorage.setItem.calledWith(DEFAULT_MODE_STORAGE_KEY, 'system')).to.equal( - true, - ); - }); - it('save lightColorScheme and darkColorScheme', () => { function Data() { const { setMode, setColorScheme, ...data } = useCurrentColorScheme({ From f29c3e74770529e2ab81919480313e0615f411e5 Mon Sep 17 00:00:00 2001 From: Siriwat K Date: Thu, 27 Feb 2025 08:34:34 +0700 Subject: [PATCH 10/10] Apply suggestions from code review Co-authored-by: Sam Sycamore <71297412+mapache-salvaje@users.noreply.github.com> Signed-off-by: Siriwat K --- docs/data/material/customization/dark-mode/dark-mode.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/data/material/customization/dark-mode/dark-mode.md b/docs/data/material/customization/dark-mode/dark-mode.md index 07c28b815b2cf8..ce0eb9e7f793b0 100644 --- a/docs/data/material/customization/dark-mode/dark-mode.md +++ b/docs/data/material/customization/dark-mode/dark-mode.md @@ -177,7 +177,7 @@ function App() { ``` :::warning -If you are using the `InitColorSchemeScript` component to [prevent SSR flicker](/material-ui/customization/css-theme-variables/configuration/#preventing-ssr-flickering), you have to include the `localStorage` implementation in your custom storage manager. +If you are using the `InitColorSchemeScript` component to [prevent SSR flickering](/material-ui/customization/css-theme-variables/configuration/#preventing-ssr-flickering), you have to include the `localStorage` implementation in your custom storage manager. ::: ### Disable storage @@ -191,7 +191,7 @@ To disable the storage manager, pass `null` to the `storageManager` prop: ``` :::warning -Disabling the storage manager will reset the app to its default mode when users refresh the page. +Disabling the storage manager will cause the app to reset to its default mode whenever the user refreshes the page. ::: ## Disable transitions