From 2739ac96100a90e43017204f67ef81ad919834f7 Mon Sep 17 00:00:00 2001 From: Abdulrasaq1515 Date: Sun, 30 Aug 2026 11:49:34 +0100 Subject: [PATCH] fix(i18n): persist selected locale to localStorage and restore on init Persist the selected language to localStorage via a shared storage key constant and restore it on provider mount. Adds validation against supported languages so corrupted or invalid stored values fall back to the default locale. Wraps localStorage writes in try-catch for resilience in private browsing modes. Also reuses isRTLUtil for html dir consistency. Added comprehensive unit tests covering persistence, restoration, validation, re-mount (reload simulation), document/cookie updates, and fallback behaviour outside the provider. closes #1294 --- .../useInternationalization.test.tsx | 217 ++++++++++++++++++ src/hooks/useInternationalization.tsx | 21 +- 2 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 src/hooks/__tests__/useInternationalization.test.tsx diff --git a/src/hooks/__tests__/useInternationalization.test.tsx b/src/hooks/__tests__/useInternationalization.test.tsx new file mode 100644 index 00000000..028b28a4 --- /dev/null +++ b/src/hooks/__tests__/useInternationalization.test.tsx @@ -0,0 +1,217 @@ +import React from 'react'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { I18nProvider, useInternationalization } from '../useInternationalization'; + +const STORAGE_KEY = 'i18n:language'; + +vi.mock('@/lib/i18n/config', () => ({ + __esModule: true, + default: { + language: 'en', + changeLanguage: vi.fn(), + hasResourceBundle: vi.fn().mockReturnValue(true), + isInitialized: true, + }, + loadLocale: vi.fn().mockResolvedValue(undefined), + getHtmlDir: vi.fn().mockReturnValue('ltr'), +})); + +vi.mock('@/locales/translationManager', () => ({ + loadTranslations: vi.fn().mockResolvedValue({ common: { test: 'Test' } }), + getTranslation: vi.fn((_t: unknown, key: string) => key), + getMissingTranslations: vi.fn().mockReturnValue([]), +})); + +vi.mock('@/utils/i18nUtils', () => ({ + getCulturalPreferences: vi.fn().mockReturnValue({ + dateFormat: 'MM/dd/yyyy', + timeFormat: 'HH:mm', + numberFormat: 'en-US', + currency: 'USD', + currencySymbol: '$', + firstDayOfWeek: 0, + decimalSeparator: '.', + thousandsSeparator: ',', + direction: 'ltr', + }), + formatDate: vi.fn(), + formatRelativeTime: vi.fn(), + preloadDateFnsLocale: vi.fn(), + formatNumber: vi.fn(), + formatCurrency: vi.fn(), + formatPercentage: vi.fn(), + parseNumber: vi.fn(), + getTextDirection: vi + .fn() + .mockImplementation((lang: string) => (lang === 'ar' || lang === 'he' ? 'rtl' : 'ltr')), + isRTL: vi.fn().mockImplementation((lang: string) => lang === 'ar' || lang === 'he'), + formatFileSize: vi.fn(), + formatDuration: vi.fn(), +})); + +vi.mock('@/lib/logging', () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +describe('useInternationalization', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + const setup = (defaultLanguage = 'en') => + renderHook(() => useInternationalization(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + + describe('localStorage persistence', () => { + it('restores saved language from localStorage on mount', async () => { + localStorage.setItem(STORAGE_KEY, 'es'); + + const { result } = setup(); + + await waitFor(() => { + expect(result.current.language).toBe('es'); + }); + }); + + it('persists language to localStorage when changeLanguage is called', async () => { + const { result } = setup('en'); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.changeLanguage('es'); + }); + + expect(localStorage.getItem(STORAGE_KEY)).toBe('es'); + }); + + it('uses default language when no saved language exists', async () => { + const { result } = setup('en'); + + await waitFor(() => { + expect(result.current.language).toBe('en'); + }); + }); + + it('ignores invalid saved language and falls back to default', async () => { + localStorage.setItem(STORAGE_KEY, 'invalid_lang'); + + const { result } = setup('en'); + + await waitFor(() => { + expect(result.current.language).toBe('en'); + }); + }); + + it('survives re-mount simulating page reload', async () => { + const { result, unmount } = setup('en'); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.changeLanguage('es'); + }); + + expect(localStorage.getItem(STORAGE_KEY)).toBe('es'); + + unmount(); + + const { result: result2 } = setup('en'); + + await waitFor(() => { + expect(result2.current.language).toBe('es'); + }); + }); + + it('updates document.documentElement lang and dir for RTL language', async () => { + const { result } = setup('en'); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.changeLanguage('ar'); + }); + + expect(document.documentElement.lang).toBe('ar'); + expect(document.documentElement.dir).toBe('rtl'); + }); + + it('sets document.cookie when language changes', async () => { + const { result } = setup('en'); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.changeLanguage('es'); + }); + + expect(document.cookie).toMatch(/i18n:language=es/); + }); + + it('continues functioning when localStorage.setItem throws', async () => { + const setItemSpy = vi + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(() => { + throw new Error('Storage full'); + }); + + const { result } = setup('en'); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.changeLanguage('es'); + }); + + expect(setItemSpy).toHaveBeenCalled(); + expect(result.current.language).toBe('es'); + + setItemSpy.mockRestore(); + }); + }); + + describe('fallback when used outside provider', () => { + it('returns default values', () => { + const { result } = renderHook(() => useInternationalization()); + + expect(result.current.language).toBe('en'); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('changeLanguage is a no-op outside provider', async () => { + const { result } = renderHook(() => useInternationalization()); + + await act(async () => { + await result.current.changeLanguage('es'); + }); + + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + }); +}); diff --git a/src/hooks/useInternationalization.tsx b/src/hooks/useInternationalization.tsx index 8a600388..adcf8a82 100644 --- a/src/hooks/useInternationalization.tsx +++ b/src/hooks/useInternationalization.tsx @@ -11,7 +11,7 @@ import { getTranslation, getMissingTranslations, } from '@/locales/translationManager'; -import { DEFAULT_LANGUAGE } from '@/locales/config'; +import { DEFAULT_LANGUAGE, getAvailableLanguages } from '@/locales/config'; import { getCulturalPreferences, formatDate as formatDateUtil, @@ -31,6 +31,8 @@ import { createLogger } from '@/lib/logging'; const logger = createLogger('use-internationalization'); +const I18N_LANGUAGE_STORAGE_KEY = 'i18n:language'; + interface I18nContextValue { language: LanguageCode; translations: Translations; @@ -131,8 +133,12 @@ export function I18nProvider({ // Load initial language from localStorage useEffect(() => { - const savedLanguage = localStorage.getItem('i18n:language') as LanguageCode | null; - if (savedLanguage && savedLanguage !== language) { + const savedLanguage = localStorage.getItem(I18N_LANGUAGE_STORAGE_KEY) as LanguageCode | null; + if ( + savedLanguage && + getAvailableLanguages().includes(savedLanguage) && + savedLanguage !== language + ) { setLanguage(savedLanguage); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -141,13 +147,18 @@ export function I18nProvider({ // Save language preference to localStorage + cookie (cookie enables SSR-side lang/dir) const changeLanguage = useCallback(async (newLanguage: LanguageCode) => { setLanguage(newLanguage); - localStorage.setItem('i18n:language', newLanguage); + + try { + localStorage.setItem(I18N_LANGUAGE_STORAGE_KEY, newLanguage); + } catch { + // localStorage may be unavailable (e.g. private browsing) — persist best-effort. + } // Persist to cookie so the server can read it on next request for SSR lang/dir. document.cookie = `i18n:language=${newLanguage};path=/;max-age=31536000;SameSite=Lax`; // Update and immediately for the current page visit. - const newDir = ['ar', 'he', 'fa', 'ur'].includes(newLanguage) ? 'rtl' : 'ltr'; + const newDir = isRTLUtil(newLanguage) ? 'rtl' : 'ltr'; document.documentElement.lang = newLanguage; document.documentElement.dir = newDir; }, []);