Browse Source

fix(docs): prevent theme switch hydration mismatch

Sanaei 18 giờ trước cách đây
mục cha
commit
ece1655939
1 tập tin đã thay đổi với 29 bổ sung8 xóa
  1. 29 8
      docs/components/theme-switch.tsx

+ 29 - 8
docs/components/theme-switch.tsx

@@ -1,7 +1,7 @@
 'use client';
 
 import { Moon, Sun } from 'lucide-react';
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useState, useSyncExternalStore } from 'react';
 import type { ComponentProps } from 'react';
 import { cn } from '@/lib/cn';
 
@@ -10,6 +10,12 @@ type ThemePref = 'light' | 'dark' | 'system';
 
 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 {
   if (typeof window === 'undefined') return 'system';
   const raw = window.localStorage.getItem(STORAGE_KEY);
@@ -38,22 +44,37 @@ export function DocsThemeSwitch({
   className?: string;
   mode?: ThemeMode;
 } & 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(() => {
-    window.localStorage.setItem(STORAGE_KEY, theme);
-    applyTheme(theme);
-  }, [theme]);
+    if (hydrated) applyTheme(theme);
+  }, [hydrated, theme]);
 
   useEffect(() => {
+    if (!hydrated) return;
     if (theme !== 'system') return;
     const media = window.matchMedia('(prefers-color-scheme: dark)');
     const update = () => applyTheme('system');
     media.addEventListener('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 = () => {
     if (mode === 'light-dark') return resolved === 'dark' ? 'light' : 'dark';
@@ -80,4 +101,4 @@ export function DocsThemeSwitch({
       </button>
     </div>
   );
-}
+}