Skip to content

Commit 842b699

Browse files
committed
fix(core,runtime,spec): the in-memory i18n fallback learns the declared i18n.fallbackLocale
`i18n.fallbackLocale` is authorable and `FileI18nAdapter` honours it, but the kernel's in-memory fallback was never told it: `AppPlugin.loadTranslations` injected `defaultLocale` and `supportedLocales` and nothing else, and the provider had no setter. A stack declaring `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'` answered a missing `zh-CN` key from `en` under `I18nServicePlugin` and from `zh-CN` -- not at all -- under the fallback. - `II18nService.setFallbackLocale?(locale)`: new optional member, the injection counterpart of `getFallbackLocale`, same shape as `setDefaultLocale`. - `createMemoryI18n`: holds the declared fallback and consults it per KEY after the requested locale, the way `FileI18nAdapter.t()` does. Undeclared keeps today's chain byte for byte. - `AppPlugin.loadTranslations`: threads it through the same optional-capability probe, guarded on the app having declared something. `getFallbackLocale()` stays deliberately absent from the memory fallback -- deriving one from `defaultLocale` would settle the contract question #14882 leaves open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
1 parent c252041 commit 842b699

6 files changed

Lines changed: 407 additions & 1 deletion

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/core": minor
4+
"@objectstack/runtime": minor
5+
---
6+
7+
The kernel's in-memory i18n fallback learns the declared `i18n.fallbackLocale`, so one declaration stops answering two ways (#15694)
8+
9+
`i18n.fallbackLocale` is authorable on the stack artifact (`TranslationConfigSchema`), and `FileI18nAdapter` — the provider `I18nServicePlugin` installs — has always honoured it: `os serve` and the dev plugin construct it with `fallbackLocale || defaultLocale || 'en'`, and its `t()` consults that locale, per key, after the requested one.
10+
11+
The kernel's in-memory fallback is constructed with nothing. `AppPlugin.loadTranslations` injected the declared `defaultLocale` and `supportedLocales` (#7679) into whichever `i18n` service was registered, but never `fallbackLocale`, and the provider had no setter to receive one. On every stack running that fallback — any stack that declares `translations` without `@objectstack/service-i18n` registered (not installed, or `tierEnabled('i18n')` false) — the declaration was inert. A stack declaring `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'` answered a missing `zh-CN` key from `en` under `I18nServicePlugin` and from `zh-CN`, i.e. not at all, under the fallback: one declaration, two providers, two answers. That the fallback self-declares `degraded` licenses fewer capabilities, not a different answer to the same declared key.
12+
13+
What changed:
14+
15+
- **`II18nService.setFallbackLocale?(locale)`** — a new OPTIONAL member, the injection counterpart of `getFallbackLocale`. It is the same shape `setDefaultLocale` and `setSupportedLocales` already have, and for the same reason: the declaration lives on the stack artifact, which only the runtime app-plugin layer can see. A provider constructed with its fallback (`FileI18nAdapter`) omits the method and keeps the value it was built with.
16+
- **`createMemoryI18n` receives it and acts on it.** `t()` now consults the declared fallback per KEY after the requested locale — the same second leg `FileI18nAdapter.t()` has. Per key, not per bundle: the pre-existing `resolveTranslations(locale) ?? mergedLocale(defaultLocale)` line swaps whole bundles and only when the requested locale has none, so a `zh-CN` bundle that simply lacked the key never reached anything else. That older leg is unchanged.
17+
- **`AppPlugin.loadTranslations` threads the declaration**, through the same `typeof … === 'function'` optional-capability probe as `setDefaultLocale`, and guarded on the app having declared something — several `AppPlugin`s can share one kernel, and an app that declares no `i18n` block must not clear a fallback another app declared.
18+
19+
A stack that declares no `fallbackLocale` gets exactly the behaviour it has today: the setter is never called, and `t()` walks the same chain it always did. A fallback nobody asked for would be a new chain, not a fix.
20+
21+
`getFallbackLocale()` is deliberately still absent from the memory fallback. The setter is what the provider is TOLD; the accessor is what the serving layer ASKS it when building the metadata-document translators' fallback chain (#14882). Answering the second from `defaultLocale` — the only value always available there — would settle the default-locale contract question #14882 leaves deliberately open, from a degraded provider. Those reads keep the resolvers' own default, which is known and intentional.

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

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,3 +431,145 @@ describe('createMemoryI18n supportedLocales narrowing (#7679)', () => {
431431
expect(i18n.getLocales()).toEqual(['en', 'zh-CN']);
432432
});
433433
});
434+
435+
describe('createMemoryI18n declared fallbackLocale (#15694)', () => {
436+
// WHAT WENT WRONG
437+
//
438+
// `i18n.fallbackLocale` is authorable (`TranslationConfigSchema`) and
439+
// `FileI18nAdapter` has always honoured it — `os serve` and the dev plugin
440+
// construct it with `fallbackLocale || defaultLocale || 'en'`. The kernel's
441+
// in-memory fallback is constructed with nothing and had no setter, so on a
442+
// stack running it the declaration was INERT: `t()` consulted the requested
443+
// locale and, only if that locale had NO bundle at all, `defaultLocale`.
444+
//
445+
// A stack declaring `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'`
446+
// therefore answered a missing `zh-CN` key from `en` under
447+
// `I18nServicePlugin` and from `zh-CN` — i.e. not at all — under the
448+
// fallback. One declaration, two providers, two answers. The provider
449+
// self-declaring `degraded` licenses FEWER capabilities, not a different
450+
// answer to the same declared key.
451+
//
452+
// The contrast surface (`FileI18nAdapter.t()`'s second leg) is pinned in
453+
// service-i18n's own suite and is deliberately untouched here.
454+
455+
/** The card's scenario: `en` carries a key the `zh-CN` bundle never got. */
456+
function bootDeclaredStack() {
457+
const i18n = createMemoryI18n();
458+
i18n.loadTranslations('en', { objects: { property: { label: 'Property', tip: 'Only in English' } } });
459+
i18n.loadTranslations('zh-CN', { objects: { property: { label: '房源' } } });
460+
i18n.setDefaultLocale('zh-CN');
461+
i18n.setFallbackLocale('en');
462+
return i18n;
463+
}
464+
465+
it('a key missing in the requested locale is answered from the DECLARED fallback', () => {
466+
// The whole card in one assertion. Pre-fix this returned the key
467+
// itself: the `zh-CN` bundle exists, so the old whole-bundle swap never
468+
// fired, and nothing else was ever consulted.
469+
expect(bootDeclaredStack().t('objects.property.tip', 'zh-CN')).toBe('Only in English');
470+
});
471+
472+
it('the requested locale still wins where it HAS the key', () => {
473+
// The fallback is a second leg, never a preference: a translated key
474+
// must not start answering in English because a fallback was declared.
475+
expect(bootDeclaredStack().t('objects.property.label', 'zh-CN')).toBe('房源');
476+
});
477+
478+
it('a key in NEITHER locale is still the key itself', () => {
479+
expect(bootDeclaredStack().t('objects.property.missing', 'zh-CN')).toBe('objects.property.missing');
480+
});
481+
482+
it('per KEY, not per bundle — a bundle that exists but lacks the key still reaches the fallback', () => {
483+
// Stated separately because this is the exact shape the old code got
484+
// wrong. `resolveTranslations(locale) ?? mergedLocale(defaultLocale)`
485+
// picks ONE bundle and then looks the key up in it, so the fallback
486+
// could only ever fire for a locale with no bundle at all — which is
487+
// never the interesting case.
488+
const i18n = createMemoryI18n();
489+
i18n.loadTranslations('en', { greeting: 'Hello' });
490+
i18n.loadTranslations('ja-JP', { farewell: 'さようなら' });
491+
i18n.setFallbackLocale('en');
492+
493+
expect(i18n.t('farewell', 'ja-JP')).toBe('さようなら');
494+
expect(i18n.t('greeting', 'ja-JP')).toBe('Hello');
495+
});
496+
497+
it('DECISION — an app that declared no fallbackLocale keeps today\'s behaviour exactly', () => {
498+
// The compatibility half, and the reason the setter is guarded rather
499+
// than defaulted: every stack written before this declared nothing, and
500+
// a fallback nobody asked for is a new chain, not a fix.
501+
const i18n = createMemoryI18n();
502+
i18n.loadTranslations('en', { objects: { property: { tip: 'Only in English' } } });
503+
i18n.loadTranslations('zh-CN', { objects: { property: { label: '房源' } } });
504+
i18n.setDefaultLocale('zh-CN');
505+
506+
expect(i18n.t('objects.property.tip', 'zh-CN')).toBe('objects.property.tip');
507+
});
508+
509+
it('the pre-existing whole-bundle fall to defaultLocale is untouched', () => {
510+
// A requested locale with NO bundle still lands on `defaultLocale`,
511+
// declared fallback or not — that leg is older than this card.
512+
const i18n = createMemoryI18n();
513+
i18n.loadTranslations('zh-CN', { objects: { property: { label: '房源' } } });
514+
i18n.setDefaultLocale('zh-CN');
515+
i18n.setFallbackLocale('en');
516+
517+
expect(i18n.t('objects.property.label', 'fr-FR')).toBe('房源');
518+
});
519+
520+
it('a fallback equal to the requested locale does not re-ask the lookup that just failed', () => {
521+
const i18n = createMemoryI18n();
522+
i18n.loadTranslations('en', { greeting: 'Hello' });
523+
i18n.setFallbackLocale('en');
524+
525+
expect(i18n.t('greeting', 'en')).toBe('Hello');
526+
expect(i18n.t('missing', 'en')).toBe('missing');
527+
});
528+
529+
it('interpolation applies to a value resolved from the fallback', () => {
530+
const i18n = createMemoryI18n();
531+
i18n.loadTranslations('en', { welcome: 'Welcome, {{name}}' });
532+
i18n.loadTranslations('zh-CN', {});
533+
i18n.setFallbackLocale('en');
534+
535+
expect(i18n.t('welcome', 'zh-CN', { name: 'Ada' })).toBe('Welcome, Ada');
536+
});
537+
538+
it('the fallback leg sees the AUTHORED overlay, not just the static bundle', () => {
539+
// #2591's authored layer wins on read for the requested locale, so it
540+
// must win on the fallback leg too — otherwise a key authored at
541+
// runtime resolves for one locale and not for the locale that falls
542+
// back to it.
543+
const i18n = createMemoryI18n();
544+
i18n.loadTranslations('en', { greeting: 'Hello' });
545+
i18n.loadTranslations('zh-CN', { other: '其他' });
546+
i18n.replaceAuthoredTranslations({ en: { greeting: 'Hi there' } });
547+
i18n.setFallbackLocale('en');
548+
549+
expect(i18n.t('greeting', 'zh-CN')).toBe('Hi there');
550+
});
551+
552+
it('the fallback leg resolves a locale CODE the way the requested leg does', () => {
553+
const i18n = createMemoryI18n();
554+
i18n.loadTranslations('en-US', { greeting: 'Hello' });
555+
i18n.loadTranslations('zh-CN', { other: '其他' });
556+
i18n.setFallbackLocale('en');
557+
558+
expect(i18n.t('greeting', 'zh-CN')).toBe('Hello');
559+
});
560+
561+
it('⛔ the SETTER exists and the ACCESSOR deliberately does not (#14882)', () => {
562+
// The fence, pinned so the next reader does not "complete" this by
563+
// adding the accessor. `setFallbackLocale` is what the provider is
564+
// TOLD; `getFallbackLocale` is what the serving layer ASKS it when it
565+
// builds the metadata-document translators' fallback chain. Answering
566+
// the second from `defaultLocale` — the only value always available
567+
// here — would settle the default-locale contract question #14882
568+
// leaves deliberately open, from a degraded provider. Those reads keep
569+
// the resolvers' own default instead, which is known and intentional.
570+
const i18n = createMemoryI18n() as Record<string, unknown>;
571+
572+
expect(typeof i18n.setFallbackLocale).toBe('function');
573+
expect(i18n.getFallbackLocale).toBeUndefined();
574+
});
575+
});

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ export function createMemoryI18n() {
9393
// platform plugins push their bundles at `kernel:ready`, after the app
9494
// plugin has run, so anything pruned once would grow back.
9595
let supportedLocales: string[] | undefined;
96+
// [#15694] The app's DECLARED `i18n.fallbackLocale`, injected by
97+
// `AppPlugin.loadTranslations` the same way `defaultLocale` and
98+
// `supportedLocales` are. `undefined` means the app declared nothing, which
99+
// must leave `t()` answering exactly as it did before this existed — an app
100+
// that never wrote a `fallbackLocale` is not opting into a longer chain.
101+
let fallbackLocale: string | undefined;
96102

97103
/**
98104
* Resolve a dot-notation key from a nested object.
@@ -145,7 +151,24 @@ export function createMemoryI18n() {
145151

146152
t(key: string, locale: string, params?: Record<string, unknown>): string {
147153
const data = resolveTranslations(locale) ?? mergedLocale(defaultLocale);
148-
const value = data ? resolveKey(data, key) : undefined;
154+
let value = data ? resolveKey(data, key) : undefined;
155+
156+
// [#15694] The DECLARED fallback (`i18n.fallbackLocale`), consulted per
157+
// KEY after the requested locale — the same second leg
158+
// `FileI18nAdapter.t()` has taken all along, so ONE declaration gets ONE
159+
// answer whichever provider is serving. Per key, not per bundle: the
160+
// line above swaps whole bundles and only when the requested locale has
161+
// none, so a `zh-CN` bundle that simply lacks the key never reached
162+
// anything else and `t()` returned the key itself.
163+
//
164+
// Guarded on `fallbackLocale` being set, so a stack that declared none
165+
// keeps today's chain exactly; `!== locale` skips the re-lookup that
166+
// just failed, matching the adapter.
167+
if (value === undefined && fallbackLocale && fallbackLocale !== locale) {
168+
const fallbackData = resolveTranslations(fallbackLocale);
169+
value = fallbackData ? resolveKey(fallbackData, key) : undefined;
170+
}
171+
149172
if (value == null) return key;
150173
if (!params) return value;
151174
// Interpolation format: {{paramName}} — matches FileI18nAdapter convention
@@ -211,5 +234,21 @@ export function createMemoryI18n() {
211234
setDefaultLocale(locale: string): void {
212235
defaultLocale = locale;
213236
},
237+
238+
/**
239+
* @see II18nService.setFallbackLocale — [#15694]
240+
*
241+
* ⛔ There is deliberately NO `getFallbackLocale()` beside this. The two
242+
* are different questions: this one is what the provider was TOLD, the
243+
* accessor is what the serving layer ASKS it in order to build the
244+
* metadata-document translators' fallback chain (#14882). Answering the
245+
* second from `defaultLocale` — the only value that was always available
246+
* here — would settle the default-locale contract question #14882 leaves
247+
* deliberately open, from a degraded provider. Without the accessor those
248+
* reads keep the resolvers' own default, which is known and intentional.
249+
*/
250+
setFallbackLocale(locale: string): void {
251+
fallbackLocale = locale;
252+
},
214253
};
215254
}

packages/runtime/src/app-plugin.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1805,6 +1805,28 @@ export class AppPlugin implements Plugin {
18051805
ctx.logger.debug('[i18n] Set default locale', { appId, locale: i18nConfig.defaultLocale });
18061806
}
18071807

1808+
// [#15694] Thread the declared `i18n.fallbackLocale` the same way, for
1809+
// the same reason: it is authorable on the stack artifact and this is
1810+
// the only layer that can see it. A provider CONSTRUCTED with it —
1811+
// `FileI18nAdapter`, which `os serve` and the dev plugin hand
1812+
// `fallbackLocale || defaultLocale || 'en'` — has no setter and is
1813+
// skipped by the probe, keeping the value it was built with. The
1814+
// kernel's in-memory fallback (auto-registered above when no i18n
1815+
// plugin is installed) is constructed with nothing, so without this
1816+
// line the declaration was INERT there: a stack declaring
1817+
// `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'` answered a
1818+
// missing `zh-CN` key from `en` under `I18nServicePlugin` and from
1819+
// `zh-CN` — i.e. not at all — under the fallback.
1820+
//
1821+
// Same optional-capability probe as `setDefaultLocale` above, and
1822+
// guarded on "declared something" for the same reason: several
1823+
// AppPlugins can share one kernel, and an app that declares no `i18n`
1824+
// block must not clear a fallback another app declared.
1825+
if (i18nConfig?.fallbackLocale && typeof i18nService.setFallbackLocale === 'function') {
1826+
i18nService.setFallbackLocale(i18nConfig.fallbackLocale);
1827+
ctx.logger.debug('[i18n] Set fallback locale', { appId, locale: i18nConfig.fallbackLocale });
1828+
}
1829+
18081830
// [#7679] Narrow what `getLocales()` REPORTS to the locales the app
18091831
// declared. This is the only layer that can: `getLocales()` sees the
18101832
// loaded set, and what is loaded is not the app's decision — every

0 commit comments

Comments
 (0)