fix(i18n): initialize locale from browser/localStorage and add fallback chain

- Initialize useState with getInitialLocale() instead of hardcoded 'en',
  so the correct locale is used from the very first render
- Default to 'pt-BR' instead of 'en' for SSR and fallback
- Add fallback chain in t(): tries current locale -> pt-BR -> en
- Extract resolveKey() helper for cleaner key resolution
- Cast dictionaries as Record<string, unknown> to avoid type issues
- Wrap localStorage access in try-catch for blocked storage scenarios

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tiago Yamamoto 2026-02-18 06:02:06 -06:00
parent 8fb358a984
commit 55705a0fbb

View file

@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect, startTransition } from 'react'; import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
import en from '@/i18n/en.json'; import en from '@/i18n/en.json';
import es from '@/i18n/es.json'; import es from '@/i18n/es.json';
import ptBR from '@/i18n/pt-BR.json'; import ptBR from '@/i18n/pt-BR.json';
@ -13,12 +13,14 @@ interface I18nContextType {
t: (key: string, params?: Record<string, string | number>) => string; t: (key: string, params?: Record<string, string | number>) => string;
} }
const dictionaries: Record<Locale, typeof en> = { const dictionaries: Record<Locale, Record<string, unknown>> = {
en, en: en as Record<string, unknown>,
es, es: es as Record<string, unknown>,
'pt-BR': ptBR, 'pt-BR': ptBR as Record<string, unknown>,
}; };
const VALID_LOCALES: Locale[] = ['en', 'es', 'pt-BR'];
const I18nContext = createContext<I18nContextType | null>(null); const I18nContext = createContext<I18nContextType | null>(null);
const localeStorageKey = 'locale'; const localeStorageKey = 'locale';
@ -30,63 +32,77 @@ const normalizeLocale = (language: string): Locale => {
}; };
const getInitialLocale = (): Locale => { const getInitialLocale = (): Locale => {
if (typeof window === 'undefined') return 'en'; if (typeof window === 'undefined') return 'pt-BR';
try {
const storedLocale = localStorage.getItem(localeStorageKey); const storedLocale = localStorage.getItem(localeStorageKey);
if (storedLocale && Object.keys(dictionaries).includes(storedLocale)) { if (storedLocale && VALID_LOCALES.includes(storedLocale as Locale)) {
return storedLocale as Locale; return storedLocale as Locale;
} }
} catch {
// localStorage might be blocked
}
if (typeof navigator !== 'undefined' && navigator.language) { if (typeof navigator !== 'undefined' && navigator.language) {
return normalizeLocale(navigator.language); return normalizeLocale(navigator.language);
} }
return 'en'; return 'pt-BR';
}; };
function resolveKey(dict: Record<string, unknown>, key: string): string | undefined {
const keys = key.split('.');
let value: unknown = dict;
for (const k of keys) {
if (value && typeof value === 'object' && value !== null && k in value) {
value = (value as Record<string, unknown>)[k];
} else {
return undefined;
}
}
return typeof value === 'string' ? value : undefined;
}
export function I18nProvider({ children }: { children: ReactNode }) { export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>('en'); const [locale, setLocaleState] = useState<Locale>(getInitialLocale);
const setLocale = (newLocale: Locale) => { const setLocale = (newLocale: Locale) => {
setLocaleState(newLocale); setLocaleState(newLocale);
}; };
const t = useCallback((key: string, params?: Record<string, string | number>) => { const t = useCallback((key: string, params?: Record<string, string | number>) => {
const keys = key.split('.'); // Try current locale first, then fallback to pt-BR, then en
let value: unknown = dictionaries[locale]; const dict = dictionaries[locale] || dictionaries['pt-BR'];
let result = resolveKey(dict, key);
for (const k of keys) { // Fallback chain: try pt-BR if not found, then en
if (value && typeof value === 'object' && value !== null && k in value) { if (result === undefined && locale !== 'pt-BR') {
value = (value as Record<string, unknown>)[k]; result = resolveKey(dictionaries['pt-BR'], key);
} else { }
if (result === undefined && locale !== 'en') {
result = resolveKey(dictionaries['en'], key);
}
if (result === undefined) {
return key; return key;
} }
}
if (typeof value !== 'string') return key;
if (params) { if (params) {
return Object.entries(params).reduce( return Object.entries(params).reduce(
(str, [paramKey, paramValue]) => str.replace(`{${paramKey}}`, String(paramValue)), (str, [paramKey, paramValue]) => str.replace(new RegExp(`\\{${paramKey}\\}`, 'g'), String(paramValue)),
value result
); );
} }
return value; return result;
}, [locale]); }, [locale]);
// Restore locale from localStorage after hydration completes.
// Using startTransition to mark this as non-urgent so React
// finishes hydration before re-rendering with the stored locale.
useEffect(() => {
const stored = getInitialLocale();
if (stored !== 'en') {
startTransition(() => {
setLocaleState(stored);
});
}
}, []);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try {
localStorage.setItem(localeStorageKey, locale); localStorage.setItem(localeStorageKey, locale);
} catch {
// localStorage might be blocked
}
document.documentElement.lang = locale; document.documentElement.lang = locale;
}, [locale]); }, [locale]);