Skip to content
62 changes: 53 additions & 9 deletions ui/desktop/src/i18n/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { getLocale } from './index';
import { getLocale, loadMessagesWithCatalogLoader } 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>) {
Expand Down Expand Up @@ -61,18 +70,53 @@ describe('getLocale', () => {
});

describe('loadMessages', () => {
it('returns empty object for English locale', async () => {
const { loadMessages } = await import('./index');
const messages = await loadMessages('en');
expect(messages).toEqual({});
it('returns compiled English messages for English locale', async () => {
const loadCatalog = vi.fn().mockResolvedValue(englishMessages);
const messages = await loadMessagesWithCatalogLoader('en', loadCatalog);

expect(messages).toEqual(englishMessages);
expect(loadCatalog).toHaveBeenCalledOnce();
expect(loadCatalog).toHaveBeenCalledWith('en');
});

it('returns empty object for unsupported locale (with warning)', async () => {
it('merges locale messages over English fallback messages', async () => {
const loadCatalog = vi.fn(async (locale: string) =>
locale === 'zh-CN' ? zhCnMessages : englishMessages
);
const messages = await loadMessagesWithCatalogLoader('zh-CN', loadCatalog);

expect(messages.shared).toBe(zhCnMessages.shared);
expect(messages.englishOnly).toBe(englishMessages.englishOnly);
expect(loadCatalog).toHaveBeenCalledWith('en');
expect(loadCatalog).toHaveBeenCalledWith('zh-CN');
});

it('returns English messages for unsupported locale (with warning)', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { loadMessages } = await import('./index');
const messages = await loadMessages('xx');
expect(messages).toEqual({});
const loadCatalog = vi.fn(async (locale: string) => {
if (locale === 'xx') {
throw new Error('missing catalog');
}
return englishMessages;
});
const messages = await loadMessagesWithCatalogLoader('xx', loadCatalog);

expect(messages).toEqual(englishMessages);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No message catalog found'));
warnSpy.mockRestore();
});

it('falls back to default messages when the English catalog is unavailable', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const loadCatalog = vi.fn(async () => {
throw new Error('missing catalog');
});
const messages = await loadMessagesWithCatalogLoader('en', loadCatalog);

expect(messages).toEqual({});
expect(warnSpy).toHaveBeenCalledWith(
'[i18n] No English fallback catalog found; missing messages will use source defaultMessage values.'
);
warnSpy.mockRestore();
});
});
41 changes: 34 additions & 7 deletions ui/desktop/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,24 +90,51 @@ export const currentLocale = resolvedLocale.locale;
/** Base language for loading message catalogs (e.g. "en"). */
export const currentMessageLocale = resolvedLocale.messageLocale;

export async function loadCompiledMessages(locale: string): Promise<Record<string, string>> {
const mod = await import(`./compiled/${locale}.json`);
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>> {
return loadMessagesWithCatalogLoader(locale, loadCompiledMessages);
}

export async function loadMessagesWithCatalogLoader(
locale: string,
loadCatalog: (locale: string) => Promise<Record<string, string>>
): Promise<Record<string, string>> {
let englishMessages: Record<string, string>;

try {
englishMessages = await loadCatalog('en');
} catch {
console.warn(
'[i18n] No English fallback catalog found; missing messages will use source defaultMessage values.'
);
englishMessages = {};
}

if (locale === 'en') {
// English strings live in source code as defaultMessage — no catalog needed.
return {};
return englishMessages;
}

try {
// Dynamic import so compiled translation bundles are code-split.
const mod = await import(`./compiled/${locale}.json`);
return mod.default ?? mod;
const messages = await loadCatalog(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.`
`[i18n] No message catalog found for locale "${locale}"; using fallback messages.`
);
return {};
return englishMessages;
}
}
Loading