From d036fe0c095eea7471600652a9ae535f8ecf8716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E4=B8=8D=E8=AF=AD?= <2364309541@qq.com> Date: Wed, 16 Sep 2026 23:31:11 +0800 Subject: [PATCH 1/2] Load one interface table per locale instead of all seven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seven tables were merged into one chunk that every visitor downloaded before the first frame — 535 KB raw / 155 KB gzip, the largest item on the start-up payload, and every locale's prose rather than theirs. English has to stay eager (`translateFor` falls back to it for any key a locale is missing), so it does; the other six are now their own chunks and arrive with the language that reads them. - `i18n/locales/index.ts`: one static specifier per table. `ensureLocaleStrings(locale)` registers the table as an i18n OVERLAY through the same `registerExtraStrings` the Help Center already uses for its prose, and shares one in-flight import between concurrent callers. A failed fetch is dropped from the memo so a later attempt retries; the caller still sees the rejection. - `i18n/context.tsx` reads the eager table (English alone) and then the overlay, unchanged otherwise. - `main.tsx` renders once the saved language's table is in hand, for the same reason the cursor properties are written there: a frame in the wrong language is a flash. English resolves without a request, and a table that fails to arrive renders anyway rather than leaving the page blank. - `Windows.tsx` fetches before committing a language switch, so the picker and the interface never disagree; a failed fetch keeps the previous language rather than storing one nothing can render. - `i18n/translations.ts` keeps the merged record for tests and scripts and is now off the runtime graph. `__tests__/i18n/eager-tables.test.ts` fails if any module the app loads imports it again, if a second locale joins the eager set, or if a table on disk has no loader. - `SettingsModal` derives its pills from a `Record` of endonyms, so a table that exists without a pill is a compile error rather than a language a reader can be in but not pick. Measured on the production build (entry script + every `rel=modulepreload` + stylesheet, gzip -9): 3,368,078 raw / 1,013,088 gzip → 3,002,313 raw / 910,672 gzip — 102,416 gzip, 10.1%, off the first paint — with no locale chunk preloaded. Verified in the built app: it boots in the saved language with no English beneath it, and switching to Japanese then French through the real picker replaces the tables rather than layering on English (each switch shows exactly that locale's strings, console clean, no chunk errors). Tests: 8763 passed, 0 failures. `vitest.setup.ts` installs the merged record before any test runs, because a test that renders in French should not have to fetch French — it would otherwise read English silently. Not covered here: `zh` is still on the eager bundle, because `legal/providers-list.ts` resolves its disclosure strings through the whole table (`strings[key]`) and the bundler cannot narrow a dynamic key. Splitting that means giving those two lists a narrow per-locale record of their own; left out to keep this change to the loading mechanism. Signed-off-by: 天不语 <2364309541@qq.com> --- src/__tests__/i18n/eager-tables.test.ts | 74 +++++++++++++++++++++++++ src/agent/exec/runner.ts | 7 ++- src/i18n/context.tsx | 21 +++++-- src/i18n/locales/index.ts | 57 +++++++++++++++++++ src/i18n/translations.ts | 11 +++- src/main.tsx | 9 ++- src/ui/chrome/modals/SettingsModal.tsx | 18 +++--- src/ui/shell/windows/Windows.tsx | 16 +++++- vitest.setup.ts | 16 ++++++ 9 files changed, 207 insertions(+), 22 deletions(-) create mode 100644 src/__tests__/i18n/eager-tables.test.ts create mode 100644 src/i18n/locales/index.ts diff --git a/src/__tests__/i18n/eager-tables.test.ts b/src/__tests__/i18n/eager-tables.test.ts new file mode 100644 index 00000000..93bf1142 --- /dev/null +++ b/src/__tests__/i18n/eager-tables.test.ts @@ -0,0 +1,74 @@ +/** + * English is the only interface table on the eager bundle; the other six are their own chunks + * (`i18n/locales/index.ts`). This is what keeps them off it: the merged record in + * `i18n/translations.ts` is for tests and scripts, and the moment a module the app loads imports + * it, all seven tables are back on the start-up payload — 155 KB gzip of the first paint. + */ +import { describe, it, expect } from 'vitest'; +// @ts-ignore - node:fs is untyped here (no @types/node) +import { readFileSync, readdirSync, statSync } from 'node:fs'; +// @ts-ignore - node:path is untyped here (no @types/node) +import { join, resolve, relative } from 'node:path'; +import { LOCALES } from '../../i18n/locales'; + +declare const __dirname: string; + +const SRC = resolve(__dirname, '../..'); +const LOCALES_DIR = join(SRC, 'i18n', 'locales'); + +/** An import of the merged record, whichever relative depth it is written from. */ +const MERGED_IMPORT = /from '(?:[^']*\/)?translations'/; + +function filesUnder(dir: string): string[] { + const out: string[] = []; + const walk = (d: string): void => { + for (const name of readdirSync(d)) { + const full = join(d, name); + if (statSync(full).isDirectory()) walk(full); + else if (/\.tsx?$/.test(name)) out.push(full); + } + }; + walk(dir); + return out; +} + +const toPosix = (file: string): string => file.replace(/\\/g, '/'); + +/** Every module the app can reach: `src` minus the test tree, which legitimately reads the merged + * record, and minus `locales/` itself, whose whole job is to hold the tables. */ +const runtimeFiles = (): string[] => filesUnder(SRC).filter((file) => { + const path = toPosix(file); + return !path.includes('/__tests__/') && !path.includes('/i18n/locales/'); +}); + +describe('eager interface tables', () => { + it('nothing the app loads imports the merged record', () => { + const offenders = runtimeFiles() + .filter((file) => MERGED_IMPORT.test(readFileSync(file, 'utf8'))) + .map((file) => toPosix(relative(SRC, file))); + expect(offenders, 'read the table for the locale in hand, not all seven').toEqual([]); + }); + + it('the eager table set is English alone', () => { + // `translateFor` falls back to English for every key a locale is missing, so it is the one table + // that cannot arrive late. Anything else imported here is a chunk that stopped being a chunk. + const context = readFileSync(join(SRC, 'i18n', 'context.tsx'), 'utf8'); + const eager = [...context.matchAll(/from '\.\/locales\/([a-z]+)'/g)].map((m) => m[1]); + expect(eager).toEqual(['en']); + }); + + it('every table on disk is one the loader can fetch', () => { + // Adding `de.ts` without listing it in LOCALES (settings) and LOADERS (the fetch) would leave a + // language that can be read but never loaded. + const onDisk = readdirSync(LOCALES_DIR) + .filter((name) => /^[a-z]{2}\.ts$/.test(name)) + .map((name) => name.slice(0, -3)) + .sort(); + expect([...LOCALES].sort()).toEqual(onDisk); + const loader = readFileSync(join(LOCALES_DIR, 'index.ts'), 'utf8'); + for (const locale of onDisk) { + if (locale === 'en') continue; + expect(loader, `${locale} has a table but no loader`).toContain(`import('./${locale}')`); + } + }); +}); diff --git a/src/agent/exec/runner.ts b/src/agent/exec/runner.ts index 25b068fd..075e9f0a 100644 --- a/src/agent/exec/runner.ts +++ b/src/agent/exec/runner.ts @@ -36,7 +36,7 @@ import { regionBounds } from '../tools/tools-common'; import { createExecutor, wireSchemas, type DelegateOpts } from './executor'; import { reviewText, ReviewWorkerLease } from '../../io/moderation/text/reviewer'; import { translateFor } from '../../i18n/context'; -import { translations } from '../../i18n/translations'; +import { isLocale } from '../../i18n/locales'; /** Same default `runJob` itself falls back to when `contextWindow` is omitted; kept explicit here * since `budgetTokens` (unlike `contextWindow`) has no built-in fallback. */ @@ -128,8 +128,9 @@ async function orderRefused(text: string): Promise { } function refusalText(uiLocale: string | undefined): string { - const locale = uiLocale !== undefined && uiLocale in translations ? uiLocale as keyof typeof translations : 'en'; - return translateFor(locale, 'agent.refusal.content'); + // An absent or unrecognised tag falls back to English, which is also where `translateFor` lands + // for a key the locale is missing. + return translateFor(uiLocale !== undefined && isLocale(uiLocale) ? uiLocale : 'en', 'agent.refusal.content'); } export function createRunner(cfg: RunnerConfig): { diff --git a/src/i18n/context.tsx b/src/i18n/context.tsx index dca79f2f..9de50a9f 100644 --- a/src/i18n/context.tsx +++ b/src/i18n/context.tsx @@ -1,5 +1,5 @@ import { createContext, useContext, useCallback, useEffect, type ReactNode } from 'react'; -import { translations } from './translations'; +import { en } from './locales/en'; import { useEditorStore } from '../state/store'; import { brandName } from '../version'; import type { Locale, LocalizedName } from '../core/model/types'; @@ -14,9 +14,10 @@ export function localizedName(name: LocalizedName, locale: Locale): string { const I18nContext = createContext((key) => key); /** - * Late-arriving string tables, registered by a lazy chunk for its own keys (the Help Center's - * tables are megabytes of prose nobody pays for until the window opens). An overlay only ever ADDS - * keys: the main tables always win, so a chunk cannot re-word the interface. + * Late-arriving string tables, registered by a lazy chunk for its own keys: the Help Center's + * tables are megabytes of prose nobody pays for until the window opens, and a locale's whole + * interface table arrives this way too (`locales/index.ts`). An overlay only ever ADDS keys: the + * eager table always wins, so a chunk cannot re-word the interface it is displayed in. */ let extraTables: Partial>> = {}; @@ -30,12 +31,20 @@ export function registerExtraStrings(tables: Partial>> = { en }; + /** Resolve a key for a specific locale (falling back to English) + interpolate `{name}` params. * The `{app}` token is always resolved from the central brand name (see version.ts), so no * translation string ever hardcodes the project name. */ export function translateFor(locale: Locale, key: string, params?: Record): string { - let text = translations[locale]?.[key] ?? extraTables[locale]?.[key] - ?? translations['en'][key] ?? extraTables['en']?.[key] ?? key; + let text = eagerTables[locale]?.[key] ?? extraTables[locale]?.[key] + ?? en[key] ?? extraTables['en']?.[key] ?? key; text = text.split('{app}').join(brandName(locale)); if (params) { // split/join, not replace: a param value is literal text, never a replacement pattern. diff --git a/src/i18n/locales/index.ts b/src/i18n/locales/index.ts new file mode 100644 index 00000000..f7118910 --- /dev/null +++ b/src/i18n/locales/index.ts @@ -0,0 +1,57 @@ +/* + * The interface tables, one chunk per locale, registered as an i18n OVERLAY when they arrive — the + * same mechanism the Help Center uses for its prose (`locales/help/index.ts`). + * + * English stays on the eager bundle: `translateFor` falls back to it for every key a locale is + * missing, so it has to be readable before the first render. The other six are only ever read by + * someone reading that language, and merged into one chunk they were the single largest item on the + * start-up payload (535 KB raw / 155 KB gzip, seven tables, all of it modulepreloaded), so each now + * arrives with the language that needs it. `i18n/translations.ts` still holds the merged record for + * tests, scripts and the legal-page generator; nothing on the runtime path imports it. + */ +import type { Locale } from '../../core/model/types'; +import { registerExtraStrings } from '../context'; + +/** Every locale a saved preference may name, in the order the settings list them. */ +export const LOCALES: readonly Locale[] = ['en', 'zh', 'ja', 'ru', 'th', 'id', 'fr']; + +/** True for a string that names a locale we ship tables for (a stored preference, an agent's + * reported UI language, a URL parameter). */ +export function isLocale(value: string): value is Locale { + return (LOCALES as readonly string[]).includes(value); +} + +/** Static specifiers, so the bundler gives each table its own chunk and fetches exactly one. */ +const LOADERS: Record, () => Promise>>> = { + zh: () => import('./zh').then((m) => ({ zh: m.zh })), + ja: () => import('./ja').then((m) => ({ ja: m.ja })), + ru: () => import('./ru').then((m) => ({ ru: m.ru })), + th: () => import('./th').then((m) => ({ th: m.th })), + id: () => import('./id').then((m) => ({ id: m.id })), + fr: () => import('./fr').then((m) => ({ fr: m.fr })), +}; + +/** In-flight or finished loads, so concurrent callers (boot, and a switch before it lands) share + * one import instead of racing two. */ +const pending = new Map>(); + +/** + * Put `locale`'s table where `translateFor` can see it. + * + * Resolves without a request for English, which is already on the bundle. Safe to call repeatedly: + * the second call returns the same promise. A failed fetch is dropped from the memo rather than + * remembered, so a later attempt retries — but the caller still sees the rejection, because a + * locale that did not arrive is a fact the caller may need (boot renders anyway; the switcher + * decides whether to keep the old language). + */ +export function ensureLocaleStrings(locale: Locale): Promise { + if (locale === 'en') return Promise.resolve(); + const running = pending.get(locale); + if (running) return running; + const load = LOADERS[locale]().then(registerExtraStrings, (err: unknown) => { + pending.delete(locale); + throw err; + }); + pending.set(locale, load); + return load; +} diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 59dd1008..9abfa6fc 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -1,3 +1,12 @@ +/* + * Every interface table merged into one record — FOR TESTS AND SCRIPTS ONLY. + * + * The runtime does not import this, and must not: pulling it in puts all seven tables back on the + * eager bundle, which is the 155 KB gzip the start-up payload just shed. The app reads English + * eagerly (`i18n/context.tsx`) and every other locale through `locales/index.ts:ensureLocaleStrings`, + * which registers the table as an i18n overlay when its chunk arrives. + * `__tests__/i18n/eager-tables.test.ts` fails if this module reappears on the runtime graph. + */ import type { Locale } from '../core/model/types'; import { en } from './locales/en'; import { zh } from './locales/zh'; @@ -7,7 +16,7 @@ import { th } from './locales/th'; import { id } from './locales/id'; import { fr } from './locales/fr'; -// The Help Center's tables are deliberately NOT merged here: they are an order of magnitude +// The Help Center's tables are deliberately NOT merged here either: they are an order of magnitude // longer than any other surface's strings, so they live in the help chunk and arrive through // `context.tsx:registerExtraStrings` when that chunk loads (`locales/help/index.ts`). export const translations: Record> = { en, zh, ja, ru, th, id, fr }; diff --git a/src/main.tsx b/src/main.tsx index 83dceee3..d78aa396 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,6 +8,7 @@ import App from './App'; import { printConsoleBanner } from './console-banner'; import { publishCursorPreference } from './ui/design/cursors/cursor-vars'; import { useEditorStore } from './state/store'; +import { ensureLocaleStrings } from './i18n/locales'; import { preloadScene3D } from './canvas/map3d/preload'; // Before the first render: the global `html { cursor: var(…) }` rule falls back to the OS keyword @@ -27,8 +28,14 @@ printConsoleBanner(); document.documentElement.style.fontFamily = APP_FONT_FAMILY; -createRoot(document.getElementById('root')!).render( +// Render once the interface table for the saved language is in hand. Six of the seven tables are +// their own chunk now (see i18n/locales/index.ts), so a non-English session has one small fetch to +// wait for — and waiting is the point: a frame in the wrong language is a flash, the same reason +// the cursor properties are written above. English resolves without a request, and a table that +// fails to arrive renders anyway rather than leaving the page blank. +const mount = () => createRoot(document.getElementById('root')!).render( , ); +void ensureLocaleStrings(useEditorStore.getState().locale).catch(() => {}).then(mount); diff --git a/src/ui/chrome/modals/SettingsModal.tsx b/src/ui/chrome/modals/SettingsModal.tsx index bf9f097c..ac3072cb 100644 --- a/src/ui/chrome/modals/SettingsModal.tsx +++ b/src/ui/chrome/modals/SettingsModal.tsx @@ -442,16 +442,14 @@ function UiScaleSlider({ label }: { label: string }) { // Language pills — native endonyms so each is recognizable in its own script, // all seven visible at once. Order matches the translation source spreadsheet -// (zh first). -const LOCALES: { code: Locale; label: string }[] = [ - { code: 'zh', label: '中文' }, - { code: 'en', label: 'English' }, - { code: 'ja', label: '日本語' }, - { code: 'ru', label: 'Русский' }, - { code: 'th', label: 'ไทย' }, - { code: 'id', label: 'Indonesia' }, - { code: 'fr', label: 'Français' }, -]; +// (zh first). The LABELS are the only thing here that is not derivable: `Record` +// makes a locale whose table exists but whose pill was forgotten a compile error, rather than a +// language a reader can be in but cannot pick. +const LOCALE_LABELS: Record = { + zh: '中文', en: 'English', ja: '日本語', ru: 'Русский', th: 'ไทย', id: 'Indonesia', fr: 'Français', +}; +const LOCALES: { code: Locale; label: string }[] = (['zh', 'en', 'ja', 'ru', 'th', 'id', 'fr'] as const) + .map((code) => ({ code, label: LOCALE_LABELS[code] })); export interface SettingsModalProps { /** Drives the shared `ModalShell` open/close choreography. Defaults to `true` diff --git a/src/ui/shell/windows/Windows.tsx b/src/ui/shell/windows/Windows.tsx index 69fd9d79..59e434f8 100644 --- a/src/ui/shell/windows/Windows.tsx +++ b/src/ui/shell/windows/Windows.tsx @@ -14,6 +14,8 @@ import { Suspense, lazy, useCallback, useEffect, useRef } from 'react'; import type { Arrival, ArrivalLine } from '../../../core/runtime/arrival-bus'; import { announceArrival } from '../../../core/runtime/arrival-bus'; +import { ensureLocaleStrings } from '../../../i18n/locales'; +import type { Locale } from '../../../core/model/types'; import { currentKit } from '../../../kit/context'; import type { TransferCounts } from '../../../kit/operations'; import { newMap, transferMap } from '../../../kit/operations'; @@ -115,6 +117,18 @@ export function Windows() { const locale = useEditorStore((s) => s.locale); const setLocale = useEditorStore((s) => s.setLocale); + // A language whose table is a separate chunk (see i18n/locales/index.ts): fetch it first, then + // commit the preference, so the interface never switches to a language it cannot speak yet. If + // the fetch fails the previous language stays selected — the picker and the interface agree, + // which is worth more here than storing a preference nothing can render. + const changeLocale = useCallback(async (next: Locale) => { + try { + await ensureLocaleStrings(next); + } catch { + return; + } + setLocale(next); + }, [setLocale]); const showGrid = useEditorStore((s) => s.showGrid); const setShowGrid = useEditorStore((s) => s.setShowGrid); const showChunkBounds = useEditorStore((s) => s.showChunkBounds); @@ -164,7 +178,7 @@ export function Windows() { motionPref={motionPref} systemCursors={systemCursors} quality3d={quality3d} - onLocaleChange={setLocale} + onLocaleChange={changeLocale} onShowGridChange={setShowGrid} onShowChunksChange={setShowChunkBounds} onMotionPrefChange={setMotionPref} diff --git a/vitest.setup.ts b/vitest.setup.ts index 7413a48f..7480f363 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -40,3 +40,19 @@ if (typeof HTMLCanvasElement !== 'undefined') { value: () => null, }); } + +// Every locale's interface table, installed before any test runs. The app fetches ONE table at boot +// (`i18n/locales/index.ts`) precisely so the other six stay off the start-up payload, but a test that +// renders in French should not have to fetch anything to see French words — and would silently read +// English instead, which is how this went in. `translations` is the merged record kept for tests and +// scripts (see the note on it), and it goes in through the SAME overlay the runtime uses, so the +// lookup path under test is the one that ships. +// +// Loaded dynamically, and awaited, for two reasons: a static import would be evaluated before the +// storage stub above (ESM imports run first, and the store reads localStorage as it loads), and an +// un-awaited one would let tests start before the tables are in. +const [{ registerExtraStrings }, { translations }] = await Promise.all([ + import('./src/i18n/context'), + import('./src/i18n/translations'), +]); +registerExtraStrings(translations); From d38c29d4d1037042331e0d470f8d53762d4aaf8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E4=B8=8D=E8=AF=AD?= <2364309541@qq.com> Date: Thu, 17 Sep 2026 13:30:43 +0800 Subject: [PATCH 2/2] Fetch a language's table under the reader's control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answer the review of the lazy-locale change. - The two late-arriving resource classes are separate now. A locale's INTERFACE table goes through `registerBaseStrings`; a window's prose (the Help Center) stays on `registerExtraStrings`. Base outranks extra in the lookup, so a help key cannot re-word the interface it is displayed in. - Boot no longer waits indefinitely. It waits for the saved language's table up to a deadline, then paints regardless, and `I18nProvider` subscribes to arrivals so the table reaches what already rendered. Before this, a fetch that never settled left the page blank. - The switch is a hook (`ui/shell/windows/use-locale-switch.ts`) with a ticket, so the LATEST choice wins: an earlier fetch landing late cannot take the interface back to a language already left. A failure keeps the current language, reports it through the toast, and leaves the chunk unremembered, so the next press is the retry. `modal.settings_language_failed` is new in all seven tables. - Tests: an arrival reaches a rendered tree, interface keys outrank prose, a failed chunk is fetched again by the next call, a chunk is fetched once for concurrent callers, and the switch's ordering and failure rules. Signed-off-by: 天不语 <2364309541@qq.com> --- .../i18n/locale-table-loading.test.tsx | 64 +++++++++++++++ .../ui/shell/use-locale-switch.test.ts | 77 +++++++++++++++++++ src/i18n/context.tsx | 66 ++++++++++++---- src/i18n/locales/en.ts | 1 + src/i18n/locales/fr.ts | 1 + src/i18n/locales/id.ts | 1 + src/i18n/locales/index.ts | 34 ++++---- src/i18n/locales/ja.ts | 1 + src/i18n/locales/ru.ts | 1 + src/i18n/locales/th.ts | 1 + src/i18n/locales/zh.ts | 1 + src/main.tsx | 14 ++-- src/ui/shell/windows/Windows.tsx | 19 +---- src/ui/shell/windows/use-locale-switch.ts | 33 ++++++++ vitest.setup.ts | 19 ++--- 15 files changed, 266 insertions(+), 67 deletions(-) create mode 100644 src/__tests__/i18n/locale-table-loading.test.tsx create mode 100644 src/__tests__/ui/shell/use-locale-switch.test.ts create mode 100644 src/ui/shell/windows/use-locale-switch.ts diff --git a/src/__tests__/i18n/locale-table-loading.test.tsx b/src/__tests__/i18n/locale-table-loading.test.tsx new file mode 100644 index 00000000..9cce651d --- /dev/null +++ b/src/__tests__/i18n/locale-table-loading.test.tsx @@ -0,0 +1,64 @@ +/** + * A locale's interface table is its own chunk (`i18n/locales/index.ts`). These cover what happens + * around that arrival: it reaches a tree that already rendered, an interface key outranks a window's + * own prose, a chunk is fetched once however many callers ask, and a chunk that failed is retried by + * the next call rather than remembered as broken. + * + * Every case uses a key no shipped table carries, so the installed tables (vitest.setup.ts) cannot + * answer for the layer under test. + */ +import { describe, expect, it, vi } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import { I18nProvider, registerBaseStrings, registerExtraStrings, translateFor, useT } from '../../i18n/context'; +import { useEditorStore } from '../../state/store'; + +const chunk = vi.hoisted(() => ({ attempts: 0 })); +vi.mock('../../i18n/locales/id', () => { + chunk.attempts += 1; + if (chunk.attempts === 1) throw new Error('chunk failed to load'); + return { id: { 'probe.retried': 'Berjaya selepas cuba semula' } }; +}); + +const { ensureLocaleStrings } = await import('../../i18n/locales'); + +function Probe() { + const t = useT(); + return {t('probe.late')}; +} + +describe('a table that arrives after the first frame', () => { + it('reaches the tree that already rendered with the fallback', () => { + useEditorStore.setState({ locale: 'fr' }); + render(); + expect(screen.getByTestId('probe').textContent).toBe('probe.late'); + act(() => { registerBaseStrings({ fr: { 'probe.late': 'tardif' } }); }); + expect(screen.getByTestId('probe').textContent).toBe('tardif'); + }); +}); + +describe('the two late-arriving layers', () => { + it('keeps the interface wording when a window registers the same key', () => { + registerBaseStrings({ ru: { 'probe.shared': 'интерфейс' } }); + registerExtraStrings({ ru: { 'probe.shared': 'справка' } }); + expect(translateFor('ru', 'probe.shared')).toBe('интерфейс'); + }); +}); + +describe('a chunk that fails to load', () => { + it('is fetched again by the next call instead of being remembered as broken', async () => { + // The mock factory's throw arrives wrapped by the runner, so the failure itself is what is + // asserted here, not its text. + await expect(ensureLocaleStrings('id')).rejects.toThrow(); + expect(chunk.attempts).toBe(1); + + await expect(ensureLocaleStrings('id')).resolves.toBeUndefined(); + expect(chunk.attempts).toBe(2); + expect(translateFor('id', 'probe.retried')).toBe('Berjaya selepas cuba semula'); + }); + + it('is fetched once however many callers ask for it', async () => { + const before = chunk.attempts; + await Promise.all([ensureLocaleStrings('id'), ensureLocaleStrings('id'), ensureLocaleStrings('id')]); + expect(chunk.attempts).toBe(before); + }); +}); diff --git a/src/__tests__/ui/shell/use-locale-switch.test.ts b/src/__tests__/ui/shell/use-locale-switch.test.ts new file mode 100644 index 00000000..32333ae4 --- /dev/null +++ b/src/__tests__/ui/shell/use-locale-switch.test.ts @@ -0,0 +1,77 @@ +/** + * Switching language fetches that language's table before committing the preference. Two rules are + * covered here: the LATEST choice wins even when an earlier fetch lands afterwards, and a failure + * keeps the language the reader is in while saying so. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { useEditorStore } from '../../../state/store'; + +const { ensureLocaleStrings, showToast } = vi.hoisted(() => ({ + ensureLocaleStrings: vi.fn(), + showToast: vi.fn(), +})); +vi.mock('../../../i18n/locales', () => ({ ensureLocaleStrings: (locale: string) => ensureLocaleStrings(locale) })); +vi.mock('../../../core/runtime/toast-bus', () => ({ showToast: (text: string, type: string) => showToast(text, type) })); + +const { useLocaleSwitch } = await import('../../../ui/shell/windows/use-locale-switch'); + +/** A promise this test settles when it chooses, so a fetch can be held open across a later choice. */ +function deferred() { + let resolve!: () => void; + const promise = new Promise((res) => { resolve = res; }); + return { promise, resolve }; +} + +beforeEach(() => { + ensureLocaleStrings.mockReset(); + showToast.mockReset(); + useEditorStore.setState({ locale: 'en' }); +}); + +describe('useLocaleSwitch', () => { + it('commits the language whose table arrived', async () => { + ensureLocaleStrings.mockResolvedValue(undefined); + const { result } = renderHook(() => useLocaleSwitch()); + await act(async () => { await result.current('fr'); }); + expect(useEditorStore.getState().locale).toBe('fr'); + expect(showToast).not.toHaveBeenCalled(); + }); + + it('lets the latest choice win when an earlier fetch lands afterwards', async () => { + const first = deferred(); + ensureLocaleStrings.mockReturnValueOnce(first.promise).mockResolvedValue(undefined); + const { result } = renderHook(() => useLocaleSwitch()); + + const earlier = result.current('ja'); // held open, ticket 1 + await act(async () => { await result.current('fr'); }); + expect(useEditorStore.getState().locale).toBe('fr'); + + // The earlier fetch lands late: it must not take the interface back to a language already left. + first.resolve(); + await act(async () => { await earlier; }); + expect(useEditorStore.getState().locale).toBe('fr'); + }); + + it('keeps the language the reader is in, and says so, when the fetch fails', async () => { + ensureLocaleStrings.mockRejectedValue(new Error('chunk failed')); + const { result } = renderHook(() => useLocaleSwitch()); + await act(async () => { await result.current('ru'); }); + expect(useEditorStore.getState().locale).toBe('en'); + expect(showToast).toHaveBeenCalledTimes(1); + }); + + it('does not report a failure a later choice has already superseded', async () => { + const failing = deferred(); + ensureLocaleStrings.mockReturnValueOnce(failing.promise).mockResolvedValue(undefined); + const { result } = renderHook(() => useLocaleSwitch()); + + const earlier = result.current('ru'); + await act(async () => { await result.current('fr'); }); + + failing.resolve(); + await act(async () => { await earlier; }); + expect(useEditorStore.getState().locale).toBe('fr'); + expect(showToast).not.toHaveBeenCalled(); + }); +}); diff --git a/src/i18n/context.tsx b/src/i18n/context.tsx index 9de50a9f..77f26c4d 100644 --- a/src/i18n/context.tsx +++ b/src/i18n/context.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useCallback, useEffect, type ReactNode } from 'react'; +import { createContext, useContext, useCallback, useEffect, useSyncExternalStore, type ReactNode } from 'react'; import { en } from './locales/en'; import { useEditorStore } from '../state/store'; import { brandName } from '../version'; @@ -14,28 +14,59 @@ export function localizedName(name: LocalizedName, locale: Locale): string { const I18nContext = createContext((key) => key); /** - * Late-arriving string tables, registered by a lazy chunk for its own keys: the Help Center's - * tables are megabytes of prose nobody pays for until the window opens, and a locale's whole - * interface table arrives this way too (`locales/index.ts`). An overlay only ever ADDS keys: the - * eager table always wins, so a chunk cannot re-word the interface it is displayed in. + * Late-arriving string tables, in two layers that are managed separately: + * + * - `baseTables` — a locale's INTERFACE table, one per locale, arriving with the language that reads + * it (`locales/index.ts`). + * - `extraTables` — prose a window registers for its own keys: the Help Center's tables are megabytes + * nobody pays for until the window opens. + * + * Base outranks extra, so a help string that happens to use an interface key cannot re-word the + * interface it is displayed in. Both only ADD keys: the eager table (English) wins over either. */ +let baseTables: Partial>> = {}; let extraTables: Partial>> = {}; -/** Merge a per-locale table set into the overlay. Idempotent per call site by construction: the - * caller registers a module-level constant, and re-merging the same table changes nothing. */ -export function registerExtraStrings(tables: Partial>>): void { - const next: typeof extraTables = { ...extraTables }; +let version = 0; +const stringsListeners = new Set<() => void>(); + +/** Re-render when a table lands: the interface is painted before its locale's table has to be in + * hand, so an arrival has to reach the components that already rendered in the fallback. */ +export function subscribeStrings(listener: () => void): () => void { + stringsListeners.add(listener); + return () => { stringsListeners.delete(listener); }; +} + +export function stringsVersion(): number { + return version; +} + +function merge(store: Partial>>, tables: Partial>>): typeof store { + const next = { ...store }; for (const [locale, table] of Object.entries(tables) as [Locale, Record][]) { next[locale] = { ...next[locale], ...table }; } - extraTables = next; + version += 1; + for (const listener of stringsListeners) listener(); + return next; +} + +/** Merge a locale's interface table. Idempotent per call site: the caller registers a module-level + * constant, and re-merging the same table changes nothing. */ +export function registerBaseStrings(tables: Partial>>): void { + baseTables = merge(baseTables, tables); +} + +/** Merge a window's own tables into the prose overlay. */ +export function registerExtraStrings(tables: Partial>>): void { + extraTables = merge(extraTables, tables); } /** - * The tables that ship on the eager bundle. English alone: it is the fallback every other locale - * leans on, so it has to be readable before the first render. Every other locale arrives through - * `ensureLocaleStrings` (`locales/index.ts`) as an overlay, which is what keeps six tables of - * interface strings off the start-up payload. + * The table that ships on the eager bundle. English alone: it is the fallback every other locale + * leans on, so it has to be readable before the first render. The other six arrive through + * `ensureLocaleStrings` (`locales/index.ts`), which keeps six tables of interface strings off the + * start-up payload. */ const eagerTables: Partial>> = { en }; @@ -43,7 +74,7 @@ const eagerTables: Partial>> = { en }; * The `{app}` token is always resolved from the central brand name (see version.ts), so no * translation string ever hardcodes the project name. */ export function translateFor(locale: Locale, key: string, params?: Record): string { - let text = eagerTables[locale]?.[key] ?? extraTables[locale]?.[key] + let text = eagerTables[locale]?.[key] ?? baseTables[locale]?.[key] ?? extraTables[locale]?.[key] ?? en[key] ?? extraTables['en']?.[key] ?? key; text = text.split('{app}').join(brandName(locale)); if (params) { @@ -61,7 +92,10 @@ export function translate(key: string, params?: Record) export function I18nProvider({ children }: { children: ReactNode }) { const locale = useEditorStore((s) => s.locale); - const t: TFunction = useCallback((key, params) => translateFor(locale, key, params), [locale]); + // A table that lands after the first frame has to reach the components already rendered with the + // fallback, so the provider reads the arrival counter and the translate function is keyed on it. + const revision = useSyncExternalStore(subscribeStrings, stringsVersion, stringsVersion); + const t: TFunction = useCallback((key, params) => translateFor(locale, key, params), [locale, revision]); // The deployment owns the document head; a saved editor locale applies only to the app subtree. useEffect(() => { document.getElementById('root')?.setAttribute('lang', locale === 'zh' ? 'zh-CN' : locale); diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7ee44542..20994276 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -145,6 +145,7 @@ export const en: Record = { 'planet.start_over_hint': 'This clears everything you have built, and cannot be undone.', 'modal.settings_title': 'Settings', 'modal.settings_language': 'Language', + 'modal.settings_language_failed': 'Could not load that language — pick it again to retry.', 'modal.settings_ui_scale': 'UI scale', 'modal.settings_grid': 'Grid lines', 'modal.settings_chunks': 'Chunk bounds', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 6f03fe3f..299e95cb 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -145,6 +145,7 @@ export const fr: Record = { 'planet.start_over_hint': 'Tout ce que vous avez construit ici sera supprimé, et cette action est irréversible.', 'modal.settings_title': 'Paramètres', 'modal.settings_language': 'Langue', + 'modal.settings_language_failed': 'Impossible de charger cette langue — sélectionnez-la à nouveau pour réessayer.', 'modal.settings_ui_scale': 'Échelle de l’interface', 'modal.settings_grid': 'Grille', 'modal.settings_chunks': 'Limites de chunk', diff --git a/src/i18n/locales/id.ts b/src/i18n/locales/id.ts index 54f73319..9bb6020b 100644 --- a/src/i18n/locales/id.ts +++ b/src/i18n/locales/id.ts @@ -145,6 +145,7 @@ export const id: Record = { 'planet.start_over_hint': 'Semua yang Anda bangun di pulau ini akan dihapus dan tidak dapat dibatalkan.', 'modal.settings_title': 'Pengaturan', 'modal.settings_language': 'Bahasa', + 'modal.settings_language_failed': 'Gagal memuat bahasa itu — pilih lagi untuk mencoba ulang.', 'modal.settings_ui_scale': 'Skala UI', 'modal.settings_grid': 'Garis kisi', 'modal.settings_chunks': 'Batas chunk', diff --git a/src/i18n/locales/index.ts b/src/i18n/locales/index.ts index f7118910..13cfb2f7 100644 --- a/src/i18n/locales/index.ts +++ b/src/i18n/locales/index.ts @@ -1,16 +1,13 @@ /* - * The interface tables, one chunk per locale, registered as an i18n OVERLAY when they arrive — the - * same mechanism the Help Center uses for its prose (`locales/help/index.ts`). - * - * English stays on the eager bundle: `translateFor` falls back to it for every key a locale is - * missing, so it has to be readable before the first render. The other six are only ever read by - * someone reading that language, and merged into one chunk they were the single largest item on the - * start-up payload (535 KB raw / 155 KB gzip, seven tables, all of it modulepreloaded), so each now - * arrives with the language that needs it. `i18n/translations.ts` still holds the merged record for - * tests, scripts and the legal-page generator; nothing on the runtime path imports it. + * The interface tables, one chunk per locale, registered when they arrive. English stays on the + * eager bundle: `translateFor` falls back to it for every key a locale is missing, so it has to be + * readable before the first render. The other six are read only by someone reading that language, + * and merged into a single chunk they were the largest item on the start-up payload — 535 KB raw / + * 155 KB gzip, all of it modulepreloaded. `i18n/translations.ts` keeps the merged record for tests, + * scripts and the legal-page generator; nothing on the runtime path imports it. */ import type { Locale } from '../../core/model/types'; -import { registerExtraStrings } from '../context'; +import { registerBaseStrings } from '../context'; /** Every locale a saved preference may name, in the order the settings list them. */ export const LOCALES: readonly Locale[] = ['en', 'zh', 'ja', 'ru', 'th', 'id', 'fr']; @@ -31,24 +28,23 @@ const LOADERS: Record, () => Promise import('./fr').then((m) => ({ fr: m.fr })), }; -/** In-flight or finished loads, so concurrent callers (boot, and a switch before it lands) share - * one import instead of racing two. */ +/** In-flight or finished loads, so concurrent callers (boot, and a switch before it lands) share one + * import instead of racing two. */ const pending = new Map>(); /** - * Put `locale`'s table where `translateFor` can see it. + * Register `locale`'s interface table. * - * Resolves without a request for English, which is already on the bundle. Safe to call repeatedly: - * the second call returns the same promise. A failed fetch is dropped from the memo rather than - * remembered, so a later attempt retries — but the caller still sees the rejection, because a - * locale that did not arrive is a fact the caller may need (boot renders anyway; the switcher - * decides whether to keep the old language). + * Resolves without a request for English. Safe to call repeatedly: the second call returns the same + * promise. A failed fetch is dropped from the memo rather than remembered, so a later attempt + * retries — the caller still sees the rejection, because a language that did not arrive is a fact + * the caller decides on (boot renders anyway; the switcher keeps the old language and offers a retry). */ export function ensureLocaleStrings(locale: Locale): Promise { if (locale === 'en') return Promise.resolve(); const running = pending.get(locale); if (running) return running; - const load = LOADERS[locale]().then(registerExtraStrings, (err: unknown) => { + const load = LOADERS[locale]().then(registerBaseStrings, (err: unknown) => { pending.delete(locale); throw err; }); diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index a866ce72..698071f2 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -145,6 +145,7 @@ export const ja: Record = { 'planet.start_over_hint': 'この島に建てたものはすべて消え、取り消せません。', 'modal.settings_title': '設定', 'modal.settings_language': '言語', + 'modal.settings_language_failed': 'その言語を読み込めませんでした — もう一度選ぶと再試行します。', 'modal.settings_ui_scale': 'UIスケール', 'modal.settings_grid': 'グリッド線', 'modal.settings_chunks': 'チャンク境界', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index e3561703..f4abc2bd 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -145,6 +145,7 @@ export const ru: Record = { 'planet.start_over_hint': 'Всё построенное на этом острове будет удалено, и отменить это нельзя.', 'modal.settings_title': 'Настройки', 'modal.settings_language': 'Язык', + 'modal.settings_language_failed': 'Не удалось загрузить этот язык — выберите его снова, чтобы повторить.', 'modal.settings_ui_scale': 'Масштаб интерфейса', 'modal.settings_grid': 'Сетка', 'modal.settings_chunks': 'Границы чанков', diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts index 356081c3..f85fe889 100644 --- a/src/i18n/locales/th.ts +++ b/src/i18n/locales/th.ts @@ -145,6 +145,7 @@ export const th: Record = { 'planet.start_over_hint': 'ทุกสิ่งที่คุณสร้างไว้บนเกาะนี้จะถูกลบ และย้อนกลับไม่ได้', 'modal.settings_title': 'ตั้งค่า', 'modal.settings_language': 'ภาษา', + 'modal.settings_language_failed': 'โหลดภาษานั้นไม่สำเร็จ — เลือกอีกครั้งเพื่อลองใหม่', 'modal.settings_ui_scale': 'ขนาดอินเทอร์เฟซ', 'modal.settings_grid': 'เส้นตาราง', 'modal.settings_chunks': 'ขอบเขตส่วนแผนที่', diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 07bc34a5..4677ff6f 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -145,6 +145,7 @@ export const zh: Record = { 'planet.start_over_hint': '当前建设将被清空,无法撤销。', 'modal.settings_title': '设置', 'modal.settings_language': '语言', + 'modal.settings_language_failed': '无法加载该语言 —— 再次选择即可重试。', 'modal.settings_ui_scale': '界面缩放', 'modal.settings_grid': '网格线', 'modal.settings_chunks': '区块边界', diff --git a/src/main.tsx b/src/main.tsx index d78aa396..3082c062 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -28,14 +28,16 @@ printConsoleBanner(); document.documentElement.style.fontFamily = APP_FONT_FAMILY; -// Render once the interface table for the saved language is in hand. Six of the seven tables are -// their own chunk now (see i18n/locales/index.ts), so a non-English session has one small fetch to -// wait for — and waiting is the point: a frame in the wrong language is a flash, the same reason -// the cursor properties are written above. English resolves without a request, and a table that -// fails to arrive renders anyway rather than leaving the page blank. +const FIRST_FRAME_WAIT_MS = 150; const mount = () => createRoot(document.getElementById('root')!).render( , ); -void ensureLocaleStrings(useEditorStore.getState().locale).catch(() => {}).then(mount); +// A non-English session has one small fetch here for its interface table. Waiting for it keeps the +// first frame from being a flash of English, but only up to a deadline: a fetch that never settles +// still paints, and the table re-renders the interface when it lands (see i18n/context.tsx). +void Promise.race([ + ensureLocaleStrings(useEditorStore.getState().locale).catch(() => {}), + new Promise((resolve) => { setTimeout(resolve, FIRST_FRAME_WAIT_MS); }), +]).then(mount); diff --git a/src/ui/shell/windows/Windows.tsx b/src/ui/shell/windows/Windows.tsx index 59e434f8..cc4f3b83 100644 --- a/src/ui/shell/windows/Windows.tsx +++ b/src/ui/shell/windows/Windows.tsx @@ -14,8 +14,7 @@ import { Suspense, lazy, useCallback, useEffect, useRef } from 'react'; import type { Arrival, ArrivalLine } from '../../../core/runtime/arrival-bus'; import { announceArrival } from '../../../core/runtime/arrival-bus'; -import { ensureLocaleStrings } from '../../../i18n/locales'; -import type { Locale } from '../../../core/model/types'; +import { useLocaleSwitch } from './use-locale-switch'; import { currentKit } from '../../../kit/context'; import type { TransferCounts } from '../../../kit/operations'; import { newMap, transferMap } from '../../../kit/operations'; @@ -116,19 +115,9 @@ export function Windows() { const helpMounted = helpEver.current; const locale = useEditorStore((s) => s.locale); - const setLocale = useEditorStore((s) => s.setLocale); - // A language whose table is a separate chunk (see i18n/locales/index.ts): fetch it first, then - // commit the preference, so the interface never switches to a language it cannot speak yet. If - // the fetch fails the previous language stays selected — the picker and the interface agree, - // which is worth more here than storing a preference nothing can render. - const changeLocale = useCallback(async (next: Locale) => { - try { - await ensureLocaleStrings(next); - } catch { - return; - } - setLocale(next); - }, [setLocale]); + // A language whose table is a separate chunk is fetched before the preference is committed; see + // `use-locale-switch.ts` for the ordering rule and the failure path. + const changeLocale = useLocaleSwitch(); const showGrid = useEditorStore((s) => s.showGrid); const setShowGrid = useEditorStore((s) => s.setShowGrid); const showChunkBounds = useEditorStore((s) => s.showChunkBounds); diff --git a/src/ui/shell/windows/use-locale-switch.ts b/src/ui/shell/windows/use-locale-switch.ts new file mode 100644 index 00000000..59464398 --- /dev/null +++ b/src/ui/shell/windows/use-locale-switch.ts @@ -0,0 +1,33 @@ +/** + * A language whose table is its own chunk (see i18n/locales/index.ts): fetch it, then commit the + * preference, so the interface never switches to a language it cannot speak yet. + * + * The ticket makes the LATEST choice win. A switch made while an earlier fetch is still in flight is + * the one the reader asked for last, so only it may commit the preference or report a failure — an + * earlier fetch that resolves late must not drag the interface back to a language already left. + * + * A failed fetch keeps the previous language, says so, and leaves the failed chunk unremembered, so + * the picker's next press is the retry. + */ +import { useCallback, useRef } from 'react'; +import type { Locale } from '../../../core/model/types'; +import { translate } from '../../../i18n/context'; +import { ensureLocaleStrings } from '../../../i18n/locales'; +import { showToast } from '../../../core/runtime/toast-bus'; +import { useEditorStore } from '../../../state/store'; + +export function useLocaleSwitch(): (next: Locale) => Promise { + const setLocale = useEditorStore((s) => s.setLocale); + const latest = useRef(0); + + return useCallback(async (next: Locale) => { + const ticket = ++latest.current; + try { + await ensureLocaleStrings(next); + } catch { + if (ticket === latest.current) showToast(translate('modal.settings_language_failed'), 'error'); + return; + } + if (ticket === latest.current) setLocale(next); + }, [setLocale]); +} diff --git a/vitest.setup.ts b/vitest.setup.ts index 7480f363..bcba58d4 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -41,18 +41,15 @@ if (typeof HTMLCanvasElement !== 'undefined') { }); } -// Every locale's interface table, installed before any test runs. The app fetches ONE table at boot -// (`i18n/locales/index.ts`) precisely so the other six stay off the start-up payload, but a test that -// renders in French should not have to fetch anything to see French words — and would silently read -// English instead, which is how this went in. `translations` is the merged record kept for tests and -// scripts (see the note on it), and it goes in through the SAME overlay the runtime uses, so the -// lookup path under test is the one that ships. +// Every locale's interface table, installed before any test runs, so a test that renders in French +// reads French without fetching anything. It goes in through `registerBaseStrings` — the layer a +// locale's own table occupies at runtime — so the lookup path under test is the one that ships. // -// Loaded dynamically, and awaited, for two reasons: a static import would be evaluated before the -// storage stub above (ESM imports run first, and the store reads localStorage as it loads), and an -// un-awaited one would let tests start before the tables are in. -const [{ registerExtraStrings }, { translations }] = await Promise.all([ +// Loaded dynamically, and awaited: a static import would be evaluated before the storage stub above +// (ESM imports run first, and the store reads localStorage as it loads), and an un-awaited one would +// let tests start before the tables are in. +const [{ registerBaseStrings }, { translations }] = await Promise.all([ import('./src/i18n/context'), import('./src/i18n/translations'), ]); -registerExtraStrings(translations); +registerBaseStrings(translations);