Skip to content

Commit 8075ff7

Browse files
committed
fix(i18n): report the app's declared supportedLocales on GET /i18n/locales (#7679)
`GET /api/v1/i18n/locales` answered with four descriptors (`en`, `zh-CN`, `ja-JP`, `es-ES`) on the showcase, whose artifact declares `i18n.supportedLocales: ['en','zh-CN']`. The envelope was right (#3636); the set was a superset. Nothing was wrong with what had been LOADED — every platform plugin ships an `en/zh-CN/ja-JP/es-ES` bundle and pushes it at `kernel:ready`. What was wrong is that the loaded set was reported as the OFFERED set, so any picker built from this route (the platform's own Settings > Localization select included) offered locales in which only `sys_*` objects are translated. `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` and `FileI18nAdapter` — narrow what `getLocales()` reports to it. Applied as a read-time filter, never a prune: the platform bundles arrive after the app plugin has run. Two decisions the filing left open: - Unset `supportedLocales` means NO narrowing. Every app predating this change declared nothing and keeps reporting every loaded locale. - A declared locale with no bundle is REPORTED (declared-but-unserved), not intersected away. The declaration is the app's statement of intent, a silently shortened list hides the gap from both ends, and an intersection would depend on how much had loaded when the route was called. Only the reported set narrows — `GET /i18n/translations/ja-JP` still answers on a stack that no longer advertises `ja-JP`. Verified on a live showcase boot (fresh file DB): the route now returns descriptors for `en` and `zh-CN` only, and `ja-JP` translations still serve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu
1 parent 7a8476f commit 8075ff7

13 files changed

Lines changed: 810 additions & 0 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/core": minor
4+
"@objectstack/runtime": minor
5+
"@objectstack/service-i18n": minor
6+
---
7+
8+
fix(i18n): `GET /i18n/locales` reports the locales the app declared, not every locale a plugin happened to load (#7679)
9+
10+
`GET /api/v1/i18n/locales` answered with four locale descriptors — `en`,
11+
`zh-CN`, `ja-JP`, `es-ES` — on the showcase app, whose artifact declares
12+
`i18n.supportedLocales: ['en', 'zh-CN']`. The envelope was correct (#3636); the
13+
**set** was a superset.
14+
15+
Nothing was wrong with what had been *loaded*. Every platform plugin
16+
(`platform-objects`, `service-settings`, `service-storage`, `service-messaging`,
17+
`service-realtime`, `plugin-security`, `plugin-sharing`, `plugin-webhooks`)
18+
ships an `en/zh-CN/ja-JP/es-ES` bundle and pushes it at `kernel:ready`, which is
19+
what a platform should do. What was wrong is that the **loaded** set was
20+
reported as the **offered** set — two different facts owned by two different
21+
parties. So a locale picker built from this route, including the platform's own
22+
Settings > Localization select, offered `ja-JP` and `es-ES`: locales in which
23+
only `sys_*` objects are translated, guaranteeing a mixed-language session for
24+
everything the app itself owns.
25+
26+
**What changed.** `II18nService` gains an optional
27+
`setSupportedLocales(locales)`. `AppPlugin.loadTranslations` threads the
28+
artifact's `i18n.supportedLocales` into it exactly the way it already threads
29+
`defaultLocale`, and both providers of the `i18n` slot — `createMemoryI18n` in
30+
`@objectstack/core` and `FileI18nAdapter` in `@objectstack/service-i18n`
31+
narrow what `getLocales()` reports to that declaration. The runtime app-plugin
32+
layer is the only place this can originate: `getLocales()` sees what is loaded,
33+
and the app's declaration is not visible below it.
34+
35+
The narrowing is applied as a filter at **read** time, never as a prune of what
36+
is stored, because the platform bundles arrive *after* the app plugin has run.
37+
38+
**Only the reported set narrows.** Bundles stay loaded and stay servable:
39+
`GET /i18n/translations/ja-JP` still answers on a stack that no longer
40+
advertises `ja-JP`, and `t()` still resolves it. Unloading those bundles buys
41+
nothing — `sys_*` translations for an unadvertised locale cost nothing sitting
42+
in the map.
43+
44+
Two questions the fix had to settle, both behaviour in their own right:
45+
46+
- **An app that declares no `supportedLocales` is not narrowed.** Absent means
47+
"no narrowing", and it keeps reporting every loaded locale — the behaviour it
48+
has today. Every app written before this change declared nothing, so
49+
narrowing an undeclared app to zero (or to its default alone) would have
50+
emptied the picker on every stack whose author never opted in. An
51+
`i18n` block carrying only a `defaultLocale`, and a `supportedLocales: []`
52+
that declares no usable code, are both read the same way.
53+
- **A declared locale with no bundle behind it is reported, not dropped.** If an
54+
app declares a locale the platform plugins never shipped, it appears in the
55+
response as declared-but-unserved rather than being silently intersected away.
56+
The declaration is the app's statement of intent and the client is entitled to
57+
see it; a quietly shortened list hides the authoring gap from both ends.
58+
Reporting the declaration is also the only answer that does not depend on how
59+
many bundles had loaded by the time the route was called. Reads for such a
60+
locale degrade to the default/fallback exactly as a half-translated bundle's
61+
missing keys already do.
62+
63+
Reported locales now follow the **declared order** rather than the insertion
64+
order of whichever plugin loaded first, so a picker renders the ordering the app
65+
author wrote.
66+
67+
`setSupportedLocales` is optional on the contract, like `setDefaultLocale`: a
68+
third-party `II18nService` that does not implement it keeps its current
69+
behaviour instead of failing to boot.

packages/core/src/fallbacks/fallbacks.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,3 +314,114 @@ describe('createMemoryI18n locale fallback', () => {
314314
expect(i18n.t('hello', 'ja')).toBe('Hello');
315315
});
316316
});
317+
318+
describe('createMemoryI18n supportedLocales narrowing (#7679)', () => {
319+
// `GET /i18n/locales` advertised four locales on an app declaring two.
320+
// Nothing was wrong with what was LOADED — every platform plugin ships an
321+
// `en/zh-CN/ja-JP/es-ES` bundle and pushes it at `kernel:ready`, which is
322+
// correct. What was wrong is that the loaded set was REPORTED as the set
323+
// the app offers, so a picker built from the route handed users locales in
324+
// which only `sys_*` metadata is translated.
325+
326+
/** The four-locale platform reality this fix has to narrow. */
327+
function loadedFourLocales() {
328+
const i18n = createMemoryI18n();
329+
i18n.loadTranslations('en', { objects: { sys_user: { label: 'User' } } });
330+
i18n.loadTranslations('zh-CN', { objects: { sys_user: { label: '用户' } } });
331+
i18n.loadTranslations('ja-JP', { objects: { sys_user: { label: 'ユーザー' } } });
332+
i18n.loadTranslations('es-ES', { objects: { sys_user: { label: 'Usuario' } } });
333+
return i18n;
334+
}
335+
336+
it('reports only the declared locales — the #7679 repro', () => {
337+
const i18n = loadedFourLocales();
338+
expect(i18n.getLocales().sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']);
339+
340+
i18n.setSupportedLocales(['en', 'zh-CN']);
341+
expect(i18n.getLocales()).toEqual(['en', 'zh-CN']);
342+
});
343+
344+
it('narrows what is REPORTED, not what is LOADED — undeclared locales stay servable', () => {
345+
// Explicitly out of scope for #7679, and pinned so nobody "completes"
346+
// the fix by unloading the bundles. `sys_*` translations for a locale
347+
// the app does not advertise cost nothing sitting in the map, and
348+
// anything that already asked for them by code keeps working.
349+
const i18n = loadedFourLocales();
350+
i18n.setSupportedLocales(['en', 'zh-CN']);
351+
352+
expect(i18n.getLocales()).not.toContain('ja-JP');
353+
expect(i18n.getTranslations('ja-JP')).toEqual({ objects: { sys_user: { label: 'ユーザー' } } });
354+
expect(i18n.t('objects.sys_user.label', 'ja-JP')).toBe('ユーザー');
355+
});
356+
357+
it('applies at READ time, so bundles loaded AFTER the declaration are narrowed too', () => {
358+
// The ordering that makes a one-shot prune wrong: `AppPlugin` declares
359+
// the set during its own setup, and the platform plugins push their
360+
// bundles later, at `kernel:ready`. A prune would narrow only whatever
361+
// had loaded first and the rest would grow straight back.
362+
const i18n = createMemoryI18n();
363+
i18n.setSupportedLocales(['en', 'zh-CN']);
364+
i18n.loadTranslations('en', { a: 'a' });
365+
i18n.loadTranslations('ja-JP', { a: 'あ' });
366+
367+
expect(i18n.getLocales()).toEqual(['en', 'zh-CN']);
368+
});
369+
370+
it('DECISION 1 — an app that declares nothing reports every loaded locale', () => {
371+
const i18n = loadedFourLocales();
372+
expect(i18n.getLocales().sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']);
373+
374+
// Same answer for an explicit clear and for a declaration carrying no
375+
// usable code: absent is "no narrowing", never "narrow to nothing".
376+
i18n.setSupportedLocales(undefined);
377+
expect(i18n.getLocales().sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']);
378+
i18n.setSupportedLocales([]);
379+
expect(i18n.getLocales().sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']);
380+
});
381+
382+
it('DECISION 2 — a declared locale with no bundle is REPORTED, not dropped', () => {
383+
// Declared-but-unserved. The declaration is the app's statement of
384+
// intent and the client is entitled to see it; a silently shortened
385+
// list leaves the authoring gap invisible on both sides. Reads degrade
386+
// to the default locale exactly the way a half-translated bundle does.
387+
const i18n = loadedFourLocales();
388+
i18n.setSupportedLocales(['en', 'zh-CN', 'fr-FR']);
389+
390+
expect(i18n.getLocales()).toEqual(['en', 'zh-CN', 'fr-FR']);
391+
expect(i18n.getTranslations('fr-FR')).toEqual({});
392+
expect(i18n.t('objects.sys_user.label', 'fr-FR')).toBe('User');
393+
});
394+
395+
it('a declaration can be replaced, and clearing restores the loaded set', () => {
396+
const i18n = loadedFourLocales();
397+
i18n.setSupportedLocales(['en']);
398+
expect(i18n.getLocales()).toEqual(['en']);
399+
i18n.setSupportedLocales(['zh-CN', 'en']);
400+
expect(i18n.getLocales()).toEqual(['zh-CN', 'en']);
401+
i18n.setSupportedLocales(undefined);
402+
expect(i18n.getLocales().sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']);
403+
});
404+
405+
it('the caller cannot mutate the stored declaration through the array it passed or got back', () => {
406+
const i18n = loadedFourLocales();
407+
const declared = ['en', 'zh-CN'];
408+
i18n.setSupportedLocales(declared);
409+
declared.push('ja-JP');
410+
expect(i18n.getLocales()).toEqual(['en', 'zh-CN']);
411+
412+
i18n.getLocales().push('es-ES');
413+
expect(i18n.getLocales()).toEqual(['en', 'zh-CN']);
414+
});
415+
416+
it('narrows the authored overlay too — authored-only locales are not a bypass', () => {
417+
// `getLocales()` unions the static and authored maps, so a locale that
418+
// exists only as runtime-authored `translation` metadata (#2591) would
419+
// otherwise slip past the declaration.
420+
const i18n = loadedFourLocales();
421+
i18n.replaceAuthoredTranslations({ 'ko-KR': { objects: { sys_user: { label: '사용자' } } } });
422+
expect(i18n.getLocales()).toContain('ko-KR');
423+
424+
i18n.setSupportedLocales(['en', 'zh-CN']);
425+
expect(i18n.getLocales()).toEqual(['en', 'zh-CN']);
426+
});
427+
});

packages/core/src/fallbacks/memory-i18n.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

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

8897
/**
8998
* Resolve a dot-notation key from a nested object.
@@ -170,10 +179,31 @@ export function createMemoryI18n() {
170179
}
171180
},
172181

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

202+
/** @see II18nService.setSupportedLocales — [#7679] */
203+
setSupportedLocales(locales: readonly string[] | undefined): void {
204+
supportedLocales = normalizeSupportedLocales(locales);
205+
},
206+
177207
getDefaultLocale(): string {
178208
return defaultLocale;
179209
},

packages/runtime/src/app-plugin.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ describe('AppPlugin', () => {
143143
mockI18n = {
144144
loadTranslations: vi.fn(),
145145
setDefaultLocale: vi.fn(),
146+
setSupportedLocales: vi.fn(),
146147
getLocales: vi.fn().mockReturnValue([]),
147148
getDefaultLocale: vi.fn().mockReturnValue('en'),
148149
};
@@ -184,6 +185,86 @@ describe('AppPlugin', () => {
184185
expect(mockI18n.setDefaultLocale).toHaveBeenCalledWith('zh-CN');
185186
});
186187

188+
// ── #7679: `supportedLocales` is threaded like `defaultLocale` ──────
189+
// This layer is the only one that can see the app's declaration, so
190+
// these assertions are about the HANDOFF; what the provider then does
191+
// with it is pinned in core / service-i18n, and the two ends are wired
192+
// together in `i18n-supported-locales.test.ts`.
193+
194+
it('should thread supportedLocales from i18n config to the i18n service', async () => {
195+
const bundle = {
196+
id: 'com.test.supported',
197+
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
198+
translations: [{ en: { messages: { hello: 'Hello' } } }],
199+
};
200+
const plugin = new AppPlugin(bundle);
201+
await plugin.start!(mockContext);
202+
203+
expect(mockI18n.setSupportedLocales).toHaveBeenCalledWith(['en', 'zh-CN']);
204+
});
205+
206+
it('should thread supportedLocales even when the app ships no bundles of its own', async () => {
207+
// The showcase's shape: the app declares which locales it offers,
208+
// while the translations arrive from the platform plugins. An
209+
// early return on "no bundles" here would leave exactly the app
210+
// this issue was filed against unnarrowed.
211+
const bundle = {
212+
id: 'com.test.declared-only',
213+
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
214+
};
215+
const plugin = new AppPlugin(bundle);
216+
await plugin.start!(mockContext);
217+
218+
expect(mockI18n.setSupportedLocales).toHaveBeenCalledWith(['en', 'zh-CN']);
219+
expect(mockI18n.loadTranslations).not.toHaveBeenCalled();
220+
});
221+
222+
it('should NOT touch supportedLocales when the app declares none', async () => {
223+
// Not merely "does not narrow" — does not CALL. Several AppPlugins
224+
// share one kernel (the config apps are AppPlugins too), so an app
225+
// with no `i18n` block clearing the declaration would undo the
226+
// narrowing a sibling app had just set.
227+
const bundle = {
228+
id: 'com.test.undeclared',
229+
i18n: { defaultLocale: 'zh-CN' },
230+
translations: [{ en: { messages: { hello: 'Hello' } } }],
231+
};
232+
const plugin = new AppPlugin(bundle);
233+
await plugin.start!(mockContext);
234+
235+
expect(mockI18n.setSupportedLocales).not.toHaveBeenCalled();
236+
expect(mockI18n.setDefaultLocale).toHaveBeenCalledWith('zh-CN');
237+
});
238+
239+
it('should treat an empty supportedLocales declaration as no declaration', async () => {
240+
const bundle = {
241+
id: 'com.test.emptylocales',
242+
i18n: { defaultLocale: 'en', supportedLocales: [] },
243+
translations: [{ en: { messages: { hello: 'Hello' } } }],
244+
};
245+
const plugin = new AppPlugin(bundle);
246+
await plugin.start!(mockContext);
247+
248+
expect(mockI18n.setSupportedLocales).not.toHaveBeenCalled();
249+
});
250+
251+
it('should skip narrowing on a provider that does not implement setSupportedLocales', async () => {
252+
// `setSupportedLocales` is OPTIONAL on `II18nService` (as
253+
// `setDefaultLocale` is), so a third-party provider must keep
254+
// booting rather than crash on a method it never declared.
255+
delete mockI18n.setSupportedLocales;
256+
const bundle = {
257+
id: 'com.test.oldprovider',
258+
i18n: { defaultLocale: 'en', supportedLocales: ['en'] },
259+
translations: [{ en: { messages: { hello: 'Hello' } } }],
260+
};
261+
const plugin = new AppPlugin(bundle);
262+
await plugin.start!(mockContext);
263+
264+
expect(mockI18n.loadTranslations).toHaveBeenCalledWith('en', { messages: { hello: 'Hello' } });
265+
expect(mockContext.logger.error).not.toHaveBeenCalled();
266+
});
267+
187268
it('should auto-register in-memory i18n fallback when service is not registered', async () => {
188269
vi.mocked(mockContext.getService).mockImplementation((name: string) => {
189270
if (name === 'objectql') return mockQL;

packages/runtime/src/app-plugin.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1472,6 +1472,49 @@ export class AppPlugin implements Plugin {
14721472
ctx.logger.debug('[i18n] Set default locale', { appId, locale: i18nConfig.defaultLocale });
14731473
}
14741474

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

0 commit comments

Comments
 (0)