diff --git a/.changeset/memory-i18n-declared-fallback-locale.md b/.changeset/memory-i18n-declared-fallback-locale.md new file mode 100644 index 0000000000..ab7d855cee --- /dev/null +++ b/.changeset/memory-i18n-declared-fallback-locale.md @@ -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. diff --git a/content/docs/kernel/services-checklist.mdx b/content/docs/kernel/services-checklist.mdx index d9b763e938..ffd6e3ed36 100644 --- a/content/docs/kernel/services-checklist.mdx +++ b/content/docs/kernel/services-checklist.mdx @@ -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 diff --git a/packages/core/src/fallbacks/fallbacks.test.ts b/packages/core/src/fallbacks/fallbacks.test.ts index b7bb6119db..2f8d78d6e1 100644 --- a/packages/core/src/fallbacks/fallbacks.test.ts +++ b/packages/core/src/fallbacks/fallbacks.test.ts @@ -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; + + expect(typeof i18n.setFallbackLocale).toBe('function'); + expect(i18n.getFallbackLocale).toBeUndefined(); + }); +}); diff --git a/packages/core/src/fallbacks/memory-i18n.ts b/packages/core/src/fallbacks/memory-i18n.ts index c9870ac074..2fea413e14 100644 --- a/packages/core/src/fallbacks/memory-i18n.ts +++ b/packages/core/src/fallbacks/memory-i18n.ts @@ -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. @@ -145,7 +151,24 @@ export function createMemoryI18n() { t(key: string, locale: string, params?: Record): 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 @@ -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; + }, }; } diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 7dfa5287c2..47813e0757 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -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 diff --git a/packages/runtime/src/i18n-fallback-locale.test.ts b/packages/runtime/src/i18n-fallback-locale.test.ts new file mode 100644 index 0000000000..dcb673de49 --- /dev/null +++ b/packages/runtime/src/i18n-fallback-locale.test.ts @@ -0,0 +1,142 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The declared `i18n.fallbackLocale` reaches the kernel's in-memory i18n + * provider (#15694). + * + * WHAT WENT WRONG + * + * `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'` + * (`packages/cli/src/commands/serve.ts` and `plugin-dev`'s `dev-i18n.ts`), and + * its `t()` consults that locale after the requested one. + * + * The kernel's in-memory fallback is constructed with nothing. `AppPlugin` + * injected `defaultLocale` (#4058-era) and `supportedLocales` (#7679) into + * whichever `i18n` service was registered, but never `fallbackLocale`, and the + * provider had no setter to receive one. So on a stack running the fallback — + * which is every stack that declares `translations` without installing + * `@objectstack/service-i18n` (not installed, or `tierEnabled('i18n')` false) — + * the declaration was INERT. + * + * 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. That the fallback self-declares `degraded` + * licenses FEWER capabilities, not a different answer to the same declared key. + * + * WHY THE TEST LOOKS LIKE THIS + * + * The two halves live in two packages — `AppPlugin` is the only layer that can + * see the declaration, and `t()` is the only thing that acts on it — so a unit + * test on either half alone passes while the stack still answers wrong. This + * suite wires the real `AppPlugin` to the real `createMemoryI18n` provider and + * asserts what `t()` returns, which is the thing the issue is about. The + * provider's own semantics are pinned next to it in + * `packages/core/src/fallbacks/fallbacks.test.ts`; the contrast surface + * (`FileI18nAdapter`'s second leg) is pinned in service-i18n's own suite and is + * deliberately untouched by this card. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { AppPlugin } from './app-plugin.js'; +import { createMemoryI18n } from '@objectstack/core'; +import type { PluginContext } from '@objectstack/core'; + +/** `en` carries a key the `zh-CN` bundle never got — the card's scenario. */ +const APP_BUNDLE: Record> = { + 'en': { objects: { property: { label: 'Property', tip: 'Only in English' } } }, + 'zh-CN': { objects: { property: { label: '房源' } } }, +}; + +function makeContext(i18n: unknown): PluginContext { + return { + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name === 'i18n') return i18n; + if (name === 'objectql') return { registry: {} }; + return undefined; + }), + getServices: vi.fn(), + hook: vi.fn(), + trigger: vi.fn(), + } as unknown as PluginContext; +} + +/** Start a real `AppPlugin` declaring `i18nConfig`, against a real provider. */ +async function bootStack(i18nConfig: Record | undefined, i18n: unknown = createMemoryI18n()) { + const ctx = makeContext(i18n); + const plugin = new AppPlugin({ + id: 'com.test.showcase', + ...(i18nConfig ? { i18n: i18nConfig } : {}), + translations: [APP_BUNDLE], + }); + await plugin.start!(ctx); + return { i18n: i18n as ReturnType, ctx }; +} + +describe('AppPlugin threads the declared i18n.fallbackLocale (#15694)', () => { + it('a missing zh-CN key is answered from the declared en fallback', async () => { + // The card in one assertion. Pre-fix this returned the KEY: the + // declaration never left the stack artifact. + const { i18n } = await bootStack({ + defaultLocale: 'zh-CN', + supportedLocales: ['zh-CN', 'en'], + fallbackLocale: 'en', + }); + + expect(i18n.t('objects.property.tip', 'zh-CN')).toBe('Only in English'); + }); + + it('one declaration, one answer — the key the zh-CN bundle DOES carry is unchanged', async () => { + const { i18n } = await bootStack({ + defaultLocale: 'zh-CN', + supportedLocales: ['zh-CN', 'en'], + fallbackLocale: 'en', + }); + + expect(i18n.t('objects.property.label', 'zh-CN')).toBe('房源'); + }); + + it('DECISION — an app declaring no fallbackLocale keeps the answer it has today', async () => { + // Every stack written before this declared nothing. Defaulting the + // fallback for them would be a new chain nobody asked for, not a fix. + const { i18n } = await bootStack({ defaultLocale: 'zh-CN', supportedLocales: ['zh-CN', 'en'] }); + + expect(i18n.t('objects.property.tip', 'zh-CN')).toBe('objects.property.tip'); + }); + + it('an app declaring no i18n block at all is untouched', async () => { + const { i18n } = await bootStack(undefined); + + expect(i18n.t('objects.property.tip', 'zh-CN')).toBe('objects.property.tip'); + expect(i18n.t('objects.property.label', 'zh-CN')).toBe('房源'); + }); + + it('the injection is an OPTIONAL capability — a provider without the setter still boots', async () => { + // Same probe shape `setDefaultLocale` and `setSupportedLocales` use: a + // provider that has not implemented it keeps today's behaviour rather + // than taking the stack down. `FileI18nAdapter` is exactly such a + // provider — it is CONSTRUCTED with its fallback and has no setter. + const bare = { + loadTranslations: vi.fn(), + t: vi.fn(() => 'x'), + getTranslations: vi.fn(() => ({})), + getLocales: vi.fn(() => []), + }; + + await expect(bootStack({ defaultLocale: 'zh-CN', fallbackLocale: 'en' }, bare)).resolves.toBeDefined(); + expect(bare.loadTranslations).toHaveBeenCalled(); + }); + + it('the declaration is threaded exactly once, with the value the app declared', async () => { + const spy = { ...createMemoryI18n(), setFallbackLocale: vi.fn() }; + await bootStack({ defaultLocale: 'zh-CN', fallbackLocale: 'en' }, spy); + + expect(spy.setFallbackLocale).toHaveBeenCalledTimes(1); + expect(spy.setFallbackLocale).toHaveBeenCalledWith('en'); + }); +}); diff --git a/packages/spec/src/contracts/i18n-service.ts b/packages/spec/src/contracts/i18n-service.ts index 7b30cc0409..f80af1f6a4 100644 --- a/packages/spec/src/contracts/i18n-service.ts +++ b/packages/spec/src/contracts/i18n-service.ts @@ -95,6 +95,49 @@ export interface II18nService { */ getFallbackLocale?(): string | undefined; + /** + * Set the locale `t()` consults after the requested one — the app's + * DECLARED `i18n.fallbackLocale`. + * + * [#15694] The INJECTION counterpart of {@link getFallbackLocale}, and the + * same threading `setDefaultLocale` and `setSupportedLocales` already get + * from `AppPlugin.loadTranslations`: the declaration lives on the stack + * artifact, which only the runtime app-plugin layer can see, so a provider + * built without it (the kernel's in-memory fallback, auto-registered when + * no i18n plugin is installed) has no other way to learn it. A provider + * constructed WITH it — `FileI18nAdapter`, which both boot paths build + * with `fallbackLocale || defaultLocale || 'en'` — omits this method and + * keeps the value it was built with. It does implement `setDefaultLocale` + * and `setSupportedLocales`; omitting a setter is per-value, not + * per-provider. + * + * Why it exists: `i18n.fallbackLocale` is authorable + * (`TranslationConfigSchema`), and until this was threaded it was INERT on + * the in-memory provider. 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. + * + * Semantics implementations must honour: + * - The value is the SECOND locale `t()` consults, per KEY, after the + * requested one — the same shape `FileI18nAdapter.t()` has. Not a + * whole-bundle swap: a requested locale that HAS a bundle but is missing + * the key must still reach the fallback. + * - Never called means no declaration, which must keep the provider's + * existing behaviour exactly. An app that declares no `i18n.fallbackLocale` + * is not opting into a new chain. + * + * ⛔ Implementing this does NOT oblige implementing {@link getFallbackLocale}. + * They answer different questions — what the provider was TOLD versus what + * the serving layer may ASK it — and a provider whose accessor would have + * to invent a value it was never given must keep omitting the accessor, so + * the document translators fall to their own default rather than to a + * derived one (#14882). + * + * @param locale - BCP-47 locale code + */ + setFallbackLocale?(locale: string): void; + /** * Narrow what `getLocales()` reports to the locales the APP declared * (`i18n.supportedLocales` on the stack artifact).