fix(docs): prevent theme switch hydration mismatch

This commit is contained in:
Sanaei
2026-08-06 20:29:00 +02:00
parent cb902314db
commit ece1655939
+29 -8
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { Moon, Sun } from 'lucide-react'; import { Moon, Sun } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState, useSyncExternalStore } from 'react';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
@@ -10,6 +10,12 @@ type ThemePref = 'light' | 'dark' | 'system';
const STORAGE_KEY = 'docs-theme'; const STORAGE_KEY = 'docs-theme';
// `useSyncExternalStore` supplies the same value for SSR and hydration, then
// switches to the browser value after React has attached to the markup.
const subscribeToHydration = () => () => {};
const getHydrationClientSnapshot = () => true;
const getHydrationServerSnapshot = () => false;
function getStoredTheme(): ThemePref { function getStoredTheme(): ThemePref {
if (typeof window === 'undefined') return 'system'; if (typeof window === 'undefined') return 'system';
const raw = window.localStorage.getItem(STORAGE_KEY); const raw = window.localStorage.getItem(STORAGE_KEY);
@@ -38,22 +44,37 @@ export function DocsThemeSwitch({
className?: string; className?: string;
mode?: ThemeMode; mode?: ThemeMode;
} & Omit<ComponentProps<'div'>, 'children'>) { } & Omit<ComponentProps<'div'>, 'children'>) {
const [theme, setTheme] = useState<ThemePref>(getStoredTheme); // Keep the server and first client render identical. Reading localStorage or
// matchMedia here would make a persisted/system preference change the client
// markup before React has finished hydrating it.
const [selectedTheme, setSelectedTheme] = useState<ThemePref>('system');
const hydrated = useSyncExternalStore(
subscribeToHydration,
getHydrationClientSnapshot,
getHydrationServerSnapshot,
);
const theme = hydrated ? getStoredTheme() : selectedTheme;
useEffect(() => { useEffect(() => {
window.localStorage.setItem(STORAGE_KEY, theme); if (hydrated) applyTheme(theme);
applyTheme(theme); }, [hydrated, theme]);
}, [theme]);
useEffect(() => { useEffect(() => {
if (!hydrated) return;
if (theme !== 'system') return; if (theme !== 'system') return;
const media = window.matchMedia('(prefers-color-scheme: dark)'); const media = window.matchMedia('(prefers-color-scheme: dark)');
const update = () => applyTheme('system'); const update = () => applyTheme('system');
media.addEventListener('change', update); media.addEventListener('change', update);
return () => media.removeEventListener('change', update); return () => media.removeEventListener('change', update);
}, [theme]); }, [hydrated, theme]);
const resolved = useMemo(() => getResolvedTheme(theme), [theme]); const resolved = hydrated ? getResolvedTheme(theme) : 'light';
const setTheme = (nextTheme: ThemePref) => {
window.localStorage.setItem(STORAGE_KEY, nextTheme);
applyTheme(nextTheme);
setSelectedTheme(nextTheme);
};
const nextTheme = () => { const nextTheme = () => {
if (mode === 'light-dark') return resolved === 'dark' ? 'light' : 'dark'; if (mode === 'light-dark') return resolved === 'dark' ? 'light' : 'dark';
@@ -80,4 +101,4 @@ export function DocsThemeSwitch({
</button> </button>
</div> </div>
); );
} }