diff --git a/src/lib/i18n/utils.test.tsx b/src/lib/i18n/utils.test.tsx new file mode 100644 index 0000000..f49dd57 --- /dev/null +++ b/src/lib/i18n/utils.test.tsx @@ -0,0 +1,53 @@ +// In-memory stand-in for the MMKV string hook (mirrors the global +// react-native-mmkv manual mock, plus useMMKVString). +const mockMMKVStrings = new Map(); + +jest.mock('react-native-mmkv', () => ({ + createMMKV: jest.fn(() => ({ + set: jest.fn(), + getString: jest.fn(() => undefined), + getAllKeys: jest.fn(() => []), + remove: jest.fn(), + })), + useMMKVString: (key: string) => [ + mockMMKVStrings.get(key), + (value: string) => { + mockMMKVStrings.set(key, value); + }, + ], +})); + +import { renderHook } from '@testing-library/react-native'; + +import i18n from './index'; +import { LOCAL, useSelectedLanguage } from './utils'; + +beforeEach(() => { + mockMMKVStrings.clear(); +}); + +describe('useSelectedLanguage', () => { + it('falls back to the language i18n is actually rendering before any pick', () => { + // Fresh install: nothing stored under the LOCAL key — the selector must + // still highlight what the bootstrap detected (i18n.language), not nothing. + const { result } = renderHook(() => useSelectedLanguage()); + + expect(result.current.language).toBe(i18n.language); + }); + + it('prefers the stored language once the user has picked one', async () => { + const { result } = renderHook(() => useSelectedLanguage()); + + result.current.setLanguage('ja'); + + // The stored pick wins on the next mount (and i18n switched too). + expect(i18n.language).toBe('ja'); + expect(mockMMKVStrings.get(LOCAL)).toBe('ja'); + const { result: again } = renderHook(() => useSelectedLanguage()); + expect(again.current.language).toBe('ja'); + + // Restore the default for other suites sharing the i18n singleton. + await i18n.changeLanguage('en'); + mockMMKVStrings.set(LOCAL, 'en'); + }); +}); diff --git a/src/lib/i18n/utils.tsx b/src/lib/i18n/utils.tsx index fb452c8..ac69f85 100644 --- a/src/lib/i18n/utils.tsx +++ b/src/lib/i18n/utils.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useMMKVString } from 'react-native-mmkv'; import { storage } from '../storage'; -import type { Language, resources } from './resources'; +import { type Language, resources } from './resources'; import type { RecursiveKeyOf } from './types'; type DefaultLocale = typeof resources.en.translation; @@ -34,6 +34,13 @@ export const changeLanguage = (lang: Language) => { void i18n.changeLanguage(lang); }; +/** + * The active language for selectors: the stored preference, or — before the + * user has ever picked one — whatever the i18n bootstrap detected and is + * actually rendering. Reading the i18n instance (instead of re-deriving from + * the device locale) keeps the highlighted pill and the rendered language + * from disagreeing on a fresh install. + */ export const useSelectedLanguage = () => { const [language, setLang] = useMMKVString(LOCAL); @@ -45,5 +52,8 @@ export const useSelectedLanguage = () => { [setLang] ); - return { language: language as Language, setLanguage }; + const fallback = i18n.language && i18n.language in resources ? (i18n.language as Language) : 'en'; + const selected: Language = language && language in resources ? (language as Language) : fallback; + + return { language: selected, setLanguage }; };