fix: Ngăn chặn lỗi hydration của i18n bằng cách tái cấu trúc logic phát hiện locale và trì hoãn áp dụng locale phía client.

This commit is contained in:
Ho Ngoc Hai
2026-01-04 22:19:51 +07:00
parent 2d783af67f
commit d2da32124c
3 changed files with 75 additions and 105 deletions

View File

@@ -33,101 +33,7 @@ interface I18nContextType {
*/
export const I18nContext = React.createContext<I18nContextType | undefined>(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<Locale>(() => 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 <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
/**
* EN: Hook to use i18n context

View File

@@ -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 (
<I18nContext.Provider value={customContextValue}>
<NextIntlClientProvider
locale="en"
messages={enMessages}
timeZone={defaultTimeZone}
>
{/*
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}
</NextIntlClientProvider>
</I18nContext.Provider>
);
}
return (
<I18nContext.Provider value={customContextValue}>
<NextIntlClientProvider
locale={locale}
key={activeLocale}
locale={activeLocale}
messages={messages}
timeZone={defaultTimeZone}
>

View File

@@ -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';