Skip to content

fix(web): prevent locale bootstrap from overriding user selection (#759)#924

Merged
lefarcen merged 1 commit intomainfrom
fix/locale-bootstrap-race-windows
Apr 8, 2026
Merged

fix(web): prevent locale bootstrap from overriding user selection (#759)#924
lefarcen merged 1 commit intomainfrom
fix/locale-bootstrap-race-windows

Conversation

@lefarcen
Copy link
Copy Markdown
Collaborator

@lefarcen lefarcen commented Apr 8, 2026

What

Fix the welcome-screen language switcher being unable to switch from Chinese to English on a fresh Windows install (issue #759).

Why

LocaleProvider (apps/web/src/hooks/use-locale.tsx) runs an async bootstrap on mount that:

  1. Synchronously seeds React state via useState(detectDefault) — on a Windows zh-CN system this returns "zh".
  2. Fires an async GET to the controller's desktop preferences endpoint.
  3. After the GET resolves, calls setLocaleState(...) with either the server-stored locale (if present) or the locally detected default (if not).

On a fresh install the controller has no stored locale yet, so step 3 takes the second branch and unconditionally calls setLocaleState(localCandidate) — i.e. setLocaleState("zh"). If the user clicks English in the welcome screen language switcher before the GET resolves:

  • setLocale("en") updates React state → UI briefly switches to English
  • The in-flight GET resolves
  • bootstrapLocale calls setLocaleState("zh") → React state reverts to Chinese
  • The user perceives "the language switcher does nothing"

The bug is Windows-specific in practice because Windows users overwhelmingly run zh-CN system locale. macOS users on en-US never see it: localCandidate is "en", so the bootstrap override is effectively "en" → "en" and is invisible.

How

Two changes in apps/web/src/hooks/use-locale.tsx:

  1. Track manual interaction. New userInteractedRef is flipped to true inside setLocale before any state update. bootstrapLocale now takes this ref as a parameter and bails out early if the user has already interacted while the GET was in flight.

  2. Don't call setLocaleState in the "server has no stored locale" branch. The useState initializer has already seeded React state to localCandidate synchronously on first render, so calling setLocaleState(localCandidate) here was redundant in the non-racy path and only ever existed to fight the race window. Removing it eliminates the override entirely. We still persist to localStorage and POST to the controller so the credit-guard plugin and other locale-aware code see a consistent value.

The existing "server has a stored value" branch is unchanged: when the controller already has a locale, it continues to win on bootstrap (the typical "sync from server on app open" behavior), gated by the user-interaction check.

Affected areas

  • Web dashboard (React UI)

Checklist

  • pnpm typecheck passes — not run locally (worktree without node_modules); change is type-stable: only adds a useRef<boolean> and a parameter of the same shape ({ current: boolean })
  • pnpm lint passes — not run locally for the same reason
  • pnpm test passes — N/A, no tests cover this path today
  • pnpm generate-types run (if API routes/schemas changed) — N/A
  • No credentials or tokens in code or logs
  • No any types introduced

Notes for reviewers

  • Verification path: fresh Windows 11 install with system locale zh-CN. Launch Nexu for the first time, click "English" on the welcome screen, confirm UI switches and stays in English. Restart the app to confirm the choice is persisted via the controller round-trip.
  • Edge cases I traced through:
    • Existing user with both localStorage and server set: server still wins on bootstrap (unchanged).
    • Existing user, GET fails (network error): no setLocaleState call — same behavior as before for the response === null case.
    • StrictMode double-mount: didBootstrapRef still ensures bootstrap runs once.
    • User clicks AFTER bootstrap completes (server-null branch): setLocale runs normally, no race because bootstrap already returned.
  • This fix is based on code analysis; I was unable to verify on a Windows machine. The PR author will validate on their Windows hardware.

Closes #759

The LocaleProvider runs an async bootstrap on mount that fetches the
desktop preferences from the controller and then calls setLocaleState
with either the server value (when present) or the locally detected
default (when the server has no stored locale yet). On a fresh Windows
zh-CN install the second branch always fires, and if the user clicks
the language switcher before the GET resolves, the bootstrap reverts
their selection back to "zh".

Fix:
- Track manual user interaction via a ref. setLocale flips it to true
  before any state update so the bootstrap can detect that a manual
  selection has happened during its in-flight fetch.
- bootstrapLocale checks the ref after awaiting the GET and bails out
  early if the user has already interacted.
- In the "server has no stored locale" branch, stop calling
  setLocaleState entirely. The useState initializer has already set
  React state to the detected default, so this call was redundant
  even in the non-racy path and only existed to fight the race window.
  We still persist to localStorage and POST to the controller so the
  credit-guard plugin and other locale-aware code see a consistent value.

Closes #759
@sentry
Copy link
Copy Markdown

sentry Bot commented Apr 8, 2026

Codecov Report

❌ Patch coverage is 0% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
apps/web/src/hooks/use-locale.tsx 0.00% 7 Missing ⚠️

📢 Thoughts on this report? Let us know!

@lefarcen lefarcen merged commit 0f25731 into main Apr 8, 2026
15 checks passed
@lefarcen lefarcen mentioned this pull request Apr 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug][P1] Unable to switch language to English on Windows startup page

2 participants