fix(theme): fix useColorMode().colorMode leading to React hydration mismatches (#10954)

This commit is contained in:
Sébastien Lorber 2025-02-27 16:32:28 +01:00 committed by GitHub
parent 9d7ceec189
commit 396deedba4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 57 additions and 33 deletions

View file

@ -25,7 +25,8 @@ type ContextValue = {
/** Set new color mode. */ /** Set new color mode. */
readonly setColorMode: (colorMode: ColorMode) => void; readonly setColorMode: (colorMode: ColorMode) => void;
// TODO legacy APIs kept for retro-compatibility: deprecate them // TODO Docusaurus v4
// legacy APIs kept for retro-compatibility: deprecate them
readonly isDarkTheme: boolean; readonly isDarkTheme: boolean;
readonly setLightTheme: () => void; readonly setLightTheme: () => void;
readonly setDarkTheme: () => void; readonly setDarkTheme: () => void;
@ -47,22 +48,55 @@ export type ColorMode = (typeof ColorModes)[keyof typeof ColorModes];
const coerceToColorMode = (colorMode?: string | null): ColorMode => const coerceToColorMode = (colorMode?: string | null): ColorMode =>
colorMode === ColorModes.dark ? ColorModes.dark : ColorModes.light; colorMode === ColorModes.dark ? ColorModes.dark : ColorModes.light;
const getInitialColorMode = (defaultMode: ColorMode | undefined): ColorMode => const ColorModeAttribute = {
ExecutionEnvironment.canUseDOM get: () => {
? coerceToColorMode(document.documentElement.getAttribute('data-theme')) return coerceToColorMode(
: coerceToColorMode(defaultMode); document.documentElement.getAttribute('data-theme'),
);
},
set: (colorMode: ColorMode) => {
document.documentElement.setAttribute(
'data-theme',
coerceToColorMode(colorMode),
);
},
};
const readInitialColorMode = (): ColorMode => {
if (!ExecutionEnvironment.canUseDOM) {
throw new Error("Can't read initial color mode on the server");
}
return ColorModeAttribute.get();
};
const storeColorMode = (newColorMode: ColorMode) => { const storeColorMode = (newColorMode: ColorMode) => {
ColorModeStorage.set(coerceToColorMode(newColorMode)); ColorModeStorage.set(coerceToColorMode(newColorMode));
}; };
// The color mode state is initialized in useEffect on purpose
// to avoid a React hydration mismatch errors
// The useColorMode() hook value lags behind on purpose
// This helps users avoid hydration mismatch errors in their code
// See also https://github.com/facebook/docusaurus/issues/7986
function useColorModeState() {
const {
colorMode: {defaultMode},
} = useThemeConfig();
const [colorMode, setColorModeState] = useState(defaultMode);
useEffect(() => {
setColorModeState(readInitialColorMode());
}, []);
return [colorMode, setColorModeState] as const;
}
function useContextValue(): ContextValue { function useContextValue(): ContextValue {
const { const {
colorMode: {defaultMode, disableSwitch, respectPrefersColorScheme}, colorMode: {defaultMode, disableSwitch, respectPrefersColorScheme},
} = useThemeConfig(); } = useThemeConfig();
const [colorMode, setColorModeState] = useState( const [colorMode, setColorModeState] = useColorModeState();
getInitialColorMode(defaultMode),
);
useEffect(() => { useEffect(() => {
// A site is deployed without disableSwitch // A site is deployed without disableSwitch
@ -77,49 +111,38 @@ function useContextValue(): ContextValue {
const setColorMode = useCallback( const setColorMode = useCallback(
(newColorMode: ColorMode | null, options: {persist?: boolean} = {}) => { (newColorMode: ColorMode | null, options: {persist?: boolean} = {}) => {
const {persist = true} = options; const {persist = true} = options;
if (newColorMode) { if (newColorMode) {
ColorModeAttribute.set(newColorMode);
setColorModeState(newColorMode); setColorModeState(newColorMode);
if (persist) { if (persist) {
storeColorMode(newColorMode); storeColorMode(newColorMode);
} }
} else { } else {
if (respectPrefersColorScheme) { if (respectPrefersColorScheme) {
setColorModeState( const osColorMode = window.matchMedia('(prefers-color-scheme: dark)')
window.matchMedia('(prefers-color-scheme: dark)').matches .matches
? ColorModes.dark ? ColorModes.dark
: ColorModes.light, : ColorModes.light;
); ColorModeAttribute.set(osColorMode);
setColorModeState(osColorMode);
} else { } else {
ColorModeAttribute.set(defaultMode);
setColorModeState(defaultMode); setColorModeState(defaultMode);
} }
ColorModeStorage.del(); ColorModeStorage.del();
} }
}, },
[respectPrefersColorScheme, defaultMode], [setColorModeState, respectPrefersColorScheme, defaultMode],
); );
useEffect(() => {
document.documentElement.setAttribute(
'data-theme',
coerceToColorMode(colorMode),
);
}, [colorMode]);
useEffect(() => { useEffect(() => {
if (disableSwitch) { if (disableSwitch) {
return undefined; return undefined;
} }
const onChange = (e: StorageEvent) => { return ColorModeStorage.listen((e) => {
if (e.key !== ColorModeStorageKey) { setColorMode(coerceToColorMode(e.newValue));
return; });
}
const storedColorMode = ColorModeStorage.get();
if (storedColorMode !== null) {
setColorMode(coerceToColorMode(storedColorMode));
}
};
window.addEventListener('storage', onChange);
return () => window.removeEventListener('storage', onChange);
}, [disableSwitch, setColorMode]); }, [disableSwitch, setColorMode]);
// PCS is coerced to light mode when printing, which causes the color mode to // PCS is coerced to light mode when printing, which causes the color mode to

View file

@ -9,6 +9,7 @@ import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import type {PrismTheme} from 'prism-react-renderer'; import type {PrismTheme} from 'prism-react-renderer';
import type {DeepPartial} from 'utility-types'; import type {DeepPartial} from 'utility-types';
import type {MagicCommentConfig} from './codeBlockUtils'; import type {MagicCommentConfig} from './codeBlockUtils';
import type {ColorMode} from '../contexts/colorMode';
export type DocsVersionPersistence = 'localStorage' | 'none'; export type DocsVersionPersistence = 'localStorage' | 'none';
@ -44,7 +45,7 @@ export type Navbar = {
}; };
export type ColorModeConfig = { export type ColorModeConfig = {
defaultMode: 'light' | 'dark'; defaultMode: ColorMode;
disableSwitch: boolean; disableSwitch: boolean;
respectPrefersColorScheme: boolean; respectPrefersColorScheme: boolean;
}; };