@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.
- package/CHANGELOG.md +4320 -0
- package/README.md +192 -13
- package/dist/builtinAggregateLabels.d.ts +40 -0
- package/dist/builtinAggregateLabels.js +23 -0
- package/dist/currency.d.ts +27 -3
- package/dist/currency.js +28 -4
- package/dist/fallbackInterpolation.d.ts +79 -0
- package/dist/fallbackInterpolation.js +124 -0
- package/dist/i18n.d.ts +27 -1
- package/dist/i18n.js +99 -16
- package/dist/index.d.ts +7 -13
- package/dist/index.js +41 -17
- package/dist/locales/ar.d.ts +946 -113
- package/dist/locales/ar.js +1095 -161
- package/dist/locales/de.d.ts +618 -113
- package/dist/locales/de.js +715 -134
- package/dist/locales/en.d.ts +632 -127
- package/dist/locales/en.js +1044 -162
- package/dist/locales/es.d.ts +700 -113
- package/dist/locales/es.js +794 -130
- package/dist/locales/fr.d.ts +700 -113
- package/dist/locales/fr.js +795 -132
- package/dist/locales/index.d.ts +6990 -1169
- package/dist/locales/index.js +48 -9
- package/dist/locales/ja.d.ts +618 -113
- package/dist/locales/ja.js +708 -128
- package/dist/locales/ko.d.ts +618 -113
- package/dist/locales/ko.js +712 -132
- package/dist/locales/pt.d.ts +700 -113
- package/dist/locales/pt.js +794 -130
- package/dist/locales/registry.d.ts +102 -0
- package/dist/locales/registry.js +220 -0
- package/dist/locales/rtl.d.ts +24 -0
- package/dist/locales/rtl.js +26 -0
- package/dist/locales/ru.d.ts +782 -113
- package/dist/locales/ru.js +910 -152
- package/dist/locales/zh.d.ts +635 -130
- package/dist/locales/zh.js +725 -131
- package/dist/pickLocalized.d.ts +30 -0
- package/dist/pickLocalized.js +39 -0
- package/dist/provider.d.ts +101 -12
- package/dist/provider.js +335 -50
- package/dist/translateFn.d.ts +29 -0
- package/dist/translateFn.js +8 -0
- package/dist/useDisplayLocale.d.ts +11 -0
- package/dist/useDisplayLocale.js +11 -0
- package/dist/useObjectLabel.d.ts +40 -18
- package/dist/useObjectLabel.js +46 -14
- package/dist/useSafeTranslation.js +40 -42
- package/dist/utils/formatting.d.ts +16 -0
- package/dist/utils/formatting.js +23 -3
- package/dist/utils/spec-formatters.d.ts +13 -23
- package/dist/utils/spec-formatters.js +41 -2
- package/dist/utils/spec-translations.d.ts +16 -3
- package/dist/utils/spec-translations.js +52 -13
- 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 {
|
|
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
|
|
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
|
|
70
|
-
* everything and must survive a
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
|
77
|
-
* the device to a stale locale forever, and (b) suppress browser
|
|
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
|
|
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
|
|
101
|
-
* of the stale-while-revalidate contract (objectstack#5419,
|
|
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
|
|
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
|
|
130
|
-
*
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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.
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
* `
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
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
|
|
220
|
-
*
|
|
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
|
-
|
|
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
|
|
238
|
-
*
|
|
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
|
|
257
|
-
// environment tier.
|
|
258
|
-
// browser's incidental
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|
|
@@ -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.
|
package/dist/useDisplayLocale.js
CHANGED
|
@@ -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.
|