diff --git a/apps/desktop/src/i18n/context.test.tsx b/apps/desktop/src/i18n/context.test.tsx index 092b252f66..f0b9b13fac 100644 --- a/apps/desktop/src/i18n/context.test.tsx +++ b/apps/desktop/src/i18n/context.test.tsx @@ -310,4 +310,41 @@ describe('I18nProvider', () => { vi.useRealTimers() }) + + it('a late startup read never overrides a language the user picked mid-retry', async () => { + vi.useFakeTimers() + + const getConfig = vi + .fn() + .mockRejectedValueOnce(new Error('backend not ready yet')) + .mockResolvedValue({ display: { language: 'en' } }) + + const configClient: I18nConfigClient = { + getConfig, + saveConfig: vi.fn().mockResolvedValue({ ok: true }) + } + + render( + + + + ) + + await act(async () => {}) + expect(screen.getByTestId('locale').textContent).toBe('en') + + // User picks Japanese while the startup retry is still pending. + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'switch' })) + }) + expect(screen.getByTestId('locale').textContent).toBe('ja') + + // The retry resolves with the stale on-disk value; the explicit pick wins. + await act(async () => { + vi.advanceTimersByTime(3_000) + }) + expect(screen.getByTestId('locale').textContent).toBe('ja') + + vi.useRealTimers() + }) }) diff --git a/apps/desktop/src/i18n/context.tsx b/apps/desktop/src/i18n/context.tsx index 45fa502592..8d49ea6c2b 100644 --- a/apps/desktop/src/i18n/context.tsx +++ b/apps/desktop/src/i18n/context.tsx @@ -99,6 +99,9 @@ export function I18nProvider({ children, configClient = defaultConfigClient, ini const [configLoadError, setConfigLoadError] = useState(null) const [saveError, setSaveError] = useState(null) const localeRef = useRef(locale) + // Set once the user picks a language through setLocale: a startup read that + // resolves (or fails) after that must never overwrite an explicit choice. + const userLocaleRef = useRef(false) // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { @@ -132,12 +135,12 @@ export function I18nProvider({ children, configClient = defaultConfigClient, ini return configClient .getConfig() .then(config => { - if (!cancelled) { + if (!cancelled && !userLocaleRef.current) { setLocaleState(normalizeLocale(getConfigDisplayLanguage(config))) } }) .catch(error => { - if (cancelled) { + if (cancelled || userLocaleRef.current) { return } @@ -173,6 +176,7 @@ export function I18nProvider({ children, configClient = defaultConfigClient, ini async (next: Locale) => { const previousLocale = localeRef.current + userLocaleRef.current = true setSaveError(null) setLocaleState(next)