Skip to content
42 changes: 37 additions & 5 deletions ui/desktop/src/i18n/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { getLocale } from './index';

const englishMessages = {
shared: 'English shared message',
englishOnly: 'English fallback message',
};

const zhCnMessages = {
shared: 'Chinese shared message',
};

// Helper to mock window.appConfig for tests
function mockAppConfig(values: Record<string, unknown>) {
(window as unknown as Record<string, unknown>).appConfig = {
Expand Down Expand Up @@ -61,17 +70,40 @@ describe('getLocale', () => {
});

describe('loadMessages', () => {
it('returns empty object for English locale', async () => {
afterEach(() => {
vi.doUnmock('./compiled/en.json');
vi.doUnmock('./compiled/zh-CN.json');
vi.resetModules();
});

async function loadMessagesWithMockedCatalogs() {
vi.resetModules();
vi.doMock('./compiled/en.json', () => ({ default: englishMessages }));
vi.doMock('./compiled/zh-CN.json', () => ({ default: zhCnMessages }));

const { loadMessages } = await import('./index');
return loadMessages;
}

it('returns compiled English messages for English locale', async () => {
const loadMessages = await loadMessagesWithMockedCatalogs();
const messages = await loadMessages('en');
expect(messages).toEqual({});
expect(messages).toEqual(englishMessages);
});

it('returns empty object for unsupported locale (with warning)', async () => {
it('merges locale messages over English fallback messages', async () => {
const loadMessages = await loadMessagesWithMockedCatalogs();
const messages = await loadMessages('zh-CN');

expect(messages.shared).toBe(zhCnMessages.shared);
expect(messages.englishOnly).toBe(englishMessages.englishOnly);
});

it('returns English messages for unsupported locale (with warning)', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { loadMessages } = await import('./index');
const loadMessages = await loadMessagesWithMockedCatalogs();
const messages = await loadMessages('xx');
expect(messages).toEqual({});
expect(messages).toEqual(englishMessages);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No message catalog found'));
warnSpy.mockRestore();
});
Expand Down
23 changes: 17 additions & 6 deletions ui/desktop/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,24 +90,35 @@
/** Base language for loading message catalogs (e.g. "en"). */
export const currentMessageLocale = resolvedLocale.messageLocale;

async function loadCompiledMessages(locale: string): Promise<Record<string, string>> {
const mod = await import(`./compiled/${locale}.json`);

Check failure on line 94 in ui/desktop/src/i18n/index.ts

View workflow job for this annotation

GitHub Actions / Test and Lint Electron Desktop App

src/i18n/i18n.test.ts > loadMessages > returns English messages for unsupported locale (with warning)

Error: Unknown variable dynamic import: ./compiled/en.json ❯ vite/dynamic-import-helper.js:5:106 ❯ vite/dynamic-import-helper.js:4:9 ❯ loadCompiledMessages src/i18n/index.ts:94:48 ❯ loadMessages src/i18n/index.ts:105:33 ❯ src/i18n/i18n.test.ts:105:28

Check failure on line 94 in ui/desktop/src/i18n/index.ts

View workflow job for this annotation

GitHub Actions / Test and Lint Electron Desktop App

src/i18n/i18n.test.ts > loadMessages > merges locale messages over English fallback messages

Error: Unknown variable dynamic import: ./compiled/en.json ❯ vite/dynamic-import-helper.js:5:106 ❯ vite/dynamic-import-helper.js:4:9 ❯ loadCompiledMessages src/i18n/index.ts:94:48 ❯ loadMessages src/i18n/index.ts:105:33 ❯ src/i18n/i18n.test.ts:96:28

Check failure on line 94 in ui/desktop/src/i18n/index.ts

View workflow job for this annotation

GitHub Actions / Test and Lint Electron Desktop App

src/i18n/i18n.test.ts > loadMessages > returns compiled English messages for English locale

Error: Unknown variable dynamic import: ./compiled/en.json ❯ vite/dynamic-import-helper.js:5:106 ❯ vite/dynamic-import-helper.js:4:9 ❯ loadCompiledMessages src/i18n/index.ts:94:48 ❯ loadMessages src/i18n/index.ts:105:33 ❯ src/i18n/i18n.test.ts:90:28
return (mod.default ?? mod) as Record<string, string>;
}

/**
* Load compiled messages for a given locale.
* Returns an empty object for English (react-intl uses defaultMessage as fallback).
* English messages are always loaded as the fallback catalog so regional
* English locales can keep their locale for date/number formatting without
* triggering missing translation warnings for every message.
*/
export async function loadMessages(locale: string): Promise<Record<string, string>> {
const englishMessages = await loadCompiledMessages('en');

if (locale === 'en') {
// English strings live in source code as defaultMessage — no catalog needed.
return {};
return englishMessages;
Comment thread
angiejones marked this conversation as resolved.
Outdated
}

try {
// Dynamic import so compiled translation bundles are code-split.
const mod = await import(`./compiled/${locale}.json`);
return mod.default ?? mod;
const messages = await loadCompiledMessages(locale);
return {
...englishMessages,
...messages,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve source defaultMessage fallback for untranslated keys

Merging englishMessages into every non-English catalog causes untranslated keys to resolve from src/i18n/compiled/en.json instead of React Intl’s in-code defaultMessage, so copy can become stale when defaultMessage text changes but i18n:extract has not been rerun. In this repo, ui/desktop/package.json shows common startup paths (start-gui) run i18n:compile but not i18n:extract, so existing IDs with updated defaultMessage will display outdated English text for partially translated locales (for example zh-CN) without any warning. Fresh evidence: the new merge at these lines applies that compiled-English fallback to all non-English locales, not just en.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i18n:check re-extracts messages and fails if en.json is stale, so CI should catch this. The English fallback is intentional to avoid missing-translation noise

};
} catch {
console.warn(
`[i18n] No message catalog found for locale "${locale}", falling back to English.`
);
return {};
return englishMessages;
}
}
Loading