From 66bbf07d0a10d5b6a5e419e8e9d504d6c1a2d595 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:51:25 -0700 Subject: [PATCH] fix(mobile): bound locale retries per language --- .../src/i18n/use-mobile-locale-reload.test.ts | 160 ++++++++++++++++++ mobile/src/i18n/use-mobile-locale-reload.ts | 33 ++-- 2 files changed, 181 insertions(+), 12 deletions(-) diff --git a/mobile/src/i18n/use-mobile-locale-reload.test.ts b/mobile/src/i18n/use-mobile-locale-reload.test.ts index 1f42d2059f4..3392567ec75 100644 --- a/mobile/src/i18n/use-mobile-locale-reload.test.ts +++ b/mobile/src/i18n/use-mobile-locale-reload.test.ts @@ -14,6 +14,7 @@ vi.mock('expo-localization', () => ({ })) import { useMobileLocaleReload } from './use-mobile-locale-reload' +import { mobileI18n } from './mobile-i18n' describe('useMobileLocaleReload', () => { let renderer: ReactTestRenderer | null = null @@ -24,6 +25,19 @@ describe('useMobileLocaleReload', () => { return null } + async function selectLocale(languageTag: string): Promise { + localeState.current = [{ languageTag }] + await act(async () => { + renderer?.update(createElement(Harness)) + }) + } + + async function advanceRetryTimer(): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000) + }) + } + beforeEach(() => { vi.useFakeTimers() globalThis.IS_REACT_ACT_ENVIRONMENT = true @@ -41,8 +55,10 @@ describe('useMobileLocaleReload', () => { afterEach(() => { act(() => renderer?.unmount()) renderer = null + const pendingTimerCount = vi.getTimerCount() vi.useRealTimers() consoleSpy.mockRestore() + expect(pendingTimerCount).toBe(0) }) it('retries when the native reload request rejects', async () => { @@ -79,6 +95,150 @@ describe('useMobileLocaleReload', () => { expect(reloadAppAsync).toHaveBeenCalledTimes(4) }) + it('caps retries for each locale for the hook lifetime', async () => { + reloadAppAsync.mockRejectedValue(new Error('reload unavailable')) + expect(mobileI18n.language).toBe('en') + await act(async () => { + renderer = create(createElement(Harness)) + }) + + await advanceRetryTimer() + await advanceRetryTimer() + expect(reloadAppAsync).toHaveBeenCalledTimes(3) + + await selectLocale('ja-JP') + await advanceRetryTimer() + await advanceRetryTimer() + expect(reloadAppAsync).toHaveBeenCalledTimes(6) + + for (const languageTag of ['en-US', 'es-MX', 'ja-JP', 'es-MX', 'ja-JP']) { + await selectLocale(languageTag) + } + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000) + }) + + expect(reloadAppAsync).toHaveBeenCalledTimes(6) + expect(vi.getTimerCount()).toBe(0) + }) + + it('retries the current locale after an exhausted stale target rejects', async () => { + let rejectThirdEsReload!: (error: Error) => void + reloadAppAsync + .mockRejectedValueOnce(new Error('reload unavailable')) + .mockRejectedValueOnce(new Error('reload unavailable')) + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectThirdEsReload = reject + }) + ) + .mockRejectedValue(new Error('reload unavailable')) + await act(async () => { + renderer = create(createElement(Harness)) + }) + await advanceRetryTimer() + await advanceRetryTimer() + expect(reloadAppAsync).toHaveBeenCalledTimes(3) + + await selectLocale('ja-JP') + await act(async () => { + rejectThirdEsReload(new Error('reload unavailable')) + await Promise.resolve() + }) + await advanceRetryTimer() + + expect(reloadAppAsync).toHaveBeenCalledTimes(4) + }) + + it('treats a successful reload as terminal after its target becomes stale', async () => { + let resolveEsReload!: () => void + reloadAppAsync.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveEsReload = resolve + }) + ) + await act(async () => { + renderer = create(createElement(Harness)) + }) + + await selectLocale('ja-JP') + await act(async () => { + resolveEsReload() + await Promise.resolve() + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000) + }) + + expect(reloadAppAsync).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('starts fresh attempt budgets after remount', async () => { + reloadAppAsync.mockRejectedValue(new Error('reload unavailable')) + await act(async () => { + renderer = create(createElement(Harness)) + }) + await advanceRetryTimer() + await advanceRetryTimer() + expect(reloadAppAsync).toHaveBeenCalledTimes(3) + + act(() => renderer?.unmount()) + renderer = null + expect(vi.getTimerCount()).toBe(0) + await act(async () => { + renderer = create(createElement(Harness)) + }) + await advanceRetryTimer() + await advanceRetryTimer() + + expect(reloadAppAsync).toHaveBeenCalledTimes(6) + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not retry a request that rejects after unmount', async () => { + let rejectReload!: (error: Error) => void + reloadAppAsync.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectReload = reject + }) + ) + await act(async () => { + renderer = create(createElement(Harness)) + }) + + act(() => renderer?.unmount()) + renderer = null + await act(async () => { + rejectReload(new Error('reload unavailable')) + await Promise.resolve() + await vi.advanceTimersByTimeAsync(10_000) + }) + + expect(reloadAppAsync).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('clears a pending retry timer on unmount', async () => { + reloadAppAsync.mockRejectedValue(new Error('reload unavailable')) + await act(async () => { + renderer = create(createElement(Harness)) + }) + expect(vi.getTimerCount()).toBe(1) + + act(() => renderer?.unmount()) + renderer = null + expect(vi.getTimerCount()).toBe(0) + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000) + }) + + expect(reloadAppAsync).toHaveBeenCalledTimes(1) + }) + it('retries a rejected request after locale preferences change while it is pending', async () => { let rejectReload!: (error: Error) => void reloadAppAsync.mockImplementationOnce( diff --git a/mobile/src/i18n/use-mobile-locale-reload.ts b/mobile/src/i18n/use-mobile-locale-reload.ts index 492c5851698..41e630d68dc 100644 --- a/mobile/src/i18n/use-mobile-locale-reload.ts +++ b/mobile/src/i18n/use-mobile-locale-reload.ts @@ -11,10 +11,12 @@ import { const LOCALE_RELOAD_RETRY_MS = 1_000 const LOCALE_RELOAD_MAX_ATTEMPTS = 3 +type LocaleReloadRequest = { target: MobileUiLocale } + export function useMobileLocaleReload(): void { const locales = useLocales() - const reloadRequestedRef = useRef(false) - const reloadAttemptsRef = useRef(0) + const reloadRequestRef = useRef(null) + const reloadAttemptsRef = useRef>>({}) const reloadTargetRef = useRef(null) const mountedRef = useRef(true) const [retryPending, setRetryPending] = useState(false) @@ -24,6 +26,7 @@ export function useMobileLocaleReload(): void { mountedRef.current = true return () => { mountedRef.current = false + reloadRequestRef.current = null } }, []) @@ -42,22 +45,28 @@ export function useMobileLocaleReload(): void { const languageTags = locales.map((locale) => locale.languageTag) const reloadTarget = selectPreferredMobileUiLocale(languageTags) if (!shouldReloadForMobileLocaleChange(mobileI18n.language as MobileUiLocale, languageTags)) { - reloadAttemptsRef.current = 0 reloadTargetRef.current = null return } - if (reloadTargetRef.current !== reloadTarget) { - reloadAttemptsRef.current = 0 - reloadTargetRef.current = reloadTarget - } - if (reloadRequestedRef.current || reloadAttemptsRef.current >= LOCALE_RELOAD_MAX_ATTEMPTS) { + reloadTargetRef.current = reloadTarget + const reloadAttempts = reloadAttemptsRef.current[reloadTarget] ?? 0 + if (reloadRequestRef.current || reloadAttempts >= LOCALE_RELOAD_MAX_ATTEMPTS) { return } - reloadRequestedRef.current = true - reloadAttemptsRef.current += 1 + const request: LocaleReloadRequest = { target: reloadTarget } + reloadRequestRef.current = request + reloadAttemptsRef.current[request.target] = reloadAttempts + 1 void reloadAppAsync('Mobile locale changed').catch(() => { - reloadRequestedRef.current = false - if (!mountedRef.current || reloadAttemptsRef.current >= LOCALE_RELOAD_MAX_ATTEMPTS) { + if (reloadRequestRef.current !== request) { + return + } + reloadRequestRef.current = null + const currentTarget = reloadTargetRef.current + if ( + !mountedRef.current || + currentTarget === null || + (reloadAttemptsRef.current[currentTarget] ?? 0) >= LOCALE_RELOAD_MAX_ATTEMPTS + ) { return } setRetryPending(true)