mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 08:07:18 +00:00
fix(mobile): bound locale retries per language
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user