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. */
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 setLightTheme: () => void;
readonly setDarkTheme: () => void;
@ -47,22 +48,55 @@ export type ColorMode = (typeof ColorModes)[keyof typeof ColorModes];
const coerceToColorMode = (colorMode?: string | null): ColorMode =>
colorMode === ColorModes.dark ? ColorModes.dark : ColorModes.light;
const getInitialColorMode = (defaultMode: ColorMode | undefined): ColorMode =>
ExecutionEnvironment.canUseDOM
? coerceToColorMode(document.documentElement.getAttribute('data-theme'))
: coerceToColorMode(defaultMode);
const ColorModeAttribute = {
get: () => {
return coerceToColorMode(
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) => {
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 {
const {
colorMode: {defaultMode, disableSwitch, respectPrefersColorScheme},
} = useThemeConfig();
const [colorMode, setColorModeState] = useState(
getInitialColorMode(defaultMode),
);
const [colorMode, setColorModeState] = useColorModeState();
useEffect(() => {
// A site is deployed without disableSwitch
@ -77,49 +111,38 @@ function useContextValue(): ContextValue {
const setColorMode = useCallback(
(newColorMode: ColorMode | null, options: {persist?: boolean} = {}) => {
const {persist = true} = options;
if (newColorMode) {
ColorModeAttribute.set(newColorMode);
setColorModeState(newColorMode);
if (persist) {
storeColorMode(newColorMode);
}
} else {
if (respectPrefersColorScheme) {
setColorModeState(
window.matchMedia('(prefers-color-scheme: dark)').matches
? ColorModes.dark
: ColorModes.light,
);
const osColorMode = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? ColorModes.dark
: ColorModes.light;
ColorModeAttribute.set(osColorMode);
setColorModeState(osColorMode);
} else {
ColorModeAttribute.set(defaultMode);
setColorModeState(defaultMode);
}
ColorModeStorage.del();
}
},
[respectPrefersColorScheme, defaultMode],
[setColorModeState, respectPrefersColorScheme, defaultMode],
);
useEffect(() => {
document.documentElement.setAttribute(
'data-theme',
coerceToColorMode(colorMode),
);
}, [colorMode]);
useEffect(() => {
if (disableSwitch) {
return undefined;
}
const onChange = (e: StorageEvent) => {
if (e.key !== ColorModeStorageKey) {
return;
}
const storedColorMode = ColorModeStorage.get();
if (storedColorMode !== null) {
setColorMode(coerceToColorMode(storedColorMode));
}
};
window.addEventListener('storage', onChange);
return () => window.removeEventListener('storage', onChange);
return ColorModeStorage.listen((e) => {
setColorMode(coerceToColorMode(e.newValue));
});
}, [disableSwitch, setColorMode]);
// 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 {DeepPartial} from 'utility-types';
import type {MagicCommentConfig} from './codeBlockUtils';
import type {ColorMode} from '../contexts/colorMode';
export type DocsVersionPersistence = 'localStorage' | 'none';
@ -44,7 +45,7 @@ export type Navbar = {
};
export type ColorModeConfig = {
defaultMode: 'light' | 'dark';
defaultMode: ColorMode;
disableSwitch: boolean;
respectPrefersColorScheme: boolean;
};