Skip to content

fix(i18n): 语言选择写入 localStorage 并在启动时恢复,刷新不再退回 en - #3376

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-5406-locale-persistence
Aug 5, 2026
Merged

fix(i18n): 语言选择写入 localStorage 并在启动时恢复,刷新不再退回 en#3376
yinlianghui merged 1 commit into
mainfrom
claude/issue-5406-locale-persistence

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes objectstack-ai/objectstack#5406

问题

控制台切换语言(头像菜单 → Preferences → Language)只改动内存里的 i18next 实例:packages/i18n/src/provider.tsxpackages/app-shell/src/layout/LocaleSwitcher.tsx 全无持久化写入。任何一次刷新或新开标签页都会重建 provider,UI 整个退回 en —— 非 en 语言只能演示,无法真正使用。前提已在 origin/main 复核成立(i18n 面零 localStorage 命中)。

方案(PM 裁定:localStorage 持久化 + 启动时恢复)

  • 写入点是 provider 的 languageChanged 监听,不是 LocaleSwitcher 的点击回调。 i18next 对「context 的 changeLanguage」和「直接调 i18n.changeLanguage()」都会触发该事件,所以这是唯一收口点 —— 换任何一种写法的切换器都不会漏写偏好。bootstrap 不触发该事件,因此「恢复」不会反过来重写存储。据此 LocaleSwitcher.tsx 本身无需改动(只补了一条端到端测试)。
  • 恢复发生在实例创建时(进 createI18n 的入参),而不是挂载后再 changeLanguage。provider 依据实例语言设置 html 的 lang 属性,createAuthenticatedFetch 又拿它拼 Accept-Language(chore: release packages #1319):晚一步切换会让首屏那批服务端解析的标签用错语言取回,再靠 remount 补救。
  • 优先级:已存选择 → 浏览器语言 → defaultLanguage 显式选择必须压过浏览器探测,否则「在 ja 浏览器上选了中文」的用户每次刷新都会被塞回 ja,偏好对最需要它的人反而失效。
  • 脏值静默回退并清理。 存的语言若不在「内置语言包 + config.resources」内(app 换了 locale 集),回退默认语言并删除该键,一条陈旧记录不会把 UI 锁死在没有翻译的 locale 上。判定用 hasOwnProperty 而非 in,免得 constructor 这类垃圾值蒙混过关。
  • 防御式访问。 SSR 无 window;Safari 隐私模式 / 分区 iframe 下访问 localStorage 直接抛错。读写一律 try/catch 降级 —— 语言偏好不值得让应用崩掉,存不下时本次会话的切换照样生效。

新增公开面(@object-ui/i18n):persistLanguage(默认 true;预览、demo、截图夹具等固定语言场景传 false,既不恢复也不写入)、LOCALE_STORAGE_KEY(objectui-locale,循 objectui-favorites / objectui-recent-items 一族命名)、readStoredLanguage()(自带 i18next 实例的 app 用它沿用同一偏好;这类 app 的 bootstrap 语言仍归自己决定)。

反向验证(方向先定,再跑)

预判:两条肢都是常规「红」方向 —— 新测试钉的是此前根本不存在的行为,不存在 #5018 那种反转。逐条关掉后实测与预判集合完全一致:

  • 关掉恢复一肢:7 failed | 5 passed —— 红的是跨重载恢复、优先级压过浏览器探测、RTL 恢复、config.resources locale、脏值清理(语言回退这半截仍绿,清理那半截红)、垃圾值清理,以及 LocaleSwitcher 的重载断言;写入类断言全绿。
  • 关掉写入一肢:5 failed | 7 passed —— 红的是切换写入、跨重载恢复(无可恢复之物)、直接调实例 API 的写入、readStoredLanguage 回读,以及 LocaleSwitcher;手工塞存储的那批恢复用例全绿。

测试

  • 新增 packages/i18n/src/__tests__/provider-locale-persistence.test.tsx(11 例):切换写入、重载恢复且落在首帧(不是异步补正)、直接调 i18n.changeLanguage 也持久化、显式选择压过浏览器探测(先用无存储的基线钉出探测确实赢过 defaultLanguage)、RTL 方向、config.resources 提供的 locale、脏值/垃圾值回退并清理、persistLanguage={false} 两头都不动、存储被禁时不崩。
  • 新增 packages/i18n/src/__tests__/locale-persistence-ssr.test.ts:刻意用 .test.ts 落进 unit 项目的 node 环境 —— 这里真的没有 window,不靠 stub,SSR 读取必须返回 null 而非抛错。
  • 新增 packages/app-shell/src/layout/__tests__/LocaleSwitcher.persistence.test.tsx:issue 报告的那条路径端到端 —— 点 中文 → 写入 → 重载后 UI 仍是中文。将来若有人把切换器改到绕过 i18n context 的 API 上,这里会红,而不是又悄悄退回 en
  • packages/i18n/src/__tests__/provider.test.tsx:加 beforeEach 清 localStorage。持久化默认开启后,那条 changeLanguage 用例会把 zh 递给它后面的每个用例(包括断言 config 指定 bootstrap 语言的 ar 用例)—— 每条用例都该是一次全新的浏览器。

命令与结果(全部持容器验证锁跑):

  • pnpm exec vitest run --maxWorkers=2 --reporter=verbose 四个相关文件 → Test Files 4 passed (4) / Tests 25 passed (25),输出里逐条列出文件名(本仓 --filter ... test -- --run path 会静默忽略路径过滤,故走根 vitest)。
  • pnpm exec vitest run --maxWorkers=2 packages/i18n19 passed / 171 passed
  • pnpm exec vitest run --maxWorkers=2 packages/app-shell275 passed / 2382 passed | 1 skipped(含 metadata-admin 那条直接切实例语言的 studio-locale 用例)。
  • pnpm exec vitest run --maxWorkers=2 apps/console20 passed / 184 passed
  • pnpm --filter @object-ui/i18n type-checkpnpm --filter @object-ui/app-shell type-check(先 pnpm --workspace-concurrency=2 --filter "@object-ui/app-shell^..." build 备齐依赖 d.ts)→ 均通过。
  • 改动文件 eslint → 0 error(仅测试 mock 的 any 与 provider 早已存在的 fast-refresh / exhaustive-deps 警告)。
  • pnpm changeset:check → fixed 组齐全、无 major 声明。

影响半径

持久化默认开启,意味着所有 I18nProvider 消费方(预览画廊、各 plugin demo)也会跟随同源下用户选过的语言;需要固定语言的表面传 persistLanguage={false} 即可。已按「规则的消费半径」而非「改动的包」清点:全仓仅 3 个测试文件调用过 changeLanguage,其中 2 个经由 provider(已处理/已复跑),另一个在 packages/i18n 内部直接操作实例;app-shell、console 两个项目全量跑绿。

顺带的文档:packages/i18n/README.md 补了一节「Language persistence」(优先级、脏值语义、persistLanguage 与自带实例的用法)。

顺手记下、未在本 PR 修的


Generated by Claude Code

The console language switch only lived in the i18next instance: any reload
or new tab rebuilt the provider and reverted the whole UI to `en`, which
made every non-`en` locale demo-only (objectstack#5406).

I18nProvider now writes every language change to localStorage and boots the
next session in that language. The restore happens at instance creation, not
in a post-mount changeLanguage, so `<html lang>` — and therefore the
Accept-Language header on the first API calls (#1319) — is correct on the
first render. A stored choice outranks browser detection; a stored value the
app no longer offers is ignored and purged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 5, 2026 10:47am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests labels Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.1 KB 350 KB
Entry file index-DuefdZDk.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.47KB 3.09KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 18.38KB 4.49KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 3.65KB 1.42KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.25KB 0.53KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 477.09KB 104.71KB
core (index.js) 2.25KB 0.80KB
create-plugin (index.js) 9.28KB 2.98KB
data-objectstack (index.js) 136.23KB 34.75KB
fields (index.js) 227.16KB 55.71KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 26.14KB 6.07KB
i18n (useSafeTranslation.js) 3.26KB 1.44KB
layout (index.js) 38.53KB 10.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.05KB 1.53KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 44.98KB 12.37KB
plugin-charts (index.js) 60.83KB 17.25KB
plugin-chatbot (index.js) 180.09KB 42.72KB
plugin-dashboard (index.js) 112.01KB 28.86KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 231.11KB 56.98KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 111.54KB 26.97KB
plugin-gantt (index.js) 162.25KB 39.55KB
plugin-grid (index.js) 185.08KB 49.04KB
plugin-kanban (index.js) 47.89KB 13.18KB
plugin-list (index.js) 105.02KB 25.36KB
plugin-map (index.js) 16.81KB 5.24KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.55KB 10.59KB
plugin-timeline (index.js) 25.76KB 7.33KB
plugin-tree (index.js) 8.34KB 2.82KB
plugin-view (index.js) 83.67KB 20.43KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 19.28KB 6.38KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.02KB 0.55KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.46KB 1.21KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 0.20KB 0.18KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.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 5, 2026 10:58
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 5, 2026
Merged via the queue into main with commit 0554e88 Aug 5, 2026
16 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-5406-locale-persistence branch August 5, 2026 10:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

console: language choice is held in memory only — any reload reverts to en, so no locale is usable beyond a demo

2 participants