Skip to content
Merged
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
69 changes: 69 additions & 0 deletions .changeset/i18n-locales-supported-locales.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
"@objectstack/spec": minor
"@objectstack/core": minor
"@objectstack/runtime": minor
"@objectstack/service-i18n": minor
---

fix(i18n): `GET /i18n/locales` reports the locales the app declared, not every locale a plugin happened to load (#7679)

`GET /api/v1/i18n/locales` answered with four locale descriptors — `en`,
`zh-CN`, `ja-JP`, `es-ES` — on the showcase app, whose artifact declares
`i18n.supportedLocales: ['en', 'zh-CN']`. The envelope was correct (#3636); the
**set** was a superset.

Nothing was wrong with what had been *loaded*. Every platform plugin
(`platform-objects`, `service-settings`, `service-storage`, `service-messaging`,
`service-realtime`, `plugin-security`, `plugin-sharing`, `plugin-webhooks`)
ships an `en/zh-CN/ja-JP/es-ES` bundle and pushes it at `kernel:ready`, which is
what a platform should do. What was wrong is that the **loaded** set was
reported as the **offered** set — two different facts owned by two different
parties. So a locale picker built from this route, including the platform's own
Settings > Localization select, offered `ja-JP` and `es-ES`: locales in which
only `sys_*` objects are translated, guaranteeing a mixed-language session for
everything the app itself owns.

**What changed.** `II18nService` gains an optional
`setSupportedLocales(locales)`. `AppPlugin.loadTranslations` threads the
artifact's `i18n.supportedLocales` into it exactly the way it already threads
`defaultLocale`, and both providers of the `i18n` slot — `createMemoryI18n` in
`@objectstack/core` and `FileI18nAdapter` in `@objectstack/service-i18n` —
narrow what `getLocales()` reports to that declaration. The runtime app-plugin
layer is the only place this can originate: `getLocales()` sees what is loaded,
and the app's declaration is not visible below it.

The narrowing is applied as a filter at **read** time, never as a prune of what
is stored, because the platform bundles arrive *after* the app plugin has run.

**Only the reported set narrows.** Bundles stay loaded and stay servable:
`GET /i18n/translations/ja-JP` still answers on a stack that no longer
advertises `ja-JP`, and `t()` still resolves it. Unloading those bundles buys
nothing — `sys_*` translations for an unadvertised locale cost nothing sitting
in the map.

Two questions the fix had to settle, both behaviour in their own right:

- **An app that declares no `supportedLocales` is not narrowed.** Absent means
"no narrowing", and it keeps reporting every loaded locale — the behaviour it
has today. Every app written before this change declared nothing, so
narrowing an undeclared app to zero (or to its default alone) would have
emptied the picker on every stack whose author never opted in. An
`i18n` block carrying only a `defaultLocale`, and a `supportedLocales: []`
that declares no usable code, are both read the same way.
- **A declared locale with no bundle behind it is reported, not dropped.** If an
app declares a locale the platform plugins never shipped, it appears in the
response as declared-but-unserved rather than being silently intersected away.
The declaration is the app's statement of intent and the client is entitled to
see it; a quietly shortened list hides the authoring gap from both ends.
Reporting the declaration is also the only answer that does not depend on how
many bundles had loaded by the time the route was called. Reads for such a
locale degrade to the default/fallback exactly as a half-translated bundle's
missing keys already do.

Reported locales now follow the **declared order** rather than the insertion
order of whichever plugin loaded first, so a picker renders the ordering the app
author wrote.

`setSupportedLocales` is optional on the contract, like `setDefaultLocale`: a
third-party `II18nService` that does not implement it keeps its current
behaviour instead of failing to boot.
111 changes: 111 additions & 0 deletions packages/core/src/fallbacks/fallbacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,114 @@ describe('createMemoryI18n locale fallback', () => {
expect(i18n.t('hello', 'ja')).toBe('Hello');
});
});

describe('createMemoryI18n supportedLocales narrowing (#7679)', () => {
// `GET /i18n/locales` advertised four locales on an app declaring two.
// Nothing was wrong with what was LOADED — every platform plugin ships an
// `en/zh-CN/ja-JP/es-ES` bundle and pushes it at `kernel:ready`, which is
// correct. What was wrong is that the loaded set was REPORTED as the set
// the app offers, so a picker built from the route handed users locales in
// which only `sys_*` metadata is translated.

/** The four-locale platform reality this fix has to narrow. */
function loadedFourLocales() {
const i18n = createMemoryI18n();
i18n.loadTranslations('en', { objects: { sys_user: { label: 'User' } } });
i18n.loadTranslations('zh-CN', { objects: { sys_user: { label: '用户' } } });
i18n.loadTranslations('ja-JP', { objects: { sys_user: { label: 'ユーザー' } } });
i18n.loadTranslations('es-ES', { objects: { sys_user: { label: 'Usuario' } } });
return i18n;
}

it('reports only the declared locales — the #7679 repro', () => {
const i18n = loadedFourLocales();
expect(i18n.getLocales().sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']);

i18n.setSupportedLocales(['en', 'zh-CN']);
expect(i18n.getLocales()).toEqual(['en', 'zh-CN']);
});

it('narrows what is REPORTED, not what is LOADED — undeclared locales stay servable', () => {
// Explicitly out of scope for #7679, and pinned so nobody "completes"
// the fix by unloading the bundles. `sys_*` translations for a locale
// the app does not advertise cost nothing sitting in the map, and
// anything that already asked for them by code keeps working.
const i18n = loadedFourLocales();
i18n.setSupportedLocales(['en', 'zh-CN']);

expect(i18n.getLocales()).not.toContain('ja-JP');
expect(i18n.getTranslations('ja-JP')).toEqual({ objects: { sys_user: { label: 'ユーザー' } } });
expect(i18n.t('objects.sys_user.label', 'ja-JP')).toBe('ユーザー');
});

it('applies at READ time, so bundles loaded AFTER the declaration are narrowed too', () => {
// The ordering that makes a one-shot prune wrong: `AppPlugin` declares
// the set during its own setup, and the platform plugins push their
// bundles later, at `kernel:ready`. A prune would narrow only whatever
// had loaded first and the rest would grow straight back.
const i18n = createMemoryI18n();
i18n.setSupportedLocales(['en', 'zh-CN']);
i18n.loadTranslations('en', { a: 'a' });
i18n.loadTranslations('ja-JP', { a: 'あ' });

expect(i18n.getLocales()).toEqual(['en', 'zh-CN']);
});

it('DECISION 1 — an app that declares nothing reports every loaded locale', () => {
const i18n = loadedFourLocales();
expect(i18n.getLocales().sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']);

// Same answer for an explicit clear and for a declaration carrying no
// usable code: absent is "no narrowing", never "narrow to nothing".
i18n.setSupportedLocales(undefined);
expect(i18n.getLocales().sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']);
i18n.setSupportedLocales([]);
expect(i18n.getLocales().sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']);
});

it('DECISION 2 — a declared locale with no bundle is REPORTED, not dropped', () => {
// Declared-but-unserved. The declaration is the app's statement of
// intent and the client is entitled to see it; a silently shortened
// list leaves the authoring gap invisible on both sides. Reads degrade
// to the default locale exactly the way a half-translated bundle does.
const i18n = loadedFourLocales();
i18n.setSupportedLocales(['en', 'zh-CN', 'fr-FR']);

expect(i18n.getLocales()).toEqual(['en', 'zh-CN', 'fr-FR']);
expect(i18n.getTranslations('fr-FR')).toEqual({});
expect(i18n.t('objects.sys_user.label', 'fr-FR')).toBe('User');
});

it('a declaration can be replaced, and clearing restores the loaded set', () => {
const i18n = loadedFourLocales();
i18n.setSupportedLocales(['en']);
expect(i18n.getLocales()).toEqual(['en']);
i18n.setSupportedLocales(['zh-CN', 'en']);
expect(i18n.getLocales()).toEqual(['zh-CN', 'en']);
i18n.setSupportedLocales(undefined);
expect(i18n.getLocales().sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']);
});

it('the caller cannot mutate the stored declaration through the array it passed or got back', () => {
const i18n = loadedFourLocales();
const declared = ['en', 'zh-CN'];
i18n.setSupportedLocales(declared);
declared.push('ja-JP');
expect(i18n.getLocales()).toEqual(['en', 'zh-CN']);

i18n.getLocales().push('es-ES');
expect(i18n.getLocales()).toEqual(['en', 'zh-CN']);
});

it('narrows the authored overlay too — authored-only locales are not a bypass', () => {
// `getLocales()` unions the static and authored maps, so a locale that
// exists only as runtime-authored `translation` metadata (#2591) would
// otherwise slip past the declaration.
const i18n = loadedFourLocales();
i18n.replaceAuthoredTranslations({ 'ko-KR': { objects: { sys_user: { label: '사용자' } } } });
expect(i18n.getLocales()).toContain('ko-KR');

i18n.setSupportedLocales(['en', 'zh-CN']);
expect(i18n.getLocales()).toEqual(['en', 'zh-CN']);
});
});
30 changes: 30 additions & 0 deletions packages/core/src/fallbacks/memory-i18n.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { normalizeSupportedLocales } from '@objectstack/spec/system';

/**
* Recursively merge `source` into `target`. Nested plain objects are merged
* rather than replaced, so multiple plugins can each contribute their own
Expand Down Expand Up @@ -84,6 +86,13 @@ export function createMemoryI18n() {
// while authored values win over static bundle values on read.
const authored = new Map<string, Record<string, unknown>>();
let defaultLocale = 'en';
// [#7679] The app's DECLARED `i18n.supportedLocales`, injected by
// `AppPlugin.loadTranslations` the same way `defaultLocale` is. `undefined`
// means the app declared nothing, which must keep reporting every loaded
// locale. Held as a read-time filter, never as a prune of `translations`:
// platform plugins push their bundles at `kernel:ready`, after the app
// plugin has run, so anything pruned once would grow back.
let supportedLocales: string[] | undefined;

/**
* Resolve a dot-notation key from a nested object.
Expand Down Expand Up @@ -170,10 +179,31 @@ export function createMemoryI18n() {
}
},

/**
* Report the locales this stack offers.
*
* [#7679] When the app declared `i18n.supportedLocales`, that declaration
* IS the answer — in declared order, and including a declared locale no
* bundle was ever loaded for (declared-but-unserved). Reporting the
* declaration rather than an intersection is what gives a client the
* signal that the locale it is being offered has nothing behind it yet;
* quietly dropping it would leave the gap invisible on both sides. It is
* also the only answer that does not depend on how much had loaded by the
* time this was called.
*
* With nothing declared, the loaded set — the behaviour every app that
* never opted in already has.
*/
getLocales(): string[] {
if (supportedLocales) return [...supportedLocales];
return [...new Set([...translations.keys(), ...authored.keys()])];
},

/** @see II18nService.setSupportedLocales — [#7679] */
setSupportedLocales(locales: readonly string[] | undefined): void {
supportedLocales = normalizeSupportedLocales(locales);
},

getDefaultLocale(): string {
return defaultLocale;
},
Expand Down
81 changes: 81 additions & 0 deletions packages/runtime/src/app-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ describe('AppPlugin', () => {
mockI18n = {
loadTranslations: vi.fn(),
setDefaultLocale: vi.fn(),
setSupportedLocales: vi.fn(),
getLocales: vi.fn().mockReturnValue([]),
getDefaultLocale: vi.fn().mockReturnValue('en'),
};
Expand Down Expand Up @@ -184,6 +185,86 @@ describe('AppPlugin', () => {
expect(mockI18n.setDefaultLocale).toHaveBeenCalledWith('zh-CN');
});

// ── #7679: `supportedLocales` is threaded like `defaultLocale` ──────
// This layer is the only one that can see the app's declaration, so
// these assertions are about the HANDOFF; what the provider then does
// with it is pinned in core / service-i18n, and the two ends are wired
// together in `i18n-supported-locales.test.ts`.

it('should thread supportedLocales from i18n config to the i18n service', async () => {
const bundle = {
id: 'com.test.supported',
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
translations: [{ en: { messages: { hello: 'Hello' } } }],
};
const plugin = new AppPlugin(bundle);
await plugin.start!(mockContext);

expect(mockI18n.setSupportedLocales).toHaveBeenCalledWith(['en', 'zh-CN']);
});

it('should thread supportedLocales even when the app ships no bundles of its own', async () => {
// The showcase's shape: the app declares which locales it offers,
// while the translations arrive from the platform plugins. An
// early return on "no bundles" here would leave exactly the app
// this issue was filed against unnarrowed.
const bundle = {
id: 'com.test.declared-only',
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
};
const plugin = new AppPlugin(bundle);
await plugin.start!(mockContext);

expect(mockI18n.setSupportedLocales).toHaveBeenCalledWith(['en', 'zh-CN']);
expect(mockI18n.loadTranslations).not.toHaveBeenCalled();
});

it('should NOT touch supportedLocales when the app declares none', async () => {
// Not merely "does not narrow" — does not CALL. Several AppPlugins
// share one kernel (the config apps are AppPlugins too), so an app
// with no `i18n` block clearing the declaration would undo the
// narrowing a sibling app had just set.
const bundle = {
id: 'com.test.undeclared',
i18n: { defaultLocale: 'zh-CN' },
translations: [{ en: { messages: { hello: 'Hello' } } }],
};
const plugin = new AppPlugin(bundle);
await plugin.start!(mockContext);

expect(mockI18n.setSupportedLocales).not.toHaveBeenCalled();
expect(mockI18n.setDefaultLocale).toHaveBeenCalledWith('zh-CN');
});

it('should treat an empty supportedLocales declaration as no declaration', async () => {
const bundle = {
id: 'com.test.emptylocales',
i18n: { defaultLocale: 'en', supportedLocales: [] },
translations: [{ en: { messages: { hello: 'Hello' } } }],
};
const plugin = new AppPlugin(bundle);
await plugin.start!(mockContext);

expect(mockI18n.setSupportedLocales).not.toHaveBeenCalled();
});

it('should skip narrowing on a provider that does not implement setSupportedLocales', async () => {
// `setSupportedLocales` is OPTIONAL on `II18nService` (as
// `setDefaultLocale` is), so a third-party provider must keep
// booting rather than crash on a method it never declared.
delete mockI18n.setSupportedLocales;
const bundle = {
id: 'com.test.oldprovider',
i18n: { defaultLocale: 'en', supportedLocales: ['en'] },
translations: [{ en: { messages: { hello: 'Hello' } } }],
};
const plugin = new AppPlugin(bundle);
await plugin.start!(mockContext);

expect(mockI18n.loadTranslations).toHaveBeenCalledWith('en', { messages: { hello: 'Hello' } });
expect(mockContext.logger.error).not.toHaveBeenCalled();
});

it('should auto-register in-memory i18n fallback when service is not registered', async () => {
vi.mocked(mockContext.getService).mockImplementation((name: string) => {
if (name === 'objectql') return mockQL;
Expand Down
43 changes: 43 additions & 0 deletions packages/runtime/src/app-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1472,6 +1472,49 @@ export class AppPlugin implements Plugin {
ctx.logger.debug('[i18n] Set default locale', { appId, locale: i18nConfig.defaultLocale });
}

// [#7679] Narrow what `getLocales()` REPORTS to the locales the app
// declared. This is the only layer that can: `getLocales()` sees the
// loaded set, and what is loaded is not the app's decision — every
// platform plugin (platform-objects, service-settings, service-storage,
// service-messaging, service-realtime, plugin-security, plugin-sharing,
// plugin-webhooks) pushes its own `en/zh-CN/ja-JP/es-ES` bundle at
// `kernel:ready`. A showcase declaring `['en','zh-CN']` therefore
// advertised four locales on `GET /i18n/locales`, two of which
// translate `sys_*` metadata and nothing the app owns.
//
// Threaded exactly like `defaultLocale` immediately above, and applied
// through the same optional-capability probe: `setSupportedLocales` is
// optional on `II18nService`, so a provider that has not implemented it
// keeps today's behaviour rather than breaking.
//
// Only the REPORTED set narrows. The bundles stay loaded and stay
// servable — an unreported locale still returns its `sys_*`
// translations if asked for by code. Unloading them is a bigger change
// than this fix and buys nothing.
//
// A locale declared with no bundle behind it is still reported
// (declared-but-unserved) rather than intersected away — see
// `normalizeSupportedLocales` / `II18nService.setSupportedLocales` for
// why, and note that an intersection computed HERE would in any case be
// wrong: the platform bundles have not arrived yet at this point in the
// lifecycle.
// Guarded on "declared something" rather than called unconditionally,
// for the same reason `setDefaultLocale` above is: several AppPlugins
// can share one kernel (the config apps are AppPlugins too), and an app
// that declares no `i18n` block must not clear the narrowing another
// app declared. Absent stays absent — that is the no-narrowing default,
// not something anyone has to write.
const declaredLocales = i18nConfig?.supportedLocales;
if (
Array.isArray(declaredLocales) && declaredLocales.length > 0
&& typeof i18nService.setSupportedLocales === 'function'
) {
i18nService.setSupportedLocales(declaredLocales);
ctx.logger.debug('[i18n] Narrowed reported locales to the app\'s declared set', {
appId, supportedLocales: declaredLocales,
});
}

if (bundles.length === 0) {
return;
}
Expand Down
Loading
Loading