Skip to content

feat(console): seed the UI language from the tenant's server-side locale - #4342

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-4035-tenant-locale-seed
Aug 11, 2026
Merged

feat(console): seed the UI language from the tenant's server-side locale#4342
yinlianghui merged 2 commits into
mainfrom
claude/issue-4035-tenant-locale-seed

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#4035

GET /auth/me/localization (ADR-0053) has always been fetched on every console
boot, but its locale only ever fed currency/date formatting. The UI
language was decided entirely client-side, so a tenant configured zh-CN still
handed every new device an English console until each user switched by hand.

This puts the tenant locale into the language precedence chain, implementing
option B of the maintainer's final ruling on objectstack#5419.

The ruling of record

objectstack#5419, final maintainer ruling of 2026-08-06 04:14Z (comment),
which supersedes the two interim comments above it. Quoted verbatim:

最终裁决(2026-08-06,维护者经讨论确认,PM 会话 session_01MN3ao9kJEtD7PNAKwFVp96 转达):B —— 最小做,console-only,含以下设计点,取代此前两条评论的中间状态:

  1. 优先序:本地显式选择 > 租户 locale(/auth/me/localization)> 浏览器语言 > en。租户 locale 是管理员有意配置的信号,排在环境信号(浏览器)之前;不合适的用户手动切一次即持久。
  2. 种子出身(provenance):服务端种子 ⛔ 不得写进显式选择的存储位。种子带 seed 标记缓存,只有用户手动切换才升级为显式选择;stale-while-revalidate —— 缓存种子 boot 即用,后台以本次 fetch 返回值刷新。租户改配置后,无显式选择的设备下次启动自动跟随,不被旧种子钉死。
  3. 闪换处理:无显式选择且无缓存种子(真·首次)时,i18n 初始化与 fetch 做有界赛跑(~500ms 超时,fail-open 到浏览器/en)。⛔ 不阻塞首绘,不改变该 fetch cosmetic / never-fail-closed 的既有契约。
  4. 种子校验:租户 locale 不在实际发运的 locale 集内(如配了 fr 而 console 无法语包)时,落到下一级回退。与 #5418(语言菜单应问应用实际带哪些 locale)同一面,适合同批或前后脚派发。
  5. 范围:零平台新增 —— 不动 sys_user_preference、不加 client 读写 API、无写回端点。A(用户级偏好 + 跨设备双向同步)本轮不立项;B 是 A 的严格子集,未来有业务拉动做 A 时本单逻辑原样保留。

How each ruling point is met

1 — Precedence.resolveBootstrapConfig now consults the tenant tier when
there is no explicit choice, applying the seed with detectBrowserLanguage: false
so it outranks the environment. The chain is: explicit choice (objectui-locale)
then tenant seed (objectui-locale-seed) then browser language then en. A
stored choice that gets purged as unshippable falls to the tenant tier too,
rather than skipping it and landing on the browser.

2 — Seed provenance. The seed lives in its own LOCALE_SEED_STORAGE_KEY
slot and is never written into the explicit-choice slot. Applying a seed at
bootstrap cannot promote it: i18next does not fire languageChanged for the
bootstrap language, and that event is the single choke point where an explicit
choice is persisted, so only a real manual switch promotes. Stale-while-revalidate
is split across the two halves the ruling names — a cached seed applies
synchronously at boot, and LocalizationFetchProvider refreshes the cache from
every successful answer, so a tenant that changes its locale reaches choice-less
devices on their next boot without being pinned by an old seed. A null locale
from an authenticated answer clears the cache rather than leaving the stale value.

3 — Bounded race, no first-paint block. On a true first visit only (no
explicit choice and no cached seed), seedTenantLanguage races the fetch against
a 500ms timeout and fails open to browser/en. It joins the Promise.all the
console already awaits before createRoot().render(), so it runs concurrently
with the runtime-config and auth-preflight round-trips rather than adding a
serial delay, and it short-circuits to a no-op on every later boot. A seed that
arrives after the bound is cached for the next boot rather than re-languaging a
live session. The existing fetch keeps its cosmetic / never-fail-closed /
retried-in-the-background contract untouched: the added line is a cache write.

4 — Seed validation reuses canResolveLanguage. The predicate from #4039 is
the verdict, deliberately not a second "do we ship this locale" check. Two
things are layered on top, both about which question to ask it rather than
second-guessing the answer: a region-qualified tag falls back to its base
language (the platform answers zh-CN, the packs are keyed zh), exact tag
first so a genuine pt-BR pack still wins over pt; and the seed does not get
the dynamic loader's optimistic credit, because that credit exists for a
menu-picked value backed by the provisional self-heal, and a seed has neither.
An unshippable or malformed tenant locale falls through to the next tier.

5 — Zero platform additions. objectui-only. No new endpoint, no client
read/write API, sys_user_preference untouched.

Tests

New: packages/i18n/src/__tests__/provider-tenant-locale-seed.test.tsx (14
cases), apps/console/src/languageSeed.test.ts (8 cases), plus a
tenant language seed cache block in
apps/console/src/LocalizationFetchProvider.test.tsx (3 cases).

  • npx vitest run apps/console/src/languageSeed.test.ts apps/console/src/LocalizationFetchProvider.test.tsx packages/i18n/src/__tests__/ — 42 files, 710 tests passed.
  • pnpm --filter @object-ui/i18n --filter @object-ui/console type-check — green (both tsc commands per package, after building the dependency closure).
  • pnpm --filter @object-ui/i18n --filter @object-ui/console lint — 0 errors.
  • changeset:check, check:i18n-keys, check:i18n-drift, check:control-bytes, lint:coverage, type-check:coverage — all green.

Reverse verification

Five revert experiments, each with its expected direction written down before
running, then reverted:

ExperimentPredictedObserved
Remove the seed tier from the precedence chain5 precedence cases red; the fall-through cases stay green for an empty reasonexactly that, plus 2 more red whose first assertion is the precedence one
Drop the canResolveLanguage validationthe three fall-through cases redred — this is what proves those negative assertions are live rather than vacuous
Point cacheLanguageSeed at the explicit-choice slotprovenance cases red5 red, incl. "cannot overrule a choice the user makes while it is still in flight"
Remove the bound from the first-visit racered by timing out, not by assertion diffboth cases red, timed out at 15s
Remove the revalidate cache writethe two refresh cases red, the "keeps a good seed when the fetch never succeeds" case stays greenexactly that

The second experiment is the load-bearing one: the fall-through cases stay green
when the tier is removed (nothing is produced, so a negative assertion holds
vacuously) and only go red when the validation itself is dropped. Reported in
full rather than collapsed into "before green, after red", because two of the
five do not have that shape.

One honest note on coverage: keeps the seed in a slot of its own asserts only
that the two key constants differ, so no realistic regression in their usage
can turn it red. It is a documentation-grade guard; the provenance contract is
actually pinned by the other cases above.

Provenance of this branch

The first agent on this card was killed by a host restart after committing and
before running any verification. This continuation inherited worktree and commit
fbc46e7ab unchanged, audited it against the five ruling points, merged current
main, and ran the whole verification ladder above for the first time. The
inherited commit was not amended, so the ratchet stays visible in the history.


Generated by Claude Code

…ale (#4035)
`/auth/me/localization` was fetched on every boot but its `locale` only fed
currency/date formatting, so the UI language was decided entirely client-side
and a tenant configured zh-CN handed every new device an English console.
Precedence is now: explicit choice > tenant seed > browser language > en.
The server value is cached in its own `objectui-locale-seed` slot and never
written into the explicit-choice slot, so it cannot masquerade as a preference
the user expressed; only a manual switch promotes a language to a choice.
Implements the maintainer ruling on objectstack#5419 (2026-08-06).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercelBot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 11, 2026 5:52pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)29.6 KB350 KB
Entry fileindex-BVSow77f.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.56KB3.59KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)489.12KB108.41KB
core (index.js)3.04KB1.15KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)150.04KB39.79KB
fields (index.js)228.37KB56.62KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)3.35KB1.38KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)23.12KB7.62KB
i18n (useDisplayLocale.js)2.33KB1.20KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.98KB10.85KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)45.23KB12.45KB
plugin-charts (index.js)62.18KB17.67KB
plugin-chatbot (index.js)180.33KB42.79KB
plugin-dashboard (index.js)121.56KB31.56KB
plugin-designer (index.js)210.91KB42.67KB
plugin-detail (index.js)238.95KB59.76KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)114.58KB27.68KB
plugin-gantt (index.js)164.14KB39.98KB
plugin-grid (index.js)188.00KB49.94KB
plugin-kanban (index.js)48.60KB13.41KB
plugin-list (index.js)110.10KB26.74KB
plugin-map (index.js)18.05KB5.80KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.60KB10.58KB
plugin-timeline (index.js)26.21KB7.52KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)23.71KB7.96KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.23KB0.66KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.05KB1.52KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghui
yinlianghui marked this pull request as ready for review August 11, 2026 18:01
@yinlianghui
yinlianghui added this pull request to the merge queueAug 11, 2026
Merged via the queue into main with commit 78fa331Aug 11, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4035-tenant-locale-seed branch August 11, 2026 18:01
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

console: the tenant's server-side locale (/auth/me/localization) is fetched but never seeds the UI language — first login is always browser-or-en

2 participants

@yinlianghui@claude