From 55705a0fbb41846cd35e155b9e266c87bab55ee7 Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Wed, 18 Feb 2026 06:02:06 -0600 Subject: [PATCH] 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 to avoid type issues - Wrap localStorage access in try-catch for blocked storage scenarios Co-Authored-By: Claude Opus 4.6 --- frontend/src/lib/i18n.tsx | 88 +++++++++++++++++++++++---------------- 1 file changed, 52 insertions(+), 36 deletions(-) diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 726262a..09301f2 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -1,6 +1,6 @@ '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 es from '@/i18n/es.json'; import ptBR from '@/i18n/pt-BR.json'; @@ -13,12 +13,14 @@ interface I18nContextType { t: (key: string, params?: Record) => string; } -const dictionaries: Record = { - en, - es, - 'pt-BR': ptBR, +const dictionaries: Record> = { + en: en as Record, + es: es as Record, + 'pt-BR': ptBR as Record, }; +const VALID_LOCALES: Locale[] = ['en', 'es', 'pt-BR']; + const I18nContext = createContext(null); const localeStorageKey = 'locale'; @@ -30,63 +32,77 @@ const normalizeLocale = (language: string): Locale => { }; const getInitialLocale = (): Locale => { - if (typeof window === 'undefined') return 'en'; - const storedLocale = localStorage.getItem(localeStorageKey); - if (storedLocale && Object.keys(dictionaries).includes(storedLocale)) { - return storedLocale as Locale; + if (typeof window === 'undefined') return 'pt-BR'; + try { + const storedLocale = localStorage.getItem(localeStorageKey); + if (storedLocale && VALID_LOCALES.includes(storedLocale as Locale)) { + return storedLocale as Locale; + } + } catch { + // localStorage might be blocked } if (typeof navigator !== 'undefined' && navigator.language) { return normalizeLocale(navigator.language); } - return 'en'; + return 'pt-BR'; }; +function resolveKey(dict: Record, 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)[k]; + } else { + return undefined; + } + } + + return typeof value === 'string' ? value : undefined; +} + export function I18nProvider({ children }: { children: ReactNode }) { - const [locale, setLocaleState] = useState('en'); + const [locale, setLocaleState] = useState(getInitialLocale); const setLocale = (newLocale: Locale) => { setLocaleState(newLocale); }; const t = useCallback((key: string, params?: Record) => { - const keys = key.split('.'); - let value: unknown = dictionaries[locale]; + // Try current locale first, then fallback to pt-BR, then en + const dict = dictionaries[locale] || dictionaries['pt-BR']; + let result = resolveKey(dict, key); - for (const k of keys) { - if (value && typeof value === 'object' && value !== null && k in value) { - value = (value as Record)[k]; - } else { - return key; - } + // Fallback chain: try pt-BR if not found, then en + if (result === undefined && locale !== 'pt-BR') { + result = resolveKey(dictionaries['pt-BR'], key); + } + if (result === undefined && locale !== 'en') { + result = resolveKey(dictionaries['en'], key); } - if (typeof value !== 'string') return key; + if (result === undefined) { + return key; + } if (params) { return Object.entries(params).reduce( - (str, [paramKey, paramValue]) => str.replace(`{${paramKey}}`, String(paramValue)), - value + (str, [paramKey, paramValue]) => str.replace(new RegExp(`\\{${paramKey}\\}`, 'g'), String(paramValue)), + result ); } - return value; + return result; }, [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(() => { if (typeof window === 'undefined') return; - localStorage.setItem(localeStorageKey, locale); + try { + localStorage.setItem(localeStorageKey, locale); + } catch { + // localStorage might be blocked + } document.documentElement.lang = locale; }, [locale]);