@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.
@@ -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
- #wanted = new Map();
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 servedLocales(this.#config.loaders ?? [], this.#translations);
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
- setRoute = (input) => untrack(() => {
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] !== handable[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 { parser, fallbackLocale, ...rest } = this.#config ?? {};
510
- return translate({
511
- parser,
512
- key,
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 again from the raw one, since a custom
604
- * `preprocess` may have renamed the keys that would have to go. Both tables
605
- * are computed before anything is written, so a `preprocess` that throws
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
- const deliveries = [...applied].sort((a, b) => loaders.indexOf(a.loader) - loaders.indexOf(b.loader));
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 seeded = this.#merged({ raw, translations: this.#translations }, this.#externalOf(replaced));
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 = unique(replaced.map(({ locale }) => locale)).reduce((acc, locale) => ({ ...acc, [locale]: this.#preprocess(read(merged.raw, locale)) }), merged.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 = typeof preprocess !== 'function' && preprocess !== 'none'
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
- this.#deliveries.set(loader, { ...delivery, data: dotted });
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
- ...acc,
787
- [locale]: mergeTranslations(read(acc, locale) || {}, this.#preprocess(read(sanitized, locale)), locale),
788
- }), tables.translations),
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.locales;
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.locales.length || this.#resolveLocale(locale) !== undefined)
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
- const own = loaders.filter(({ locale }) => locale === sanitizedLocale);
887
- return unique(own.map(({ namespace }) => namespace)).filter((namespace) => {
888
- const feeding = own.filter((loader) => loader.namespace === namespace);
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
- const { loaders = [], fallbackLocale } = this.#config ?? {};
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
- const { loaders = [], fallbackLocale } = this.#config ?? {};
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 { loaders = [] } = this.#config ?? {};
967
- const wants = new Map([
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: keeps the requested locale and the route it is
977
- * about to replace — with the params `#load` adds, what is put back should
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, wanted: new Map() }, failed: false, threw: [], resumed: false };
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
- this.#rewant();
1019
- const changed = before.locale !== this.#requestedLocale || before.route !== this.#route;
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.forEach((signature, loader) => {
1037
- if (signature === undefined)
1038
- this.#wanted.delete(loader);
1039
- else
1040
- this.#wanted.set(loader, signature);
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 failed load applies what
1048
- * its other loaders delivered, so that is filtered by what is wanted now.
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
- this.#applyWanted(this.#claimParked(matching).map(({ delivery }) => delivery));
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 { loaders = [] } = this.#config ?? {};
1156
- return JSON.stringify([locale, route, ...matching.map(({ loader, signature }) => [loaders.indexOf(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
- /** Joins the load in flight under `key` that delivers what `selected` lacks, or starts one. */
1159
- #loadSelection(locale, route, key, selected, calls) {
1160
- const requests = this.#unloaded(selected);
1161
- const unparked = calls.length ? this.#claimParked(selected) : [];
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
- this.#applyWanted(unparked.map(({ delivery }) => delivery));
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
- const inflight = this.#joinable(key, requests);
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
- return this.#start(locale, route, key, requests, calls, unparked);
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.#wanted.get(loader);
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.#wanted.get(loader);
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 current = deliveries.filter(({ loader }) => !entry.severed.has(loader));
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
- const held = entry.unparked
1307
- .filter(({ request, delivery }) => !entry.severed.has(request.loader) && this.#parked.get(request.loader) === delivery)
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(`Failed to load translations for '${locale}' locale${onRoute}.`, error);
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
- : `Failed to load translations for '${locale}' locale${onRoute}.`, error);
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 promise;
1675
+ return entry;
1381
1676
  }
1382
1677
  /**
1383
1678
  * Finishes an activating load an invalidation cut `severed` off: it fetches