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:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user