@object-ui/i18n 17.3.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +1295 -1
  2. package/README.md +0 -13
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.js +11 -1
  5. package/dist/locales/ar.d.ts +466 -107
  6. package/dist/locales/ar.js +559 -153
  7. package/dist/locales/de.d.ts +466 -107
  8. package/dist/locales/de.js +574 -175
  9. package/dist/locales/en.d.ts +502 -143
  10. package/dist/locales/en.js +678 -145
  11. package/dist/locales/es.d.ts +466 -107
  12. package/dist/locales/es.js +553 -155
  13. package/dist/locales/fr.d.ts +466 -107
  14. package/dist/locales/fr.js +553 -152
  15. package/dist/locales/index.d.ts +4734 -1144
  16. package/dist/locales/ja.d.ts +466 -107
  17. package/dist/locales/ja.js +545 -146
  18. package/dist/locales/ko.d.ts +466 -107
  19. package/dist/locales/ko.js +551 -153
  20. package/dist/locales/pt.d.ts +466 -107
  21. package/dist/locales/pt.js +552 -154
  22. package/dist/locales/ru.d.ts +466 -107
  23. package/dist/locales/ru.js +563 -153
  24. package/dist/locales/zh.d.ts +501 -142
  25. package/dist/locales/zh.js +542 -142
  26. package/dist/pickLocalized.d.ts +21 -0
  27. package/dist/pickLocalized.js +40 -7
  28. package/dist/provider.d.ts +83 -1
  29. package/dist/provider.js +303 -29
  30. package/dist/useDisplayLocale.d.ts +49 -0
  31. package/dist/useDisplayLocale.js +55 -0
  32. package/dist/useObjectLabel.d.ts +8 -2
  33. package/dist/useObjectLabel.js +28 -8
  34. package/dist/useSafeTranslation.js +50 -2
  35. package/dist/utils/index.d.ts +1 -0
  36. package/dist/utils/index.js +1 -0
  37. package/dist/utils/number-display.d.ts +32 -0
  38. package/dist/utils/number-display.js +32 -0
  39. package/dist/utils/spec-formatters.d.ts +39 -13
  40. package/dist/utils/spec-formatters.js +3 -3
  41. package/package.json +4 -3
@@ -11,5 +11,26 @@
11
11
  *
12
12
  * Pure — pair it with `useObjectTranslation().language` (or any current-locale
13
13
  * source) at the call site.
14
+ *
15
+ * Every limb reads **own properties only** and accepts **only `string` values**
16
+ * (objectui#3907). Both guards used to hold on the regional and last-resort
17
+ * limbs alone, which left the other four able to resolve a locale against
18
+ * `Object.prototype` — `pickLocalized({ en: 'Pricing' }, 'constructor')`
19
+ * rendered the constructor's source text as the label — and able to
20
+ * short-circuit the chain with a non-string value, rendering `[object Object]`.
21
+ * Neither is reachable by an input the contract admits (no BCP-47 tag is an
22
+ * `Object.prototype` member, and `InlineLocaleMapSchema` is
23
+ * `z.record(<tag>, z.string())`), so applying them uniformly changes nothing
24
+ * for any real language tag. It makes this function agree limb for limb with
25
+ * the backend twin `resolveI18nLabel` (`@objectstack/spec`, objectstack#6765),
26
+ * whose locale can arrive from an untrusted `Accept-Language` header and which
27
+ * shipped with exactly these two narrowings recorded as deliberate departures.
28
+ * The only remaining difference between the two is how each spells a miss —
29
+ * `''` here for a text node, `undefined` there for a producer's `?? name` chain
30
+ * — pinned in `plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts`.
31
+ *
32
+ * A guard makes its limb **miss**; it never aborts the resolution. An unusable
33
+ * entry falls through to the next limb exactly as an absent one does, so
34
+ * `pickLocalized({ en: 'Pricing' }, 'constructor')` is `'Pricing'`, not `''`.
14
35
  */
15
36
  export declare function pickLocalized(value: unknown, language: string | undefined | null): string;
@@ -11,6 +11,27 @@
11
11
  *
12
12
  * Pure — pair it with `useObjectTranslation().language` (or any current-locale
13
13
  * source) at the call site.
14
+ *
15
+ * Every limb reads **own properties only** and accepts **only `string` values**
16
+ * (objectui#3907). Both guards used to hold on the regional and last-resort
17
+ * limbs alone, which left the other four able to resolve a locale against
18
+ * `Object.prototype` — `pickLocalized({ en: 'Pricing' }, 'constructor')`
19
+ * rendered the constructor's source text as the label — and able to
20
+ * short-circuit the chain with a non-string value, rendering `[object Object]`.
21
+ * Neither is reachable by an input the contract admits (no BCP-47 tag is an
22
+ * `Object.prototype` member, and `InlineLocaleMapSchema` is
23
+ * `z.record(<tag>, z.string())`), so applying them uniformly changes nothing
24
+ * for any real language tag. It makes this function agree limb for limb with
25
+ * the backend twin `resolveI18nLabel` (`@objectstack/spec`, objectstack#6765),
26
+ * whose locale can arrive from an untrusted `Accept-Language` header and which
27
+ * shipped with exactly these two narrowings recorded as deliberate departures.
28
+ * The only remaining difference between the two is how each spells a miss —
29
+ * `''` here for a text node, `undefined` there for a producer's `?? name` chain
30
+ * — pinned in `plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts`.
31
+ *
32
+ * A guard makes its limb **miss**; it never aborts the resolution. An unusable
33
+ * entry falls through to the next limb exactly as an absent one does, so
34
+ * `pickLocalized({ en: 'Pricing' }, 'constructor')` is `'Pricing'`, not `''`.
14
35
  */
15
36
  export function pickLocalized(value, language) {
16
37
  if (value == null)
@@ -21,18 +42,30 @@ export function pickLocalized(value, language) {
21
42
  return String(value);
22
43
  if (typeof value === 'object') {
23
44
  const o = value;
45
+ /**
46
+ * One entry, or `undefined` when this limb does not hit — which is what
47
+ * lets the limbs keep chaining through `??` in the same order as before.
48
+ */
49
+ const read = (key) => {
50
+ if (!Object.prototype.hasOwnProperty.call(o, key))
51
+ return undefined;
52
+ const entry = o[key];
53
+ return typeof entry === 'string' ? entry : undefined;
54
+ };
24
55
  const lang = (language || 'en').trim();
25
56
  const base = lang.split('-')[0];
26
57
  // Runtime language is often a bare base code ('zh') while metadata authors
27
58
  // write full BCP-47 tags ('zh-CN') — upgrade to any key sharing the base.
28
- const regional = Object.keys(o).find((k) => k.split('-')[0] === base && typeof o[k] === 'string');
29
- const pick = o[lang] ??
30
- o[base] ??
31
- (regional !== undefined ? o[regional] : undefined) ??
32
- o.default ??
33
- o.en ??
59
+ // `Object.keys` is already own-and-enumerable, so `read` here is uniformity
60
+ // rather than a second guard.
61
+ const regional = Object.keys(o).find((k) => k.split('-')[0] === base && read(k) !== undefined);
62
+ const pick = read(lang) ??
63
+ read(base) ??
64
+ (regional !== undefined ? read(regional) : undefined) ??
65
+ read('default') ??
66
+ read('en') ??
34
67
  Object.values(o).find((v) => typeof v === 'string');
35
- return pick == null ? '' : String(pick);
68
+ return pick == null ? '' : pick;
36
69
  }
37
70
  return String(value);
38
71
  }
@@ -28,6 +28,50 @@ export declare const LOCALE_STORAGE_KEY = "objectui-locale";
28
28
  * stored".
29
29
  */
30
30
  export declare function readStoredLanguage(): string | null;
31
+ /**
32
+ * `localStorage` key caching the TENANT's server-side locale — a *seed*, not a
33
+ * choice (objectui#4035).
34
+ *
35
+ * Deliberately a separate slot from {@link LOCALE_STORAGE_KEY}, and that
36
+ * separation is the whole point rather than a tidiness preference. The two
37
+ * values have different provenance and therefore different rights:
38
+ *
39
+ * - {@link LOCALE_STORAGE_KEY} is what the *user* picked. It outranks
40
+ * everything and must survive a tenant reconfiguration.
41
+ * - This slot is what the *admin* configured, cached so the next boot can apply
42
+ * it synchronously. It outranks only the environment (browser language).
43
+ *
44
+ * Writing the server seed into the explicit-choice slot would erase that
45
+ * difference permanently: the seed would then be indistinguishable from a
46
+ * deliberate user choice, so it would (a) outrank a later tenant change and pin
47
+ * the device to a stale locale forever, and (b) suppress browser detection for
48
+ * a user who never expressed a preference. Only a real switch promotes a
49
+ * language to the explicit slot — see the `languageChanged` choke point.
50
+ */
51
+ export declare const LOCALE_SEED_STORAGE_KEY = "objectui-locale-seed";
52
+ /**
53
+ * Read the cached tenant locale seed, or `null` when nothing is cached.
54
+ *
55
+ * Same defensive posture as {@link readStoredLanguage}: `localStorage` is
56
+ * absent under SSR and *throws* in Safari private mode / partitioned iframes,
57
+ * and a language seed is never worth taking the app down for.
58
+ */
59
+ export declare function readCachedLanguageSeed(): string | null;
60
+ /**
61
+ * Cache the tenant locale the server just answered with — the "revalidate" half
62
+ * of the stale-while-revalidate contract (objectstack#5419, ruling point 2).
63
+ *
64
+ * Call this with whatever `/auth/me/localization` actually returned for an
65
+ * authenticated caller. A `null`/`undefined` locale CLEARS the cache rather
66
+ * than leaving the old value: a tenant that unsets its locale must reach
67
+ * choice-less devices on their next boot, and "keep the last good seed forever"
68
+ * is precisely the being-pinned-by-a-stale-seed failure the ruling forbids.
69
+ *
70
+ * A *failed* fetch must simply not call this — the cache is then left intact,
71
+ * which is the correct stale-while-revalidate behaviour when revalidation
72
+ * cannot complete.
73
+ */
74
+ export declare function cacheLanguageSeed(locale: string | null | undefined): void;
31
75
  interface I18nContextValue {
32
76
  /** Current language code */
33
77
  language: string;
@@ -37,6 +81,19 @@ interface I18nContextValue {
37
81
  direction: 'ltr' | 'rtl';
38
82
  /** The underlying i18next instance */
39
83
  i18n: I18nInstance;
84
+ /**
85
+ * The languages a switcher may offer (objectui#4039): the app's own locale
86
+ * list ∩ what this renderer can resolve. Computed here because only the
87
+ * provider knows both sides — the built-in packs, `config.resources` and
88
+ * whether a dynamic loader is wired.
89
+ *
90
+ * `null` means "still asking the app". Consumers render nothing (or a
91
+ * skeleton) rather than a list they will have to retract a tick later.
92
+ * Without {@link I18nProviderProps.loadLocales} wiring, or when the app's
93
+ * endpoint cannot be reached, this is the offline fallback: the built-in
94
+ * packs plus `config.resources`.
95
+ */
96
+ offerableLanguages: readonly string[] | null;
40
97
  }
41
98
  export interface I18nProviderProps {
42
99
  /** i18n configuration options */
@@ -61,6 +118,24 @@ export interface I18nProviderProps {
61
118
  * ```
62
119
  */
63
120
  loadLanguage?: (lang: string) => Promise<Record<string, unknown>>;
121
+ /**
122
+ * The app's own locale list — the codes it actually ships translations for
123
+ * (objectui#4039). Wired the same way as {@link I18nProviderProps.loadLanguage}:
124
+ * the app owns the transport, the provider owns what is done with the answer.
125
+ *
126
+ * The console reads `GET /api/v1/i18n/locales`; see
127
+ * `apps/console/src/loadLocales.ts`. Without this prop the switcher keeps
128
+ * offering the built-in packs (plus `config.resources`) exactly as before, so
129
+ * an app that never wires it is unaffected.
130
+ *
131
+ * @example
132
+ * ```tsx
133
+ * <I18nProvider loadLanguage={loadLanguage} loadLocales={loadLocales}>
134
+ * <App />
135
+ * </I18nProvider>
136
+ * ```
137
+ */
138
+ loadLocales?: () => Promise<string[]>;
64
139
  /**
65
140
  * Remember the active language in `localStorage` ({@link LOCALE_STORAGE_KEY})
66
141
  * and restore it on the next mount. Default: `true`.
@@ -94,7 +169,7 @@ export interface I18nProviderProps {
94
169
  * </I18nProvider>
95
170
  * ```
96
171
  */
97
- export declare function I18nProvider({ config, instance: externalInstance, loadLanguage, persistLanguage, children, }: I18nProviderProps): React.FunctionComponentElement<React.ProviderProps<I18nContextValue | null>>;
172
+ export declare function I18nProvider({ config, instance: externalInstance, loadLanguage, loadLocales, persistLanguage, children, }: I18nProviderProps): React.FunctionComponentElement<React.ProviderProps<I18nContextValue | null>>;
98
173
  /**
99
174
  * Hook to access Object UI i18n context
100
175
  *
@@ -117,6 +192,13 @@ export declare function useObjectTranslation(ns?: string): {
117
192
  direction: "ltr" | "rtl";
118
193
  /** The underlying i18next instance */
119
194
  i18n: I18nInstance;
195
+ /**
196
+ * Languages a switcher may offer (objectui#4039). `null` while the app's
197
+ * locale list is in flight. Outside a provider there is nobody to ask, so
198
+ * the built-in packs are the answer — not `null`, which would leave a
199
+ * standalone switcher permanently blank.
200
+ */
201
+ offerableLanguages: readonly string[] | null;
120
202
  };
121
203
  /**
122
204
  * Hook to access the i18n context directly
package/dist/provider.js CHANGED
@@ -59,46 +59,228 @@ function clearStoredLanguage() {
59
59
  }
60
60
  }
61
61
  /**
62
- * Languages `createI18n(config)` will know about: the built-in packs plus any
63
- * extra `config.resources`.
62
+ * `localStorage` key caching the TENANT's server-side locale — a *seed*, not a
63
+ * choice (objectui#4035).
64
+ *
65
+ * Deliberately a separate slot from {@link LOCALE_STORAGE_KEY}, and that
66
+ * separation is the whole point rather than a tidiness preference. The two
67
+ * values have different provenance and therefore different rights:
68
+ *
69
+ * - {@link LOCALE_STORAGE_KEY} is what the *user* picked. It outranks
70
+ * everything and must survive a tenant reconfiguration.
71
+ * - This slot is what the *admin* configured, cached so the next boot can apply
72
+ * it synchronously. It outranks only the environment (browser language).
73
+ *
74
+ * Writing the server seed into the explicit-choice slot would erase that
75
+ * difference permanently: the seed would then be indistinguishable from a
76
+ * deliberate user choice, so it would (a) outrank a later tenant change and pin
77
+ * the device to a stale locale forever, and (b) suppress browser detection for
78
+ * a user who never expressed a preference. Only a real switch promotes a
79
+ * language to the explicit slot — see the `languageChanged` choke point.
80
+ */
81
+ export const LOCALE_SEED_STORAGE_KEY = 'objectui-locale-seed';
82
+ /**
83
+ * Read the cached tenant locale seed, or `null` when nothing is cached.
84
+ *
85
+ * Same defensive posture as {@link readStoredLanguage}: `localStorage` is
86
+ * absent under SSR and *throws* in Safari private mode / partitioned iframes,
87
+ * and a language seed is never worth taking the app down for.
88
+ */
89
+ export function readCachedLanguageSeed() {
90
+ if (typeof window === 'undefined')
91
+ return null;
92
+ try {
93
+ return window.localStorage.getItem(LOCALE_SEED_STORAGE_KEY);
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ /**
100
+ * Cache the tenant locale the server just answered with — the "revalidate" half
101
+ * of the stale-while-revalidate contract (objectstack#5419, ruling point 2).
102
+ *
103
+ * Call this with whatever `/auth/me/localization` actually returned for an
104
+ * authenticated caller. A `null`/`undefined` locale CLEARS the cache rather
105
+ * than leaving the old value: a tenant that unsets its locale must reach
106
+ * choice-less devices on their next boot, and "keep the last good seed forever"
107
+ * is precisely the being-pinned-by-a-stale-seed failure the ruling forbids.
108
+ *
109
+ * A *failed* fetch must simply not call this — the cache is then left intact,
110
+ * which is the correct stale-while-revalidate behaviour when revalidation
111
+ * cannot complete.
112
+ */
113
+ export function cacheLanguageSeed(locale) {
114
+ if (typeof window === 'undefined')
115
+ return;
116
+ try {
117
+ if (typeof locale === 'string' && locale.length > 0) {
118
+ window.localStorage.setItem(LOCALE_SEED_STORAGE_KEY, locale);
119
+ }
120
+ else {
121
+ window.localStorage.removeItem(LOCALE_SEED_STORAGE_KEY);
122
+ }
123
+ }
124
+ catch {
125
+ // Storage blocked or full — this boot simply goes unseeded.
126
+ }
127
+ }
128
+ /**
129
+ * Languages `createI18n(config)` will know about *synchronously*: the built-in
130
+ * packs plus any extra `config.resources`.
64
131
  *
65
132
  * `hasOwnProperty` rather than `in`: a junk stored value like `constructor`
66
133
  * would pass an `in` check against the locale map's prototype.
67
134
  */
68
- function isKnownLanguage(lang, config) {
135
+ function isStaticallyKnownLanguage(lang, config) {
69
136
  const own = Object.prototype.hasOwnProperty;
70
137
  return own.call(builtInLocales, lang) || Boolean(config?.resources && own.call(config.resources, lang));
71
138
  }
139
+ /** The built-in packs' codes, as a stable identity for consumers' dep arrays. */
140
+ const BUILT_IN_LANGUAGE_CODES = Object.freeze(Object.keys(builtInLocales));
141
+ /**
142
+ * Every language this renderer can produce *without asking the app*: the
143
+ * built-in packs plus `config.resources`.
144
+ *
145
+ * This is the offline/no-backend answer — the set the switcher offers when
146
+ * there is no `loadLocales` wiring, or when the app's locale endpoint cannot
147
+ * be reached (objectui#4039).
148
+ */
149
+ function resolvableLanguages(config) {
150
+ const extra = Object.keys(config?.resources ?? {}).filter((code) => !BUILT_IN_LANGUAGE_CODES.includes(code));
151
+ return [...BUILT_IN_LANGUAGE_CODES, ...extra];
152
+ }
72
153
  /**
73
- * Resolve the bootstrap config for a provider-owned i18next instance, applying
74
- * a stored language choice when there is a usable one.
75
- *
76
- * Applied at instance creation rather than through a post-mount
77
- * `changeLanguage`, because the very first render must already be in the right
78
- * locale: `<html lang>` seeds the `Accept-Language` header on every API call
79
- * (`createAuthenticatedFetch`, issue #1319), so a late switch would fetch the
80
- * first wave of server-resolved metadata labels in the wrong language and then
81
- * have to remount to fix it.
82
- *
83
- * A stored choice outranks browser detection (which itself outranks
84
- * `defaultLanguage` in `createI18n`) — otherwise a user who picked 中文 on a
85
- * `ja` browser would be handed `ja` back on every reload, and the preference
86
- * would be unusable for exactly the users who need it.
87
- *
88
- * A stored value the app no longer offers is dropped *and purged*, so one stale
89
- * entry can never lock the UI to a locale that has no translations.
154
+ * Whether `tag` is a well-formed BCP-47 language tag.
155
+ *
156
+ * `Intl.getCanonicalLocales` is the standard's own parser, so this rejects
157
+ * exactly what a locale code cannot be — `constructor`, `__proto__`, `en_US`,
158
+ * `''` all throw `RangeError` — while accepting codes no pack exists for yet
159
+ * (`th`, `pt-BR`). Guarded for runtimes that ship no `Intl`: a language
160
+ * preference is never worth taking the app down for.
90
161
  */
91
- function resolveBootstrapConfig(config, persist) {
162
+ function isWellFormedLanguageTag(tag) {
163
+ if (typeof Intl === 'undefined' || typeof Intl.getCanonicalLocales !== 'function') {
164
+ return /^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/.test(tag);
165
+ }
166
+ try {
167
+ return Intl.getCanonicalLocales(tag).length === 1;
168
+ }
169
+ catch {
170
+ return false;
171
+ }
172
+ }
173
+ /**
174
+ * Whether this provider can end up rendering `lang` at all.
175
+ *
176
+ * Wider than {@link isStaticallyKnownLanguage} by exactly one case: when a
177
+ * dynamic {@link I18nProviderProps.loadLanguage} loader is wired, that loader
178
+ * *is* the mechanism that produces app-shipped locales, and nothing can
179
+ * enumerate what it will answer for synchronously — the app's pack for `th`
180
+ * lives behind `GET /api/v1/i18n/translations/th`, not in `config.resources`.
181
+ *
182
+ * This is the lockstep half of objectui#4039. The switcher now offers the
183
+ * app's own locales, so the restore validation had to grow with it or a
184
+ * user-picked app locale would be purged on the next reload — the exact bug
185
+ * objectstack#5418 predicted this fix would otherwise mint. The bound stays
186
+ * honest rather than absent: only well-formed tags qualify, and the app's own
187
+ * locale list adjudicates the choice for real once it lands (see the
188
+ * `provisional` self-heal in {@link I18nProvider}).
189
+ */
190
+ function canResolveLanguage(lang, config, hasLoader = false) {
191
+ if (isStaticallyKnownLanguage(lang, config))
192
+ return true;
193
+ return hasLoader && isWellFormedLanguageTag(lang);
194
+ }
195
+ /**
196
+ * Adjudicate a tenant locale seed into a language this renderer can actually
197
+ * boot in, or `null` to fall through to the next tier (objectui#4035).
198
+ *
199
+ * {@link canResolveLanguage} is the verdict — the same predicate the language
200
+ * menu uses, deliberately not a second "do we have this locale" check
201
+ * (objectstack#5418 / objectui#4039). Two things are layered on top of it, both
202
+ * of which are about *which question to ask it*, not about second-guessing the
203
+ * answer:
204
+ *
205
+ * **1. Region subtags are normalised away as a fallback.** A tenant locale is a
206
+ * full BCP-47 tag — the platform answers `zh-CN`, not `zh` (see
207
+ * `LocalizationFetchProvider`'s fixtures) — while the packs are keyed by base
208
+ * language. Asking only about `zh-CN` would reject the single most common
209
+ * tenant configuration there is. The exact tag is tried first so a genuine
210
+ * `pt-BR` pack still wins over `pt`; this mirrors `createI18n`'s own browser
211
+ * detection (`navigator.language.split('-')[0]`) and `pickLocalized`'s
212
+ * documented exact-then-base order, so it is this codebase's existing
213
+ * convention rather than a new rule.
214
+ *
215
+ * **2. The seed does NOT get the dynamic loader's credit** (`hasLoader` is left
216
+ * at its default `false`). That credit exists for a *user-picked* value: the
217
+ * user chose it from a menu built out of the app's real locale list, and a
218
+ * stored choice that turns out unshippable is adjudicated afterwards by the
219
+ * `provisional` self-heal. A tenant seed has neither property — it is an
220
+ * arbitrary admin-authored string that passed through no menu, and there is no
221
+ * self-heal behind it. Extending the credit would mean booting into a locale we
222
+ * cannot confirm we ship and then retracting it, i.e. manufacturing exactly the
223
+ * flash that ruling point 3 bounds, on the very first-visit path it bounds it
224
+ * on. Falling through instead is what ruling point 4 asks for, and it is
225
+ * self-correcting in one step: the user switches once, and that switch is a
226
+ * real explicit choice which outranks the seed from then on.
227
+ */
228
+ function resolveSeedLanguage(seed, config) {
229
+ if (canResolveLanguage(seed, config))
230
+ return seed;
231
+ const base = seed.split('-')[0];
232
+ if (base && base !== seed && canResolveLanguage(base, config))
233
+ return base;
234
+ return null;
235
+ }
236
+ /**
237
+ * The tenant tier: a cached server seed, applied only when the user has
238
+ * expressed no choice of their own (objectui#4035).
239
+ *
240
+ * Returns the bootstrap resolution for the seed, or `null` when there is no
241
+ * usable seed and the caller should fall through to browser detection.
242
+ *
243
+ * `provisional` is deliberately always `null` here: that flag marks a value
244
+ * accepted on the loader's optimistic credit so the app's locale list can
245
+ * adjudicate it later, and {@link resolveSeedLanguage} never extends that
246
+ * credit — a seed is either resolvable right now or it falls through.
247
+ */
248
+ function resolveSeedBootstrap(config) {
249
+ const seed = readCachedLanguageSeed();
250
+ if (!seed)
251
+ return null;
252
+ const resolved = resolveSeedLanguage(seed, config);
253
+ if (!resolved)
254
+ return null;
255
+ return {
256
+ // `detectBrowserLanguage: false` is what makes the tenant tier outrank the
257
+ // environment tier. The admin's deliberate configuration beats the
258
+ // browser's incidental one — ruling point 1.
259
+ config: { ...config, defaultLanguage: resolved, detectBrowserLanguage: false },
260
+ provisional: null,
261
+ };
262
+ }
263
+ function resolveBootstrapConfig(config, persist, hasLoader) {
264
+ // `persistLanguage: false` surfaces (previews, demos, screenshot harnesses)
265
+ // must stay on a fixed language regardless of what is on this origin — that
266
+ // covers the tenant seed too, not just the user's choice.
92
267
  if (!persist)
93
- return config;
268
+ return { config, provisional: null };
94
269
  const stored = readStoredLanguage();
270
+ // No explicit choice at all → the tenant seed gets its turn.
95
271
  if (!stored)
96
- return config;
97
- if (!isKnownLanguage(stored, config)) {
272
+ return resolveSeedBootstrap(config) ?? { config, provisional: null };
273
+ if (!canResolveLanguage(stored, config, hasLoader)) {
98
274
  clearStoredLanguage();
99
- return config;
275
+ // A purged choice is no choice, so this falls to the same tenant tier —
276
+ // otherwise dropping one unshippable stored value would skip the seed and
277
+ // land straight on the browser language.
278
+ return resolveSeedBootstrap(config) ?? { config, provisional: null };
100
279
  }
101
- return { ...config, defaultLanguage: stored, detectBrowserLanguage: false };
280
+ return {
281
+ config: { ...config, defaultLanguage: stored, detectBrowserLanguage: false },
282
+ provisional: isStaticallyKnownLanguage(stored, config) ? null : stored,
283
+ };
102
284
  }
103
285
  const ObjectI18nContext = createContext(null);
104
286
  /**
@@ -111,8 +293,19 @@ const ObjectI18nContext = createContext(null);
111
293
  * </I18nProvider>
112
294
  * ```
113
295
  */
114
- export function I18nProvider({ config, instance: externalInstance, loadLanguage, persistLanguage = true, children, }) {
115
- const i18nInstance = useMemo(() => externalInstance || createI18n(resolveBootstrapConfig(config, persistLanguage)), [externalInstance, config, persistLanguage]);
296
+ export function I18nProvider({ config, instance: externalInstance, loadLanguage, loadLocales, persistLanguage = true, children, }) {
297
+ // A boolean, not `loadLanguage` itself: an inline arrow would change identity
298
+ // every render and rebuild the i18next instance (and with it the language)
299
+ // on each one. What the bootstrap needs to know is only whether a loader
300
+ // exists at all.
301
+ const hasLoader = Boolean(loadLanguage);
302
+ const bootstrap = useMemo(() => {
303
+ if (externalInstance)
304
+ return { instance: externalInstance, provisional: null };
305
+ const resolved = resolveBootstrapConfig(config, persistLanguage, hasLoader);
306
+ return { instance: createI18n(resolved.config), provisional: resolved.provisional };
307
+ }, [externalInstance, config, persistLanguage, hasLoader]);
308
+ const i18nInstance = bootstrap.instance;
116
309
  const [language, setLanguage] = useState(i18nInstance.language || 'en');
117
310
  const direction = getDirection(language);
118
311
  // Track which languages have had app-specific translations loaded
@@ -172,6 +365,79 @@ export function I18nProvider({ config, instance: externalInstance, loadLanguage,
172
365
  console.warn(`[i18n] Failed to load app translations for '${currentLang}':`, err);
173
366
  });
174
367
  }, [i18nInstance, loadLanguage]);
368
+ // The app's own locale list (objectui#4039). `null` = not answered yet; `[]`
369
+ // = asked and got nothing usable, which is "I don't know", not "I ship no
370
+ // locales" — both degrade to the offline fallback below.
371
+ const [appLocales, setAppLocales] = useState(null);
372
+ // Asked exactly once per provider, guarded by a ref — the same shape as the
373
+ // `loadedAppLangs` guard on `loadLanguage` above, and for two reasons at
374
+ // once. An inline `loadLocales={() => …}` changes identity every render, so
375
+ // without the guard the answer would set state, the re-render would mint a
376
+ // new arrow, and the new arrow would re-run the effect: a fetch loop. And
377
+ // StrictMode's double-invoked effect must not fire a second request.
378
+ //
379
+ // Deliberately no `cancelled` flag: under StrictMode the cleanup would
380
+ // cancel the only in-flight request while the re-run declines to start
381
+ // another, leaving the list permanently unanswered and the switcher
382
+ // permanently blank. Settling state after unmount is a no-op in React 18 —
383
+ // which is exactly why the `loadLanguage` effect above does the same.
384
+ const hasLocaleLoader = Boolean(loadLocales);
385
+ const askedForLocales = useRef(false);
386
+ useEffect(() => {
387
+ if (!loadLocales || askedForLocales.current)
388
+ return;
389
+ askedForLocales.current = true;
390
+ loadLocales()
391
+ .then((codes) => setAppLocales(Array.isArray(codes) ? codes : []))
392
+ .catch((err) => {
393
+ // A language menu is not worth an unhandled rejection: fall back to the
394
+ // locales this renderer can produce on its own.
395
+ setAppLocales([]);
396
+ console.warn('[i18n] Failed to load the app locale list:', err);
397
+ });
398
+ }, [loadLocales]);
399
+ const offerableLanguages = useMemo(() => {
400
+ const fallback = resolvableLanguages(config);
401
+ // No wiring at all: the built-in packs are the whole truth, as before.
402
+ if (!hasLocaleLoader)
403
+ return fallback;
404
+ if (appLocales === null)
405
+ return null;
406
+ const offerable = appLocales.filter((code) => canResolveLanguage(code, config, hasLoader));
407
+ // An app that answers with nothing this renderer can produce leaves the
408
+ // user with no way to switch at all — strictly worse than the offline
409
+ // fallback, so the fallback wins.
410
+ return offerable.length > 0 ? offerable : fallback;
411
+ }, [config, hasLocaleLoader, appLocales, hasLoader]);
412
+ // Self-heal: a stored language accepted on the loader's credit alone is
413
+ // adjudicated by the app's real list once it lands. Without this, widening
414
+ // the restore validation would reintroduce exactly what it was written to
415
+ // stop — one stale entry locking the UI to a locale with no translations.
416
+ // Scoped to our own optimism: only the bootstrap language, only while the
417
+ // user has not moved on, and never on an empty/failed answer.
418
+ useEffect(() => {
419
+ const provisional = bootstrap.provisional;
420
+ if (!provisional || !appLocales || appLocales.length === 0)
421
+ return;
422
+ if (appLocales.includes(provisional))
423
+ return;
424
+ if ((i18nInstance.language || '') !== provisional)
425
+ return;
426
+ const revertTo = config?.defaultLanguage || config?.fallbackLanguage || 'en';
427
+ void (async () => {
428
+ await i18nInstance.changeLanguage(revertTo);
429
+ // Purge AFTER the switch, not before: every language change persists
430
+ // itself (that is the point of the single choke point in the
431
+ // `languageChanged` handler), so clearing first would just be overwritten
432
+ // with `revertTo` — recording a preference the user never expressed and
433
+ // suppressing browser detection from then on. Ending with nothing stored
434
+ // is what the bootstrap purge has always left behind.
435
+ // Guarded: if the user picked something in the meantime, that choice is
436
+ // theirs to keep.
437
+ if ((i18nInstance.language || '') === revertTo)
438
+ clearStoredLanguage();
439
+ })();
440
+ }, [bootstrap, appLocales, i18nInstance, config]);
175
441
  const contextValue = useMemo(() => ({
176
442
  language,
177
443
  changeLanguage: async (lang) => {
@@ -191,7 +457,8 @@ export function I18nProvider({ config, instance: externalInstance, loadLanguage,
191
457
  },
192
458
  direction,
193
459
  i18n: i18nInstance,
194
- }), [language, direction, i18nInstance, loadLanguage]);
460
+ offerableLanguages,
461
+ }), [language, direction, i18nInstance, loadLanguage, offerableLanguages]);
195
462
  return React.createElement(ObjectI18nContext.Provider, { value: contextValue }, React.createElement(I18nextProvider, { i18n: i18nInstance }, children));
196
463
  }
197
464
  /**
@@ -219,6 +486,13 @@ export function useObjectTranslation(ns) {
219
486
  direction: context?.direction || 'ltr',
220
487
  /** The underlying i18next instance */
221
488
  i18n,
489
+ /**
490
+ * Languages a switcher may offer (objectui#4039). `null` while the app's
491
+ * locale list is in flight. Outside a provider there is nobody to ask, so
492
+ * the built-in packs are the answer — not `null`, which would leave a
493
+ * standalone switcher permanently blank.
494
+ */
495
+ offerableLanguages: context ? context.offerableLanguages : BUILT_IN_LANGUAGE_CODES,
222
496
  };
223
497
  }
224
498
  /**
@@ -0,0 +1,49 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * `useDisplayLocale` — the BCP-47 tag that field / cell / metric renderers
10
+ * format numbers and other `Intl` values with.
11
+ *
12
+ * This composes the two locale channels this repo already has; it does not add
13
+ * a third. The precedence, highest first:
14
+ *
15
+ * 1. **`useLocalization().locale`** — the tenant's resolved regional default
16
+ * (ADR-0053, served by `GET /api/v1/auth/me/localization`). An org that has
17
+ * explicitly configured a display locale means it, so it outranks the UI
18
+ * language. Frequently `undefined`: the endpoint is
19
+ * cosmetic and non-blocking, so an unconfigured or still-loading tenant
20
+ * simply has no answer here (the state objectui#4033 was measured in — a
21
+ * fresh database, console running `zh-CN`).
22
+ *
23
+ * 2. **`useObjectTranslation().language`** — the ACTIVE UI language, i.e. what
24
+ * the user picked in the language switcher. This is the channel that makes
25
+ * grouping and decimal marks follow the user's language when the tenant has
26
+ * stated no regional preference.
27
+ *
28
+ * 3. **`'en'`** — a concrete last resort rather than `undefined`. `undefined`
29
+ * would hand `Intl` the *machine's* locale, which is invisible in review and
30
+ * non-deterministic in CI; `'en'` at least fails the same way everywhere.
31
+ * Note this is `'en'`, not `'en-US'`: the point of objectui#4033 is that
32
+ * `en-US` is not the world's default. In practice step 2 already answers,
33
+ * since `useObjectTranslation` falls back to the react-i18next global
34
+ * instance and ultimately reports `'en'` itself.
35
+ *
36
+ * Provider-safe at every step: `useLocalization` returns `{}` outside its
37
+ * provider and `useObjectTranslation` reads an optional context, so a renderer
38
+ * mounted standalone (tests, docs demos) degrades instead of throwing.
39
+ *
40
+ * **This composition is also the whole `'zh'` ↔ BCP-47 story** (objectui#4468).
41
+ * There is no mapping table anywhere and none is needed: the UI language codes
42
+ * this renderer ships (`'zh'`, `'ja'`, `'de'`, …) are already well-formed
43
+ * BCP-47 language subtags, so `Intl` accepts them verbatim. The one thing a
44
+ * caller must not do is reach past this hook for the raw tenant locale and hand
45
+ * `Intl` the `undefined` it gets on an unconfigured workspace — `undefined`
46
+ * means "the MACHINE's locale", which is neither channel. Every date, number
47
+ * and currency renderer goes through here for exactly that reason.
48
+ */
49
+ export declare function useDisplayLocale(): string;