From e81a22b85640d33a85ac7386e6b38c84d32c4f99 Mon Sep 17 00:00:00 2001 From: wendyamoni-creator Date: Mon, 27 Jul 2026 09:10:05 +0000 Subject: [PATCH] fix: prevent i18n singleton and React locale state desync i18n.setLocale() was a no-op for unimplemented locales (es/fr/de) but returned void, so useI18n's wrapper always called setLocaleState() regardless of whether the change actually applied. This left React state showing e.g. 'es' while i18n.t() still rendered using 'en'. Fix: - I18n.setLocale() now returns boolean (true = applied, false = no-op) - useI18n.setLocale() guards setLocaleState() behind that return value Tests: - Extend i18n.test.ts: assert return values and that internal locale stays 'en' after an unimplemented locale request - Add useI18n.test.ts: assert React state and singleton stay in sync for both implemented ('en') and unimplemented (es/fr/de) locales Closes #1160 --- frontend/src/lib/__tests__/i18n.test.ts | 17 +++ .../src/lib/hooks/__tests__/useI18n.test.ts | 128 ++++++++++++++++++ frontend/src/lib/hooks/useI18n.ts | 6 +- frontend/src/lib/i18n.ts | 4 +- 4 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/hooks/__tests__/useI18n.test.ts diff --git a/frontend/src/lib/__tests__/i18n.test.ts b/frontend/src/lib/__tests__/i18n.test.ts index 8f0776b8..bb6c38e7 100644 --- a/frontend/src/lib/__tests__/i18n.test.ts +++ b/frontend/src/lib/__tests__/i18n.test.ts @@ -22,6 +22,23 @@ describe('i18n', () => { i18n.setLocale('invalid' as any); expect(i18n.getLocale()).toBe('en'); }); + + it('should return true when setting an implemented locale', () => { + const result = i18n.setLocale('en'); + expect(result).toBe(true); + }); + + it('should return false for unimplemented locales (es, fr, de)', () => { + expect(i18n.setLocale('es')).toBe(false); + expect(i18n.setLocale('fr')).toBe(false); + expect(i18n.setLocale('de')).toBe(false); + }); + + it('should not change internal locale when an unimplemented locale is requested', () => { + i18n.setLocale('en'); + i18n.setLocale('es'); + expect(i18n.getLocale()).toBe('en'); + }); }); describe('t (translation)', () => { diff --git a/frontend/src/lib/hooks/__tests__/useI18n.test.ts b/frontend/src/lib/hooks/__tests__/useI18n.test.ts new file mode 100644 index 00000000..c2d4ca5b --- /dev/null +++ b/frontend/src/lib/hooks/__tests__/useI18n.test.ts @@ -0,0 +1,128 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useI18n } from '../useI18n'; +import { i18n } from '../../i18n'; + +describe('useI18n', () => { + beforeEach(() => { + // Reset singleton and storage before each test + i18n.setLocale('en'); + localStorage.clear(); + }); + + it('initialises with en locale', async () => { + const { result } = renderHook(() => useI18n()); + + await waitFor(() => expect(result.current.locale).toBe('en')); + expect(i18n.getLocale()).toBe('en'); + }); + + describe('setLocale with implemented locale (en)', () => { + it('updates React state when locale is implemented', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + act(() => { + result.current.setLocale('en'); + }); + + expect(result.current.locale).toBe('en'); + expect(i18n.getLocale()).toBe('en'); + }); + + it('keeps React state and singleton in sync for en', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + act(() => { + result.current.setLocale('en'); + }); + + // Both sides agree + expect(result.current.locale).toBe(i18n.getLocale()); + }); + }); + + describe('setLocale with unimplemented locales (es, fr, de)', () => { + it('does NOT update React state when locale is unimplemented (es)', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + act(() => { + result.current.setLocale('es'); + }); + + // React state must stay at 'en' — no desync + expect(result.current.locale).toBe('en'); + }); + + it('does NOT update React state when locale is unimplemented (fr)', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + act(() => { + result.current.setLocale('fr'); + }); + + expect(result.current.locale).toBe('en'); + }); + + it('does NOT update React state when locale is unimplemented (de)', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + act(() => { + result.current.setLocale('de'); + }); + + expect(result.current.locale).toBe('en'); + }); + + it('keeps React state and i18n singleton in sync when unimplemented locale is requested', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + act(() => { + result.current.setLocale('es'); + }); + + // The core invariant: hook-visible locale === what the singleton actually uses + expect(result.current.locale).toBe(i18n.getLocale()); + }); + + it('singleton internal locale stays en after unimplemented locale request', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + act(() => { + result.current.setLocale('fr'); + }); + + expect(i18n.getLocale()).toBe('en'); + }); + }); + + describe('t() translations', () => { + it('returns translation for a known key', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + expect(result.current.t('nav.features')).toBe('Features'); + }); + + it('returns fallback for unknown key', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + expect(result.current.t('nonexistent.key', 'fallback')).toBe('fallback'); + }); + }); + + describe('availableLocales', () => { + it('returns at least en', async () => { + const { result } = renderHook(() => useI18n()); + await waitFor(() => expect(result.current.locale).toBe('en')); + + expect(result.current.availableLocales).toContain('en'); + }); + }); +}); diff --git a/frontend/src/lib/hooks/useI18n.ts b/frontend/src/lib/hooks/useI18n.ts index 300d2063..b3886a8b 100644 --- a/frontend/src/lib/hooks/useI18n.ts +++ b/frontend/src/lib/hooks/useI18n.ts @@ -13,8 +13,10 @@ export function useI18n() { }, []); const setLocale = (newLocale: Locale) => { - i18n.setLocale(newLocale); - setLocaleState(newLocale); + const applied = i18n.setLocale(newLocale); + if (applied) { + setLocaleState(newLocale); + } }; const t = (key: string, defaultValue?: string) => { diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 92eefea6..a5706f79 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -90,13 +90,15 @@ const translations: LocaleData = { class I18n { private currentLocale: Locale = 'en'; - setLocale(locale: Locale): void { + setLocale(locale: Locale): boolean { if (locale in translations) { this.currentLocale = locale; if (typeof window !== 'undefined') { localStorage.setItem('locale', locale); } + return true; } + return false; } getLocale(): Locale {