Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/__tests__/i18n/eager-tables.test.ts
Original file line number Diff line number Diff line change
@@ -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}')`);
}
});
});
64 changes: 64 additions & 0 deletions src/__tests__/i18n/locale-table-loading.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <span data-testid="probe">{t('probe.late')}</span>;
}

describe('a table that arrives after the first frame', () => {
it('reaches the tree that already rendered with the fallback', () => {
useEditorStore.setState({ locale: 'fr' });
render(<I18nProvider><Probe /></I18nProvider>);
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);
});
});
77 changes: 77 additions & 0 deletions src/__tests__/ui/shell/use-locale-switch.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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();
});
});
7 changes: 4 additions & 3 deletions src/agent/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -128,8 +128,9 @@ async function orderRefused(text: string): Promise<boolean> {
}

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): {
Expand Down
69 changes: 56 additions & 13 deletions src/i18n/context.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,28 +14,68 @@ export function localizedName(name: LocalizedName, locale: Locale): string {
const I18nContext = createContext<TFunction>((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<Record<Locale, Record<string, string>>> = {};
let extraTables: Partial<Record<Locale, Record<string, string>>> = {};

/** 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<Record<Locale, Record<string, string>>>): 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<Record<Locale, Record<string, string>>>, tables: Partial<Record<Locale, Record<string, string>>>): typeof store {
const next = { ...store };
for (const [locale, table] of Object.entries(tables) as [Locale, Record<string, string>][]) {
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<Record<Locale, Record<string, string>>>): void {
baseTables = merge(baseTables, tables);
}

/** Merge a window's own tables into the prose overlay. */
export function registerExtraStrings(tables: Partial<Record<Locale, Record<string, string>>>): 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<Record<Locale, Record<string, string>>> = { 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, string | number>): 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.
Expand All @@ -52,7 +92,10 @@ export function translate(key: string, params?: Record<string, string | number>)

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);
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export const en: Record<string, string> = {
'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',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export const fr: Record<string, string> = {
'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',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export const id: Record<string, string> = {
'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',
Expand Down
Loading