Skip to content

fix(ui): ensure i18n locale is loaded before initial render (closes #24803)#46366

Closed
Br1an67 wants to merge 1 commit intoopenclaw:mainfrom
Br1an67:fix/24803
Closed

fix(ui): ensure i18n locale is loaded before initial render (closes #24803)#46366
Br1an67 wants to merge 1 commit intoopenclaw:mainfrom
Br1an67:fix/24803

Conversation

@Br1an67
Copy link
Copy Markdown
Contributor

@Br1an67 Br1an67 commented Mar 14, 2026

Summary

Fixes #24803 — Control UI defaults to English on initialization even when user has configured a different locale.

Change Type

Bug fix (non-breaking)

Change Scope

UI i18n initialization

Security Impact

None

How to Reproduce

  1. Configure non-English locale in Control UI settings
  2. Reload the page
  3. UI initially shows English, then may flash to correct locale

Evidence

  • I18nManager now exposes a ready promise that resolves when the initial locale finishes loading
  • OpenClawApp gates its first render() on locale readiness — no English flash
  • Test updated to use i18n.ready instead of polling with vi.waitFor()

Human Verification

  • Page load with non-English locale shows correct language immediately

Backward Compatibility

Fully backward compatible

Risks

Minimal — changes initialization order only

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 14, 2026

Greptile Summary

This PR addresses the locale-flash bug (#24803) by ensuring the initial locale is fully loaded before the first render. Two targeted changes are made to I18nManager in translate.ts: a public ready: Promise<void> field that resolves when startup locale loading completes, and a localeSeq monotonic counter inside setLocale that implements last-writer-wins semantics — aborting any in-flight locale fetch that has been superseded by a newer call.

Key changes:

  • loadLocale() is now async and its returned promise is exposed as i18n.ready, giving consumers a clean await point before rendering.
  • The localeSeq counter correctly guards both the post-fetch state commit (this.translations[locale] = translation) and the final locale commit (this.locale = locale), preventing stale async resolutions from clobbering a more-recent call.
  • ready always resolves (never rejects) — translation load failures are caught and logged, degrading gracefully to the default English locale.
  • The startup test is correctly updated from polling with vi.waitFor() to await fresh.i18n.ready, which tests the new contract directly and eliminates timing-sensitivity.

Minor observation:

  • On the synchronous path (no translation fetch needed), the if (seq !== this.localeSeq) return guard on line 99 is never false — no await intervened so localeSeq cannot have changed. The check is harmless but effectively dead code in that branch.
  • The this.locale = DEFAULT_LOCALE assignment in the initialLocale === DEFAULT_LOCALE early-return of loadLocale (line 66) is redundant since locale is already initialized to DEFAULT_LOCALE at the class level.

Confidence Score: 3/5

  • The translate.ts changes are correct and well-structured, but the overall fix depends on companion app.ts changes (not visible in this diff) that have known open issues flagged in prior review threads.
  • The i18n changes themselves are solid — the ready promise, seq counter placement, graceful error handling, and test update are all correct. However, the topbar observer breakage (caused by rendering nothing before localeReady is set) and the race condition window between ready resolving and an external setLocale completing are unresolved concerns in app.ts that directly affect whether the overall locale-flash fix is safe to ship.
  • ui/src/ui/app.ts — the companion changes gating render on localeReady have open issues (topbar ResizeObserver never created after the blank-render cycle, and ready may resolve before an external setLocale from the app constructor commits its locale).

Last reviewed commit: 9fb136f

ui/src/ui/app.ts Outdated
Comment on lines +729 to +731
if (!this.localeReady) {
return nothing;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

firstUpdated fires on blank render, breaking topbarObserver

Returning nothing on the first render means firstUpdated() is called while no DOM exists. handleFirstUpdated calls observeTopbar, which queries host.querySelector(".topbar"). Since nothing produces no DOM, it finds no element and returns early — leaving host.topbarObserver as null permanently.

When localeReady becomes true and the component re-renders with real content (including .topbar), LitElement will call updated() but not firstUpdated() again. The topbar ResizeObserver is never created, so the --topbar-height CSS variable is never set.

A straightforward fix is to call observeTopbar the first time localeReady flips to true inside updated(), e.g.:

protected updated(changed: Map<PropertyKey, unknown>) {
  if (changed.has('localeReady') && this.localeReady) {
    observeTopbar(this);
  }
  handleUpdated(this as unknown as Parameters<typeof handleUpdated>[0], changed);
}

Or alternatively, call observeTopbar directly in the Promise.all callback after setting localeReady = true.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/app.ts
Line: 729-731

Comment:
**`firstUpdated` fires on blank render, breaking `topbarObserver`**

Returning `nothing` on the first render means `firstUpdated()` is called while no DOM exists. `handleFirstUpdated` calls `observeTopbar`, which queries `host.querySelector(".topbar")`. Since `nothing` produces no DOM, it finds no element and returns early — leaving `host.topbarObserver` as `null` permanently.

When `localeReady` becomes `true` and the component re-renders with real content (including `.topbar`), LitElement will call `updated()` but **not** `firstUpdated()` again. The topbar `ResizeObserver` is never created, so the `--topbar-height` CSS variable is never set.

A straightforward fix is to call `observeTopbar` the first time `localeReady` flips to `true` inside `updated()`, e.g.:

```ts
protected updated(changed: Map<PropertyKey, unknown>) {
  if (changed.has('localeReady') && this.localeReady) {
    observeTopbar(this);
  }
  handleUpdated(this as unknown as Parameters<typeof handleUpdated>[0], changed);
}
```

Or alternatively, call `observeTopbar` directly in the `Promise.all` callback after setting `localeReady = true`.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 119 to 133
constructor() {
super();
if (isSupportedLocale(this.settings.locale)) {
void i18n.setLocale(this.settings.locale);
}
const settingsLocale = isSupportedLocale(this.settings.locale)
? i18n.setLocale(this.settings.locale)
: Promise.resolve();
Promise.all([i18n.ready, settingsLocale]).then(
() => {
this.localeReady = true;
},
() => {
// Locale load failed; render with the default locale.
this.localeReady = true;
},
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race condition: concurrent setLocale calls may interleave

I18nManager is a module-level singleton instantiated at import time. Its constructor immediately calls loadLocale(), which calls setLocale(initialLocale) asynchronously. When OpenClawApp's constructor then calls i18n.setLocale(this.settings.locale), both calls can be in-flight simultaneously.

setLocale has no mutual-exclusion guard — if two calls overlap for the same locale that needs a translation download, loadLazyLocaleTranslation is called twice (wasteful but not data-corrupting). For different locales, the final value of this.locale depends on which await resolves last, not on which call was initiated last. For example: if settings.locale is "fr" but initialLocale (from navigator) is "zh-CN", the component could end up with either locale after Promise.all resolves.

Consider adding a simple "last writer wins with a generation counter" guard inside setLocale, or at minimum document that i18n.ready only reflects the startup locale — not the override applied in the OpenClawApp constructor.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/app.ts
Line: 119-133

Comment:
**Race condition: concurrent `setLocale` calls may interleave**

`I18nManager` is a module-level singleton instantiated at import time. Its constructor immediately calls `loadLocale()`, which calls `setLocale(initialLocale)` asynchronously. When `OpenClawApp`'s constructor then calls `i18n.setLocale(this.settings.locale)`, both calls can be in-flight simultaneously.

`setLocale` has no mutual-exclusion guard — if two calls overlap for the same locale that needs a translation download, `loadLazyLocaleTranslation` is called twice (wasteful but not data-corrupting). For **different** locales, the final value of `this.locale` depends on which `await` resolves last, not on which call was initiated last. For example: if `settings.locale` is `"fr"` but `initialLocale` (from navigator) is `"zh-CN"`, the component could end up with either locale after `Promise.all` resolves.

Consider adding a simple "last writer wins with a generation counter" guard inside `setLocale`, or at minimum document that `i18n.ready` only reflects the startup locale — not the override applied in the `OpenClawApp` constructor.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4c6ea8789f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

ui/src/ui/app.ts Outdated
Comment on lines +729 to +730
if (!this.localeReady) {
return nothing;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid blanking UI while locale chunks are still loading

Blocking render() on localeReady means the entire Control UI stays empty until i18n.ready and settingsLocale resolve; when a non-English locale chunk load is slow or stalls, users get a blank screen instead of a usable fallback UI. This is a startup availability regression introduced by this change because there is no timeout, loading state, or fallback render path while those promises are pending.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 567528bf04

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines 79 to 82
const seq = ++this.localeSeq;
const needsTranslationLoad = locale !== DEFAULT_LOCALE && !this.translations[locale];
if (this.locale === locale && !needsTranslationLoad) {
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid canceling startup locale load on no-op setLocale calls

setLocale() now increments localeSeq before the early-return no-op check, so a concurrent no-op call (for example the startup call in ui/src/ui/app.ts:121 when the current locale is already en) can invalidate the in-flight loadLocale() request started in the constructor. Because i18n.ready only awaits that initial loadLocale() call, it can resolve after the request is canceled rather than after the active startup locale has actually loaded, which means callers that gate first render on i18n.ready can still render in the wrong language.

Useful? React with 👍 / 👎.

@Br1an67 Br1an67 force-pushed the fix/24803 branch 2 times, most recently from 1f2740b to 9fb136f Compare March 15, 2026 04:33
@Br1an67 Br1an67 closed this Mar 15, 2026
@Br1an67 Br1an67 reopened this Mar 15, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9fb136f8fb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines 79 to 82
const seq = ++this.localeSeq;
const needsTranslationLoad = locale !== DEFAULT_LOCALE && !this.translations[locale];
if (this.locale === locale && !needsTranslationLoad) {
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move sequence bump after no-op locale short-circuit

Incrementing localeSeq before the no-op return allows a no-op setLocale() call to cancel an in-flight locale load that should still complete. In this commit, if startup loadLocale() begins loading a saved non-English locale and a concurrent setLocale("en") returns early because the current locale is already English, the sequence mismatch aborts the startup load and leaves i18n.ready resolved with the wrong locale; I verified this by reproducing openclaw.i18n.locale=zh-CN followed by immediate setLocale("en"), which ends with getLocale() still at en.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0f56dca0df

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +88 to +90
if (seq !== this.localeSeq) {
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep i18n.ready pending until winning locale load finishes

i18n.ready now awaits loadLocale(), but the stale-request guard can make that startup call return early when another setLocale() runs first, so ready can resolve before the active locale request completes. Fresh evidence: with openclaw.i18n.locale=zh-CN and an immediate setLocale("pt-BR"), await i18n.ready returns while getLocale() is still en. Any caller that gates first render on ready can still render in the wrong language.

Useful? React with 👍 / 👎.

@openclaw-barnacle openclaw-barnacle bot added docs Improvements or additions to documentation size: S and removed size: XS labels Mar 15, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f1e204ca44

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

id: string,
factory: ContextEngineFactory,
): ContextEngineRegistrationResult {
return registerContextEngineForOwner(id, factory, PUBLIC_CONTEXT_ENGINE_OWNER);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep core ownership when registering legacy context engine

registerContextEngine() now hard-codes PUBLIC_CONTEXT_ENGINE_OWNER, but registerContextEngineForOwner() rejects non-core owners for the default context-engine id (legacy). The built-in startup path still registers legacy through registerContextEngine("legacy", ...), so that call now returns { ok: false } and leaves no default engine installed; subsequent ensureContextEnginesInitialized() + resolveContextEngine() flows can fail with Context engine "legacy" is not registered.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2545533e14

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

id: string,
factory: ContextEngineFactory,
): ContextEngineRegistrationResult {
return registerContextEngineForOwner(id, factory, PUBLIC_CONTEXT_ENGINE_OWNER);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Register core default engine through privileged owner

registerContextEngine() now always uses the unprivileged public-sdk owner, but registerContextEngineForOwner() rejects that owner for the default context-engine slot id (legacy). The built-in bootstrap still calls registerContextEngine("legacy", ...) via registerLegacyContextEngine(), so startup silently fails to register the default engine and later resolveContextEngine() can throw Context engine "legacy" is not registered even in core-only setups.

Useful? React with 👍 / 👎.

@Br1an67
Copy link
Copy Markdown
Contributor Author

Br1an67 commented Mar 17, 2026

Closing to manage active PR count. Will reopen when slot is available.

@Br1an67 Br1an67 closed this Mar 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app: web-ui App: web-ui docs Improvements or additions to documentation size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Control UI i18n: non-English locale not loaded on initialization + incomplete translation coverage

1 participant