diff --git a/CHANGELOG.md b/CHANGELOG.md index 16d1c52eb..64475e3a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -192,6 +192,9 @@ notes. Entries describe what users experience, not internal refactors. detector is no longer shipped. - The local history page no longer downloads the editor to show its list (122 KB instead of 148 KB compressed). +- The editor and history pages load only the interface language you use rather + than all seven, so the editor starts on 105 KB instead of 162 KB compressed, + and the history page on 61 KB instead of 121 KB. - Voice controls stay in place while recording or finishing. Explanations appear without moving the draft, and cancelling preserves the text typed before dictation. - **Configured assistants reopen in chat.** Enable a downloaded model or reconnect diff --git a/CLAUDE.md b/CLAUDE.md index 9e2083862..65145db89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,7 +110,10 @@ packages/ # pnpm workspace,供 ran 生态三处站点共享(包 src/i18n/messages/ # 一个语言一个文件:zh-CN / en / ja / ko / de / es / pt, # 七张表都是完整的(编辑器 UI 语言另由 vendor 45 语言包提供)。 # 2026-09-12 从 1263 行的单文件拆开:加一条文案是改七个文件而 - # 不是同一个文件的七处,加一种语言就是加一个文件 + # 不是同一个文件的七处,加一种语言就是加一个文件(外加 i18n.ts + # `loaders` 一行)。启动包里只有 en;当前语言由 i18n.ts 顶层 await + # 按需加载,所以 `t()` 仍是同步的——别改回静态 import,也别改成 + # 入口里显式 init(见 docs/explorations/2026-10-24-active-locale-only.md) converter/ # 格式转换(2026-09-12 从 1130 行的单文件拆开,公开导出面不变) document-converter.ts # X2TConverter 本体:加载、转换、媒体、退出码分类 x2t-loading.ts # canStreamWasm / fetchWasmResponse(含重试)/ x2tInstantiateError @@ -639,7 +642,7 @@ docs/explorations/2026-08-19-ci-e2e-sharding.md。 类型决定**——需要横向比较属性的表格/卡片网格用宽版,竖向找目标的列表用读式 (`/history` 就是后者:按"它是列表页"归到 1152 时实测每行有 236px 死空隙); **正文行宽 45–75 字符**(用 `max-width: 60–72ch` 限制段落,不是限制容器)、 - **内容页有顶栏+页脚,应用页有顶栏无页脚**(`/history`、`/embed-demo` 属后者)、 + **内容页有顶栏 + 页脚,应用页有顶栏无页脚**(`/history`、`/embed-demo` 属后者)、 **顶栏页脚只有一份样式**(`landing.css`,页面复用其类名而不是抄数值)。取值与业界依据见 [docs/design-system.md](docs/design-system.md),`node bin/design-audit.mjs ` 渲染所有页面打印这些数字,`test/unit/design-contract.test.ts` 把宽度与顶栏钉死。 diff --git a/docs/explorations/2026-10-24-active-locale-only.md b/docs/explorations/2026-10-24-active-locale-only.md new file mode 100644 index 000000000..46d9f4f59 --- /dev/null +++ b/docs/explorations/2026-10-24-active-locale-only.md @@ -0,0 +1,131 @@ +# Startup payload, round three: only the active message table + +Follow-up to `2026-10-24-jschardet-and-history-payload.md`. After that round, +the largest item in the startup graph of both `/editor` and `/history` was the +same: every message table in `packages/shared/src/i18n/messages/`. + +## What it cost + +`packages/shared/src/i18n.ts` imported all seven tables statically, so they +shared one chunk (`utils-*.js`, 289 KB) with the rest of `@ranuts/shared` and +`ranuts`. That chunk is in the static graph of both pages. + +Bytes attributed through the sourcemaps (build of PR #292's branch, `39ae686f`): + +| Table | Minified bytes | +| ----- | -------------- | +| zh-CN | 29.4 KB | +| en | 30.4 KB | +| ja | 38.9 KB | +| ko | 35.3 KB | +| de | 34.3 KB | +| es | 33.1 KB | +| pt | 32.9 KB | +| Total | 234 KB | + +Gzipped, that is about 73 KB: 45% of the editor's 162 KB and 60% of the +history page's 121 KB. A visitor reads one of them. + +## Design + +Two options were considered: + +- **A (chosen):** English stays in the bundle, because it is the per-key + fallback for every other table. The other six become chunks loaded through a + literal `import()` per locale, and `i18n.ts` awaits the active one at module + top level. +- **B:** the same split, but each entry calls `await initI18n()` before it + renders. + +The network cost is the same: one extra request after the shared chunk runs, +for anyone not reading English. A is easier to maintain. Under ES module +semantics, nothing that imports `i18n.ts` runs before that top-level await +resolves, so `t()` stays synchronous and correct. None of the 34 files that +call it changed. With B, any `t()` call that runs before `initI18n()` (module +scope, import-time code, a future third entry) returns English and reports no +error, and tests run in English, so they would miss it. + +Details: + +- `loadLocale(lang)` is exported. It is a no-op for English or a loaded table, + and it rejects when the chunk cannot be fetched. +- If loading the active table fails, the module still resolves. The shell then + reads in English. A failed chunk must not keep the editor from starting. +- `setLanguage` has no production callers (only tests). It still switches + synchronously. If the table is not loaded yet, strings read in English until + it arrives, and then `languagechange` fires a second time. +- Each locale's loader is a literal `import('./i18n/messages/')`, so the + tsc-built `dist` and Rollup both see it. `Record, …>` + makes a missing loader a type error. Adding a language is one more line + there, next to the existing `LanguageCode` / `SHELL_LOCALES` edits. +- `@ranuts/shared` is not published to npm (registry 404) and chaxus/ran does + not import it, so the change does not affect anyone outside this repo. +- Offline still works. `bin/offline-assets.mjs` follows `dynamicImports` into + the core precache, and a stamped `sw.js` lists all six locale chunks + (checked on this build). +- `getMessages(lang)` for a table that is not loaded now returns English. + Only one caller depended on the old behavior. A fresh-context review of this + diff found it: `displayError` (`lib/agent-plugin/ui/presentation.ts`) checks + error text saved in restored assistant history against every language's + table. With only the page's table loaded, an error saved in Japanese and + reopened on a German page would show `agentRequestFailed`. The assistant is + not in the startup graph, so `presentation.ts` now awaits all tables at its + own top level (`Promise.allSettled`, so a failed chunk does not block the + panel). That costs about 65 KB gzip, and only when the assistant opens. + `loadLocale` keeps the in-flight promise, so concurrent calls share one load. +- Unit tests that switch languages synchronously preload every table first + (`test/unit/lib/locales.ts`). Tests on a fresh module (`vi.resetModules`) + load only the active table, the same as a real page. + +## Measurements + +The static startup graph, summed per chunk from `.vite/manifest.json` +(gzip level 9): + +| Page | Before (`39ae686f`) | After | +| ---------- | ------------------------ | ------------------------ | +| `/editor` | 537 KB raw / 162 KB gzip | 337 KB raw / 105 KB gzip | +| `/history` | 400 KB raw / 121 KB gzip | 198 KB raw / 61 KB gzip | + +The shared chunk drops from 289 KB to 86 KB. Of the 234 KB of tables, 30 KB +(English) remains. A non-English visitor also fetches one locale chunk of +29–39 KB raw, about 11 KB gzip, so their net saving is about 46 KB gzip on the +editor and about 49 KB gzip on history. + +Rolldown also splits the startup graph into more files (editor 6 → 18, history +7 → 15). Total bytes are consistent with removing the tables: +537 − 234 + 30 ≈ 337. Vite preloads all of them in parallel through +`modulepreload` links. + +Remaining items in the startup graph that belong to the ran ecosystem (the full +MIME table in `ranuts/utils/mimeType.js`, about 6.8 KB gzip on both pages, and +`@alixex/ranview`'s `mocks` chunk) were handed to a separate session for a fix +in chaxus/ran. + +## Tests + +- `test/e2e/editor-startup-payload.spec.ts`: neither page's startup scripts + contain a string from the ja or de table. `/history?locale=de` renders its + heading in German without a reload. The heading is rendered once, so a table + that arrived late would leave it in English. +- `test/unit/i18n.test.ts`: a fresh module opened with `?locale=ja` reads + Japanese as soon as the import resolves. Switching to an unloaded table reads + English and then announces the translation. A failing chunk leaves the page + in English and makes `loadLocale` reject. + +Reverse validation, run against two variants: + +1. With the static imports of `i18n.ts` restored, both startup-payload + cases fail: `ja message table is in …/assets/utils-*.js`. +2. With the loaders kept but the top-level await replaced by a fire-and-forget + `void loadLocale(…)` (what option B looks like when an entry forgets to + await), the unit case "reads Japanese as soon as the module resolves" fails. + The E2E heading case also fails, with `Received: "Saved documents"`. + +3. Without the top-level await in `presentation.ts`, the new + `agent-presentation` case fails. That case is a fresh module on + `?locale=de` with no preload, given a Japanese quota error. + +With the fix restored, all of them pass again: 252 unit files (6805 tests), +`lint:ts`, and 43 E2E cases (startup payload, editor locales, history page, +assistant history and locale and offline assets). diff --git a/lib/agent-plugin/ui/presentation.ts b/lib/agent-plugin/ui/presentation.ts index f0181c921..4fdf5f26b 100644 --- a/lib/agent-plugin/ui/presentation.ts +++ b/lib/agent-plugin/ui/presentation.ts @@ -1,8 +1,12 @@ import { requestErrorKey } from './request-error'; import { isDocumentToolName } from '../document-tool-plan'; -import { t, getLanguage, i18n, SHELL_LOCALES } from '@ranuts/shared/i18n'; +import { t, getLanguage, i18n, loadLocale, SHELL_LOCALES } from '@ranuts/shared/i18n'; import type { AgentEvent, AgentRunResult } from '@ranuts/agent-core/runtime'; +// `displayError` recognises error text saved in any shell language (restored +// history), so the assistant holds every table, not only the page's one. +await Promise.allSettled(SHELL_LOCALES.map(loadLocale)); + /** Count host-verified mutations, never read results or a model's completion prose. */ export function isVerifiedDocumentChange(event: AgentEvent): boolean { if (event.type !== 'tool_result' || event.isError || event.readOnlyHint !== false) return false; diff --git a/packages/shared/src/i18n.ts b/packages/shared/src/i18n.ts index c97e2d126..752d79e4b 100644 --- a/packages/shared/src/i18n.ts +++ b/packages/shared/src/i18n.ts @@ -1,11 +1,5 @@ import type { I18nMessages } from './i18n/types'; -import { zhCN } from './i18n/messages/zh-CN'; import { en } from './i18n/messages/en'; -import { ja } from './i18n/messages/ja'; -import { ko } from './i18n/messages/ko'; -import { de } from './i18n/messages/de'; -import { es } from './i18n/messages/es'; -import { pt } from './i18n/messages/pt'; import { getAllQueryString, getCookie, localStorageGetItem, localStorageSetItem } from 'ranuts/utils'; /** @@ -193,29 +187,48 @@ export function resolveEditorLocale(tag: string | null | undefined): string | nu export type { I18nMessages } from './i18n/types'; /** - * Which languages promise a complete table. + * English is the only table in the startup bundle: it is the per-key fallback + * every other locale needs anyway. The other six are separate chunks, and the + * module awaits the active one at the bottom of this file, so nothing that + * imports `t()` runs before its table is in place. * - * en and zh-CN are the reference pair: every key exists in both, so a new UI - * string is a two-language change rather than an eight-language one, and a - * missing translation elsewhere shows in English rather than as a raw key. - * The rest are complete too today; the type stays Partial so that staying - * complete is a choice the i18n tests enforce rather than one the compiler - * makes impossible to relax. + * en and zh-CN are the reference pair: every key exists in both (both files + * are typed `I18nMessages`), so a new UI string is a two-language change, and + * a missing translation elsewhere shows in English rather than as a raw key. */ -const completeMessages: Record = { - [LanguageCode.ZH]: zhCN, - [LanguageCode.EN]: en, +const loaders: Record, () => Promise>> = { + [LanguageCode.ZH]: () => import('./i18n/messages/zh-CN').then((m) => m.zhCN), + [LanguageCode.JA]: () => import('./i18n/messages/ja').then((m) => m.ja), + [LanguageCode.KO]: () => import('./i18n/messages/ko').then((m) => m.ko), + [LanguageCode.DE]: () => import('./i18n/messages/de').then((m) => m.de), + [LanguageCode.ES]: () => import('./i18n/messages/es').then((m) => m.es), + [LanguageCode.PT]: () => import('./i18n/messages/pt').then((m) => m.pt), }; -const partialMessages: Record, Partial> = { - [LanguageCode.JA]: ja, - [LanguageCode.KO]: ko, - [LanguageCode.DE]: de, - [LanguageCode.ES]: es, - [LanguageCode.PT]: pt, -}; +const messages: Partial>> = { [LanguageCode.EN]: en }; +const pending = new Map>(); -const messages: Record> = { ...completeMessages, ...partialMessages }; +/** + * Fetch a locale's table; a no-op once it is loaded. Rejects when the chunk + * cannot be fetched. Until then `getMessages(lang)` and `t()` read English. + */ +export function loadLocale(lang: Language): Promise { + if (messages[lang] || lang === LanguageCode.EN) return Promise.resolve(); + let load = pending.get(lang); + if (!load) { + load = loaders[lang]().then( + (table) => { + messages[lang] = table; + }, + (error: unknown) => { + pending.delete(lang); + throw error; + }, + ); + pending.set(lang, load); + } + return load; +} /** * Values for a message's `{name}` placeholders. @@ -289,27 +302,37 @@ class I18n { } /** - * Set language + * Set language. Takes effect at once; strings of a table that is not loaded + * yet read in English until it arrives, then `languagechange` fires again. */ setLanguage(lang: Language): void { if ((SHELL_LOCALES as readonly string[]).includes(lang)) { + if (!messages[lang] && !pending.has(lang)) { + void loadLocale(lang).then( + () => this.currentLanguage === lang && this.announce(lang), + () => undefined, + ); + } this.currentLanguage = lang; // An explicit shell choice also decides the editor language (falling back // to English when the vendor ships no locale for it, e.g. fa). this.editorLocale = resolveEditorLocale(lang) || OnlyOfficeLanguageCode.EN; localStorageSetItem('document-lang', lang); - // Trigger language change event - // eslint-disable-next-line n/no-unsupported-features/node-builtins - window.dispatchEvent(new CustomEvent('languagechange', { detail: { language: lang } })); + this.announce(lang); } } + private announce(lang: Language): void { + // eslint-disable-next-line n/no-unsupported-features/node-builtins + window.dispatchEvent(new CustomEvent('languagechange', { detail: { language: lang } })); + } + /** * Get translated text */ t(key: keyof I18nMessages, vars?: TemplateVars): string { // Partial locales fall back to English key by key (see `messages`). - const text = messages[this.currentLanguage]?.[key] || completeMessages[LanguageCode.EN][key] || key; + const text = messages[this.currentLanguage]?.[key] || en[key] || key; return vars ? interpolate(text, vars) : text; } @@ -317,7 +340,7 @@ class I18n { * Get all messages */ getMessages(language: Language = this.currentLanguage): I18nMessages { - return { ...completeMessages[LanguageCode.EN], ...messages[language] }; + return { ...en, ...messages[language] }; } /** @@ -336,6 +359,14 @@ class I18n { // Export singleton export const i18n = new I18n(); +// Hold every importer until the active table is in. A chunk that fails to load +// must not keep the page from starting: the shell then reads in English. +try { + await loadLocale(i18n.getLanguage()); +} catch { + // English is already in place. +} + // Export convenience functions export const t = (key: keyof I18nMessages, vars?: TemplateVars): string => i18n.t(key, vars); export const getLanguage = (): Language => i18n.getLanguage(); diff --git a/test/e2e/editor-startup-payload.spec.ts b/test/e2e/editor-startup-payload.spec.ts index 8122e12ad..78cbf47cb 100644 --- a/test/e2e/editor-startup-payload.spec.ts +++ b/test/e2e/editor-startup-payload.spec.ts @@ -15,6 +15,12 @@ const JSCHARDET = { owner: 'jschardet', probe: 'windows-1255' }; // An error the save pipeline (lib/onlyoffice/save-stream.ts) raises: the // history page lists and reopens records, it never saves through the editor. const EDITOR_STACK = { owner: 'editor save pipeline', probe: 'Save conversion failed' }; +// Two message tables the page does not speak: only English and the active +// locale belong at startup (packages/shared/src/i18n.ts awaits the latter). +const OTHER_LOCALES = [ + { owner: 'ja message table', probe: 'ドキュメントを開く / 編集' }, + { owner: 'de message table', probe: 'Dokument öffnen / bearbeiten' }, +]; /** Vite writes an entry and its whole static import graph into the HTML as the * module script plus modulepreload links: that is the startup payload. */ @@ -37,14 +43,23 @@ async function expectAbsent(page: Page, deferred: Array<{ owner: string; probe: } } -test('the editor startup scripts leave the agent tools, the PWA prompt and jschardet out', async ({ page }) => { +test('the editor startup scripts leave the agent tools, the PWA prompt, jschardet and other locales out', async ({ + page, +}) => { await page.goto('/editor?new=docx'); - await expectAbsent(page, [AGENT_TOOLS, PWA_INSTALL, JSCHARDET]); + await expectAbsent(page, [AGENT_TOOLS, PWA_INSTALL, JSCHARDET, ...OTHER_LOCALES]); }); -test('the history page startup scripts leave the editor stack and jschardet out', async ({ page }) => { +test('the history page startup scripts leave the editor stack, jschardet and other locales out', async ({ page }) => { await page.goto('/history'); - await expectAbsent(page, [EDITOR_STACK, JSCHARDET]); + await expectAbsent(page, [EDITOR_STACK, JSCHARDET, ...OTHER_LOCALES]); +}); + +test('a page opened in another language renders that language without reloading', async ({ page }) => { + await page.goto('/history?locale=de'); + await expect(page.locator('html')).toHaveAttribute('lang', 'de'); + // Rendered once, at startup: a table that arrived late would leave it English. + await expect(page.locator('h1.history-title')).toHaveText('Gespeicherte Dokumente'); }); test('the PWA install element is still defined and mounted after startup', async ({ page }) => { diff --git a/test/unit/agent-panel.test.ts b/test/unit/agent-panel.test.ts index 7547f4380..f1d05db7b 100644 --- a/test/unit/agent-panel.test.ts +++ b/test/unit/agent-panel.test.ts @@ -4,6 +4,9 @@ import { ChatView } from '@ranuts/chat-ui'; import type { EditorApi } from '../../lib/agent-plugin/editor-bridge'; import { afterEach, expect, it, vi } from 'vitest'; import { createAgentPanel, setAgentPanelOpen, setAgentPanelEnabled } from '../../lib/agent-plugin/ui/panel'; +import { loadAllLocales } from './lib/locales'; + +await loadAllLocales(); HTMLElement.prototype.scrollTo = vi.fn(); const editorState = vi.hoisted(() => ({ api: null as null | Partial })); vi.mock('@ranuts/agent-core/llm/local', () => ({ diff --git a/test/unit/agent-presentation.test.ts b/test/unit/agent-presentation.test.ts index e58d0e184..510d4f848 100644 --- a/test/unit/agent-presentation.test.ts +++ b/test/unit/agent-presentation.test.ts @@ -1,4 +1,4 @@ -import { expect, it } from 'vitest'; +import { expect, it, vi } from 'vitest'; import { assistantPresentation, AssistantDisplayStream, @@ -7,6 +7,10 @@ import { } from '../../lib/agent-plugin/ui/presentation'; import { historyToTurns } from '../../lib/agent-plugin/ui/storage'; import { LanguageCode, setLanguage, SHELL_LOCALES, t } from '@ranuts/shared/i18n'; +import { ja } from '../../packages/shared/src/i18n/messages/ja'; +import { loadAllLocales } from './lib/locales'; + +await loadAllLocales(); it.each(SHELL_LOCALES)('restores actionable model guidance saved in %s after a language switch', (language) => { try { @@ -335,3 +339,16 @@ it('retains service categories after runtime error events serialize owned provid expect(displayError('OpenAI request failed: 429 private response')).toBe(t('agentServiceRateLimited')); expect(displayError('a private unrecognized 503 response')).toBe(t('agentRequestFailed')); }); + +it('recognises an error saved in a language this page did not start in', async () => { + window.history.pushState({}, '', '/?locale=de'); + try { + vi.resetModules(); + const { displayError: fresh } = await import('../../lib/agent-plugin/ui/presentation'); + const { t: freshT } = await import('@ranuts/shared/i18n'); + expect(fresh(ja.agentModelQuota ?? '')).toBe(freshT('agentModelQuota')); + expect(freshT('agentModelQuota')).not.toBe(ja.agentModelQuota); + } finally { + window.history.pushState({}, '', '/'); + } +}); diff --git a/test/unit/agent-settings-view.test.ts b/test/unit/agent-settings-view.test.ts index e2f26bd22..b89723b29 100644 --- a/test/unit/agent-settings-view.test.ts +++ b/test/unit/agent-settings-view.test.ts @@ -1,6 +1,9 @@ import { getLanguage, setLanguage, t, LanguageCode } from '@ranuts/shared/i18n'; import { afterEach, expect, it, vi } from 'vitest'; import { createPanelSettingsView } from '../../lib/agent-plugin/ui/settings-view'; +import { loadAllLocales } from './lib/locales'; + +await loadAllLocales(); function setup(selectedSource: string) { const control = () => document.createElement('div'); diff --git a/test/unit/agent-task-entry.test.ts b/test/unit/agent-task-entry.test.ts index 025cb99a3..59bb32af7 100644 --- a/test/unit/agent-task-entry.test.ts +++ b/test/unit/agent-task-entry.test.ts @@ -3,6 +3,9 @@ import { afterEach, expect, it, vi } from 'vitest'; import { createTaskEntry } from '../../lib/agent-plugin/ui/task-entry'; import type { ComposerContext } from '../../lib/agent-plugin/ui/composer-context'; import type { DocumentToolTarget } from '../../lib/agent-plugin/document-tool-action'; +import { loadAllLocales } from './lib/locales'; + +await loadAllLocales(); let context: ComposerContext; let draft = ''; let session = 'one'; diff --git a/test/unit/i18n-locales.test.ts b/test/unit/i18n-locales.test.ts index 3abee5abb..3aaa6715b 100644 --- a/test/unit/i18n-locales.test.ts +++ b/test/unit/i18n-locales.test.ts @@ -12,6 +12,9 @@ import { type I18nMessages, type Language, } from '@ranuts/shared/i18n'; +import { loadAllLocales } from './lib/locales'; + +await loadAllLocales(); /** * Shell locales beyond en/zh (roadmap direction eight, layer 2). All eight diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts index 2b35e84fb..b724f0bfa 100644 --- a/test/unit/i18n.test.ts +++ b/test/unit/i18n.test.ts @@ -8,6 +8,9 @@ import { t, withLocale, } from '@ranuts/shared/i18n'; +import { loadAllLocales } from './lib/locales'; + +await loadAllLocales(); describe('i18n', () => { it('carries an explicit English choice into the editor despite a previous Chinese preference', async () => { @@ -298,3 +301,52 @@ describe('editor UI locale (getOnlyOfficeLang) follows the visitor beyond en/zh' } }); }); + +describe('only the active table is loaded at startup', () => { + afterEach(() => { + window.history.pushState({}, '', '/'); + localStorage.clear(); + vi.doUnmock('../../packages/shared/src/i18n/messages/ja'); + }); + + it('a page opened in Japanese reads Japanese as soon as the module resolves', async () => { + window.history.pushState({}, '', '/?locale=ja'); + vi.resetModules(); + const m = await import('@ranuts/shared/i18n'); + expect(m.t('uploadDocument')).not.toBe('View/Edit Document'); + expect(m.t('uploadDocument')).toBeTruthy(); + }); + + it('switching to a table not loaded yet reads English, then announces the translation', async () => { + vi.resetModules(); + const m = await import('@ranuts/shared/i18n'); + const announced = new Promise((resolve) => { + let calls = 0; + const onChange = () => { + calls += 1; + if (calls === 2) { + window.removeEventListener('languagechange', onChange); + resolve(); + } + }; + window.addEventListener('languagechange', onChange); + }); + m.setLanguage(LanguageCode.DE); + expect(m.getLanguage()).toBe(LanguageCode.DE); + expect(m.t('uploadDocument')).toBe('View/Edit Document'); + await announced; + expect(m.t('uploadDocument')).not.toBe('View/Edit Document'); + }); + + it('a table that cannot be fetched leaves the page starting in English', async () => { + vi.doMock('../../packages/shared/src/i18n/messages/ja', () => { + throw new Error('chunk failed'); + }); + window.history.pushState({}, '', '/?locale=ja'); + vi.resetModules(); + const m = await import('@ranuts/shared/i18n'); + expect(m.getLanguage()).toBe(LanguageCode.JA); + expect(m.t('uploadDocument')).toBe('View/Edit Document'); + await expect(m.loadLocale(LanguageCode.JA)).rejects.toThrow(); + }); +}); diff --git a/test/unit/lib/locales.ts b/test/unit/lib/locales.ts new file mode 100644 index 000000000..96d5d3598 --- /dev/null +++ b/test/unit/lib/locales.ts @@ -0,0 +1,4 @@ +import { loadLocale, SHELL_LOCALES } from '@ranuts/shared/i18n'; + +/** Tests that switch languages synchronously need every table in memory first. */ +export const loadAllLocales = (): Promise => Promise.all(SHELL_LOCALES.map(loadLocale));