@object-ui/i18n 17.6.0 → 17.7.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 (56) hide show
  1. package/CHANGELOG.md +4320 -0
  2. package/README.md +192 -13
  3. package/dist/builtinAggregateLabels.d.ts +40 -0
  4. package/dist/builtinAggregateLabels.js +23 -0
  5. package/dist/currency.d.ts +27 -3
  6. package/dist/currency.js +28 -4
  7. package/dist/fallbackInterpolation.d.ts +79 -0
  8. package/dist/fallbackInterpolation.js +124 -0
  9. package/dist/i18n.d.ts +27 -1
  10. package/dist/i18n.js +99 -16
  11. package/dist/index.d.ts +7 -13
  12. package/dist/index.js +41 -17
  13. package/dist/locales/ar.d.ts +946 -113
  14. package/dist/locales/ar.js +1095 -161
  15. package/dist/locales/de.d.ts +618 -113
  16. package/dist/locales/de.js +715 -134
  17. package/dist/locales/en.d.ts +632 -127
  18. package/dist/locales/en.js +1044 -162
  19. package/dist/locales/es.d.ts +700 -113
  20. package/dist/locales/es.js +794 -130
  21. package/dist/locales/fr.d.ts +700 -113
  22. package/dist/locales/fr.js +795 -132
  23. package/dist/locales/index.d.ts +6990 -1169
  24. package/dist/locales/index.js +48 -9
  25. package/dist/locales/ja.d.ts +618 -113
  26. package/dist/locales/ja.js +708 -128
  27. package/dist/locales/ko.d.ts +618 -113
  28. package/dist/locales/ko.js +712 -132
  29. package/dist/locales/pt.d.ts +700 -113
  30. package/dist/locales/pt.js +794 -130
  31. package/dist/locales/registry.d.ts +102 -0
  32. package/dist/locales/registry.js +220 -0
  33. package/dist/locales/rtl.d.ts +24 -0
  34. package/dist/locales/rtl.js +26 -0
  35. package/dist/locales/ru.d.ts +782 -113
  36. package/dist/locales/ru.js +910 -152
  37. package/dist/locales/zh.d.ts +635 -130
  38. package/dist/locales/zh.js +725 -131
  39. package/dist/pickLocalized.d.ts +30 -0
  40. package/dist/pickLocalized.js +39 -0
  41. package/dist/provider.d.ts +101 -12
  42. package/dist/provider.js +335 -50
  43. package/dist/translateFn.d.ts +29 -0
  44. package/dist/translateFn.js +8 -0
  45. package/dist/useDisplayLocale.d.ts +11 -0
  46. package/dist/useDisplayLocale.js +11 -0
  47. package/dist/useObjectLabel.d.ts +40 -18
  48. package/dist/useObjectLabel.js +46 -14
  49. package/dist/useSafeTranslation.js +40 -42
  50. package/dist/utils/formatting.d.ts +16 -0
  51. package/dist/utils/formatting.js +23 -3
  52. package/dist/utils/spec-formatters.d.ts +13 -23
  53. package/dist/utils/spec-formatters.js +41 -2
  54. package/dist/utils/spec-translations.d.ts +16 -3
  55. package/dist/utils/spec-translations.js +52 -13
  56. package/package.json +12 -6
package/dist/provider.js CHANGED
@@ -5,8 +5,9 @@
5
5
  */
6
6
  import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
7
7
  import { I18nextProvider, useTranslation } from 'react-i18next';
8
- import { createI18n, getDirection } from './i18n.js';
9
- import { builtInLocales } from './locales/index.js';
8
+ import { createI18n, getDirection, pickInitialLanguage } from './i18n.js';
9
+ import { interpolateFallback, optionsOf } from './fallbackInterpolation.js';
10
+ import { BUILT_IN_LANGUAGE_CODES as BUILT_IN_CODES, isBuiltInLanguage, loadBuiltInLocale, matchLanguageTag, } from './locales/registry.js';
10
11
  /**
11
12
  * `localStorage` key holding the user's explicit language choice.
12
13
  *
@@ -18,6 +19,22 @@ import { builtInLocales } from './locales/index.js';
18
19
  * Exported so an app that builds its own i18next instance (and therefore owns
19
20
  * its bootstrap language — see {@link I18nProviderProps.instance}) can honour
20
21
  * the same preference, and so a sign-out flow can clear it.
22
+ *
23
+ * ⚠️ **What this slot means changed for a SIGNED-IN user (objectui#10059).**
24
+ * A signed-in user's language has one source of truth and it is not on the
25
+ * device: it is the server column `sys_user.locale`, read back through
26
+ * `GET /auth/me/localization` and written by both the profile page's language
27
+ * card and the console's globe menu. For that user this slot is a CACHE of the
28
+ * column — it still decides the first paint, because the column cannot be read
29
+ * synchronously, and it is then overwritten by whatever the column says. It
30
+ * decides on its own only BEFORE sign-in, which is the case this provider can
31
+ * see and the only one it adjudicates.
32
+ *
33
+ * This provider deliberately does not implement that rule: reading a `sys_user`
34
+ * row needs an authenticated data adapter, which is a host concern and not a
35
+ * dependency a renderer package takes. The host applies the column through
36
+ * {@link I18nContextValue.changeLanguage} like any other switch — in this
37
+ * repository, `@object-ui/app-shell`'s `useUserLocale`.
21
38
  */
22
39
  export const LOCALE_STORAGE_KEY = 'objectui-locale';
23
40
  /**
@@ -59,28 +76,53 @@ function clearStoredLanguage() {
59
76
  }
60
77
  }
61
78
  /**
62
- * `localStorage` key caching the TENANT's server-side locale — a *seed*, not a
63
- * choice (objectui#4035).
79
+ * `localStorage` key caching the server's resolved locale for the signed-in
80
+ * caller — a *seed*, not a choice (objectui#4035).
81
+ *
82
+ * ⚠️ **The value is per-USER; the slot is per-DEVICE (objectui#10193).**
83
+ * `GET /auth/me/localization` answers `locale` as the caller's own
84
+ * `sys_user.locale` when set, else the request's `Accept-Language`, else the
85
+ * deployment default — and answers no locale at all before sign-in. It is not
86
+ * a tenant setting, whatever earlier prose here said. The slot is deliberately
87
+ * NOT keyed by user: the boot reads it before anyone is signed in, when no
88
+ * user id exists to key by. What bounds it to one owner is
89
+ * `@object-ui/auth`'s change-of-owner purge (objectui#5664), which deletes it
90
+ * when a different user adopts the browser, and which the host answers by
91
+ * re-resolving the live language (`@object-ui/app-shell`'s
92
+ * `useSignedInUserLocale`).
93
+ *
94
+ * **What a visitor who is not signed in reads**, declared as device behaviour:
95
+ * the language the LAST signed-in owner of this browser was served, until
96
+ * someone else signs in. That is the only server answer this device has ever
97
+ * received — the endpoint gives none before sign-in — and the sign-in page is
98
+ * the one surface it paints.
64
99
  *
65
100
  * Deliberately a separate slot from {@link LOCALE_STORAGE_KEY}, and that
66
101
  * separation is the whole point rather than a tidiness preference. The two
67
102
  * values have different provenance and therefore different rights:
68
103
  *
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).
104
+ * - {@link LOCALE_STORAGE_KEY} is what the *user* picked on this device. It
105
+ * outranks everything this provider can see and must survive a change in
106
+ * what the server answers for the seed below. ⚠️ It does NOT outrank the signed-in user's own
107
+ * `sys_user.locale`, which no tier here represents — see that key's own
108
+ * docblock (objectui#10059).
109
+ * - This slot is what the server resolved for the signed-in caller, cached so
110
+ * the next boot can apply it synchronously. It outranks only the environment
111
+ * (browser language).
73
112
  *
74
113
  * Writing the server seed into the explicit-choice slot would erase that
75
114
  * 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
115
+ * deliberate user choice, so it would (a) outrank a later server-side change
116
+ * and pin the device to a stale locale forever, and (b) suppress browser
117
+ * detection for
78
118
  * a user who never expressed a preference. Only a real switch promotes a
79
119
  * language to the explicit slot — see the `languageChanged` choke point.
80
120
  */
81
121
  export const LOCALE_SEED_STORAGE_KEY = 'objectui-locale-seed';
82
122
  /**
83
- * Read the cached tenant locale seed, or `null` when nothing is cached.
123
+ * Read the cached locale seed — the server's last resolved locale for this
124
+ * browser's last signed-in caller (see {@link LOCALE_SEED_STORAGE_KEY}) — or
125
+ * `null` when nothing is cached.
84
126
  *
85
127
  * Same defensive posture as {@link readStoredLanguage}: `localStorage` is
86
128
  * absent under SSR and *throws* in Safari private mode / partitioned iframes,
@@ -97,12 +139,13 @@ export function readCachedLanguageSeed() {
97
139
  }
98
140
  }
99
141
  /**
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).
142
+ * Cache the locale the server just resolved for the signed-in caller — the
143
+ * "revalidate" half of the stale-while-revalidate contract (objectstack#5419,
144
+ * ruling point 2).
102
145
  *
103
146
  * Call this with whatever `/auth/me/localization` actually returned for an
104
147
  * authenticated caller. A `null`/`undefined` locale CLEARS the cache rather
105
- * than leaving the old value: a tenant that unsets its locale must reach
148
+ * than leaving the old value: a server answer that drops the locale must reach
106
149
  * choice-less devices on their next boot, and "keep the last good seed forever"
107
150
  * is precisely the being-pinned-by-a-stale-seed failure the ruling forbids.
108
151
  *
@@ -126,18 +169,27 @@ export function cacheLanguageSeed(locale) {
126
169
  }
127
170
  }
128
171
  /**
129
- * Languages `createI18n(config)` will know about *synchronously*: the built-in
130
- * packs plus any extra `config.resources`.
172
+ * Languages this renderer ships or was handed: a built-in catalogue, or an
173
+ * entry in `config.resources`.
174
+ *
175
+ * ⚠️ Since objectui#7479 a built-in catalogue is FETCHED, not statically
176
+ * resident, so "known" here no longer means "renderable on the very next
177
+ * frame" — it means this provider can produce it without asking the app, which
178
+ * is the question every caller of this predicate is actually asking (may this
179
+ * stored value be restored; may this seed be honoured). The one caller that
180
+ * needs the stricter reading is the bootstrap itself, and it gets it by
181
+ * AWAITING {@link preloadBootstrapLocale} before render rather than by asking a
182
+ * different predicate.
131
183
  *
132
184
  * `hasOwnProperty` rather than `in`: a junk stored value like `constructor`
133
- * would pass an `in` check against the locale map's prototype.
185
+ * would pass an `in` check against a plain object's prototype.
134
186
  */
135
187
  function isStaticallyKnownLanguage(lang, config) {
136
188
  const own = Object.prototype.hasOwnProperty;
137
- return own.call(builtInLocales, lang) || Boolean(config?.resources && own.call(config.resources, lang));
189
+ return isBuiltInLanguage(lang) || Boolean(config?.resources && own.call(config.resources, lang));
138
190
  }
139
191
  /** The built-in packs' codes, as a stable identity for consumers' dep arrays. */
140
- const BUILT_IN_LANGUAGE_CODES = Object.freeze(Object.keys(builtInLocales));
192
+ const BUILT_IN_LANGUAGE_CODES = BUILT_IN_CODES;
141
193
  /**
142
194
  * Every language this renderer can produce *without asking the app*: the
143
195
  * built-in packs plus `config.resources`.
@@ -193,7 +245,10 @@ function canResolveLanguage(lang, config, hasLoader = false) {
193
245
  return hasLoader && isWellFormedLanguageTag(lang);
194
246
  }
195
247
  /**
196
- * Adjudicate a tenant locale seed into a language this renderer can actually
248
+ * Adjudicate the locale seed — the last signed-in owner's cached server
249
+ * answer (their `sys_user.locale`, else `Accept-Language`, else the deployment
250
+ * default), bounded to that one owner by the objectui#5664 purge; see
251
+ * {@link LOCALE_SEED_STORAGE_KEY} — into a language this renderer can actually
197
252
  * boot in, or `null` to fall through to the next tier (objectui#4035).
198
253
  *
199
254
  * {@link canResolveLanguage} is the verdict — the same predicate the language
@@ -202,23 +257,23 @@ function canResolveLanguage(lang, config, hasLoader = false) {
202
257
  * of which are about *which question to ask it*, not about second-guessing the
203
258
  * answer:
204
259
  *
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.
260
+ * **1. Exact tag first, then its base language** — the registry's
261
+ * `matchLanguageTag`, the one implementation of that order in this package
262
+ * (objectui#11326). A seed is a full BCP-47 tag — the platform answers
263
+ * `zh-CN`, not `zh` (see `LocalizationFetchProvider`'s fixtures). Since
264
+ * objectui#11326 the built-in packs answer a region-tagged code themselves
265
+ * (`zh-CN` is served by the `zh` catalogue), so for them the exact step
266
+ * already succeeds and the seed boots in the tag the server stated; the base
267
+ * step is what still reaches an app-supplied `config.resources` pack keyed by
268
+ * base language. Exact first, so a genuine `pt-BR` pack still wins over `pt`.
214
269
  *
215
270
  * **2. The seed does NOT get the dynamic loader's credit** (`hasLoader` is left
216
271
  * at its default `false`). That credit exists for a *user-picked* value: the
217
272
  * user chose it from a menu built out of the app's real locale list, and a
218
273
  * 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
274
+ * `provisional` self-heal. A seed has neither property — it is whatever the
275
+ * server resolved (a stored column, a request header, a deployment default),
276
+ * it passed through no menu, and there is no self-heal behind it. Extending the credit would mean booting into a locale we
222
277
  * cannot confirm we ship and then retracting it, i.e. manufacturing exactly the
223
278
  * flash that ruling point 3 bounds, on the very first-visit path it bounds it
224
279
  * on. Falling through instead is what ruling point 4 asks for, and it is
@@ -226,16 +281,12 @@ function canResolveLanguage(lang, config, hasLoader = false) {
226
281
  * real explicit choice which outranks the seed from then on.
227
282
  */
228
283
  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;
284
+ return matchLanguageTag(seed, (code) => canResolveLanguage(code, config));
235
285
  }
236
286
  /**
237
- * The tenant tier: a cached server seed, applied only when the user has
238
- * expressed no choice of their own (objectui#4035).
287
+ * The seed tier: the last signed-in owner's cached server answer (see
288
+ * {@link LOCALE_SEED_STORAGE_KEY}), applied only when nobody has expressed a
289
+ * choice on this device (objectui#4035).
239
290
  *
240
291
  * Returns the bootstrap resolution for the seed, or `null` when there is no
241
292
  * usable seed and the caller should fall through to browser detection.
@@ -253,9 +304,9 @@ function resolveSeedBootstrap(config) {
253
304
  if (!resolved)
254
305
  return null;
255
306
  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.
307
+ // `detectBrowserLanguage: false` is what makes the seed tier outrank the
308
+ // environment tier. What the server resolved for this device's signed-in
309
+ // owner beats the browser's incidental setting — ruling point 1.
259
310
  config: { ...config, defaultLanguage: resolved, detectBrowserLanguage: false },
260
311
  provisional: null,
261
312
  };
@@ -263,16 +314,16 @@ function resolveSeedBootstrap(config) {
263
314
  function resolveBootstrapConfig(config, persist, hasLoader) {
264
315
  // `persistLanguage: false` surfaces (previews, demos, screenshot harnesses)
265
316
  // 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.
317
+ // covers the seed too, not just the user's choice.
267
318
  if (!persist)
268
319
  return { config, provisional: null };
269
320
  const stored = readStoredLanguage();
270
- // No explicit choice at all → the tenant seed gets its turn.
321
+ // No explicit choice at all → the seed gets its turn.
271
322
  if (!stored)
272
323
  return resolveSeedBootstrap(config) ?? { config, provisional: null };
273
324
  if (!canResolveLanguage(stored, config, hasLoader)) {
274
325
  clearStoredLanguage();
275
- // A purged choice is no choice, so this falls to the same tenant tier —
326
+ // A purged choice is no choice, so this falls to the same seed tier —
276
327
  // otherwise dropping one unshippable stored value would skip the seed and
277
328
  // land straight on the browser language.
278
329
  return resolveSeedBootstrap(config) ?? { config, provisional: null };
@@ -282,7 +333,106 @@ function resolveBootstrapConfig(config, persist, hasLoader) {
282
333
  provisional: isStaticallyKnownLanguage(stored, config) ? null : stored,
283
334
  };
284
335
  }
336
+ /**
337
+ * The language {@link I18nProvider} will boot in, computed WITHOUT creating an
338
+ * i18next instance (objectui#7479).
339
+ *
340
+ * The full precedence chain, unchanged: explicit choice → seed →
341
+ * browser language → `defaultLanguage` → `en`.
342
+ */
343
+ export function resolveBootstrapLanguage(options = {}) {
344
+ const { config, persistLanguage = true, hasLoader = false } = options;
345
+ const resolved = resolveBootstrapConfig(config, persistLanguage, hasLoader);
346
+ return pickInitialLanguage(resolved.config);
347
+ }
348
+ /**
349
+ * Fetch the catalogue {@link I18nProvider} will boot in, BEFORE you render it.
350
+ *
351
+ * ## Why a host would await this
352
+ *
353
+ * Built-in catalogues are lazy since objectui#7479, so the provider can be in
354
+ * one of two states on its first render, and the difference is visible:
355
+ *
356
+ * - **catalogue already resident** — the first paint is in the user's
357
+ * language, exactly as it was when all ten packs shipped eagerly;
358
+ * - **catalogue still in flight** — the first paint renders through
359
+ * `fallbackLng: 'en'` and re-renders in the user's language when the fetch
360
+ * lands. Correct, never blank, and a flash of English for a `zh-CN` viewer.
361
+ *
362
+ * ⇒ any host that owns an await before `createRoot().render()` should spend it
363
+ * here; `apps/console/src/main.tsx` does, alongside the runtime-config and auth
364
+ * preflight round-trips it already waits on. A host that has no such seam gets
365
+ * the documented `en`-while-loading behaviour instead, which is why this is a
366
+ * function a host CALLS rather than a requirement the provider imposes.
367
+ *
368
+ * Never rejects: a catalogue that will not download must not take the boot
369
+ * down, and the provider retries on mount.
370
+ */
371
+ export async function preloadBootstrapLocale(options = {}) {
372
+ try {
373
+ await loadBuiltInLocale(resolveBootstrapLanguage(options));
374
+ }
375
+ catch (err) {
376
+ console.warn('[i18n] Failed to preload the bootstrap locale catalogue:', err);
377
+ }
378
+ }
285
379
  const ObjectI18nContext = createContext(null);
380
+ /**
381
+ * Re-render a reader when the store GAINS translations, not only when the
382
+ * language changes (objectui#10382).
383
+ *
384
+ * react-i18next re-renders a `useTranslation` reader on the events its
385
+ * `bindI18n` / `bindI18nStore` options name, and `bindI18nStore` defaults to
386
+ * none. So a bundle added after mount — the `loadLanguage` answer, a built-in
387
+ * catalogue that lands late — reached nobody who had already rendered: they
388
+ * kept drawing the fallback until something unrelated re-rendered them. The
389
+ * provider's old remedy, setting its `language` state to the value it already
390
+ * held, was a same-value update React bails out of.
391
+ *
392
+ * With `added` bound, every store write bumps react-i18next's own revision and
393
+ * hands each reader a NEW `t`. That is more than a re-render: a reader that
394
+ * memoises on `t`, or on a resolver built from it (`useObjectLabel`'s return
395
+ * value, the column memo in `ObjectDataTable`), recomputes too — a re-render
396
+ * alone would leave it on the fallback.
397
+ *
398
+ * Passed per call rather than set on the instance, so it holds for an instance
399
+ * the host built itself (the `instance` prop) and does not leak into
400
+ * react-i18next's process-wide defaults. `useTranslation` spreads this argument
401
+ * over the instance's `react` options before its `subscribe` reads
402
+ * `bindI18nStore` (measured in `react-i18next@17.0.11`, `useTranslation.js`).
403
+ * The hook's typings do not list the key, so the type borrows it from i18next's
404
+ * `ReactOptions`, where it is declared. Module-level so its identity is stable:
405
+ * `useTranslation` keys its options memo, and with it the subscription, on it.
406
+ *
407
+ * ⚠️ The price: every store write re-renders every reader and re-runs whatever
408
+ * keys on `t`. So the provider writes a built-in catalogue only when the merge
409
+ * adds something ({@link mergeAddsKeys}), and writes silently where
410
+ * `languageChanged` follows and re-renders once anyway.
411
+ */
412
+ const STORE_WRITES_RERENDER = Object.freeze({ bindI18nStore: 'added' });
413
+ /** For the switch path's writes: `changeLanguage` announces them, once, in the new language. */
414
+ const SILENT_WRITE = Object.freeze({ silent: true });
415
+ function isNested(value) {
416
+ return typeof value === 'object' && value !== null;
417
+ }
418
+ /**
419
+ * Whether `addResourceBundle(…, deep: true, overwrite: false)` of `source` into
420
+ * `target` would write anything — a key `target` lacks, at any depth where
421
+ * both sides hold objects. Mirrors i18next's `deepExtend` with `overwrite` off,
422
+ * which leaves a key present on both sides alone when either value is not an
423
+ * object, so such a key adds nothing.
424
+ */
425
+ function mergeAddsKeys(target, source) {
426
+ for (const key of Object.keys(source)) {
427
+ if (!(key in target))
428
+ return true;
429
+ const into = target[key];
430
+ const from = source[key];
431
+ if (isNested(into) && isNested(from) && mergeAddsKeys(into, from))
432
+ return true;
433
+ }
434
+ return false;
435
+ }
286
436
  /**
287
437
  * I18nProvider - Wraps your app with i18n support
288
438
  *
@@ -345,6 +495,52 @@ export function I18nProvider({ config, instance: externalInstance, loadLanguage,
345
495
  i18nInstance.off('languageChanged', handleLanguageChanged);
346
496
  };
347
497
  }, [i18nInstance, persistLanguage]);
498
+ // Fetch the BUILT-IN catalogue for whatever language this instance booted
499
+ // into (objectui#7479). A host that awaited `preloadBootstrapLocale` finds it
500
+ // already resident and this resolves without a network round-trip; a host
501
+ // that did not gets the documented `en`-while-loading render and this is what
502
+ // ends it.
503
+ //
504
+ // ⛔ `overwrite: false` on the merge, unlike the app-specific bundle below.
505
+ // `createI18n` has already deep-merged `config.resources` over an EMPTY
506
+ // built-in bundle, so overwriting here would let a catalogue that arrives
507
+ // late silently undo the caller's own overrides — a precedence inversion
508
+ // whose only symptom is that it depends on network timing.
509
+ //
510
+ // The write is what re-renders anything already on screen in the `en`
511
+ // fallback ({@link STORE_WRITES_RERENDER}); ⛔ never a `setLanguage` here,
512
+ // which would set the state this effect was keyed on and bail out
513
+ // (objectui#10382). And ⛔ never an unconditional write: this effect re-runs
514
+ // on every language change, and after a switch through `changeLanguage`
515
+ // (which already merged the catalogue), or for a catalogue that was resident
516
+ // when the instance was created, the write would add nothing and still
517
+ // re-render every reader.
518
+ useEffect(() => {
519
+ const currentLang = i18nInstance.language || 'en';
520
+ if (!isBuiltInLanguage(currentLang))
521
+ return;
522
+ let cancelled = false;
523
+ void loadBuiltInLocale(currentLang)
524
+ .then((catalogue) => {
525
+ if (cancelled || !catalogue)
526
+ return;
527
+ const bundle = i18nInstance.getResourceBundle(currentLang, 'translation');
528
+ if (isNested(bundle)) {
529
+ if (!mergeAddsKeys(bundle, catalogue))
530
+ return;
531
+ i18nInstance.addResourceBundle(currentLang, 'translation', catalogue, true, false);
532
+ }
533
+ else {
534
+ i18nInstance.addResourceBundle(currentLang, 'translation', catalogue);
535
+ }
536
+ })
537
+ .catch((err) => {
538
+ console.warn(`[i18n] Failed to load the built-in catalogue for '${currentLang}':`, err);
539
+ });
540
+ return () => {
541
+ cancelled = true;
542
+ };
543
+ }, [i18nInstance, language]);
348
544
  // Load app-specific translations for the initial language on mount
349
545
  useEffect(() => {
350
546
  if (!loadLanguage)
@@ -355,9 +551,13 @@ export function I18nProvider({ config, instance: externalInstance, loadLanguage,
355
551
  loadedAppLangs.current.add(currentLang);
356
552
  loadLanguage(currentLang).then((resources) => {
357
553
  if (resources && Object.keys(resources).length > 0) {
554
+ // The write itself re-renders every reader already on screen
555
+ // ({@link STORE_WRITES_RERENDER}). ⛔ No `setLanguage(currentLang)`
556
+ // after it (objectui#10382): on the first load that sets the state to
557
+ // the value it already holds, which React bails out of; and when the
558
+ // user switched while this was in flight, it set the context back to
559
+ // the boot language while i18next stayed on the new one.
358
560
  i18nInstance.addResourceBundle(currentLang, 'translation', resources, true, true);
359
- // Force re-render so components pick up newly loaded translations
360
- setLanguage(currentLang);
361
561
  }
362
562
  }).catch((err) => {
363
563
  // Allow retry on failure by removing from loaded set
@@ -441,12 +641,36 @@ export function I18nProvider({ config, instance: externalInstance, loadLanguage,
441
641
  const contextValue = useMemo(() => ({
442
642
  language,
443
643
  changeLanguage: async (lang) => {
644
+ // The built-in catalogue first (objectui#7479): a switch to a language
645
+ // whose pack has never been fetched must RESOLVE it, not render the
646
+ // `en` fallback and hope the mount effect catches up. Awaited before
647
+ // `changeLanguage` so the switch and the strings land on the same
648
+ // frame — the whole point of doing it here rather than reactively.
649
+ //
650
+ // Both writes below are SILENT. The `changeLanguage` that follows emits
651
+ // `languageChanged` — i18next emits it for every language it resolves,
652
+ // the current one included (measured in `i18next@26.4.0`,
653
+ // `changeLanguage`) — and that re-renders every reader once, in the
654
+ // new language. An `added` event here would re-render each of them in
655
+ // the OLD language first ({@link STORE_WRITES_RERENDER}).
656
+ const builtIn = await loadBuiltInLocale(lang).catch((err) => {
657
+ console.warn(`[i18n] Failed to load the built-in catalogue for '${lang}':`, err);
658
+ return null;
659
+ });
660
+ if (builtIn) {
661
+ if (i18nInstance.hasResourceBundle(lang, 'translation')) {
662
+ i18nInstance.addResourceBundle(lang, 'translation', builtIn, true, false, SILENT_WRITE);
663
+ }
664
+ else {
665
+ i18nInstance.addResourceBundle(lang, 'translation', builtIn, false, false, SILENT_WRITE);
666
+ }
667
+ }
444
668
  // Dynamic language pack loading (v2.0.7)
445
669
  if (loadLanguage && !loadedAppLangs.current.has(lang)) {
446
670
  loadedAppLangs.current.add(lang);
447
671
  try {
448
672
  const resources = await loadLanguage(lang);
449
- i18nInstance.addResourceBundle(lang, 'translation', resources, true, true);
673
+ i18nInstance.addResourceBundle(lang, 'translation', resources, true, true, SILENT_WRITE);
450
674
  }
451
675
  catch (err) {
452
676
  loadedAppLangs.current.delete(lang);
@@ -474,7 +698,68 @@ export function I18nProvider({ config, instance: externalInstance, loadLanguage,
474
698
  */
475
699
  export function useObjectTranslation(ns) {
476
700
  const context = useContext(ObjectI18nContext);
477
- const { t, i18n } = useTranslation(ns);
701
+ // Subscribed to store writes as well as language changes, so translations
702
+ // added after this reader rendered reach it (objectui#10382).
703
+ const { t: boundT, i18n } = useTranslation(ns, STORE_WRITES_RERENDER);
704
+ // Whether react-i18next found an i18next instance at all — from props,
705
+ // from an `I18nextProvider` above, or from the module-level global that
706
+ // `createI18n` installs via `initReactI18next`.
707
+ //
708
+ // Read off the returned instance rather than the `ready` flag, and that is
709
+ // the load-bearing part. `useTranslation` returns `i18n || {}`, so a plain
710
+ // object with no `t` is exactly and only the no-instance case; `ready` is
711
+ // ALSO false for a real instance whose namespace is still loading, and there
712
+ // `t` is i18next's own `getFixedT` result, which interpolates already.
713
+ // Keying on `ready` would run a second interpolation pass over a string
714
+ // i18next had already filled — measured in
715
+ // `react-i18next@17.0.11/dist/es/useTranslation.js`, where `getSnapshot`
716
+ // returns `notReadySnapshot` under `if (!i18n)` and nothing else.
717
+ const hasInstance = typeof i18n?.t === 'function';
718
+ // objectui#6219. With no instance, react-i18next hands back `notReadyT`,
719
+ // which returns `options.defaultValue` **verbatim**: an inline default
720
+ // written `'Deleted {{count}} rows'` reached the user with the braces intact
721
+ // on every host that embeds an ObjectUI component without `I18nProvider` —
722
+ // the configuration `createSafeTranslation` exists for (objectui#3865), so a
723
+ // supported one rather than a hypothetical.
724
+ //
725
+ // This is the ONE seam: 68 inline defaults across 24 files were rendering
726
+ // through it, and every one of them is fixed here instead of at the call
727
+ // sites. It is deliberately NOT a rewrite of those call sites — at a bare
728
+ // `useObjectTranslation()` the pre-interpolated template literal
729
+ // (`` `Deleted ${n} rows` ``) is the CORRECT spelling and stays correct
730
+ // (there is no hole left to fill), so both shapes now render right and
731
+ // neither is residue. objectui#4905 specified the opposite rewrite and it
732
+ // would have introduced this defect at 29 more sites.
733
+ //
734
+ // Scope, stated as narrowly as it is implemented: this widens WHICH BINDINGS
735
+ // interpolate on the provider-less path. It does not widen WHICH SPELLINGS
736
+ // resolve — that fork is objectui#3512's, which ruled deliberately against
737
+ // teaching the fallback i18next's other three dialects, and this change
738
+ // keeps that ruling by routing through the same one interpolator
739
+ // (`fallbackInterpolation.ts`) that `createSafeTranslation`'s `fallbackT`
740
+ // uses. With an instance present, `boundT` is returned untouched: i18next
741
+ // does its own interpolation and must never be double-processed.
742
+ const t = useMemo(() => {
743
+ if (hasInstance)
744
+ return boundT;
745
+ const interpolating = (...args) => {
746
+ const rendered = boundT(...args);
747
+ // `notReadyT` can also answer with a key array's last member or `''` for
748
+ // a function key. Only a string can carry a hole; anything else is
749
+ // handed back exactly as react-i18next produced it.
750
+ if (typeof rendered !== 'string')
751
+ return rendered;
752
+ return interpolateFallback(rendered, optionsOf(args));
753
+ };
754
+ // The cast restores react-i18next's `TFunction` overloads for the ~700
755
+ // call sites that destructure `t` — the wrapper is argument-transparent by
756
+ // construction (it forwards `...args` untouched), so the declared type
757
+ // still describes it.
758
+ return interpolating;
759
+ // `notReadyT` is a module constant in react-i18next, so on this path
760
+ // `boundT` is referentially stable and so is the wrapper — which matters,
761
+ // because call sites put `t` in `useMemo`/`useCallback` dependency arrays.
762
+ }, [boundT, hasInstance]);
478
763
  return {
479
764
  /** Translation function */
480
765
  t,
@@ -0,0 +1,29 @@
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
+ * i18next's `t`, narrowed to the one call shape a plain helper needs: a key,
10
+ * optional interpolation / `defaultValue` options, and a string back.
11
+ *
12
+ * ⭐ This is the ONE authority for the name (objectui#8261, executing the
13
+ * maintainer's ruling on objectui#8165, option A). A module that is handed a
14
+ * `t` rather than calling a translation hook itself — a toast emitter, a size
15
+ * guard, a message builder — types that parameter with this and imports it.
16
+ * ⛔ It does not declare its own copy: `@object-ui/app-shell` and
17
+ * `@object-ui/fields` each carried a byte-identical one until this change, and
18
+ * both now re-export this declaration instead
19
+ * (`export type { TranslateFn } from '@object-ui/i18n'`).
20
+ *
21
+ * `scripts/__tests__/one-authority-per-exported-name-6273.test.ts` is what
22
+ * re-derives that there is one authority: it reads such a re-export as a
23
+ * re-export, and a second `export type TranslateFn = …` anywhere in a
24
+ * published package's source as a collision.
25
+ *
26
+ * Why this package and not `@object-ui/types`: it is a translation-function
27
+ * shape, not a schema type — the ruling's own ground.
28
+ */
29
+ export type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
@@ -0,0 +1,8 @@
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
+ export {};
@@ -33,6 +33,17 @@
33
33
  * since `useObjectTranslation` falls back to the react-i18next global
34
34
  * instance and ultimately reports `'en'` itself.
35
35
  *
36
+ * ⚠️ This last resort is the rule for THIS HOOK — a React renderer whose
37
+ * provider chain yielded no tag at all — and not for every caller that has
38
+ * no tag. A non-React display caller cannot call a hook, and
39
+ * `@object-ui/core` deliberately gives it a different answer:
40
+ * `DisplayNumberFormatOptions.locale`
41
+ * (`packages/core/src/utils/number-display.ts`) says `undefined` there
42
+ * means the runtime default, because the viewer's own environment is the
43
+ * honest locale for a user-facing display. The two rules govern different
44
+ * callers and do not contradict each other (objectui#10098); that docblock
45
+ * names this one in return.
46
+ *
36
47
  * Provider-safe at every step: `useLocalization` returns `{}` outside its
37
48
  * provider and `useObjectTranslation` reads an optional context, so a renderer
38
49
  * mounted standalone (tests, docs demos) degrades instead of throwing.
@@ -35,6 +35,17 @@ import { useObjectTranslation } from './provider.js';
35
35
  * since `useObjectTranslation` falls back to the react-i18next global
36
36
  * instance and ultimately reports `'en'` itself.
37
37
  *
38
+ * ⚠️ This last resort is the rule for THIS HOOK — a React renderer whose
39
+ * provider chain yielded no tag at all — and not for every caller that has
40
+ * no tag. A non-React display caller cannot call a hook, and
41
+ * `@object-ui/core` deliberately gives it a different answer:
42
+ * `DisplayNumberFormatOptions.locale`
43
+ * (`packages/core/src/utils/number-display.ts`) says `undefined` there
44
+ * means the runtime default, because the viewer's own environment is the
45
+ * honest locale for a user-facing display. The two rules govern different
46
+ * callers and do not contradict each other (objectui#10098); that docblock
47
+ * names this one in return.
48
+ *
38
49
  * Provider-safe at every step: `useLocalization` returns `{}` outside its
39
50
  * provider and `useObjectTranslation` reads an optional context, so a renderer
40
51
  * mounted standalone (tests, docs demos) degrades instead of throwing.