fix(desktop): a late startup locale read never overrides the user's in-session pick

With the startup config read now retried (previous commits, #76158), a retry
can resolve after the user has already chosen a language in Settings and
would repaint the stale on-disk value over it. Mark the explicit pick in a
ref and let every retry outcome (success or exhausted fallback) defer to it.

Idea and guard from #105470 (webtecnica), folded onto the #76158 base.

Co-authored-by: webtecnica <webtecnica@gmail.com>
This commit is contained in:
Teknium
2026-09-09 03:40:34 -07:00
parent 5353fc9b32
commit c2524736d1
2 changed files with 43 additions and 2 deletions
+37
View File
@@ -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(
<I18nProvider configClient={configClient}>
<LanguageProbe target="ja" />
</I18nProvider>
)
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()
})
})
+6 -2
View File
@@ -99,6 +99,9 @@ export function I18nProvider({ children, configClient = defaultConfigClient, ini
const [configLoadError, setConfigLoadError] = useState<Error | null>(null)
const [saveError, setSaveError] = useState<Error | null>(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)