Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions src/hooks/__tests__/useInternationalization.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<I18nProvider defaultLanguage={defaultLanguage as any}>
{children}
</I18nProvider>
),
});

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();
});
});
});
21 changes: 16 additions & 5 deletions src/hooks/useInternationalization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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 <html lang> and <html dir> 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;
}, []);
Expand Down
Loading