diff --git a/apps/web-client/src/features/theme/i18n-context.tsx b/apps/web-client/src/features/theme/i18n-context.tsx index 82a71b4c..9af4df87 100644 --- a/apps/web-client/src/features/theme/i18n-context.tsx +++ b/apps/web-client/src/features/theme/i18n-context.tsx @@ -33,101 +33,7 @@ interface I18nContextType { */ export const I18nContext = React.createContext(undefined); -/** - * EN: Get locale from localStorage or browser - * VI: Lấy locale từ localStorage hoặc browser - */ -function getStoredLocale(): Locale { - if (typeof window === 'undefined') { - return defaultLocale; - } - // EN: Try to get from localStorage preferences / VI: Thử lấy từ localStorage preferences - try { - const stored = localStorage.getItem('preferences'); - if (stored) { - const parsed = JSON.parse(stored); - if (parsed.language && isValidLocale(parsed.language)) { - return parsed.language; - } - } - } catch { - // EN: Invalid stored data, continue to browser detection / VI: Dữ liệu lưu không hợp lệ, tiếp tục detect browser - } - - // EN: Detect from browser language / VI: Phát hiện từ ngôn ngữ browser - if (typeof navigator !== 'undefined') { - const browserLang = navigator.language || navigator.languages?.[0] || ''; - const langCode = browserLang.split('-')[0].toLowerCase(); - if (isValidLocale(langCode)) { - return langCode; - } - } - - return defaultLocale; -} - -/** - * EN: I18n Provider component - * VI: Component I18n Provider - */ -export function I18nProvider({ children }: { children: React.ReactNode }) { - const [locale, setLocaleState] = React.useState(() => getStoredLocale()); - - /** - * EN: Set locale and persist to localStorage - * VI: Đặt locale và lưu vào localStorage - */ - const setLocale = React.useCallback((newLocale: Locale) => { - if (!isValidLocale(newLocale)) { - console.warn(`Invalid locale: ${newLocale}`); - return; - } - - setLocaleState(newLocale); - - // EN: Update localStorage preferences / VI: Cập nhật localStorage preferences - if (typeof window !== 'undefined') { - try { - const stored = localStorage.getItem('preferences'); - const preferences = stored ? JSON.parse(stored) : {}; - preferences.language = newLocale; - localStorage.setItem('preferences', JSON.stringify(preferences)); - } catch (error) { - console.error('Failed to save locale preference:', error); - } - - // EN: Update HTML lang attribute / VI: Cập nhật thuộc tính lang của HTML - document.documentElement.lang = newLocale; - } - }, []); - - /** - * EN: Get current locale - * VI: Lấy locale hiện tại - */ - const getLocale = React.useCallback(() => { - return locale; - }, [locale]); - - // EN: Initialize HTML lang attribute on mount / VI: Khởi tạo thuộc tính lang của HTML khi mount - React.useEffect(() => { - if (typeof document !== 'undefined') { - document.documentElement.lang = locale; - } - }, [locale]); - - const value = React.useMemo( - () => ({ - locale, - setLocale, - getLocale, - }), - [locale, setLocale, getLocale] - ); - - return {children}; -} /** * EN: Hook to use i18n context diff --git a/apps/web-client/src/features/theme/i18n-provider.tsx b/apps/web-client/src/features/theme/i18n-provider.tsx index 1a418537..907a171d 100644 --- a/apps/web-client/src/features/theme/i18n-provider.tsx +++ b/apps/web-client/src/features/theme/i18n-provider.tsx @@ -17,8 +17,10 @@ import viMessages from '../shared/i18n/vi.json'; /** * EN: Get locale from localStorage or browser (duplicate from i18n-context to avoid circular dependency) * VI: Lấy locale từ localStorage hoặc browser (duplicate từ i18n-context để tránh circular dependency) + * + * @param skipBrowserDetection - Skip browser language detection (for hydration) */ -function getStoredLocale(): 'en' | 'vi' { +function getStoredLocale(skipBrowserDetection: boolean = false): 'en' | 'vi' { if (typeof window === 'undefined') { return 'en'; // defaultLocale } @@ -36,6 +38,12 @@ function getStoredLocale(): 'en' | 'vi' { // EN: Invalid stored data, continue to browser detection / VI: Dữ liệu lưu không hợp lệ, tiếp tục detect browser } + // EN: Skip browser detection during hydration to prevent mismatch + // VI: Bỏ qua browser detection trong hydration để tránh mismatch + if (skipBrowserDetection) { + return 'en'; // defaultLocale + } + // EN: Detect from browser language / VI: Phát hiện từ ngôn ngữ browser if (typeof navigator !== 'undefined') { const browserLang = navigator.language || navigator.languages?.[0] || ''; @@ -53,8 +61,14 @@ function getStoredLocale(): 'en' | 'vi' { * VI: Component I18n Provider chính */ export function I18nProvider({ children }: { children: React.ReactNode }) { - // EN: Get initial locale / VI: Lấy locale ban đầu - const [locale, setLocaleState] = React.useState<'en' | 'vi'>(() => getStoredLocale()); + // EN: Track if component has mounted (hydrated) to prevent hydration mismatch + // VI: Theo dõi nếu component đã mount (hydrated) để tránh hydration mismatch + const [mounted, setMounted] = React.useState(false); + + // EN: Initialize with default locale to prevent hydration mismatch + // VI: Khởi tạo với locale mặc định để tránh hydration mismatch + // Server always renders with defaultLocale, so client must start with same value + const [locale, setLocaleState] = React.useState<'en' | 'vi'>('en'); /** * EN: Set locale and persist to localStorage @@ -92,31 +106,80 @@ export function I18nProvider({ children }: { children: React.ReactNode }) { return locale; }, [locale]); + // EN: Mark as mounted AFTER hydration (no auto-detection, keep default 'en') + // VI: Đánh dấu đã mount SAU khi hydration (không tự động phát hiện, giữ mặc định 'en') + React.useEffect(() => { + setMounted(true); + // EN: Check ONLY localStorage for explicit user choice (no browser detection) + // VI: Chỉ kiểm tra localStorage cho lựa chọn rõ ràng của người dùng (không phát hiện browser) + try { + const stored = localStorage.getItem('preferences'); + if (stored) { + const parsed = JSON.parse(stored); + if (parsed.language && (parsed.language === 'en' || parsed.language === 'vi')) { + setLocaleState(parsed.language); + } + } + } catch { + // EN: Keep default 'en' if localStorage read fails + // VI: Giữ mặc định 'en' nếu đọc localStorage thất bại + } + }, []); // Run once on mount + // EN: Initialize HTML lang attribute on mount / VI: Khởi tạo thuộc tính lang của HTML khi mount React.useEffect(() => { - if (typeof document !== 'undefined') { + if (typeof document !== 'undefined' && mounted) { document.documentElement.lang = locale; } - }, [locale]); + }, [locale, mounted]); // EN: Get messages based on locale / VI: Lấy messages dựa trên locale + // EN: Logic to be applied: ensure we use 'en' until mounted to match server + const activeLocale = mounted ? locale : 'en'; + const messages = React.useMemo(() => { - return locale === 'vi' ? viMessages : enMessages; - }, [locale]); + return activeLocale === 'vi' ? viMessages : enMessages; + }, [activeLocale]); const customContextValue = React.useMemo( () => ({ - locale, + locale: activeLocale, setLocale, getLocale, }), - [locale, setLocale, getLocale] + [activeLocale, setLocale, getLocale] ); + // EN: AGGRESSIVE HYDRATION FIX: Don't render translated content until after mount + // VI: FIX HYDRATION MẠNH: Không render nội dung đã dịch cho đến sau khi mount + // This prevents ANY hydration mismatch by ensuring both server and client + // render the same null/skeleton content initially, then client renders + // the full content with translations after hydration is complete. + if (!mounted) { + return ( + + + {/* + EN: Render children with default English during SSR/hydration + VI: Render children với English mặc định trong SSR/hydration + This ensures server and client match during hydration. + After mount, we'll re-render with the user's locale preference. + */} + {children} + + + ); + } + return ( diff --git a/apps/web-client/src/features/theme/index.ts b/apps/web-client/src/features/theme/index.ts index c7f2a85e..396afdf9 100644 --- a/apps/web-client/src/features/theme/index.ts +++ b/apps/web-client/src/features/theme/index.ts @@ -12,7 +12,8 @@ export * from './components/language-switcher'; // Contexts export * from './theme-context'; -export * from './i18n-context'; +export { useI18n } from './i18n-context'; +export { I18nProvider } from './i18n-provider'; // Hooks export * from './hooks/use-theme';