fix(mobile): bound locale retries per language

This commit is contained in:
Brennan Benson
2026-08-02 18:51:25 -07:00
parent 2e13dbd149
commit 66bbf07d0a
2 changed files with 181 additions and 12 deletions
@@ -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<void> {
localeState.current = [{ languageTag }]
await act(async () => {
renderer?.update(createElement(Harness))
})
}
async function advanceRetryTimer(): Promise<void> {
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<void>((_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<void>((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<void>((_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(
+21 -12
View File
@@ -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<LocaleReloadRequest | null>(null)
const reloadAttemptsRef = useRef<Partial<Record<MobileUiLocale, number>>>({})
const reloadTargetRef = useRef<MobileUiLocale | null>(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)