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/__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/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..77f26c4d 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 { createContext, useContext, useCallback, useEffect, useSyncExternalStore, type ReactNode } from 'react'; +import { en } from './locales/en'; import { useEditorStore } from '../state/store'; import { brandName } from '../version'; import type { Locale, LocalizedName } from '../core/model/types'; @@ -14,28 +14,68 @@ 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, 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 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 }; + /** 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] ?? baseTables[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. @@ -52,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 new file mode 100644 index 00000000..13cfb2f7 --- /dev/null +++ b/src/i18n/locales/index.ts @@ -0,0 +1,53 @@ +/* + * 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 { 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']; + +/** 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>(); + +/** + * Register `locale`'s interface table. + * + * 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(registerBaseStrings, (err: unknown) => { + pending.delete(locale); + throw err; + }); + pending.set(locale, load); + return load; +} 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/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..3082c062 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,16 @@ printConsoleBanner(); document.documentElement.style.fontFamily = APP_FONT_FAMILY; -createRoot(document.getElementById('root')!).render( +const FIRST_FRAME_WAIT_MS = 150; +const mount = () => createRoot(document.getElementById('root')!).render( , ); +// 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/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..cc4f3b83 100644 --- a/src/ui/shell/windows/Windows.tsx +++ b/src/ui/shell/windows/Windows.tsx @@ -14,6 +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 { useLocaleSwitch } from './use-locale-switch'; import { currentKit } from '../../../kit/context'; import type { TransferCounts } from '../../../kit/operations'; import { newMap, transferMap } from '../../../kit/operations'; @@ -114,7 +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 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); @@ -164,7 +167,7 @@ export function Windows() { motionPref={motionPref} systemCursors={systemCursors} quality3d={quality3d} - onLocaleChange={setLocale} + onLocaleChange={changeLocale} onShowGridChange={setShowGrid} onShowChunksChange={setShowChunkBounds} onMotionPrefChange={setMotionPref} 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 7413a48f..bcba58d4 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -40,3 +40,16 @@ if (typeof HTMLCanvasElement !== 'undefined') { value: () => null, }); } + +// 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: 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'), +]); +registerBaseStrings(translations);