@sveltekit-i18n/base 3.1.2 → 3.3.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/README.md +24 -23
- package/dist/I18n.svelte.d.ts +45 -2
- package/dist/I18n.svelte.js +404 -109
- package/dist/kit/define.svelte.js +140 -38
- package/dist/kit/internal.d.ts +8 -0
- package/dist/kit/server.browser.js +1 -1
- package/dist/kit/server.d.ts +1 -1
- package/dist/kit/server.js +17 -2
- package/dist/types.d.ts +39 -7
- package/dist/utils.d.ts +8 -13
- package/dist/utils.js +91 -62
- package/package.json +3 -2
package/dist/I18n.svelte.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { untrack } from 'svelte';
|
|
2
|
-
import { capturesParams, fetchTranslation, hasOwn, loaderName, maskOutputKeys, maskTranslations, mergeFetched, mergeTranslations, omitProtoKeys, paramsSignature, read, resolveLoaders, routeParams, sanitizerFactory, sanitizeTranslationLocales, serialize, servedLocales, toDotNotation, translate, unique, withoutBasePath } from './utils.js';
|
|
2
|
+
import { capturesParams, dotNotates, fetchTranslation, hasOwn, loaderName, maskOutputKeys, maskTranslations, mergeFetched, mergeTranslations, omitProtoKeys, paramsSignature, read, resolveLoaders, routeParams, sanitizerFactory, sanitizeTranslationLocales, serialize, servedLocales, toDotNotation, translate, unique, withoutBasePath } from './utils.js';
|
|
3
3
|
import { logError, logger, loggerFactory, setLogger } from './logger.js';
|
|
4
4
|
const defaultCache = Number.POSITIVE_INFINITY;
|
|
5
|
+
const unasked = { config: undefined, signatures: new Map() };
|
|
6
|
+
const noLoaders = [];
|
|
5
7
|
/**
|
|
6
8
|
* A top-level key holding part of `namespace` — the namespace itself or a key
|
|
7
9
|
* flattened out of it. Keys are strings, so an off-contract namespace that is
|
|
8
10
|
* not one holds none of them.
|
|
9
11
|
*/
|
|
10
12
|
const isNamespaceKey = (key, namespace) => typeof namespace === 'string' && (key === namespace || key.startsWith(`${namespace}.`));
|
|
13
|
+
/** What the log says of a load of `locale` on `route` whose data could not be applied. */
|
|
14
|
+
const loadFailure = (locale, route) => `Failed to load translations for '${locale}' locale${route ? ` and '${route}' route` : ''}.`;
|
|
11
15
|
class I18nCore {
|
|
12
16
|
// -- reactive state ---------------------------------------------------------
|
|
13
17
|
/**
|
|
@@ -47,13 +51,20 @@ class I18nCore {
|
|
|
47
51
|
// apart from the records: invalidation drops those, not what is displayed,
|
|
48
52
|
// and a reconfiguration hands them on to the same loaders of the new config.
|
|
49
53
|
#deliveries = new Map();
|
|
54
|
+
// The delivery each seed's masked copy in `#deliveries` stands for: a seed is
|
|
55
|
+
// no replacement, so what counts on a delivery counts on its copy.
|
|
56
|
+
#maskedFrom = new WeakMap();
|
|
50
57
|
#externalTranslations = {};
|
|
51
58
|
// The params signature the next trigger asks each loader for:
|
|
52
59
|
// the one the most recent call asked for, unless a failed call put an
|
|
53
60
|
// earlier one's back, and `null` for a loader its route does not select.
|
|
54
61
|
// Loads settle out of order, and a delivery for params the route no longer
|
|
55
62
|
// asks for must not replace what it displays; a warm load asks for nothing.
|
|
56
|
-
|
|
63
|
+
// Replaced whole, never changed in place, so a call keeps what it replaced
|
|
64
|
+
// by reference.
|
|
65
|
+
#wanted = unasked;
|
|
66
|
+
// What `#localeLoaders` found for each locale, under the config it read.
|
|
67
|
+
#byLocale = { config: undefined, loaders: new Map() };
|
|
57
68
|
// The latest delivery of each loader for params nothing wanted yet — a warm
|
|
58
69
|
// load of another route's params, typically a preload — so the trigger that
|
|
59
70
|
// wants them applies it instead of fetching again. One per loader, as a
|
|
@@ -64,7 +75,8 @@ class I18nCore {
|
|
|
64
75
|
// records keep no trigger that selects them from running them. It serves the
|
|
65
76
|
// pass it arrived with: until an activating trigger asks for another locale
|
|
66
77
|
// or route than the hand-off named, or than the first activating trigger
|
|
67
|
-
// after it where the hand-off named none
|
|
78
|
+
// after it where the hand-off named none, or until a preload, the request
|
|
79
|
+
// of a navigation.
|
|
68
80
|
#handedOff = new Map();
|
|
69
81
|
#handOffPass = {};
|
|
70
82
|
/** When each locale first received data — drives the `cache` expiry. */
|
|
@@ -91,6 +103,12 @@ class I18nCore {
|
|
|
91
103
|
* call that came later and has not failed keeps what it asked for.
|
|
92
104
|
*/
|
|
93
105
|
#calls = [];
|
|
106
|
+
// Moved by `invalidate()` and by a reconfiguration: a preload's answers
|
|
107
|
+
// predate one that moved it. Expiry leaves it: a preload judged the window
|
|
108
|
+
// at its own request, and a later request's judgement is no newer for it.
|
|
109
|
+
#epoch = 0;
|
|
110
|
+
// What each token of `preload()` stands for, until a call reads it.
|
|
111
|
+
#preloads = new WeakMap();
|
|
94
112
|
#destroyed = false;
|
|
95
113
|
constructor(config) {
|
|
96
114
|
if (config)
|
|
@@ -129,8 +147,26 @@ class I18nCore {
|
|
|
129
147
|
// Loader locales are sanitized once, when the config resolves them, and
|
|
130
148
|
// table locales once, when their data arrives; a custom `sanitizeLocales`
|
|
131
149
|
// need not be idempotent.
|
|
132
|
-
return
|
|
150
|
+
return this.#served();
|
|
133
151
|
});
|
|
152
|
+
// Svelte memoizes a server `$derived` only when it is created during a
|
|
153
|
+
// render, which an instance is not, so `locales` is not memoized there.
|
|
154
|
+
#loaderLocales = { loaders: noLoaders, locales: [] };
|
|
155
|
+
/**
|
|
156
|
+
* The locales the config and the tables serve, as a fresh array. The
|
|
157
|
+
* loaders' are read once per loader list, the tables' on every call: a
|
|
158
|
+
* navigation to new params replaces the tables, so a memo keyed by them
|
|
159
|
+
* would miss on each one, adding a lookup and an entry per navigation,
|
|
160
|
+
* held until a later collection.
|
|
161
|
+
*/
|
|
162
|
+
#served() {
|
|
163
|
+
if (!this.#config)
|
|
164
|
+
return [];
|
|
165
|
+
const { loaders = noLoaders } = this.#config;
|
|
166
|
+
if (this.#loaderLocales.loaders !== loaders)
|
|
167
|
+
this.#loaderLocales = { loaders, locales: servedLocales(loaders, {}) };
|
|
168
|
+
return unique([...this.#loaderLocales.locales, ...Object.keys(this.#translations)]);
|
|
169
|
+
}
|
|
134
170
|
initialized = $derived(this.#locale !== undefined && this.#route !== undefined && Object.keys(this.#translations).length > 0);
|
|
135
171
|
/**
|
|
136
172
|
* Translates `key` for the active locale. Reactive on two levels: the
|
|
@@ -191,7 +227,6 @@ class I18nCore {
|
|
|
191
227
|
// A reconfiguration can swap loaders or cache policy — bookkeeping from
|
|
192
228
|
// the previous config must not suppress the new loaders.
|
|
193
229
|
this.invalidate();
|
|
194
|
-
this.#wanted.clear();
|
|
195
230
|
this.#handOnDeliveries(loaders);
|
|
196
231
|
if (translations)
|
|
197
232
|
this.addTranslations(translations);
|
|
@@ -228,7 +263,7 @@ class I18nCore {
|
|
|
228
263
|
setLocale = (locale) => untrack(() => {
|
|
229
264
|
if (!locale || this.#inert('setLocale') || this.#unserved(locale))
|
|
230
265
|
return Promise.resolve();
|
|
231
|
-
const call = this.#ask();
|
|
266
|
+
const call = this.#ask({ locale, route: this.#route });
|
|
232
267
|
if (locale !== this.#requestedLocale) {
|
|
233
268
|
logger.debug(`Setting '${locale}' locale.`);
|
|
234
269
|
this.#requestedLocale = locale;
|
|
@@ -237,16 +272,21 @@ class I18nCore {
|
|
|
237
272
|
// loaded", which may mean joining a load already in flight.
|
|
238
273
|
return this.#stand(call, this.#route !== undefined ? this.#load(locale, this.#route, call) : Promise.resolve());
|
|
239
274
|
});
|
|
240
|
-
|
|
275
|
+
/**
|
|
276
|
+
* With `{ preloaded }`, the token of a `preload()` for the requested locale
|
|
277
|
+
* and this route, it shows what that preload fetched — see
|
|
278
|
+
* `loadTranslations()`.
|
|
279
|
+
*/
|
|
280
|
+
setRoute = (input, options) => untrack(() => {
|
|
241
281
|
if (this.#inert('setRoute'))
|
|
242
282
|
return Promise.resolve();
|
|
243
283
|
const route = withoutBasePath(input, this.#config?.basePath);
|
|
244
|
-
const call = this.#ask();
|
|
284
|
+
const call = this.#ask({ locale: this.#requestedLocale, route });
|
|
245
285
|
if (route !== this.#route) {
|
|
246
286
|
logger.debug(`Setting '${route}' route.`);
|
|
247
287
|
this.#route = route;
|
|
248
288
|
}
|
|
249
|
-
return this.#stand(call, this.#requestedLocale !== undefined ? this.#load(this.#requestedLocale, route, call) : Promise.resolve());
|
|
289
|
+
return this.#stand(call, this.#requestedLocale !== undefined ? this.#load(this.#requestedLocale, route, call, undefined, options?.preloaded) : Promise.resolve());
|
|
250
290
|
});
|
|
251
291
|
/**
|
|
252
292
|
* `{ activate: false }` only fills the tables: it leaves the requested
|
|
@@ -257,17 +297,89 @@ class I18nCore {
|
|
|
257
297
|
* it. It
|
|
258
298
|
* leaves `cache` expiry to the next activating trigger. A loader's
|
|
259
299
|
* `redirect()` or `error()` below 500 rejects it all the same.
|
|
300
|
+
*
|
|
301
|
+
* `{ preloaded }` hands an activating call the token of a `preload()` for
|
|
302
|
+
* the same locale and route. The call then evaluates no `cache` window — the
|
|
303
|
+
* preload did — and shows what the preload fetched instead of fetching it
|
|
304
|
+
* again, at once when nothing else is left to fetch, and otherwise with the
|
|
305
|
+
* rest, in one go. It fetches what the preload did not deliver, and what it
|
|
306
|
+
* delivered to a loader that shows something else since — a seed or
|
|
307
|
+
* another delivery replaced what it showed — wherever the call has that
|
|
308
|
+
* loader to load anyway (one with `cache: false`, or one whose record an
|
|
309
|
+
* expiry dropped, say). A token serves one call: the first activating call
|
|
310
|
+
* that reads it spends it, whether or not it serves that call, even with
|
|
311
|
+
* nothing left to fetch; a call left without a locale to show — handed an
|
|
312
|
+
* empty one or one nothing serves, say — reads none. Another instance's,
|
|
313
|
+
* one of another locale or route, one spent already and one older than a
|
|
314
|
+
* `loadConfig()` or an `invalidate()` are ignored, as is any with
|
|
315
|
+
* `{ activate: false }`, which leaves it unspent. A window a later request
|
|
316
|
+
* found elapsed does not void it: the preload judged the window at its own
|
|
317
|
+
* request.
|
|
260
318
|
*/
|
|
261
|
-
loadTranslations = (locale, route, { activate = true } = {}) => untrack(() => {
|
|
319
|
+
loadTranslations = (locale, route, { activate = true, preloaded } = {}) => untrack(() => {
|
|
262
320
|
if (!locale || this.#inert('loadTranslations') || this.#unserved(locale))
|
|
263
321
|
return Promise.resolve();
|
|
264
322
|
const target = route === undefined ? this.#route ?? '' : withoutBasePath(route, this.#config?.basePath);
|
|
265
323
|
if (!activate)
|
|
266
324
|
return this.#load(locale, target);
|
|
267
|
-
const call = this.#ask();
|
|
325
|
+
const call = this.#ask({ locale, route: target });
|
|
268
326
|
this.#requestedLocale = locale;
|
|
269
327
|
this.#route = target;
|
|
270
|
-
return this.#stand(call, this.#load(locale, target, call));
|
|
328
|
+
return this.#stand(call, this.#load(locale, target, call, undefined, preloaded));
|
|
329
|
+
});
|
|
330
|
+
/**
|
|
331
|
+
* A request for `locale` on `route` (the current route by default) that
|
|
332
|
+
* shows nothing new by itself: what a router's `load` runs ahead of a
|
|
333
|
+
* navigation, a hover's included. Like an activating call it evaluates the
|
|
334
|
+
* `cache` window, ends the pass a hand-off serves, and runs a loader with
|
|
335
|
+
* `cache: false` — sharing a fetch of it for the same params and route
|
|
336
|
+
* already in flight. Like `{ activate: false }` it writes neither the locale
|
|
337
|
+
* nor the route, does not count towards `loading`, and lands what it fetched
|
|
338
|
+
* as a warm load does. It resolves to a token for the next activating call of
|
|
339
|
+
* that locale and route, which shows what it fetched (see
|
|
340
|
+
* `loadTranslations()`), and to `undefined` once the instance was destroyed
|
|
341
|
+
* or when nothing serves the locale. A loader's `redirect()` or `error()`
|
|
342
|
+
* below 500 rejects it, unless it shares its load with an activating call,
|
|
343
|
+
* whose outcome it then gets; one nobody awaits never becomes an unhandled
|
|
344
|
+
* rejection.
|
|
345
|
+
*/
|
|
346
|
+
preload = (locale, route) => untrack(() => {
|
|
347
|
+
const resolved = !locale || this.#inert('preload') || this.#unserved(locale) ? undefined : this.#resolveLocale(locale);
|
|
348
|
+
if (!resolved)
|
|
349
|
+
return Promise.resolve(undefined);
|
|
350
|
+
const target = route === undefined ? this.#route ?? '' : withoutBasePath(route, this.#config?.basePath);
|
|
351
|
+
this.#invalidateExpired(resolved, this.#config?.fallbackLocale);
|
|
352
|
+
this.#handedOff.clear();
|
|
353
|
+
const config = this.#config;
|
|
354
|
+
const epoch = this.#epoch;
|
|
355
|
+
const matching = this.#matchLoaders(resolved, target);
|
|
356
|
+
const key = this.#inflightKey(resolved, target, matching);
|
|
357
|
+
const requests = this.#unloaded(matching);
|
|
358
|
+
const fresh = requests.filter(({ loader }) => loader.cache === false);
|
|
359
|
+
const fetching = new Set(this.#fetches);
|
|
360
|
+
const isFresh = ({ request }) => fresh.some(({ loader, signature }) => request.loader === loader && request.signature === signature);
|
|
361
|
+
const earlier = Array.from(this.#inflight).filter(({ fetches }) => fetches.some((fetch) => fetch.route !== target && isFresh(fetch)));
|
|
362
|
+
// A fetch of a loader with `cache: false` that delivered before the request
|
|
363
|
+
// is no answer to it, so a load that holds one is not joined.
|
|
364
|
+
const joinable = requests.length ? this.#joinable(key, requests) : undefined;
|
|
365
|
+
const joined = joinable?.fetches.filter(isFresh).every((fetch) => this.#fetches.has(fetch)) ? joinable : undefined;
|
|
366
|
+
const entry = joined ?? (requests.length ? this.#start(resolved, target, key, requests, [], []) : undefined);
|
|
367
|
+
const fetches = entry?.fetches ?? [];
|
|
368
|
+
const preloaded = (entry?.promise ?? Promise.resolve()).then(() => Promise.all(fetches.map(({ outcome }) => outcome))).then((fetched) => {
|
|
369
|
+
if (this.#destroyed)
|
|
370
|
+
return undefined;
|
|
371
|
+
const answers = requests.flatMap(({ loader, signature }) => {
|
|
372
|
+
const index = fetches.findIndex(({ request }) => request.loader === loader && request.signature === signature);
|
|
373
|
+
const [delivery] = fetched[index]?.deliveries ?? [];
|
|
374
|
+
const shared = loader.cache === false && (joined !== undefined || fetching.has(fetches[index]));
|
|
375
|
+
return delivery ? [{ delivery, shown: this.#deliveries.get(loader), shared }] : [];
|
|
376
|
+
});
|
|
377
|
+
const token = Object.freeze(Object.create(null));
|
|
378
|
+
this.#preloads.set(token, { config, epoch, locale: resolved, route: target, answers, earlier: earlier.filter((load) => this.#inflight.has(load)) });
|
|
379
|
+
return token;
|
|
380
|
+
});
|
|
381
|
+
preloaded.catch(() => undefined);
|
|
382
|
+
return preloaded;
|
|
271
383
|
});
|
|
272
384
|
/**
|
|
273
385
|
* Loads one namespace for the active locale (or `locale`), whatever the
|
|
@@ -318,6 +430,8 @@ class I18nCore {
|
|
|
318
430
|
* expiry leaves it alone.
|
|
319
431
|
*/
|
|
320
432
|
#invalidate(sanitized, namespace, { expiry = false } = {}) {
|
|
433
|
+
if (!expiry)
|
|
434
|
+
this.#epoch += 1;
|
|
321
435
|
const locales = sanitized === undefined ? Object.keys(this.#namespaceRecords) : [sanitized];
|
|
322
436
|
locales.forEach((recorded) => {
|
|
323
437
|
if (namespace === undefined)
|
|
@@ -441,7 +555,7 @@ class I18nCore {
|
|
|
441
555
|
let kept = relevant;
|
|
442
556
|
if (relevant !== handable) {
|
|
443
557
|
logger.warn(`Leaving a '__proto__' key of locale '${locale}' out of the snapshot: load data cannot carry it.`);
|
|
444
|
-
const lost = Object.keys(handable).filter((key) => !hasOwn(relevant, key) || relevant[key]
|
|
558
|
+
const lost = Object.keys(handable).filter((key) => !hasOwn(relevant, key) || !Object.is(relevant[key], handable[key]));
|
|
445
559
|
stripped.set(locale, lost);
|
|
446
560
|
// The namespace goes without its record, whole: data plain or unrecorded
|
|
447
561
|
// is kept on the client as data no loader delivered, which a plain
|
|
@@ -506,16 +620,10 @@ class I18nCore {
|
|
|
506
620
|
};
|
|
507
621
|
// -- internals --------------------------------------------------------------
|
|
508
622
|
#translate(locale, key, params) {
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
params,
|
|
514
|
-
translations: this.#translations,
|
|
515
|
-
locale,
|
|
516
|
-
fallbackLocale,
|
|
517
|
-
...(hasOwn(rest, 'fallbackValue') ? { fallbackValue: rest.fallbackValue } : {}),
|
|
518
|
-
});
|
|
623
|
+
const config = this.#config;
|
|
624
|
+
const translations = this.#translations;
|
|
625
|
+
const fallbackLocale = config?.fallbackLocale;
|
|
626
|
+
return translate(config, locale, key, params, locale ? read(translations, locale) : undefined, fallbackLocale ? read(translations, fallbackLocale) : undefined);
|
|
519
627
|
}
|
|
520
628
|
/**
|
|
521
629
|
* The fetches of `requests` from `route`, WITHOUT applying their data: each
|
|
@@ -600,16 +708,21 @@ class I18nCore {
|
|
|
600
708
|
* supplied without a loader and from what each of its loaders last
|
|
601
709
|
* delivered, so no key its source dropped survives and a sibling's part
|
|
602
710
|
* stays in place. The preprocessed table of a
|
|
603
|
-
* locale that lost data is derived
|
|
604
|
-
* `preprocess` may have renamed the keys that would
|
|
605
|
-
*
|
|
711
|
+
* locale that lost data is derived whole from the merged raw one, never
|
|
712
|
+
* merged, since a custom `preprocess` may have renamed the keys that would
|
|
713
|
+
* have to go and that table is what `preprocess` makes of the raw one. Both
|
|
714
|
+
* tables are computed before anything is written, so a `preprocess` that throws
|
|
606
715
|
* records no loader and the next trigger fetches it again.
|
|
607
716
|
*/
|
|
608
717
|
#applyDeliveries(applied) {
|
|
609
|
-
const { loaders = [] } = this.#config ?? {};
|
|
610
718
|
// One load, however its deliveries were gathered: a preload claimed with
|
|
611
|
-
// it comes first, yet the loader declared later still wins.
|
|
612
|
-
|
|
719
|
+
// it comes first, yet the loader declared later still wins. Only the order
|
|
720
|
+
// within a locale matters, so each delivery is placed once, among the
|
|
721
|
+
// loaders of its own locale.
|
|
722
|
+
const deliveries = applied
|
|
723
|
+
.map((delivery) => ({ delivery, position: this.#localeLoaders(delivery.loader.locale).indexOf(delivery.loader) }))
|
|
724
|
+
.sort((a, b) => a.position - b.position)
|
|
725
|
+
.map(({ delivery }) => delivery);
|
|
613
726
|
const replaced = deliveries
|
|
614
727
|
.filter(({ loader }) => this.#deliveries.has(loader))
|
|
615
728
|
.map(({ loader }) => loader);
|
|
@@ -625,12 +738,13 @@ class I18nCore {
|
|
|
625
738
|
...acc,
|
|
626
739
|
[locale]: Object.fromEntries(Object.entries(read(acc, locale) ?? {}).filter(([key]) => !isNamespaceKey(key, namespace))),
|
|
627
740
|
}), this.#rawTranslations);
|
|
628
|
-
const
|
|
741
|
+
const derived = unique(replaced.map(({ locale }) => locale));
|
|
742
|
+
const seeded = this.#merged({ raw, translations: this.#translations }, this.#externalOf(replaced), derived);
|
|
629
743
|
const merged = this.#merged(seeded, serialize([
|
|
630
744
|
...deliveries.filter(({ loader }) => !isReplaced(loader)),
|
|
631
745
|
...rebuilt,
|
|
632
|
-
].map(({ loader, data }) => ({ ...loader, data }))));
|
|
633
|
-
const translations =
|
|
746
|
+
].map(({ loader, data }) => ({ ...loader, data }))), derived);
|
|
747
|
+
const translations = derived.reduce((acc, locale) => ({ ...acc, [locale]: this.#preprocess(read(merged.raw, locale)) }), merged.translations);
|
|
634
748
|
deliveries.forEach((delivery) => this.#record(delivery));
|
|
635
749
|
this.#rawTranslations = merged.raw;
|
|
636
750
|
this.#translations = translations;
|
|
@@ -662,10 +776,17 @@ class I18nCore {
|
|
|
662
776
|
const masked = nested === undefined ? delivery.data : maskTranslations(delivery.data, nested) ?? {};
|
|
663
777
|
// Dot notation, as `#preprocess` applies it, merges both spellings of a
|
|
664
778
|
// key into one.
|
|
665
|
-
const dotted =
|
|
779
|
+
const dotted = dotNotates(preprocess)
|
|
666
780
|
? maskOutputKeys(masked, new Set(Object.keys(toDotNotation(Object.fromEntries(own), preprocess === 'preserveArrays') ?? {})), loader.namespace, preprocess === 'preserveArrays') ?? {}
|
|
667
781
|
: masked;
|
|
668
|
-
|
|
782
|
+
const seeded = { ...delivery, data: dotted };
|
|
783
|
+
this.#maskedFrom.set(seeded, this.#maskedFrom.get(delivery) ?? delivery);
|
|
784
|
+
// A call counting on what the loader showed counts on it seeded.
|
|
785
|
+
this.#inflight.forEach(({ unparked }) => unparked.forEach(({ handed }) => {
|
|
786
|
+
if (handed?.shown === delivery)
|
|
787
|
+
handed.shown = seeded;
|
|
788
|
+
}));
|
|
789
|
+
this.#deliveries.set(loader, seeded);
|
|
669
790
|
});
|
|
670
791
|
}
|
|
671
792
|
/**
|
|
@@ -772,9 +893,11 @@ class I18nCore {
|
|
|
772
893
|
/**
|
|
773
894
|
* Both tables with data keyed by sanitized locales merged in. Pure, so a
|
|
774
895
|
* caller that writes only once both are computed keeps them consistent when
|
|
775
|
-
* a `preprocess` throws.
|
|
896
|
+
* a `preprocess` throws. `derived` names the locales whose preprocessed
|
|
897
|
+
* table the caller derives from the merged raw table itself; theirs keeps
|
|
898
|
+
* its place and is left as it was.
|
|
776
899
|
*/
|
|
777
|
-
#merged(tables, sanitized) {
|
|
900
|
+
#merged(tables, sanitized, derived = []) {
|
|
778
901
|
logger.debug('Adding translations...');
|
|
779
902
|
const translationLocales = Object.keys(sanitized);
|
|
780
903
|
return {
|
|
@@ -782,10 +905,18 @@ class I18nCore {
|
|
|
782
905
|
...acc,
|
|
783
906
|
[locale]: mergeTranslations(read(acc, locale) || {}, read(sanitized, locale) ?? {}, locale),
|
|
784
907
|
}), tables.raw),
|
|
785
|
-
translations: translationLocales.reduce((acc, locale) =>
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
908
|
+
translations: translationLocales.reduce((acc, locale) => {
|
|
909
|
+
const table = read(acc, locale);
|
|
910
|
+
if (derived.includes(locale))
|
|
911
|
+
return { ...acc, [locale]: table ?? {} };
|
|
912
|
+
// Checked right before `#preprocess` reads the same config, so the
|
|
913
|
+
// check and the preprocess that runs agree.
|
|
914
|
+
const fresh = table === undefined && dotNotates(this.#config?.preprocess);
|
|
915
|
+
const input = this.#preprocess(read(sanitized, locale));
|
|
916
|
+
// The dot notation builds the table fresh, so a locale without one yet
|
|
917
|
+
// takes it as it is.
|
|
918
|
+
return { ...acc, [locale]: fresh ? input : mergeTranslations(table || {}, input, locale) };
|
|
919
|
+
}, tables.translations),
|
|
789
920
|
};
|
|
790
921
|
}
|
|
791
922
|
/**
|
|
@@ -820,7 +951,7 @@ class I18nCore {
|
|
|
820
951
|
const { fallbackLocale } = this.#config ?? {};
|
|
821
952
|
if (!inputLocale && !fallbackLocale)
|
|
822
953
|
return undefined;
|
|
823
|
-
const all = this
|
|
954
|
+
const all = this.#served();
|
|
824
955
|
// Nothing to match against yet; sanitizing here would only emit a
|
|
825
956
|
// non-standard warning for a lookup that cannot succeed anyway.
|
|
826
957
|
if (!all.length)
|
|
@@ -841,7 +972,7 @@ class I18nCore {
|
|
|
841
972
|
* known, any request is kept: a config loaded later may serve it.
|
|
842
973
|
*/
|
|
843
974
|
#unserved(locale) {
|
|
844
|
-
if (!this.
|
|
975
|
+
if (!this.#served().length || this.#resolveLocale(locale) !== undefined)
|
|
845
976
|
return false;
|
|
846
977
|
logger.debug(`Ignoring '${locale}' locale — nothing serves it.`);
|
|
847
978
|
return true;
|
|
@@ -883,29 +1014,49 @@ class I18nCore {
|
|
|
883
1014
|
/** The namespaces of `sanitizedLocale` `snapshot()` leaves out — see there. */
|
|
884
1015
|
#unsnapshottable(sanitizedLocale, withRecords) {
|
|
885
1016
|
const { loaders = [] } = this.#config ?? {};
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1017
|
+
// Grouped in one pass: a filter per namespace would be quadratic in the
|
|
1018
|
+
// number of loaders.
|
|
1019
|
+
const byNamespace = new Map();
|
|
1020
|
+
loaders.forEach((loader) => {
|
|
1021
|
+
if (loader.locale !== sanitizedLocale)
|
|
1022
|
+
return;
|
|
1023
|
+
const feeding = byNamespace.get(loader.namespace);
|
|
1024
|
+
if (feeding)
|
|
1025
|
+
feeding.push(loader);
|
|
1026
|
+
else
|
|
1027
|
+
byNamespace.set(loader.namespace, [loader]);
|
|
1028
|
+
});
|
|
1029
|
+
const named = new Set(read(this.#namespaceRecords, sanitizedLocale));
|
|
1030
|
+
return Array.from(byNamespace).filter(([namespace, feeding]) => {
|
|
889
1031
|
const several = feeding.length > 1;
|
|
890
1032
|
const params = feeding.some(({ routes }) => capturesParams(routes));
|
|
891
1033
|
// Seeded data alone would keep the client's loaders from ever running.
|
|
892
|
-
const delivered = feeding.some((loader) => this.#loaderRecords.has(loader))
|
|
893
|
-
|| (read(this.#namespaceRecords, sanitizedLocale) || []).includes(namespace);
|
|
1034
|
+
const delivered = feeding.some((loader) => this.#loaderRecords.has(loader)) || named.has(namespace);
|
|
894
1035
|
if (!withRecords)
|
|
895
1036
|
return several || params || !delivered;
|
|
896
1037
|
// Only a record lets the client replace the data once the params change.
|
|
897
1038
|
return params && (several || feeding.some((loader) => loader.id === null || !this.#loaderRecords.has(loader)));
|
|
898
|
-
});
|
|
1039
|
+
}).map(([namespace]) => namespace);
|
|
1040
|
+
}
|
|
1041
|
+
/** The loaders of `sanitizedLocale` and of the fallback locale, in `loaders` order. */
|
|
1042
|
+
#localeLoaders(sanitizedLocale) {
|
|
1043
|
+
const config = this.#config;
|
|
1044
|
+
if (this.#byLocale.config !== config)
|
|
1045
|
+
this.#byLocale = { config, loaders: new Map() };
|
|
1046
|
+
const known = this.#byLocale.loaders.get(sanitizedLocale);
|
|
1047
|
+
if (known)
|
|
1048
|
+
return known;
|
|
1049
|
+
const { loaders = [], fallbackLocale } = config ?? {};
|
|
1050
|
+
const selected = loaders.filter(({ locale }) => locale === sanitizedLocale || locale === fallbackLocale);
|
|
1051
|
+
this.#byLocale.loaders.set(sanitizedLocale, selected);
|
|
1052
|
+
return selected;
|
|
899
1053
|
}
|
|
900
1054
|
/**
|
|
901
1055
|
* The loaders of `sanitizedLocale` (and the fallback locale) whose routes
|
|
902
1056
|
* match `route`, with the params the route yields for each.
|
|
903
1057
|
*/
|
|
904
1058
|
#matchLoaders(sanitizedLocale, route) {
|
|
905
|
-
|
|
906
|
-
return loaders.flatMap((loader) => {
|
|
907
|
-
if (loader.locale !== sanitizedLocale && loader.locale !== fallbackLocale)
|
|
908
|
-
return [];
|
|
1059
|
+
return this.#localeLoaders(sanitizedLocale).flatMap((loader) => {
|
|
909
1060
|
const params = routeParams(loader.routes, route);
|
|
910
1061
|
return params ? [{ loader, params, signature: paramsSignature(params) }] : [];
|
|
911
1062
|
});
|
|
@@ -917,12 +1068,9 @@ class I18nCore {
|
|
|
917
1068
|
* delivered last serves; only a loader with no record is asked, for none.
|
|
918
1069
|
*/
|
|
919
1070
|
#matchNamespace(sanitizedLocale, namespace, route) {
|
|
920
|
-
|
|
921
|
-
return loaders.flatMap((loader) => {
|
|
1071
|
+
return this.#localeLoaders(sanitizedLocale).flatMap((loader) => {
|
|
922
1072
|
if (loader.namespace !== namespace)
|
|
923
1073
|
return [];
|
|
924
|
-
if (loader.locale !== sanitizedLocale && loader.locale !== fallbackLocale)
|
|
925
|
-
return [];
|
|
926
1074
|
const params = routeParams(loader.routes, route);
|
|
927
1075
|
if (params)
|
|
928
1076
|
return [{ loader, params, signature: paramsSignature(params) }];
|
|
@@ -963,22 +1111,24 @@ class I18nCore {
|
|
|
963
1111
|
* keep params its route asked for. Returns what it replaced.
|
|
964
1112
|
*/
|
|
965
1113
|
#want(matching) {
|
|
966
|
-
const
|
|
967
|
-
|
|
968
|
-
...loaders.map((loader) => [loader, null]),
|
|
969
|
-
...matching.map(({ loader, signature }) => [loader, signature]),
|
|
970
|
-
]);
|
|
971
|
-
const replaced = new Map(Array.from(wants.keys(), (loader) => [loader, this.#wanted.get(loader)]));
|
|
972
|
-
wants.forEach((signature, loader) => this.#wanted.set(loader, signature));
|
|
1114
|
+
const replaced = this.#wanted;
|
|
1115
|
+
this.#wanted = { config: this.#config, signatures: new Map(matching.map(({ loader, signature }) => [loader, signature])) };
|
|
973
1116
|
return replaced;
|
|
974
1117
|
}
|
|
1118
|
+
/** The params the next trigger asks `loader` for: `null` for none, `undefined` for any. */
|
|
1119
|
+
#wantedOf(loader) {
|
|
1120
|
+
const { config, signatures } = this.#wanted;
|
|
1121
|
+
if (config === undefined || config !== this.#config)
|
|
1122
|
+
return undefined;
|
|
1123
|
+
return signatures.get(loader) ?? null;
|
|
1124
|
+
}
|
|
975
1125
|
/**
|
|
976
|
-
* Starts an activating call
|
|
977
|
-
* about to replace — with the params `#load`
|
|
978
|
-
* it fail.
|
|
1126
|
+
* Starts an activating call of the request `asked`: keeps the requested
|
|
1127
|
+
* locale and the route it is about to replace — with the params `#load`
|
|
1128
|
+
* adds, what is put back should it fail.
|
|
979
1129
|
*/
|
|
980
|
-
#ask() {
|
|
981
|
-
const call = { replaced: { requestedLocale: this.#requestedLocale, route: this.#route
|
|
1130
|
+
#ask(asked) {
|
|
1131
|
+
const call = { asked, replaced: { requestedLocale: this.#requestedLocale, route: this.#route }, failed: false, threw: [], resumed: false };
|
|
982
1132
|
this.#calls = [...this.#calls, call];
|
|
983
1133
|
return call;
|
|
984
1134
|
}
|
|
@@ -1008,15 +1158,24 @@ class I18nCore {
|
|
|
1008
1158
|
});
|
|
1009
1159
|
const before = { locale: this.#requestedLocale, route: this.#route };
|
|
1010
1160
|
let threw;
|
|
1161
|
+
// What the calls whose route stands asked for: an undo back to one of
|
|
1162
|
+
// those requests puts back a request that failed.
|
|
1163
|
+
let standing = [];
|
|
1011
1164
|
for (let last = this.#calls.at(-1); last?.failed; last = this.#calls.at(-1)) {
|
|
1165
|
+
if (last.routeStands)
|
|
1166
|
+
standing = [...standing, last.asked];
|
|
1012
1167
|
this.#undo(last);
|
|
1013
1168
|
this.#calls = this.#calls.slice(0, -1);
|
|
1014
1169
|
threw = [...threw ?? [], ...last.threw];
|
|
1015
1170
|
}
|
|
1016
1171
|
if (!threw)
|
|
1017
1172
|
return undefined;
|
|
1018
|
-
|
|
1019
|
-
|
|
1173
|
+
// Back at a request that failed: what is shown keeps its records, and it
|
|
1174
|
+
// is not loaded again by itself.
|
|
1175
|
+
const resolved = this.#resolveLocale(this.#requestedLocale);
|
|
1176
|
+
const again = standing.some(({ locale, route }) => route === this.#route && this.#resolveLocale(locale) === resolved);
|
|
1177
|
+
this.#rewant(!again);
|
|
1178
|
+
const changed = !again && (before.locale !== this.#requestedLocale || before.route !== this.#route);
|
|
1020
1179
|
if (changed) {
|
|
1021
1180
|
const onRoute = this.#route ? ` on '${this.#route}' route` : '';
|
|
1022
1181
|
logger.debug(`Undoing the failed calls: the request is back to '${this.#requestedLocale}' locale${onRoute}.`);
|
|
@@ -1026,33 +1185,48 @@ class I18nCore {
|
|
|
1026
1185
|
/**
|
|
1027
1186
|
* Puts back what a failed call replaced. Whichever of the locale and the
|
|
1028
1187
|
* route was not asked for before it stands: it is all there is for the next
|
|
1029
|
-
* trigger to load.
|
|
1188
|
+
* trigger to load. Of a call whose route stands, only the locale goes back.
|
|
1030
1189
|
*/
|
|
1031
|
-
#undo({ replaced: { requestedLocale, route, wanted } }) {
|
|
1190
|
+
#undo({ replaced: { requestedLocale, route, wanted }, routeStands }) {
|
|
1032
1191
|
if (requestedLocale !== undefined)
|
|
1033
1192
|
this.#requestedLocale = requestedLocale;
|
|
1193
|
+
if (routeStands)
|
|
1194
|
+
return;
|
|
1034
1195
|
if (route !== undefined)
|
|
1035
1196
|
this.#route = route;
|
|
1036
|
-
wanted
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1197
|
+
if (wanted)
|
|
1198
|
+
this.#wanted = wanted;
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Fails the calls whose data could not be applied, as control flow fails
|
|
1202
|
+
* them, except that the route stands, whichever undo reaches them or a call
|
|
1203
|
+
* before them: the caller is on it already. A request put back for that
|
|
1204
|
+
* route is loaded there; one that is the same request again is not, as its
|
|
1205
|
+
* data is what failed.
|
|
1206
|
+
*/
|
|
1207
|
+
#failApply(calls) {
|
|
1208
|
+
const last = Math.max(...calls.map((call) => this.#calls.indexOf(call)));
|
|
1209
|
+
this.#calls.slice(0, last + 1).forEach((call) => { call.routeStands = true; });
|
|
1210
|
+
const undone = this.#fail(calls, []);
|
|
1211
|
+
if (undone?.changed)
|
|
1212
|
+
this.#settleUndo(undone);
|
|
1042
1213
|
}
|
|
1043
1214
|
/**
|
|
1044
1215
|
* The loaders the request an undo left in place selects: wanted for the
|
|
1045
1216
|
* params its route yields, and no longer recorded as loaded for others —
|
|
1046
1217
|
* whatever another load delivered for them meanwhile stays displayed until
|
|
1047
|
-
* the request loads them again. Runs before the
|
|
1048
|
-
* its other loaders delivered, so that is filtered
|
|
1218
|
+
* the request loads them again, unless `drop` is off. Runs before the
|
|
1219
|
+
* failed load applies what its other loaders delivered, so that is filtered
|
|
1220
|
+
* by what is wanted now.
|
|
1049
1221
|
*/
|
|
1050
|
-
#rewant() {
|
|
1222
|
+
#rewant(drop) {
|
|
1051
1223
|
const locale = this.#resolveLocale(this.#requestedLocale);
|
|
1052
1224
|
if (!locale || this.#route === undefined)
|
|
1053
1225
|
return;
|
|
1054
1226
|
const matching = this.#matchLoaders(locale, this.#route);
|
|
1055
1227
|
this.#want(matching);
|
|
1228
|
+
if (!drop)
|
|
1229
|
+
return;
|
|
1056
1230
|
matching.forEach(({ loader, signature }) => {
|
|
1057
1231
|
if (this.#loaderRecords.has(loader) && this.#loaderRecords.get(loader) !== signature)
|
|
1058
1232
|
this.#loaderRecords.delete(loader);
|
|
@@ -1084,14 +1258,22 @@ class I18nCore {
|
|
|
1084
1258
|
const activating = Array.from(this.#inflight).some((entry) => entry.key === key && entry.calls.length && !entry.severed.size && entry.config === this.#config);
|
|
1085
1259
|
if (activating)
|
|
1086
1260
|
return;
|
|
1087
|
-
|
|
1261
|
+
// No caller waits on it: what fails here is logged, and never replaces
|
|
1262
|
+
// what the failed load rejects with.
|
|
1263
|
+
try {
|
|
1264
|
+
this.#applyWanted(this.#claimParked(matching).map(({ delivery }) => delivery));
|
|
1265
|
+
}
|
|
1266
|
+
catch (error) {
|
|
1267
|
+
logError(loadFailure(locale, route), error);
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1088
1270
|
this.#activate(locale);
|
|
1089
1271
|
return;
|
|
1090
1272
|
}
|
|
1091
1273
|
const threw = missing.some(({ loader, signature }) => controlFlow.some((flow) => flow.loader === loader && flow.signature === signature));
|
|
1092
1274
|
if (!changed || threw)
|
|
1093
1275
|
return;
|
|
1094
|
-
const call = this.#ask();
|
|
1276
|
+
const call = this.#ask({ locale: this.#requestedLocale, route });
|
|
1095
1277
|
void this.#stand(call, this.#load(locale, route, call));
|
|
1096
1278
|
}
|
|
1097
1279
|
/**
|
|
@@ -1125,16 +1307,17 @@ class I18nCore {
|
|
|
1125
1307
|
* activating `call` — never registers either, until an activating trigger
|
|
1126
1308
|
* joins it.
|
|
1127
1309
|
*/
|
|
1128
|
-
#load(requestedLocale, route, call, namespace) {
|
|
1310
|
+
#load(requestedLocale, route, call, namespace, preloaded) {
|
|
1129
1311
|
const locale = this.#resolveLocale(requestedLocale);
|
|
1130
1312
|
if (!locale)
|
|
1131
1313
|
return Promise.resolve();
|
|
1314
|
+
const preload = call ? this.#preloadOf(preloaded, locale, route) : undefined;
|
|
1132
1315
|
// Expiry is evaluated per activating trigger, BEFORE the in-flight check.
|
|
1133
1316
|
// That order is safe: a locale is stamped only once its data arrived, so a
|
|
1134
1317
|
// shared in-flight load cannot be invalidated by its own duplicates. A
|
|
1135
1318
|
// warm trigger fills the tables and leaves their freshness to the next
|
|
1136
|
-
// activating one.
|
|
1137
|
-
if (call)
|
|
1319
|
+
// activating one, and a call with a preload's token to that preload.
|
|
1320
|
+
if (call && !preload)
|
|
1138
1321
|
this.#invalidateExpired(locale, this.#config?.fallbackLocale);
|
|
1139
1322
|
const matching = namespace === undefined ? this.#matchLoaders(locale, route) : this.#matchNamespace(locale, namespace, route);
|
|
1140
1323
|
// Recorded before the in-flight check, so a trigger joining a load, or one
|
|
@@ -1143,7 +1326,36 @@ class I18nCore {
|
|
|
1143
1326
|
call.replaced.wanted = this.#want(matching);
|
|
1144
1327
|
this.#passHandOff(locale, route);
|
|
1145
1328
|
}
|
|
1146
|
-
return this.#loadSelection(locale, route, this.#inflightKey(locale, route, matching), matching, call ? [call] : []);
|
|
1329
|
+
return this.#loadSelection(locale, route, this.#inflightKey(locale, route, matching), matching, call ? [call] : [], preload);
|
|
1330
|
+
}
|
|
1331
|
+
/** What `token` stands for while it serves a call for `locale` on `route`. A call reads it once, used or not. */
|
|
1332
|
+
#preloadOf(token, locale, route) {
|
|
1333
|
+
if (!token)
|
|
1334
|
+
return undefined;
|
|
1335
|
+
const preload = this.#preloads.get(token);
|
|
1336
|
+
this.#preloads.delete(token);
|
|
1337
|
+
if (preload && preload.config === this.#config && preload.epoch === this.#epoch && preload.locale === locale && preload.route === route)
|
|
1338
|
+
return preload;
|
|
1339
|
+
logger.debug('Ignoring a preload token that does not serve this call.');
|
|
1340
|
+
return undefined;
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* The requests `preload` answered: while the loader shows what it showed as
|
|
1344
|
+
* the preload resolved, or the answer itself, and — for a loader with
|
|
1345
|
+
* `cache: false` — no load of another route that was fetching it then is
|
|
1346
|
+
* still in flight, to land after the answer.
|
|
1347
|
+
*/
|
|
1348
|
+
#answered({ answers, earlier }, requests) {
|
|
1349
|
+
return requests.flatMap((request) => {
|
|
1350
|
+
const { loader, signature } = request;
|
|
1351
|
+
const answer = answers.find(({ delivery }) => delivery.loader === loader && delivery.signature === signature);
|
|
1352
|
+
const shown = this.#deliveries.get(loader);
|
|
1353
|
+
if (!answer || (shown !== answer.shown && shown !== answer.delivery))
|
|
1354
|
+
return [];
|
|
1355
|
+
const overtaken = loader.cache === false && earlier.some((load) => this.#inflight.has(load) && !load.severed.has(loader)
|
|
1356
|
+
&& load.fetches.some((fetch) => fetch.request.loader === loader && fetch.request.signature === signature));
|
|
1357
|
+
return overtaken ? [] : [{ request, answer }];
|
|
1358
|
+
});
|
|
1147
1359
|
}
|
|
1148
1360
|
/**
|
|
1149
1361
|
* The key of a load in flight: what the trigger selected and the route it
|
|
@@ -1152,27 +1364,81 @@ class I18nCore {
|
|
|
1152
1364
|
* navigation to it would wait on itself.
|
|
1153
1365
|
*/
|
|
1154
1366
|
#inflightKey(locale, route, matching) {
|
|
1155
|
-
const
|
|
1156
|
-
return JSON.stringify([locale, route, ...matching.map(({ loader, signature }) => [
|
|
1367
|
+
const candidates = this.#localeLoaders(locale);
|
|
1368
|
+
return JSON.stringify([locale, route, ...matching.map(({ loader, signature }) => [candidates.indexOf(loader), signature])]);
|
|
1157
1369
|
}
|
|
1158
|
-
/**
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1370
|
+
/**
|
|
1371
|
+
* Joins the load in flight under `key` that delivers what `selected` lacks,
|
|
1372
|
+
* or starts one. What `preload` answered lands with it instead of being
|
|
1373
|
+
* fetched.
|
|
1374
|
+
*/
|
|
1375
|
+
#loadSelection(locale, route, key, selected, calls, preload) {
|
|
1376
|
+
const unloaded = this.#unloaded(selected);
|
|
1377
|
+
const answered = preload ? this.#answered(preload, unloaded) : [];
|
|
1378
|
+
const requests = answered.length ? unloaded.filter((request) => !answered.some((answer) => answer.request === request)) : unloaded;
|
|
1379
|
+
const handed = answered
|
|
1380
|
+
.filter(({ request, answer }) => this.#deliveries.get(request.loader) !== answer.delivery)
|
|
1381
|
+
.map(({ request, answer }) => ({ request, delivery: answer.delivery, handed: { shown: answer.shown } }));
|
|
1382
|
+
const unparked = calls.length ? [...this.#claimParked(selected), ...handed] : [];
|
|
1383
|
+
const refresh = answered.filter(({ answer }) => answer.shared);
|
|
1384
|
+
if (calls.length)
|
|
1385
|
+
this.#rerecord(answered.map(({ answer }) => answer.delivery).filter((delivery) => this.#deliveries.get(delivery.loader) === delivery));
|
|
1162
1386
|
if (!requests.length) {
|
|
1163
1387
|
// Nothing to fetch — the locale still becomes active (its data is
|
|
1164
1388
|
// already present, parked or it has no loaders).
|
|
1165
1389
|
if (calls.length) {
|
|
1166
|
-
|
|
1390
|
+
try {
|
|
1391
|
+
this.#applyWanted(unparked.map(({ delivery }) => delivery));
|
|
1392
|
+
}
|
|
1393
|
+
catch (error) {
|
|
1394
|
+
logError(loadFailure(locale, route), error);
|
|
1395
|
+
this.#failApply(calls);
|
|
1396
|
+
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- passed on as thrown
|
|
1397
|
+
const promise = Promise.reject(error);
|
|
1398
|
+
promise.catch(() => undefined);
|
|
1399
|
+
return promise;
|
|
1400
|
+
}
|
|
1167
1401
|
this.#activate(locale);
|
|
1168
1402
|
}
|
|
1403
|
+
this.#refresh(locale, route, refresh);
|
|
1169
1404
|
return Promise.resolve();
|
|
1170
1405
|
}
|
|
1171
|
-
// What is parked lands with the load, so a call it fails puts it back.
|
|
1172
|
-
|
|
1406
|
+
// What is parked lands with the load, so a call it fails puts it back. A
|
|
1407
|
+
// load in flight may deliver what the token answered a second time.
|
|
1408
|
+
const inflight = answered.length ? undefined : this.#joinable(key, requests);
|
|
1173
1409
|
if (inflight)
|
|
1174
1410
|
return this.#join(inflight, calls, unparked);
|
|
1175
|
-
|
|
1411
|
+
const { promise } = this.#start(locale, route, key, requests, calls, unparked);
|
|
1412
|
+
if (refresh.length)
|
|
1413
|
+
promise.then(() => this.#refresh(locale, route, refresh), () => undefined);
|
|
1414
|
+
return promise;
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Records again what a preload showed itself and an expiry since dropped the
|
|
1418
|
+
* records of: the preload judged the window at its request.
|
|
1419
|
+
*/
|
|
1420
|
+
#rerecord(shown) {
|
|
1421
|
+
const dropped = shown.filter(({ loader, signature }) => this.#loaderRecords.get(loader) !== signature);
|
|
1422
|
+
dropped.forEach(({ loader, signature }) => this.#loaderRecords.set(loader, signature));
|
|
1423
|
+
this.#stamp(dropped.filter(({ loader }) => loader.cache !== false).map(({ loader }) => loader.locale));
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* Fetches again, as a warm load, what a call showed from a preload's shared
|
|
1427
|
+
* fetch: that fetch started before the preload's request. Only while it is
|
|
1428
|
+
* shown and its params are still wanted — a later delivery is no older than
|
|
1429
|
+
* the request.
|
|
1430
|
+
*/
|
|
1431
|
+
#refresh(locale, route, answered) {
|
|
1432
|
+
const requests = this.#destroyed ? [] : answered
|
|
1433
|
+
.filter(({ request, answer }) => this.#shows(request.loader, answer.delivery) && this.#isWanted(request))
|
|
1434
|
+
.map(({ request }) => request);
|
|
1435
|
+
if (requests.length)
|
|
1436
|
+
void this.#loadSelection(locale, route, this.#inflightKey(locale, route, requests), requests, []);
|
|
1437
|
+
}
|
|
1438
|
+
/** Whether `loader` still shows `delivery`, seeded or not. A reconfiguration replaces every delivery. */
|
|
1439
|
+
#shows(loader, delivery) {
|
|
1440
|
+
const shown = this.#deliveries.get(loader);
|
|
1441
|
+
return shown !== undefined && (this.#maskedFrom.get(shown) ?? shown) === delivery;
|
|
1176
1442
|
}
|
|
1177
1443
|
/** The load in flight under `key` that delivers every one of `requests`. */
|
|
1178
1444
|
#joinable(key, requests) {
|
|
@@ -1199,7 +1465,7 @@ class I18nCore {
|
|
|
1199
1465
|
* before a trigger asked.
|
|
1200
1466
|
*/
|
|
1201
1467
|
#isWanted({ loader, signature }) {
|
|
1202
|
-
const wanted = this.#
|
|
1468
|
+
const wanted = this.#wantedOf(loader);
|
|
1203
1469
|
if (wanted === undefined)
|
|
1204
1470
|
return true;
|
|
1205
1471
|
return (wanted ?? '') === signature;
|
|
@@ -1210,11 +1476,12 @@ class I18nCore {
|
|
|
1210
1476
|
* others: a warm load parks what would replace it, unless it asks for no
|
|
1211
1477
|
* params — `loadNamespace()` off the loader's routes. What an activating load
|
|
1212
1478
|
* counts on stays parked until it settles. A loader with `cache: false` runs
|
|
1213
|
-
* on every trigger that selects it, so nothing of it is parked.
|
|
1479
|
+
* on every trigger that selects it, so nothing of it is parked. What fails
|
|
1480
|
+
* to apply is fetched again.
|
|
1214
1481
|
*/
|
|
1215
1482
|
#applyWanted(deliveries) {
|
|
1216
1483
|
const wanted = deliveries.filter(({ loader, signature }) => {
|
|
1217
|
-
const wanted = this.#
|
|
1484
|
+
const wanted = this.#wantedOf(loader);
|
|
1218
1485
|
if (typeof wanted === 'string')
|
|
1219
1486
|
return wanted === signature;
|
|
1220
1487
|
const previous = this.#deliveries.get(loader);
|
|
@@ -1225,8 +1492,25 @@ class I18nCore {
|
|
|
1225
1492
|
&& !this.#claimed(delivery.loader));
|
|
1226
1493
|
parked.forEach((delivery) => this.#parked.set(delivery.loader, delivery));
|
|
1227
1494
|
this.#stamp(parked.map(({ loader }) => loader.locale));
|
|
1228
|
-
if (wanted.length)
|
|
1495
|
+
if (!wanted.length)
|
|
1496
|
+
return;
|
|
1497
|
+
try {
|
|
1229
1498
|
this.#applyDeliveries(wanted);
|
|
1499
|
+
}
|
|
1500
|
+
catch (error) {
|
|
1501
|
+
// Parked no more, so the next trigger fetches it again, and a load in
|
|
1502
|
+
// flight that claimed it fetches it again as a severed one.
|
|
1503
|
+
wanted.forEach((delivery) => {
|
|
1504
|
+
if (this.#parked.get(delivery.loader) !== delivery)
|
|
1505
|
+
return;
|
|
1506
|
+
this.#parked.delete(delivery.loader);
|
|
1507
|
+
this.#inflight.forEach((entry) => {
|
|
1508
|
+
if (entry.unparked.some((claim) => claim.delivery === delivery))
|
|
1509
|
+
entry.severed.add(delivery.loader);
|
|
1510
|
+
});
|
|
1511
|
+
});
|
|
1512
|
+
throw error;
|
|
1513
|
+
}
|
|
1230
1514
|
}
|
|
1231
1515
|
/** Takes the fetches `entry` waited on out of the table, once what they delivered is applied or parked. */
|
|
1232
1516
|
#release({ fetches }) {
|
|
@@ -1300,12 +1584,21 @@ class I18nCore {
|
|
|
1300
1584
|
// `destroy()` — that raced this load severed some of its loaders. Their
|
|
1301
1585
|
// data predates the invalidation: applying it would resurrect the
|
|
1302
1586
|
// dropped bookkeeping and permanently suppress the promised refetch.
|
|
1303
|
-
const
|
|
1587
|
+
const claims = entry.unparked.filter(({ request }) => !entry.severed.has(request.loader));
|
|
1304
1588
|
// What it counts on applies while still parked: a newer record of the
|
|
1305
|
-
// same params dropped it, and an invalidation severed it.
|
|
1306
|
-
|
|
1307
|
-
|
|
1589
|
+
// same params dropped it, and an invalidation severed it. What a token
|
|
1590
|
+
// handed it applies while the loader shows what it showed at the call;
|
|
1591
|
+
// a delivery of other params since is fetched again, as a severed
|
|
1592
|
+
// loader is, and one of the same params stands.
|
|
1593
|
+
const held = claims
|
|
1594
|
+
.filter(({ request, delivery, handed }) => (handed ? this.#deliveries.get(request.loader) === handed.shown : this.#parked.get(request.loader) === delivery))
|
|
1308
1595
|
.map(({ delivery }) => delivery);
|
|
1596
|
+
claims.forEach(({ request: { loader }, delivery, handed }) => {
|
|
1597
|
+
const shown = this.#deliveries.get(loader);
|
|
1598
|
+
if (handed && shown !== handed.shown && shown?.signature !== delivery.signature)
|
|
1599
|
+
entry.severed.add(loader);
|
|
1600
|
+
});
|
|
1601
|
+
const current = deliveries.filter(({ loader }) => !entry.severed.has(loader));
|
|
1309
1602
|
const served = [...requests, ...entry.unparked.map(({ request }) => request)];
|
|
1310
1603
|
rejection = this.#rejection(entry, locale, controlFlow);
|
|
1311
1604
|
// The calls that share the load failed, whether its control flow rejects
|
|
@@ -1323,15 +1616,17 @@ class I18nCore {
|
|
|
1323
1616
|
}
|
|
1324
1617
|
catch (error) {
|
|
1325
1618
|
if (rejection)
|
|
1326
|
-
logError(
|
|
1619
|
+
logError(loadFailure(locale, route), error);
|
|
1327
1620
|
else
|
|
1328
1621
|
failure = { error };
|
|
1329
1622
|
}
|
|
1330
1623
|
this.#release(entry);
|
|
1331
1624
|
if (undone)
|
|
1332
1625
|
this.#settleUndo(undone);
|
|
1333
|
-
if (failure)
|
|
1626
|
+
if (failure) {
|
|
1627
|
+
this.#failApply(entry.calls);
|
|
1334
1628
|
throw failure.error;
|
|
1629
|
+
}
|
|
1335
1630
|
if (rejection)
|
|
1336
1631
|
throw rejection.value;
|
|
1337
1632
|
// A load of params the route no longer asks for, whatever it returned,
|
|
@@ -1356,7 +1651,7 @@ class I18nCore {
|
|
|
1356
1651
|
}
|
|
1357
1652
|
logError(rejection
|
|
1358
1653
|
? `Rejecting the load of '${locale}' locale${onRoute} with what the ${loaderName(rejection.loader)} loader threw.`
|
|
1359
|
-
:
|
|
1654
|
+
: loadFailure(locale, route), error);
|
|
1360
1655
|
});
|
|
1361
1656
|
// Resumed once per call: a loader that invalidates what it loads each time
|
|
1362
1657
|
// it runs would otherwise be fetched again for as long as it keeps doing
|
|
@@ -1377,7 +1672,7 @@ class I18nCore {
|
|
|
1377
1672
|
// Also marks the load handled, so one nobody awaits cannot terminate the
|
|
1378
1673
|
// process; an awaiting caller still receives the rejection.
|
|
1379
1674
|
promise.then(settle, settle);
|
|
1380
|
-
return
|
|
1675
|
+
return entry;
|
|
1381
1676
|
}
|
|
1382
1677
|
/**
|
|
1383
1678
|
* Finishes an activating load an invalidation cut `severed` off: it fetches
|