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
21 changes: 21 additions & 0 deletions .changeset/memory-i18n-declared-fallback-locale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@objectstack/spec": minor
"@objectstack/core": minor
"@objectstack/runtime": minor
---

The kernel's in-memory i18n fallback learns the declared `i18n.fallbackLocale`, so one declaration stops answering two ways (#15694)

`i18n.fallbackLocale` is authorable on the stack artifact (`TranslationConfigSchema`), and `FileI18nAdapter` — the provider `I18nServicePlugin` installs — has always honoured it: both boot paths construct it with `fallbackLocale || defaultLocale || 'en'`, and its `t()` consults that locale, per key, after the requested one.

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.

What changed:

- **`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.
- **`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.
- **`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.

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.

`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.
23 changes: 19 additions & 4 deletions content/docs/kernel/services-checklist.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -471,15 +471,30 @@ When an app bundle includes an `i18n` config and `translations` array, `AppPlugi
```typescript
export default defineStack({
manifest: { id: 'com.example.crm', namespace: 'crm' },
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'], fallbackLocale: 'en' },
translations: [CrmTranslations], // TranslationBundle[]
});
```

AppPlugin will:
AppPlugin will, in this order:
1. Set the default locale via `i18nService.setDefaultLocale()`
2. Call `i18nService.loadTranslations(locale, data)` for each locale in every bundle
3. Skip gracefully if no i18n service is registered (no errors, just a debug log)
2. Set the declared `i18n.fallbackLocale` — the locale `t()` consults after the requested
one — via `i18nService.setFallbackLocale()` (#15694)
3. Narrow what `getLocales()` REPORTS to the app's declared `supportedLocales`, via
`i18nService.setSupportedLocales()` (#7679)
4. Call `i18nService.loadTranslations(locale, data)` for each locale in every bundle
5. Skip gracefully if no i18n service is registered (no errors, just a debug log)

Each of the three setters is optional on `II18nService` and applied through the same
`typeof ... === 'function'` capability probe, so a provider that has not implemented one
keeps its own behaviour rather than breaking. `FileI18nAdapter` implements
`setDefaultLocale` and `setSupportedLocales` but **not** `setFallbackLocale`: both boot
paths (`os serve` and `DevPlugin`'s auto-wiring) construct it with
`fallbackLocale || defaultLocale || 'en'` already collapsed from the stack config, so the
probe skips step 2 and it keeps the value it was built with — omitting a setter is
per-value, not per-provider. Each setter is also guarded on the app
having DECLARED the value, because several AppPlugins can share one kernel and an app that
declares no `i18n` block must not clear what another declared.

#### REST API Endpoints

Expand Down
142 changes: 142 additions & 0 deletions packages/core/src/fallbacks/fallbacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,3 +431,145 @@ describe('createMemoryI18n supportedLocales narrowing (#7679)', () => {
expect(i18n.getLocales()).toEqual(['en', 'zh-CN']);
});
});

describe('createMemoryI18n declared fallbackLocale (#15694)', () => {
// WHAT WENT WRONG
//
// `i18n.fallbackLocale` is authorable (`TranslationConfigSchema`) and
// `FileI18nAdapter` has always honoured it — both boot paths construct it
// with `fallbackLocale || defaultLocale || 'en'`. The kernel's
// in-memory fallback is constructed with nothing and had no setter, so on a
// stack running it the declaration was INERT: `t()` consulted the requested
// locale and, only if that locale had NO bundle at all, `defaultLocale`.
//
// A stack declaring `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'`
// therefore 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. The provider
// self-declaring `degraded` licenses FEWER capabilities, not a different
// answer to the same declared key.
//
// The contrast surface (`FileI18nAdapter.t()`'s second leg) is pinned in
// service-i18n's own suite and is deliberately untouched here.

/** The card's scenario: `en` carries a key the `zh-CN` bundle never got. */
function bootDeclaredStack() {
const i18n = createMemoryI18n();
i18n.loadTranslations('en', { objects: { property: { label: 'Property', tip: 'Only in English' } } });
i18n.loadTranslations('zh-CN', { objects: { property: { label: '房源' } } });
i18n.setDefaultLocale('zh-CN');
i18n.setFallbackLocale('en');
return i18n;
}

it('a key missing in the requested locale is answered from the DECLARED fallback', () => {
// The whole card in one assertion. Pre-fix this returned the key
// itself: the `zh-CN` bundle exists, so the old whole-bundle swap never
// fired, and nothing else was ever consulted.
expect(bootDeclaredStack().t('objects.property.tip', 'zh-CN')).toBe('Only in English');
});

it('the requested locale still wins where it HAS the key', () => {
// The fallback is a second leg, never a preference: a translated key
// must not start answering in English because a fallback was declared.
expect(bootDeclaredStack().t('objects.property.label', 'zh-CN')).toBe('房源');
});

it('a key in NEITHER locale is still the key itself', () => {
expect(bootDeclaredStack().t('objects.property.missing', 'zh-CN')).toBe('objects.property.missing');
});

it('per KEY, not per bundle — a bundle that exists but lacks the key still reaches the fallback', () => {
// Stated separately because this is the exact shape the old code got
// wrong. `resolveTranslations(locale) ?? mergedLocale(defaultLocale)`
// picks ONE bundle and then looks the key up in it, so the fallback
// could only ever fire for a locale with no bundle at all — which is
// never the interesting case.
const i18n = createMemoryI18n();
i18n.loadTranslations('en', { greeting: 'Hello' });
i18n.loadTranslations('ja-JP', { farewell: 'さようなら' });
i18n.setFallbackLocale('en');

expect(i18n.t('farewell', 'ja-JP')).toBe('さようなら');
expect(i18n.t('greeting', 'ja-JP')).toBe('Hello');
});

it('DECISION — an app that declared no fallbackLocale keeps today\'s behaviour exactly', () => {
// The compatibility half, and the reason the setter is guarded rather
// than defaulted: every stack written before this declared nothing, and
// a fallback nobody asked for is a new chain, not a fix.
const i18n = createMemoryI18n();
i18n.loadTranslations('en', { objects: { property: { tip: 'Only in English' } } });
i18n.loadTranslations('zh-CN', { objects: { property: { label: '房源' } } });
i18n.setDefaultLocale('zh-CN');

expect(i18n.t('objects.property.tip', 'zh-CN')).toBe('objects.property.tip');
});

it('the pre-existing whole-bundle fall to defaultLocale is untouched', () => {
// A requested locale with NO bundle still lands on `defaultLocale`,
// declared fallback or not — that leg is older than this card.
const i18n = createMemoryI18n();
i18n.loadTranslations('zh-CN', { objects: { property: { label: '房源' } } });
i18n.setDefaultLocale('zh-CN');
i18n.setFallbackLocale('en');

expect(i18n.t('objects.property.label', 'fr-FR')).toBe('房源');
});

it('a fallback equal to the requested locale does not re-ask the lookup that just failed', () => {
const i18n = createMemoryI18n();
i18n.loadTranslations('en', { greeting: 'Hello' });
i18n.setFallbackLocale('en');

expect(i18n.t('greeting', 'en')).toBe('Hello');
expect(i18n.t('missing', 'en')).toBe('missing');
});

it('interpolation applies to a value resolved from the fallback', () => {
const i18n = createMemoryI18n();
i18n.loadTranslations('en', { welcome: 'Welcome, {{name}}' });
i18n.loadTranslations('zh-CN', {});
i18n.setFallbackLocale('en');

expect(i18n.t('welcome', 'zh-CN', { name: 'Ada' })).toBe('Welcome, Ada');
});

it('the fallback leg sees the AUTHORED overlay, not just the static bundle', () => {
// #2591's authored layer wins on read for the requested locale, so it
// must win on the fallback leg too — otherwise a key authored at
// runtime resolves for one locale and not for the locale that falls
// back to it.
const i18n = createMemoryI18n();
i18n.loadTranslations('en', { greeting: 'Hello' });
i18n.loadTranslations('zh-CN', { other: '其他' });
i18n.replaceAuthoredTranslations({ en: { greeting: 'Hi there' } });
i18n.setFallbackLocale('en');

expect(i18n.t('greeting', 'zh-CN')).toBe('Hi there');
});

it('the fallback leg resolves a locale CODE the way the requested leg does', () => {
const i18n = createMemoryI18n();
i18n.loadTranslations('en-US', { greeting: 'Hello' });
i18n.loadTranslations('zh-CN', { other: '其他' });
i18n.setFallbackLocale('en');

expect(i18n.t('greeting', 'zh-CN')).toBe('Hello');
});

it('⛔ the SETTER exists and the ACCESSOR deliberately does not (#14882)', () => {
// The fence, pinned so the next reader does not "complete" this by
// adding the accessor. `setFallbackLocale` is what the provider is
// TOLD; `getFallbackLocale` is what the serving layer ASKS it when it
// builds the metadata-document translators' fallback chain. Answering
// the second from `defaultLocale` — the only value always available
// here — would settle the default-locale contract question #14882
// leaves deliberately open, from a degraded provider. Those reads keep
// the resolvers' own default instead, which is known and intentional.
const i18n = createMemoryI18n() as Record<string, unknown>;

expect(typeof i18n.setFallbackLocale).toBe('function');
expect(i18n.getFallbackLocale).toBeUndefined();
});
});
41 changes: 40 additions & 1 deletion packages/core/src/fallbacks/memory-i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ export function createMemoryI18n() {
// 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;
// [#15694] The app's DECLARED `i18n.fallbackLocale`, injected by
// `AppPlugin.loadTranslations` the same way `defaultLocale` and
// `supportedLocales` are. `undefined` means the app declared nothing, which
// must leave `t()` answering exactly as it did before this existed — an app
// that never wrote a `fallbackLocale` is not opting into a longer chain.
let fallbackLocale: string | undefined;

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

t(key: string, locale: string, params?: Record<string, unknown>): string {
const data = resolveTranslations(locale) ?? mergedLocale(defaultLocale);
const value = data ? resolveKey(data, key) : undefined;
let value = data ? resolveKey(data, key) : undefined;

// [#15694] The DECLARED fallback (`i18n.fallbackLocale`), consulted per
// KEY after the requested locale — the same second leg
// `FileI18nAdapter.t()` has taken all along, so ONE declaration gets ONE
// answer whichever provider is serving. Per key, not per bundle: the
// line above swaps whole bundles and only when the requested locale has
// none, so a `zh-CN` bundle that simply lacks the key never reached
// anything else and `t()` returned the key itself.
//
// Guarded on `fallbackLocale` being set, so a stack that declared none
// keeps today's chain exactly; `!== locale` skips the re-lookup that
// just failed, matching the adapter.
if (value === undefined && fallbackLocale && fallbackLocale !== locale) {
const fallbackData = resolveTranslations(fallbackLocale);
value = fallbackData ? resolveKey(fallbackData, key) : undefined;
}

if (value == null) return key;
if (!params) return value;
// Interpolation format: {{paramName}} — matches FileI18nAdapter convention
Expand Down Expand Up @@ -211,5 +234,21 @@ export function createMemoryI18n() {
setDefaultLocale(locale: string): void {
defaultLocale = locale;
},

/**
* @see II18nService.setFallbackLocale — [#15694]
*
* ⛔ There is deliberately NO `getFallbackLocale()` beside this. The two
* are different questions: this one is what the provider was TOLD, the
* accessor is what the serving layer ASKS it in order to build the
* metadata-document translators' fallback chain (#14882). Answering the
* second from `defaultLocale` — the only value that was always available
* here — would settle the default-locale contract question #14882 leaves
* deliberately open, from a degraded provider. Without the accessor those
* reads keep the resolvers' own default, which is known and intentional.
*/
setFallbackLocale(locale: string): void {
fallbackLocale = locale;
},
};
}
23 changes: 23 additions & 0 deletions packages/runtime/src/app-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1805,6 +1805,29 @@ export class AppPlugin implements Plugin {
ctx.logger.debug('[i18n] Set default locale', { appId, locale: i18nConfig.defaultLocale });
}

// [#15694] Thread the declared `i18n.fallbackLocale` the same way, for
// the same reason: it is authorable on the stack artifact and this is
// the only layer that can see it. A provider CONSTRUCTED with it —
// `FileI18nAdapter`, which both boot paths build with
// `fallbackLocale || defaultLocale || 'en'` — has no `setFallbackLocale`
// (it does have the other two) and is skipped by the probe, keeping the
// value it was built with. The
// kernel's in-memory fallback (auto-registered above when no i18n
// plugin is installed) is constructed with nothing, so without this
// line the declaration was INERT there: 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.
//
// Same optional-capability probe as `setDefaultLocale` above, and
// guarded on "declared something" for the same reason: several
// AppPlugins can share one kernel, and an app that declares no `i18n`
// block must not clear a fallback another app declared.
if (i18nConfig?.fallbackLocale && typeof i18nService.setFallbackLocale === 'function') {
i18nService.setFallbackLocale(i18nConfig.fallbackLocale);
ctx.logger.debug('[i18n] Set fallback locale', { appId, locale: i18nConfig.fallbackLocale });
}

// [#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
Expand Down
Loading
Loading