@sveltekit-i18n/base 3.0.0 → 3.1.0-next.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,28 +1,96 @@
1
- import { fetchTranslations, hasOwn, mergeTranslations, read, resolveLoaders, sanitizerFactory, sanitizeTranslationLocales, testRoute, toDotNotation, translate } from './utils.js';
1
+ import { untrack } from 'svelte';
2
+ import { capturesParams, fetchTranslation, hasOwn, loaderName, mergeFetched, mergeTranslations, omitProtoKeys, paramsSignature, read, resolveLoaders, routeParams, sanitizerFactory, sanitizeTranslationLocales, serialize, servedLocales, toDotNotation, translate, unique, withoutBasePath } from './utils.js';
2
3
  import { logError, logger, loggerFactory, setLogger } from './logger.js';
3
4
  const defaultCache = Number.POSITIVE_INFINITY;
5
+ /**
6
+ * A top-level key holding part of `namespace` — the namespace itself or a key
7
+ * flattened out of it. Keys are strings, so an off-contract namespace that is
8
+ * not one holds none of them.
9
+ */
10
+ const isNamespaceKey = (key, namespace) => typeof namespace === 'string' && (key === namespace || key.startsWith(`${namespace}.`));
4
11
  class I18nCore {
5
12
  // -- reactive state ---------------------------------------------------------
6
- #config = $state(undefined);
13
+ /**
14
+ * Raw, as are the tables: each is replaced whole, never changed in place,
15
+ * and deep state would hand back proxies of what it holds in the browser —
16
+ * loaders no key of `#deliveries` or the records matches, and tables a
17
+ * structured clone rejects.
18
+ */
19
+ #config = $state.raw(undefined);
7
20
  /** The ACTIVE locale — advances only after its translations resolved. */
8
21
  #locale = $state(undefined);
9
- /** The locale most recently asked for; loads fire once a route exists too. */
22
+ /**
23
+ * The locale the next trigger loads: the one most recently asked for, unless
24
+ * control flow failed that call and put back what it replaced. Loads fire
25
+ * once a route exists too.
26
+ */
10
27
  #requestedLocale = $state(undefined);
11
28
  #route = $state(undefined);
12
- #rawTranslations = $state({});
13
- #translations = $state({});
29
+ #rawTranslations = $state.raw({});
30
+ #translations = $state.raw({});
14
31
  /** Replaced immutably on every change so `loading` recomputes. */
15
32
  #pending = $state(new Set());
16
33
  /** Locale normalization, as `config.sanitizeLocales` asks for it. */
17
34
  #sanitize = $derived(sanitizerFactory(this.#config?.sanitizeLocales));
18
35
  // -- plain internal state ---------------------------------------------------
36
+ // Load records keep a loader from running twice. A loader's own record holds
37
+ // the params signature it last delivered for; a namespace record stands for
38
+ // a namespace a plain hand-off delivered, and keeps its loaders from fetching
39
+ // it again unless their params ask for other data. Seeded data records
40
+ // nothing.
41
+ #loaderRecords = new Map();
19
42
  // Null prototype: these tables are indexed by user-supplied locales, and a
20
43
  // plain object would resolve a '__proto__' assignment via the setter.
21
- #loadedKeys = Object.create(null);
44
+ #namespaceRecords = Object.create(null);
45
+ // What each source last put into a namespace, so a loader whose params
46
+ // changed can replace its own part and leave its siblings' in place. Kept
47
+ // apart from the records: invalidation drops those, not what is displayed,
48
+ // and a reconfiguration hands them on to the loaders with the same id.
49
+ #deliveries = new Map();
50
+ #externalTranslations = {};
51
+ // The params signature the next trigger asks each loader for:
52
+ // the one the most recent call asked for, unless a failed call put an
53
+ // earlier one's back, and `null` for a loader its route does not select.
54
+ // Loads settle out of order, and a delivery for params the route no longer
55
+ // asks for must not replace what it displays; a warm load asks for nothing.
56
+ #wanted = new Map();
57
+ // The latest delivery of each loader for params nothing wanted yet — a warm
58
+ // load of another route's params, typically a preload — so the trigger that
59
+ // wants them applies it instead of fetching again. One per loader, as a
60
+ // router keeps one preload. It starts its locale's `cache` window, and
61
+ // invalidation drops it with the records.
62
+ #parked = new Map();
63
+ // What a hand-off delivered for the loaders with `cache: false`, whose
64
+ // records keep no trigger that selects them from running them. It serves the
65
+ // pass it arrived with: until an activating trigger asks for another locale
66
+ // or route than the hand-off named, or than the first activating trigger
67
+ // after it where the hand-off named none.
68
+ #handedOff = new Map();
69
+ #handOffPass = {};
22
70
  /** When each locale first received data — drives the `cache` expiry. */
23
71
  #loadedAt = Object.create(null);
24
- /** In-flight loads keyed by locale and route; duplicate triggers share the promise. */
25
- #inflight = new Map();
72
+ /**
73
+ * Loads in flight, each under the key of what its trigger selected; a
74
+ * trigger with the same key shares the promise of one that delivers all it
75
+ * has to fetch. `calls` holds the activating calls that share it, so a
76
+ * warm load joined by one activates when it settles, or fails them as an
77
+ * activating load does. `unparked` holds the parked deliveries its calls
78
+ * claimed, which it applies when it settles. `severed` holds the loaders, of
79
+ * either kind, an invalidation cut off while the load was in flight.
80
+ */
81
+ #inflight = new Set();
82
+ // The loaders' fetches in flight, which loads from the same route share
83
+ // wherever their selections differ.
84
+ #fetches = new Set();
85
+ // The control flow of a shared fetch already reported: every load it
86
+ // rejects gets it, and it was thrown once.
87
+ #reported = new WeakSet();
88
+ /**
89
+ * The activating calls since the last one whose load resolved without
90
+ * failing, oldest first. A failed call is undone while it is the last, so a
91
+ * call that came later and has not failed keeps what it asked for.
92
+ */
93
+ #calls = [];
26
94
  #destroyed = false;
27
95
  constructor(config) {
28
96
  if (config)
@@ -37,7 +105,9 @@ class I18nCore {
37
105
  /**
38
106
  * The active locale. Reading it is reactive; assigning it is a shorthand for
39
107
  * a fire-and-forget `setLocale()` — the value therefore updates once the
40
- * locale's translations resolved, not synchronously on assignment.
108
+ * locale's translations resolved, not synchronously on assignment. It does
109
+ * not advance when a loader throws SvelteKit's `redirect()` or an `error()`
110
+ * below 500, which an assignment only logs.
41
111
  */
42
112
  get locale() {
43
113
  return this.#locale;
@@ -56,13 +126,10 @@ class I18nCore {
56
126
  locales = $derived.by(() => {
57
127
  if (!this.#config)
58
128
  return [];
59
- const { loaders = [] } = this.#config;
60
- const loaderLocales = loaders.map(({ locale }) => locale);
61
- const translationLocales = Object.keys(this.#translations);
62
- return Array.from(new Set([
63
- ...this.#sanitize(...loaderLocales),
64
- ...this.#sanitize(...translationLocales),
65
- ]));
129
+ // Loader locales are sanitized once, when the config resolves them, and
130
+ // table locales once, when their data arrives; a custom `sanitizeLocales`
131
+ // need not be idempotent.
132
+ return servedLocales(this.#config.loaders ?? [], this.#translations);
66
133
  });
67
134
  initialized = $derived(this.#locale !== undefined && this.#route !== undefined && Object.keys(this.#translations).length > 0);
68
135
  /**
@@ -88,11 +155,11 @@ class I18nCore {
88
155
  };
89
156
  });
90
157
  // -- configuration ----------------------------------------------------------
91
- /** Applies a config. The public entry is `loadConfig`. */
92
- async #configLoader(config) {
158
+ /** Applies a config and returns the `initLocale` load. The public entry is `loadConfig`. */
159
+ #configLoader(config) {
93
160
  if (!config) {
94
161
  logger.error('No config provided!');
95
- return;
162
+ return Promise.resolve();
96
163
  }
97
164
  // `extensions` is a construction-time directive, not configuration state —
98
165
  // it is consumed by the constructor and must not land in `#config`.
@@ -102,7 +169,7 @@ class I18nCore {
102
169
  const sanitize = sanitizerFactory(rest.sanitizeLocales);
103
170
  const [sanitizedInitLocale] = sanitize(initLocale);
104
171
  const [sanitizedFallbackLocale] = sanitize(fallbackLocale);
105
- const loaders = resolveLoaders(rest.loaders);
172
+ const loaders = resolveLoaders(rest.loaders, rest.sanitizeLocales);
106
173
  logger.debug('Setting config.');
107
174
  this.#config = {
108
175
  initLocale: sanitizedInitLocale,
@@ -112,147 +179,327 @@ class I18nCore {
112
179
  loaders,
113
180
  };
114
181
  // Report-only: the loader still runs, but `.` is the dot-notation
115
- // separator, so a dotted key collides with the flattened namespace.
182
+ // separator, so a dotted namespace collides with the flattened one.
116
183
  // `String` rather than a template literal — interpolating a Symbol throws,
117
184
  // and a config-time report must not abort the rest of the config load.
118
- loaders.forEach(({ key }) => {
119
- const name = key == null ? '' : String(key);
185
+ loaders.forEach(({ namespace }) => {
186
+ const name = namespace == null ? '' : String(namespace);
120
187
  if (name.includes('.')) {
121
- logger.error(`Invalid '${name}' loader key. It shouldn't include the '.' character.`);
188
+ logger.error(`Invalid '${name}' loader namespace. It shouldn't include the '.' character.`);
122
189
  }
123
190
  });
124
191
  // A reconfiguration can swap loaders or cache policy — bookkeeping from
125
192
  // the previous config must not suppress the new loaders.
126
193
  this.invalidate();
194
+ this.#wanted.clear();
195
+ this.#handOnDeliveries(loaders);
127
196
  if (translations)
128
197
  this.addTranslations(translations);
129
- if (sanitizedInitLocale)
130
- await this.loadTranslations(sanitizedInitLocale);
198
+ return sanitizedInitLocale ? this.loadTranslations(initLocale) : Promise.resolve();
131
199
  }
132
200
  /**
133
- * Public entry for (re)configuration. The failure is reported here and the
134
- * promise marked handled, so a fire-and-forget call cannot become an
135
- * unhandled rejection; an awaiting caller still receives it.
201
+ * Public entry for (re)configuration. It returns the promise of the
202
+ * `initLocale` load, which reports its own failure; a config that fails to
203
+ * apply is reported here. Either way the promise is marked handled, so a
204
+ * fire-and-forget call cannot become an unhandled rejection; an awaiting
205
+ * caller still receives it.
136
206
  */
137
- loadConfig = (config) => {
207
+ loadConfig = (config) => untrack(() => {
138
208
  if (this.#inert('loadConfig'))
139
209
  return Promise.resolve();
140
- const promise = this.#configLoader(config);
141
- promise.catch((error) => logError('Failed to load the i18n config.', error));
142
- return promise;
143
- };
210
+ // Not async: the `initLocale` load's own promise is returned as it is, and
211
+ // a config that throws is caught here instead of escaping the constructor.
212
+ try {
213
+ return this.#configLoader(config);
214
+ }
215
+ catch (error) {
216
+ logError('Failed to load the i18n config.', error);
217
+ // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- passed on as thrown
218
+ const promise = Promise.reject(error);
219
+ promise.catch(() => undefined);
220
+ return promise;
221
+ }
222
+ });
144
223
  // -- loading ----------------------------------------------------------------
145
- setLocale = (locale) => {
146
- if (!locale || this.#inert('setLocale'))
224
+ // The loading calls — these, `loadNamespace()` and `loadConfig()` — and
225
+ // `addTranslations()` and `hydrate()` read the state they write, untracked:
226
+ // called from an effect, they must not make it depend on that state, or the
227
+ // effect runs them again whenever a later call, a load or an undo changes it.
228
+ setLocale = (locale) => untrack(() => {
229
+ if (!locale || this.#inert('setLocale') || this.#unserved(locale))
147
230
  return Promise.resolve();
231
+ const call = this.#ask();
148
232
  if (locale !== this.#requestedLocale) {
149
233
  logger.debug(`Setting '${locale}' locale.`);
150
234
  this.#requestedLocale = locale;
151
235
  }
152
236
  // Delegated even for a repeated value — the caller awaits "this locale is
153
237
  // loaded", which may mean joining a load already in flight.
154
- if (this.#route !== undefined)
155
- return this.#load(locale, this.#route);
156
- return Promise.resolve();
157
- };
158
- setRoute = (route) => {
238
+ return this.#stand(call, this.#route !== undefined ? this.#load(locale, this.#route, call) : Promise.resolve());
239
+ });
240
+ setRoute = (input) => untrack(() => {
159
241
  if (this.#inert('setRoute'))
160
242
  return Promise.resolve();
243
+ const route = withoutBasePath(input, this.#config?.basePath);
244
+ const call = this.#ask();
161
245
  if (route !== this.#route) {
162
246
  logger.debug(`Setting '${route}' route.`);
163
247
  this.#route = route;
164
248
  }
165
- if (this.#requestedLocale !== undefined)
166
- return this.#load(this.#requestedLocale, route);
167
- return Promise.resolve();
168
- };
169
- loadTranslations = (locale, route = this.#route ?? '') => {
170
- if (!locale || this.#inert('loadTranslations'))
249
+ return this.#stand(call, this.#requestedLocale !== undefined ? this.#load(this.#requestedLocale, route, call) : Promise.resolve());
250
+ });
251
+ /**
252
+ * `{ activate: false }` only fills the tables: it leaves the requested
253
+ * locale, the route and `locale` untouched and does not count towards
254
+ * `loading` — what is rendered does not change: data of a loader whose route
255
+ * params differ from the ones the current route asks for is kept aside, the
256
+ * latest per loader, and the activating trigger that asks for them applies
257
+ * it. It
258
+ * leaves `cache` expiry to the next activating trigger. A loader's
259
+ * `redirect()` or `error()` below 500 rejects it all the same.
260
+ */
261
+ loadTranslations = (locale, route, { activate = true } = {}) => untrack(() => {
262
+ if (!locale || this.#inert('loadTranslations') || this.#unserved(locale))
171
263
  return Promise.resolve();
264
+ const target = route === undefined ? this.#route ?? '' : withoutBasePath(route, this.#config?.basePath);
265
+ if (!activate)
266
+ return this.#load(locale, target);
267
+ const call = this.#ask();
172
268
  this.#requestedLocale = locale;
173
- this.#route = route;
174
- return this.#load(locale, route);
175
- };
269
+ this.#route = target;
270
+ return this.#stand(call, this.#load(locale, target, call));
271
+ });
176
272
  /**
177
- * Marks loaded translations stale — for one locale, or all of them. Loaders
178
- * run again on the NEXT load trigger; the call itself starts no load and
179
- * keeps the currently displayed translations in place. A load still in
180
- * flight for an invalidated locale is severed: it settles, but its data is
181
- * discarded — it predates the invalidation.
273
+ * Loads one namespace for the active locale (or `locale`), whatever the
274
+ * routes of its loaders say — for what an interaction needs rather than a
275
+ * route: a modal, a panel, an editor. Warm, like `{ activate: false }`: it
276
+ * changes neither the locale nor `loading`, and it leaves `cache` expiry to
277
+ * the next activating trigger. It honours the load records, so calling it
278
+ * on every interaction fetches once — a loader with `cache: false` runs each
279
+ * time on its routes — and what it loads stays loaded across routes. A
280
+ * loader's `redirect()` or `error()` below 500 rejects it.
182
281
  */
183
- invalidate = (locale) => {
282
+ loadNamespace = (namespace, locale) => untrack(() => {
283
+ if (this.#inert('loadNamespace'))
284
+ return Promise.resolve();
285
+ const target = locale ?? this.#locale;
286
+ if (!target)
287
+ return Promise.resolve();
288
+ return this.#load(target, this.#route ?? '', undefined, namespace);
289
+ });
290
+ /**
291
+ * Marks loaded translations stale — for one locale or all of them, and for
292
+ * one namespace or all of them. Loaders run again on the NEXT load trigger;
293
+ * the call itself starts no load and keeps the currently displayed
294
+ * translations in place. A loader still in flight for what was invalidated
295
+ * is severed: its load settles, but what it returns or throws is discarded —
296
+ * it predates the invalidation — and an activating trigger fetches it again,
297
+ * once, before its locale activates. It leaves it to the next trigger when
298
+ * another loader of its load threw SvelteKit's control flow that still
299
+ * counts, which rejects the trigger, and when the refetch is severed too. A
300
+ * namespace invalidation leaves the locale's `cache` window where it was.
301
+ */
302
+ invalidate = (locale, namespace) => {
184
303
  if (this.#inert('invalidate'))
185
304
  return;
186
- if (locale !== undefined) {
187
- const [sanitized] = this.#sanitize(locale);
188
- if (sanitized !== undefined) {
189
- delete this.#loadedKeys[sanitized];
190
- delete this.#loadedAt[sanitized];
191
- // Sever matching in-flight loads — applying their pre-invalidation
192
- // data would resurrect the bookkeeping dropped above, permanently
193
- // suppressing the promised refetch.
194
- this.#inflight.forEach((_, key) => {
195
- if (key.startsWith(`${sanitized}\u0000`))
196
- this.#inflight.delete(key);
197
- });
198
- }
305
+ const [sanitized] = locale === undefined ? [] : this.#sanitize(locale);
306
+ if (locale !== undefined && sanitized === undefined)
199
307
  return;
200
- }
201
- this.#loadedKeys = Object.create(null);
202
- this.#loadedAt = Object.create(null);
203
- this.#inflight.clear();
308
+ this.#invalidate(sanitized, namespace);
204
309
  };
205
- addTranslations = (translations) => {
310
+ addTranslations = (translations) => untrack(() => {
206
311
  if (this.#inert('addTranslations'))
207
312
  return;
208
313
  this.#addTranslations(translations);
209
- };
314
+ });
315
+ /**
316
+ * Drops the bookkeeping `invalidate()` names and severs its loaders in
317
+ * flight. `config.cache` does not cover a loader with `cache: false`, so
318
+ * expiry leaves it alone.
319
+ */
320
+ #invalidate(sanitized, namespace, { expiry = false } = {}) {
321
+ const locales = sanitized === undefined ? Object.keys(this.#namespaceRecords) : [sanitized];
322
+ locales.forEach((recorded) => {
323
+ if (namespace === undefined)
324
+ delete this.#namespaceRecords[recorded];
325
+ else
326
+ this.#namespaceRecords[recorded] = (read(this.#namespaceRecords, recorded) || []).filter((name) => name !== namespace);
327
+ });
328
+ if (namespace === undefined) {
329
+ if (sanitized === undefined)
330
+ this.#loadedAt = Object.create(null);
331
+ else
332
+ delete this.#loadedAt[sanitized];
333
+ }
334
+ const covers = (loader) => !(expiry && loader.cache === false)
335
+ && (sanitized === undefined || loader.locale === sanitized)
336
+ && (namespace === undefined || loader.namespace === namespace);
337
+ this.#loaderRecords.forEach((_, loader) => {
338
+ if (covers(loader))
339
+ this.#loaderRecords.delete(loader);
340
+ });
341
+ this.#handedOff.forEach((_, loader) => {
342
+ if (covers(loader))
343
+ this.#handedOff.delete(loader);
344
+ });
345
+ this.#parked.forEach((_, loader) => {
346
+ if (covers(loader))
347
+ this.#parked.delete(loader);
348
+ });
349
+ // Applying their pre-invalidation data would resurrect the bookkeeping
350
+ // dropped above, permanently suppressing the promised refetch.
351
+ this.#sever(covers);
352
+ }
353
+ /**
354
+ * Restores the state `snapshot({ records: true })` captured on another
355
+ * instance: its data, its load records, the active locale and the route.
356
+ * A loader named by a record does not run again for the same params — one
357
+ * with `cache: false` only for the pass the envelope arrived with; data no
358
+ * record names is displayed but keeps no loader from running. An envelope
359
+ * without `records` is a plain hand-off instead: every namespace its data
360
+ * names keeps its loaders without params from running, and holds one with
361
+ * `cache: false` back for that pass. Nothing
362
+ * happens for `undefined`, so a load whose server half sent nothing can call
363
+ * it unconditionally.
364
+ */
365
+ hydrate = (envelope) => untrack(() => {
366
+ if (!envelope || this.#inert('hydrate'))
367
+ return;
368
+ // Typically `initLocale`: the constructor started its load before the
369
+ // records could keep the loaders from running.
370
+ if (this.#inflight.size)
371
+ logger.warn('Hydrating after a load started: its loaders ran regardless of the hand-off.');
372
+ const { translations = {}, records, seeds = {}, locale, route } = envelope;
373
+ if (records)
374
+ this.#hydrateRecords(translations, records, seeds);
375
+ else
376
+ this.#hydratePlain(translations);
377
+ this.#handOffPass = { locale, route };
378
+ // A hand-off stands: no call before it is put back.
379
+ this.#calls = [];
380
+ if (route !== undefined)
381
+ this.#route = route;
382
+ if (locale !== undefined) {
383
+ this.#requestedLocale = locale;
384
+ this.#locale = locale;
385
+ }
386
+ const resolved = this.#resolveLocale(locale);
387
+ if (resolved !== undefined && route !== undefined)
388
+ this.#want(this.#matchLoaders(resolved, route));
389
+ });
210
390
  /**
211
391
  * Serializes what this instance holds for the active locale and the fallback
212
- * locale, narrowed to the current route: a key owned only by loaders that do
213
- * not match the route is left out. The result is shaped like
214
- * `config.translations`, so a client hydrates by handing it back to the
215
- * constructor — the bookkeeping derived from it then keeps the matching
216
- * loaders from fetching the same data again.
392
+ * locale. The result is shaped like `config.translations`, and a client
393
+ * hands it over with `hydrate({ translations })`: a plain hand-off, whose
394
+ * namespace records keep the matching loaders without params from fetching
395
+ * it again and hold one with `cache: false` back for that pass. Passed to
396
+ * `addTranslations()` or assigned to `config.translations` it only seeds,
397
+ * and every loader runs again.
398
+ * A namespace plain data cannot hand over is left out, for the client to
399
+ * load: one fed by several loaders, whose record would suppress a part the
400
+ * payload lacks; one whose loader's routes can capture params, whose data a
401
+ * plain hand-off keeps as data no loader delivered, so the next params could
402
+ * not replace it; and one none of whose loaders delivered here and no
403
+ * hand-off named, whose seeded data would keep the client's loaders from
404
+ * ever running.
405
+ * A literal `__proto__` key is left out too, and with records its
406
+ * namespace's loaders are, and a namespace whose loader captures params,
407
+ * without them a namespace a loader serves, so the client loads it whole: the serializer SvelteKit hands load data to refuses an
408
+ * object that carries one. A locale
409
+ * named `__proto__` is left out altogether.
410
+ *
411
+ * `{ records: true }` returns an envelope for `hydrate()` instead: the same
412
+ * data, the loaders that delivered it, the active locale and the route. The
413
+ * records name each loader, so a namespace fed by several loaders is handed
414
+ * over too, and so is one a loader delivered for route params while its
415
+ * record says so — not a namespace with both, whose data the client could
416
+ * not split between them. What was seeded into the namespace of a loader
417
+ * whose routes capture params travels apart as `seeds`, so it outlives new
418
+ * params there, and reaches the client where the namespace is left out.
217
419
  */
218
- snapshot = () => {
219
- const { fallbackLocale } = this.#config ?? {};
220
- const route = this.#route ?? '';
221
- // `#locale` is already sanitized; the fallback is normalized the same way
222
- // the loaders key their data.
223
- const locales = [this.#locale, ...this.#sanitize(fallbackLocale)].filter((locale) => !!locale);
224
- return locales.reduce((acc, locale) => {
225
- if (hasOwn(acc, locale))
226
- return acc;
420
+ snapshot = ((options) => {
421
+ const withRecords = options?.records === true;
422
+ const { fallbackLocale, loaders = [] } = this.#config ?? {};
423
+ // Both are held sanitized, the way the loaders key their data.
424
+ const locales = unique([this.#locale, fallbackLocale].filter((locale) => !!locale));
425
+ const omitted = new Map(locales.map((locale) => [locale, this.#unsnapshottable(locale, withRecords)]));
426
+ const isOmitted = (locale, key) => (omitted.get(locale) ?? []).some((namespace) => isNamespaceKey(key, namespace));
427
+ // The top-level keys each locale lost a literal `__proto__` key under: a
428
+ // record of their namespace would suppress the part the payload lacks.
429
+ const stripped = new Map();
430
+ const translations = locales.reduce((acc, locale) => {
227
431
  const data = read(this.#rawTranslations, locale);
228
432
  if (!data)
229
433
  return acc;
230
- const offRoute = this.#offRouteKeys(locale, route);
231
- const relevant = Object.keys(data)
232
- .filter((key) => !offRoute.has(key))
233
- .reduce((keep, key) => ({ ...keep, [key]: read(data, key) }), {});
434
+ if (locale === '__proto__') {
435
+ logger.warn('Leaving the \'__proto__\' locale out of the snapshot: load data cannot carry it.');
436
+ return acc;
437
+ }
438
+ const handable = Object.fromEntries(Object.entries(data).filter(([key]) => !isOmitted(locale, key)));
439
+ const relevant = omitProtoKeys(handable);
440
+ let kept = relevant;
441
+ if (relevant !== handable) {
442
+ logger.warn(`Leaving a '__proto__' key of locale '${locale}' out of the snapshot: load data cannot carry it.`);
443
+ const lost = Object.keys(handable).filter((key) => !hasOwn(relevant, key) || relevant[key] !== handable[key]);
444
+ stripped.set(locale, lost);
445
+ // The namespace goes without its record, whole: data plain or unrecorded
446
+ // is kept on the client as data no loader delivered, which a plain
447
+ // hand-off marks loaded and new params could not replace. It goes for
448
+ // the client to load instead — with records, only where params can
449
+ // change. One no loader serves keeps the rest.
450
+ const served = loaders
451
+ .filter((loader) => loader.locale === locale && (!withRecords || capturesParams(loader.routes)))
452
+ .filter((loader) => lost.some((key) => isNamespaceKey(key, loader.namespace)))
453
+ .map(({ namespace }) => namespace);
454
+ kept = Object.fromEntries(Object.entries(relevant).filter(([key]) => !served.some((namespace) => isNamespaceKey(key, namespace))));
455
+ }
234
456
  // An empty entry would still stamp the locale's freshness on the client,
235
457
  // starting its `cache` window on data it never received.
236
- if (!Object.keys(relevant).length)
458
+ if (!Object.keys(kept).length)
237
459
  return acc;
238
- return { ...acc, [locale]: relevant };
460
+ return { ...acc, [locale]: kept };
239
461
  }, {});
240
- };
462
+ if (!withRecords)
463
+ return translations;
464
+ // A loader without an id cannot be named off-process: its data travels as
465
+ // data alone, and the client runs it again.
466
+ const records = loaders.flatMap((loader) => {
467
+ const { id, locale, namespace } = loader;
468
+ const signature = this.#loaderRecords.get(loader);
469
+ if (id === null || signature === undefined || !omitted.has(locale) || locale === '__proto__' || isOmitted(locale, namespace))
470
+ return [];
471
+ if ((stripped.get(locale) ?? []).some((key) => isNamespaceKey(key, namespace)))
472
+ return [];
473
+ return [signature ? { id, signature } : { id }];
474
+ });
475
+ // Where params can change, the client takes a recorded namespace whole as
476
+ // its loader's delivery and loads one left out itself, so what was seeded
477
+ // into it travels apart: the seed has to outlive the delivery new params
478
+ // replace there as it does here.
479
+ const seeds = omitProtoKeys(this.#externalOf(loaders.filter(({ locale, routes }) => omitted.has(locale) && locale !== '__proto__' && capturesParams(routes))));
480
+ return {
481
+ translations,
482
+ records,
483
+ ...(Object.keys(seeds).length ? { seeds } : {}),
484
+ ...(this.#locale === undefined ? {} : { locale: this.#locale }),
485
+ ...(this.#route === undefined ? {} : { route: this.#route }),
486
+ };
487
+ });
241
488
  /**
242
489
  * Detaches the instance from its loading lifecycle: in-flight loads settle
243
- * with their data discarded, `loading` drops to `false`, and every further
244
- * load or mutation call is ignored with a warning. Reads (`t`, `l`, `locale`,
245
- * `translations`, `snapshot`) keep working, so a component still tearing down
246
- * renders its last state instead of breaking. Idempotent.
490
+ * with whatever their loaders return or throw discarded, `loading` drops to
491
+ * `false`, and every further load or mutation call is ignored with a warning.
492
+ * Reads (`t`, `l`, `locale`, `translations`, `snapshot`) keep working, so a
493
+ * component still tearing down renders its last state instead of breaking.
494
+ * Idempotent.
247
495
  */
248
496
  destroy = () => {
249
497
  if (this.#destroyed)
250
498
  return;
251
499
  logger.debug('Destroying the i18n instance.');
252
500
  this.#destroyed = true;
253
- // Severed rather than awaited — the identity guard in `#load` makes a
254
- // settled load apply nothing once its entry is gone.
255
- this.#inflight.clear();
501
+ // Severed rather than awaited: a settled load then applies nothing.
502
+ this.#sever(() => true);
256
503
  this.#pending = new Set();
257
504
  };
258
505
  // -- internals --------------------------------------------------------------
@@ -269,77 +516,259 @@ class I18nCore {
269
516
  });
270
517
  }
271
518
  /**
272
- * Resolves loader data for a locale and route WITHOUT applying it. Returns
273
- * `[]` when there is nothing to load. The `cache` expiry is evaluated by
274
- * load triggers, not here.
519
+ * The fetches of `requests` from `route`, WITHOUT applying their data: each
520
+ * joins the one in flight for the same loader, params and route, and `run`
521
+ * starts the rest. Those are registered before `run` calls any loader, so a
522
+ * loader that starts a load of its own route joins its own fetch instead of
523
+ * running again. A fetch that delivered stays in the table until a load
524
+ * waiting on it has applied or parked the delivery; any other, and one of a
525
+ * loader with `cache: false`, leaves it with its outcome, so the next load
526
+ * runs the loader again. The `cache` expiry is evaluated by load triggers,
527
+ * not here.
275
528
  */
276
- async #getTranslationProps(locale, route) {
277
- if (!this.#config || !locale)
278
- return [];
279
- const [sanitizedLocale] = this.#sanitize(locale);
280
- const filteredLoaders = this.#filterLoaders(sanitizedLocale, route);
281
- if (!filteredLoaders.length)
282
- return [];
283
- logger.debug('Fetching translations...');
284
- const rawTranslations = await fetchTranslations(filteredLoaders, route);
285
- const loadedKeys = Object.entries(rawTranslations).reduce((acc, [translationLocale, data]) => ({ ...acc, [translationLocale]: Object.keys(data ?? {}) }), {});
286
- const keys = filteredLoaders
287
- .filter(({ key, locale: loaderLocale }) => (read(loadedKeys, loaderLocale) || []).some(
288
- // Exact or namespaced match only — `navbar` data must not mark a
289
- // sibling `nav` loader as loaded.
290
- (loadedKey) => `${loadedKey}` === key || `${loadedKey}`.startsWith(`${key}.`)))
291
- .reduce((acc, { key, locale: loaderLocale }) => ({
529
+ #fetch(requests, route) {
530
+ const started = [];
531
+ const fetches = requests.map((request) => {
532
+ const shared = Array.from(this.#fetches).find((fetch) => fetch.route === route
533
+ && fetch.request.loader === request.loader
534
+ && fetch.request.signature === request.signature);
535
+ if (shared)
536
+ return shared;
537
+ let outcome;
538
+ const fetch = { request, route, outcome: new Promise((resolve, reject) => { outcome = { resolve, reject }; }) };
539
+ this.#fetches.add(fetch);
540
+ started.push({ fetch, outcome });
541
+ return fetch;
542
+ });
543
+ const run = () => {
544
+ if (started.length)
545
+ logger.debug('Fetching translations...');
546
+ started.forEach(({ fetch, outcome }) => {
547
+ const release = (fetched) => {
548
+ if (!fetched?.deliveries.length || fetch.request.loader.cache === false)
549
+ this.#fetches.delete(fetch);
550
+ };
551
+ fetchTranslation(fetch.request, route).then((fetched) => {
552
+ release(fetched);
553
+ outcome.resolve(fetched);
554
+ }, (reason) => {
555
+ release();
556
+ outcome.reject(reason);
557
+ });
558
+ });
559
+ };
560
+ return { fetches, run };
561
+ }
562
+ /**
563
+ * Gives each delivery of the previous config to the loader of the new one
564
+ * with the same id, so its params can still replace it. What no loader can
565
+ * take over is kept as data supplied without a loader, so a namespace rebuilt
566
+ * later keeps it rather than losing it.
567
+ */
568
+ #handOnDeliveries(loaders) {
569
+ const takers = new Map(loaders.flatMap((loader) => (loader.id === null ? [] : [[loader.id, loader]])));
570
+ const deliveries = Array.from(this.#deliveries.values());
571
+ const orphaned = deliveries.filter(({ loader }) => loader.id === null || !takers.has(loader.id));
572
+ this.#deliveries = new Map(deliveries.flatMap((delivery) => {
573
+ const taker = delivery.loader.id === null ? undefined : takers.get(delivery.loader.id);
574
+ return taker ? [[taker, { ...delivery, loader: taker }]] : [];
575
+ }));
576
+ this.#keepExternal(serialize(orphaned.map(({ loader, data }) => ({ ...loader, data }))));
577
+ }
578
+ /**
579
+ * Applies what loaders delivered and records them as loaded. A loader whose
580
+ * params changed replaces the part of its namespace it delivered before:
581
+ * the namespace is rebuilt from the data supplied without a loader and from
582
+ * what each of its loaders last delivered, so no key of the previous params
583
+ * survives and a sibling's part stays in place. The preprocessed table of a
584
+ * locale that lost data is derived again from the raw one, since a custom
585
+ * `preprocess` may have renamed the keys that would have to go. Both tables
586
+ * are computed before anything is written, so a `preprocess` that throws
587
+ * records no loader and the next trigger fetches it again.
588
+ */
589
+ #applyDeliveries(deliveries) {
590
+ const replaced = deliveries
591
+ .filter(({ loader, signature }) => {
592
+ const previous = this.#deliveries.get(loader);
593
+ return previous !== undefined && previous.signature !== signature;
594
+ })
595
+ .map(({ loader }) => loader);
596
+ const delivered = new Map(deliveries.map((delivery) => [delivery.loader, delivery]));
597
+ const isReplaced = ({ locale, namespace }) => replaced.some((loader) => loader.locale === locale && loader.namespace === namespace);
598
+ const { loaders = [] } = this.#config ?? {};
599
+ const rebuilt = loaders
600
+ .filter(isReplaced)
601
+ .map((loader) => delivered.get(loader) ?? this.#deliveries.get(loader))
602
+ .filter((delivery) => delivery !== undefined);
603
+ const raw = replaced.reduce((acc, { locale, namespace }) => ({
292
604
  ...acc,
293
- [loaderLocale]: [...(read(acc, loaderLocale) || []), key],
294
- }), {});
295
- return [rawTranslations, keys];
605
+ [locale]: Object.fromEntries(Object.entries(read(acc, locale) ?? {}).filter(([key]) => !isNamespaceKey(key, namespace))),
606
+ }), this.#rawTranslations);
607
+ const seeded = this.#merged({ raw, translations: this.#translations }, this.#externalOf(replaced));
608
+ const merged = this.#merged(seeded, serialize([
609
+ ...deliveries.filter(({ loader }) => !isReplaced(loader)),
610
+ ...rebuilt,
611
+ ].map(({ loader, data }) => ({ ...loader, data }))));
612
+ const translations = unique(replaced.map(({ locale }) => locale)).reduce((acc, locale) => ({ ...acc, [locale]: this.#preprocess(read(merged.raw, locale)) }), merged.translations);
613
+ deliveries.forEach((delivery) => this.#record(delivery));
614
+ this.#rawTranslations = merged.raw;
615
+ this.#translations = translations;
616
+ this.#stamp(deliveries.filter(({ loader }) => loader.cache !== false).map(({ loader }) => loader.locale));
296
617
  }
297
618
  /**
298
- * Merges translations into the tables and registers their bookkeeping.
299
- * `keys` carries the exact loader keys of a load; without it (the public
300
- * `addTranslations` path) loaded keys derive from the data's top-level keys.
619
+ * Merges seeded data. It records no namespace, so the namespace's loaders
620
+ * still run and merge into it, and it starts no `cache` window. It is kept to
621
+ * rebuild a namespace a loader later replaces its part of.
301
622
  */
302
- #addTranslations(translations, keys) {
623
+ #addTranslations(translations) {
303
624
  if (!translations)
304
625
  return;
305
- const { preprocess } = this.#config ?? {};
306
- logger.debug('Adding translations...');
307
626
  const sanitized = sanitizeTranslationLocales(translations, this.#sanitize);
308
- const translationLocales = Object.keys(sanitized);
309
- this.#rawTranslations = translationLocales.reduce((acc, locale) => ({
627
+ this.#addSanitized(sanitized);
628
+ }
629
+ #addSanitized(sanitized) {
630
+ this.#mergeTranslations(sanitized);
631
+ this.#keepExternal(sanitized);
632
+ }
633
+ /**
634
+ * Applies plain hand-off data. It records every namespace the data names,
635
+ * which keeps its loaders without params from running, and a loader with
636
+ * `cache: false` is served by its namespace for the pass the data arrived
637
+ * with.
638
+ */
639
+ #hydratePlain(translations) {
640
+ const { loaders = [] } = this.#config ?? {};
641
+ const served = loaders.filter((loader) => loader.cache === false
642
+ && Object.keys(read(translations, loader.locale) ?? {}).some((key) => isNamespaceKey(key, loader.namespace)));
643
+ this.#addSanitized(translations);
644
+ Object.keys(translations).forEach((locale) => {
645
+ // A `null` payload for a locale must not take the whole call down —
646
+ // every step of the merge tolerates it, so this bookkeeping does too.
647
+ const data = read(translations, locale) ?? {};
648
+ this.#namespaceRecords[locale] = Array.from(new Set([
649
+ ...(read(this.#namespaceRecords, locale) || []),
650
+ ...Object.keys(data).map((key) => `${key}`.split('.')[0]),
651
+ ]));
652
+ });
653
+ served.forEach((loader) => this.#handedOff.set(loader, ''));
654
+ this.#stampHandOff(translations);
655
+ }
656
+ /**
657
+ * Applies hand-off data with the records of the loaders that delivered it.
658
+ * A recorded loader's namespace is kept as that loader's delivery, so params
659
+ * that change later replace it; the rest is kept as data supplied without a
660
+ * loader and, like a seed, records no namespace — the hand-off says which loaders it
661
+ * covers, and anything else loads again rather than going missing. `seeds`
662
+ * is what was seeded where params can change, displayed and kept as a seed
663
+ * so that it outlives a delivery. A record naming no loader of this config
664
+ * is dropped, and its loader runs again.
665
+ */
666
+ #hydrateRecords(translations, records, seeds) {
667
+ const { loaders = [] } = this.#config ?? {};
668
+ const named = new Map(loaders.flatMap((loader) => (loader.id === null ? [] : [[loader.id, loader]])));
669
+ const deliveries = records.flatMap(({ id, signature = '' }) => {
670
+ const loader = named.get(id);
671
+ if (!loader) {
672
+ logger.debug(`No loader is named '${id}'. It loads again.`);
673
+ return [];
674
+ }
675
+ return [{ loader, signature, data: read(read(translations, loader.locale), loader.namespace) ?? {} }];
676
+ });
677
+ // Under the data: it holds the seeds of the namespaces it carries already.
678
+ this.#mergeTranslations(seeds);
679
+ this.#mergeTranslations(translations);
680
+ deliveries.forEach((delivery) => {
681
+ this.#record(delivery);
682
+ if (delivery.loader.cache === false)
683
+ this.#handedOff.set(delivery.loader, delivery.signature);
684
+ });
685
+ const external = Object.keys(translations).reduce((acc, locale) => {
686
+ const rest = Object.fromEntries(Object.entries(read(translations, locale) ?? {}).filter(([key]) => !deliveries.some(({ loader }) => loader.locale === locale && loader.namespace === key)));
687
+ return Object.keys(rest).length ? { ...acc, [locale]: rest } : acc;
688
+ }, {});
689
+ this.#keepExternal(external);
690
+ this.#keepExternal(seeds);
691
+ this.#stampHandOff(translations);
692
+ }
693
+ /** Records `delivery` as its loader's; what was parked for its params is older. */
694
+ #record(delivery) {
695
+ this.#deliveries.set(delivery.loader, delivery);
696
+ this.#loaderRecords.set(delivery.loader, delivery.signature);
697
+ if (this.#parked.get(delivery.loader)?.signature === delivery.signature)
698
+ this.#parked.delete(delivery.loader);
699
+ }
700
+ /** The data held without a loader in the namespaces of `loaders`. */
701
+ #externalOf(loaders) {
702
+ return loaders.reduce((acc, { locale, namespace }) => {
703
+ const data = read(this.#externalTranslations, locale) ?? {};
704
+ const own = Object.fromEntries(Object.entries(data).filter(([key]) => isNamespaceKey(key, namespace)));
705
+ if (!Object.keys(own).length)
706
+ return acc;
707
+ return { ...acc, [locale]: { ...read(acc, locale), ...own } };
708
+ }, {});
709
+ }
710
+ /** Keeps data held without a loader, to rebuild a namespace from. */
711
+ #keepExternal(sanitized) {
712
+ this.#externalTranslations = Object.keys(sanitized).reduce((acc, locale) => ({
310
713
  ...acc,
311
714
  [locale]: mergeTranslations(read(acc, locale) || {}, read(sanitized, locale) ?? {}, locale),
312
- }), this.#rawTranslations);
313
- this.#translations = translationLocales.reduce((acc, locale) => {
314
- let dotnotate = true;
315
- let input = read(sanitized, locale);
316
- if (typeof preprocess === 'function') {
317
- input = preprocess(input);
318
- }
319
- if (typeof preprocess === 'function' || preprocess === 'none') {
320
- dotnotate = false;
321
- }
322
- return ({
715
+ }), this.#externalTranslations);
716
+ }
717
+ /** A locale's table as `config.preprocess` asks for it. */
718
+ #preprocess(input) {
719
+ const { preprocess } = this.#config ?? {};
720
+ if (typeof preprocess === 'function')
721
+ return preprocess(input) ?? {};
722
+ if (preprocess === 'none')
723
+ return input ?? {};
724
+ return toDotNotation(input, preprocess === 'preserveArrays') ?? {};
725
+ }
726
+ /** Merges data keyed by sanitized locales into both tables. */
727
+ #mergeTranslations(sanitized) {
728
+ const { raw, translations } = this.#merged({ raw: this.#rawTranslations, translations: this.#translations }, sanitized);
729
+ this.#rawTranslations = raw;
730
+ this.#translations = translations;
731
+ }
732
+ /**
733
+ * Both tables with data keyed by sanitized locales merged in. Pure, so a
734
+ * caller that writes only once both are computed keeps them consistent when
735
+ * a `preprocess` throws.
736
+ */
737
+ #merged(tables, sanitized) {
738
+ logger.debug('Adding translations...');
739
+ const translationLocales = Object.keys(sanitized);
740
+ return {
741
+ raw: translationLocales.reduce((acc, locale) => ({
323
742
  ...acc,
324
- [locale]: mergeTranslations(read(acc, locale) || {}, (dotnotate ? toDotNotation(input, preprocess === 'preserveArrays') : input) ?? {}, locale),
325
- });
326
- }, this.#translations);
327
- translationLocales.forEach((locale) => {
328
- // A `null` payload for a locale must not take the whole call down —
329
- // every step above tolerates it, so this bookkeeping does too.
330
- let localeKeys = Object.keys(read(sanitized, locale) ?? {}).map((key) => `${key}`.split('.')[0]);
331
- if (keys)
332
- localeKeys = read(keys, locale);
333
- this.#loadedKeys[locale] = Array.from(new Set([
334
- ...(read(this.#loadedKeys, locale) || []),
335
- ...(localeKeys || []),
336
- ]));
337
- // Freshness is measured from the locale's FIRST data — later partial
338
- // loads (other routes) must not extend the window.
743
+ [locale]: mergeTranslations(read(acc, locale) || {}, read(sanitized, locale) ?? {}, locale),
744
+ }), tables.raw),
745
+ translations: translationLocales.reduce((acc, locale) => ({
746
+ ...acc,
747
+ [locale]: mergeTranslations(read(acc, locale) || {}, this.#preprocess(read(sanitized, locale)), locale),
748
+ }), tables.translations),
749
+ };
750
+ }
751
+ /**
752
+ * Starts the `cache` window of each locale that has none. Freshness is
753
+ * measured from the locale's FIRST data — later partial loads (other
754
+ * routes) must not extend the window.
755
+ */
756
+ #stamp(locales) {
757
+ locales.forEach((locale) => {
339
758
  if (read(this.#loadedAt, locale) === undefined)
340
759
  this.#loadedAt[locale] = Date.now();
341
760
  });
342
761
  }
762
+ /**
763
+ * Stamps each locale hand-off data holds something for that a caching
764
+ * loader feeds: seeded data and a namespace only loaders with `cache: false`
765
+ * feed start no window.
766
+ */
767
+ #stampHandOff(translations) {
768
+ const { loaders = [] } = this.#config ?? {};
769
+ const cached = (locale, key) => loaders.some((loader) => loader.locale === locale && loader.cache !== false && isNamespaceKey(key, loader.namespace));
770
+ this.#stamp(Object.keys(translations).filter((locale) => Object.keys(read(translations, locale) ?? {}).some((key) => cached(locale, key))));
771
+ }
343
772
  /** Reports a call on a destroyed instance; `true` means "ignore the call". */
344
773
  #inert(action) {
345
774
  if (!this.#destroyed)
@@ -349,23 +778,33 @@ class I18nCore {
349
778
  }
350
779
  #resolveLocale(inputLocale) {
351
780
  const { fallbackLocale } = this.#config ?? {};
352
- const locale = inputLocale || fallbackLocale;
353
- if (!locale)
781
+ if (!inputLocale && !fallbackLocale)
354
782
  return undefined;
355
783
  const all = this.locales;
356
784
  // Nothing to match against yet; sanitizing here would only emit a
357
785
  // non-standard warning for a lookup that cannot succeed anyway.
358
786
  if (!all.length)
359
787
  return undefined;
360
- // Sanitized once per lookup rather than once per candidate locale.
361
- const sanitized = this.#sanitize(locale);
362
- const match = all.find((known) => sanitized.includes(known));
363
- if (match || !fallbackLocale || fallbackLocale === locale)
364
- return match;
365
- // Evaluated lazily: the fallback (and any non-standard warning it emits)
366
- // must not run when the requested locale resolves directly.
367
- const sanitizedFallback = this.#sanitize(fallbackLocale);
368
- return all.find((known) => sanitizedFallback.includes(known));
788
+ if (inputLocale) {
789
+ // Sanitized once per lookup rather than once per candidate locale.
790
+ const sanitized = this.#sanitize(inputLocale);
791
+ const match = all.find((known) => sanitized.includes(known));
792
+ if (match)
793
+ return match;
794
+ }
795
+ // The fallback is held sanitized.
796
+ return fallbackLocale && all.includes(fallbackLocale) ? fallbackLocale : undefined;
797
+ }
798
+ /**
799
+ * Whether nothing serves `locale` — no loader, no translations and no
800
+ * `fallbackLocale` — so a request for it changes nothing. Until a locale is
801
+ * known, any request is kept: a config loaded later may serve it.
802
+ */
803
+ #unserved(locale) {
804
+ if (!this.locales.length || this.#resolveLocale(locale) !== undefined)
805
+ return false;
806
+ logger.debug(`Ignoring '${locale}' locale — nothing serves it.`);
807
+ return true;
369
808
  }
370
809
  #cacheValue() {
371
810
  const { cache = defaultCache } = this.#config ?? {};
@@ -380,103 +819,554 @@ class I18nCore {
380
819
  const loadedAt = read(this.#loadedAt, locale);
381
820
  if (loadedAt !== undefined && Date.now() >= loadedAt + cacheValue) {
382
821
  logger.debug(`'${locale}' translations expired. Loaders will run again.`);
383
- this.invalidate(locale);
822
+ this.#invalidate(locale, undefined, { expiry: true });
384
823
  }
385
824
  });
386
825
  }
387
- /** Activates `locale` unless another request superseded its load meanwhile. */
388
- #activate(locale) {
826
+ /**
827
+ * Whether the locale the next trigger loads is other than `locale`: a later
828
+ * request asked for another, or a failed one put an earlier back.
829
+ */
830
+ #superseded(locale) {
389
831
  const requested = this.#resolveLocale(this.#requestedLocale);
390
832
  // An unresolvable most-recent request supersedes nothing — it must not
391
833
  // block a completed load from activating.
392
- if (requested !== undefined && requested !== locale)
834
+ return requested !== undefined && requested !== locale;
835
+ }
836
+ /** Activates `locale` unless another request superseded its load meanwhile. */
837
+ #activate(locale) {
838
+ if (this.#superseded(locale))
393
839
  return;
394
840
  if (this.#locale !== locale)
395
841
  this.#locale = locale;
396
842
  }
843
+ /** The namespaces of `sanitizedLocale` `snapshot()` leaves out — see there. */
844
+ #unsnapshottable(sanitizedLocale, withRecords) {
845
+ const { loaders = [] } = this.#config ?? {};
846
+ const own = loaders.filter(({ locale }) => locale === sanitizedLocale);
847
+ return unique(own.map(({ namespace }) => namespace)).filter((namespace) => {
848
+ const feeding = own.filter((loader) => loader.namespace === namespace);
849
+ const several = feeding.length > 1;
850
+ const params = feeding.some(({ routes }) => capturesParams(routes));
851
+ // Seeded data alone would keep the client's loaders from ever running.
852
+ const delivered = feeding.some((loader) => this.#loaderRecords.has(loader))
853
+ || (read(this.#namespaceRecords, sanitizedLocale) || []).includes(namespace);
854
+ if (!withRecords)
855
+ return several || params || !delivered;
856
+ // Only a record lets the client replace the data once the params change.
857
+ return params && (several || feeding.some((loader) => loader.id === null || !this.#loaderRecords.has(loader)));
858
+ });
859
+ }
860
+ /**
861
+ * The loaders of `sanitizedLocale` (and the fallback locale) whose routes
862
+ * match `route`, with the params the route yields for each.
863
+ */
864
+ #matchLoaders(sanitizedLocale, route) {
865
+ const { loaders = [], fallbackLocale } = this.#config ?? {};
866
+ return loaders.flatMap((loader) => {
867
+ if (loader.locale !== sanitizedLocale && loader.locale !== fallbackLocale)
868
+ return [];
869
+ const params = routeParams(loader.routes, route);
870
+ return params ? [{ loader, params, signature: paramsSignature(params) }] : [];
871
+ });
872
+ }
873
+ /**
874
+ * The loaders of `namespace` for `sanitizedLocale` (and the fallback
875
+ * locale), whatever their routes. A loader whose routes match `route` gets
876
+ * the params they yield. Any other has no params to ask for, so whatever it
877
+ * delivered last serves; only a loader with no record is asked, for none.
878
+ */
879
+ #matchNamespace(sanitizedLocale, namespace, route) {
880
+ const { loaders = [], fallbackLocale } = this.#config ?? {};
881
+ return loaders.flatMap((loader) => {
882
+ if (loader.namespace !== namespace)
883
+ return [];
884
+ if (loader.locale !== sanitizedLocale && loader.locale !== fallbackLocale)
885
+ return [];
886
+ const params = routeParams(loader.routes, route);
887
+ if (params)
888
+ return [{ loader, params, signature: paramsSignature(params) }];
889
+ return this.#loaderRecords.has(loader) ? [] : [{ loader, params: {}, signature: '' }];
890
+ });
891
+ }
892
+ /**
893
+ * Cuts the loaders `covers` matches off the loads in flight: a load running
894
+ * one discards that loader's data when it settles while the rest of it
895
+ * lands, and no trigger that has to fetch that loader joins it.
896
+ */
897
+ #sever(covers) {
898
+ this.#inflight.forEach(({ loaders, unparked, severed }) => {
899
+ [...loaders, ...unparked.map(({ request }) => request.loader)].filter(covers).forEach((loader) => severed.add(loader));
900
+ });
901
+ this.#fetches.forEach((fetch) => {
902
+ if (covers(fetch.request.loader))
903
+ this.#fetches.delete(fetch);
904
+ });
905
+ }
906
+ /**
907
+ * Ends the pass a hand-off serves once an activating trigger asks for
908
+ * another locale or route; the first trigger after a hand-off that named
909
+ * neither settles them.
910
+ */
911
+ #passHandOff(locale, route) {
912
+ if (!this.#handedOff.size)
913
+ return;
914
+ const { locale: passLocale = locale, route: passRoute = route } = this.#handOffPass;
915
+ if (passLocale === locale && passRoute === route)
916
+ this.#handOffPass = { locale, route };
917
+ else
918
+ this.#handedOff.clear();
919
+ }
397
920
  /**
398
- * Loader keys of `sanitizedLocale` that only ever load on OTHER routes. A key
399
- * claimed by a route-matching loader — or by no loader at all — is not
400
- * attributable to another route and is therefore absent here.
921
+ * Records the params the current route asks each matching loader for, and
922
+ * none of every other loader: a loader of a locale the request left must not
923
+ * keep params its route asked for. Returns what it replaced.
401
924
  */
402
- #offRouteKeys(sanitizedLocale, route) {
925
+ #want(matching) {
403
926
  const { loaders = [] } = this.#config ?? {};
404
- const offRoute = new Set();
405
- const onRoute = new Set();
406
- loaders.forEach(({ key, locale, routes }) => {
407
- if (this.#sanitize(locale)[0] !== sanitizedLocale)
927
+ const wants = new Map([
928
+ ...loaders.map((loader) => [loader, null]),
929
+ ...matching.map(({ loader, signature }) => [loader, signature]),
930
+ ]);
931
+ const replaced = new Map(Array.from(wants.keys(), (loader) => [loader, this.#wanted.get(loader)]));
932
+ wants.forEach((signature, loader) => this.#wanted.set(loader, signature));
933
+ return replaced;
934
+ }
935
+ /**
936
+ * Starts an activating call: keeps the requested locale and the route it is
937
+ * about to replace — with the params `#load` adds, what is put back should
938
+ * it fail.
939
+ */
940
+ #ask() {
941
+ const call = { replaced: { requestedLocale: this.#requestedLocale, route: this.#route, wanted: new Map() }, failed: false, threw: [], resumed: false };
942
+ this.#calls = [...this.#calls, call];
943
+ return call;
944
+ }
945
+ /**
946
+ * Lets a call whose load resolved without failing stand, with every call
947
+ * before it. Dropping them only bounds the chain: a later call that has not
948
+ * failed keeps them from being undone either way.
949
+ */
950
+ #stand(call, promise) {
951
+ call.settled = promise.then(() => {
952
+ if (!call.failed)
953
+ this.#calls = this.#calls.slice(this.#calls.indexOf(call) + 1);
954
+ }, () => undefined);
955
+ return promise;
956
+ }
957
+ /**
958
+ * Marks the calls a load failed for, then undoes and drops failed calls from
959
+ * the end of the chain: a failed call behind one that has not failed stays,
960
+ * and stays applied, until that one fails too. Returns whether the undo put
961
+ * another request back, with the control flow of the calls it undid and of
962
+ * this load, or `undefined` when it undid nothing.
963
+ */
964
+ #fail(calls, thrown) {
965
+ calls.forEach((call) => {
966
+ call.failed = true;
967
+ call.threw = [...call.threw, ...thrown];
968
+ });
969
+ const before = { locale: this.#requestedLocale, route: this.#route };
970
+ let threw;
971
+ for (let last = this.#calls.at(-1); last?.failed; last = this.#calls.at(-1)) {
972
+ this.#undo(last);
973
+ this.#calls = this.#calls.slice(0, -1);
974
+ threw = [...threw ?? [], ...last.threw];
975
+ }
976
+ if (!threw)
977
+ return undefined;
978
+ this.#rewant();
979
+ const changed = before.locale !== this.#requestedLocale || before.route !== this.#route;
980
+ if (changed) {
981
+ const onRoute = this.#route ? ` on '${this.#route}' route` : '';
982
+ logger.debug(`Undoing the failed calls: the request is back to '${this.#requestedLocale}' locale${onRoute}.`);
983
+ }
984
+ return { changed, threw: [...threw, ...thrown] };
985
+ }
986
+ /**
987
+ * Puts back what a failed call replaced. Whichever of the locale and the
988
+ * route was not asked for before it stands: it is all there is for the next
989
+ * trigger to load.
990
+ */
991
+ #undo({ replaced: { requestedLocale, route, wanted } }) {
992
+ if (requestedLocale !== undefined)
993
+ this.#requestedLocale = requestedLocale;
994
+ if (route !== undefined)
995
+ this.#route = route;
996
+ wanted.forEach((signature, loader) => {
997
+ if (signature === undefined)
998
+ this.#wanted.delete(loader);
999
+ else
1000
+ this.#wanted.set(loader, signature);
1001
+ });
1002
+ }
1003
+ /**
1004
+ * The loaders the request an undo left in place selects: wanted for the
1005
+ * params its route yields, and no longer recorded as loaded for others —
1006
+ * whatever another load delivered for them meanwhile stays displayed until
1007
+ * the request loads them again. Runs before the failed load applies what
1008
+ * its other loaders delivered, so that is filtered by what is wanted now.
1009
+ */
1010
+ #rewant() {
1011
+ const locale = this.#resolveLocale(this.#requestedLocale);
1012
+ if (!locale || this.#route === undefined)
1013
+ return;
1014
+ const matching = this.#matchLoaders(locale, this.#route);
1015
+ this.#want(matching);
1016
+ matching.forEach(({ loader, signature }) => {
1017
+ if (this.#loaderRecords.has(loader) && this.#loaderRecords.get(loader) !== signature)
1018
+ this.#loaderRecords.delete(loader);
1019
+ });
1020
+ }
1021
+ /**
1022
+ * Once the failed load has applied what its other loaders delivered, the
1023
+ * request the undo put back activates if its data is there — a loader with
1024
+ * `cache: false` counts once its record holds the params — unless an
1025
+ * activating load of it is in flight that activates it or fails. It is
1026
+ * loaded otherwise, by an activating call nobody awaits: the load that would
1027
+ * have activated it may have resolved without activating while it was
1028
+ * replaced, and a load still waiting to resume joins this one. It loads
1029
+ * nothing when the undo put back the request that failed, or when that
1030
+ * request needs a loader for the params an undone call's load, or this one,
1031
+ * threw control flow for: a failure never runs again by itself.
1032
+ */
1033
+ #settleUndo({ changed, threw: controlFlow }) {
1034
+ const locale = this.#resolveLocale(this.#requestedLocale);
1035
+ const route = this.#route;
1036
+ if (!locale || route === undefined)
1037
+ return;
1038
+ const matching = this.#matchLoaders(locale, route);
1039
+ const missing = this.#unloaded(matching).filter(({ loader, signature }) => loader.cache !== false || this.#loaderRecords.get(loader) !== signature);
1040
+ if (!missing.length) {
1041
+ const key = this.#inflightKey(locale, route, matching);
1042
+ // A severed load, or one of a replaced config, activates nothing when
1043
+ // it settles.
1044
+ const activating = Array.from(this.#inflight).some((entry) => entry.key === key && entry.calls.length && !entry.severed.size && entry.config === this.#config);
1045
+ if (activating)
408
1046
  return;
409
- (routes && !routes.some(testRoute(route)) ? offRoute : onRoute).add(key);
1047
+ this.#applyWanted(this.#claimParked(matching).map(({ delivery }) => delivery));
1048
+ this.#activate(locale);
1049
+ return;
1050
+ }
1051
+ const threw = missing.some(({ loader, signature }) => controlFlow.some((flow) => flow.loader === loader && flow.signature === signature));
1052
+ if (!changed || threw)
1053
+ return;
1054
+ const call = this.#ask();
1055
+ void this.#stand(call, this.#load(locale, route, call));
1056
+ }
1057
+ /**
1058
+ * The matching loaders a load has to run: all but those whose own record
1059
+ * holds the params the route yields now. A namespace a plain hand-off
1060
+ * delivered stands in for the record of a loader without params that has
1061
+ * none.
1062
+ * A loader with `cache: false` runs unless a hand-off serves it: its record
1063
+ * only names what it delivered.
1064
+ */
1065
+ #unloaded(matching) {
1066
+ return matching.filter(({ loader, signature }) => {
1067
+ if (loader.cache === false)
1068
+ return this.#handedOff.get(loader) !== signature;
1069
+ if (this.#parked.get(loader)?.signature === signature)
1070
+ return false;
1071
+ if (this.#loaderRecords.has(loader))
1072
+ return this.#loaderRecords.get(loader) !== signature;
1073
+ return signature !== '' || !(read(this.#namespaceRecords, loader.locale) || []).includes(loader.namespace);
410
1074
  });
411
- onRoute.forEach((key) => offRoute.delete(key));
412
- return offRoute;
413
- }
414
- #filterLoaders(sanitizedLocale, route) {
415
- const { loaders, fallbackLocale = '' } = this.#config ?? {};
416
- const [sanitizedFallbackLocale] = this.#sanitize(fallbackLocale);
417
- const translationForLocale = read(this.#translations, sanitizedLocale);
418
- const translationForFallbackLocale = read(this.#translations, sanitizedFallbackLocale);
419
- return (loaders || [])
420
- .map(({ locale, ...rest }) => ({ ...rest, locale: this.#sanitize(locale)[0] }))
421
- .filter(({ routes }) => !routes || (routes || []).some(testRoute(route)))
422
- .filter(({ key, locale }) => (locale === sanitizedLocale && (!translationForLocale || !(read(this.#loadedKeys, sanitizedLocale) || []).includes(key))) || (fallbackLocale && locale === sanitizedFallbackLocale && (!translationForFallbackLocale
423
- || !(read(this.#loadedKeys, sanitizedFallbackLocale) || []).includes(key))));
424
- }
425
- /**
426
- * Starts (or joins) a load. A load already in flight for the same locale
427
- * and route is returned as-is, so concurrent duplicate triggers share one
428
- * fetch. The pending entry is registered synchronously, so `loading` is
429
- * observable right after the triggering call; a load with nothing to fetch
430
- * never registers at all, so cache-served navigations do not flicker the flag.
431
- */
432
- #load(requestedLocale, route) {
1075
+ }
1076
+ /**
1077
+ * Starts (or joins) a load. A load already in flight for the same locale and
1078
+ * route that selected the same loaders for the same params is returned as-is
1079
+ * while it delivers everything the trigger has to fetch, so concurrent
1080
+ * duplicate triggers share one fetch — whether they selected by route or by
1081
+ * `namespace`. The pending entry is registered
1082
+ * synchronously, so `loading` is observable right after the triggering call;
1083
+ * a load with nothing to fetch never registers at all, so cache-served
1084
+ * navigations do not flicker the flag. A warm load — one without an
1085
+ * activating `call` — never registers either, until an activating trigger
1086
+ * joins it.
1087
+ */
1088
+ #load(requestedLocale, route, call, namespace) {
433
1089
  const locale = this.#resolveLocale(requestedLocale);
434
1090
  if (!locale)
435
1091
  return Promise.resolve();
436
- // Expiry is evaluated per load trigger, BEFORE the in-flight check. That
437
- // order is safe: a locale is stamped only once its data arrived, so a
438
- // shared in-flight load cannot be invalidated by its own duplicates.
439
- this.#invalidateExpired(locale, this.#sanitize(this.#config?.fallbackLocale)[0]);
440
- // NUL never appears in a sanitized locale, so the key is unambiguous.
441
- const inflightKey = `${locale}\u0000${route}`;
442
- const inflight = this.#inflight.get(inflightKey);
443
- if (inflight)
444
- return inflight;
445
- if (!this.#filterLoaders(locale, route).length) {
1092
+ // Expiry is evaluated per activating trigger, BEFORE the in-flight check.
1093
+ // That order is safe: a locale is stamped only once its data arrived, so a
1094
+ // shared in-flight load cannot be invalidated by its own duplicates. A
1095
+ // warm trigger fills the tables and leaves their freshness to the next
1096
+ // activating one.
1097
+ if (call)
1098
+ this.#invalidateExpired(locale, this.#config?.fallbackLocale);
1099
+ const matching = namespace === undefined ? this.#matchLoaders(locale, route) : this.#matchNamespace(locale, namespace, route);
1100
+ // Recorded before the in-flight check, so a trigger joining a load, or one
1101
+ // served from the records, still decides which params the route shows.
1102
+ if (call) {
1103
+ call.replaced.wanted = this.#want(matching);
1104
+ this.#passHandOff(locale, route);
1105
+ }
1106
+ return this.#loadSelection(locale, route, this.#inflightKey(locale, route, matching), matching, call ? [call] : []);
1107
+ }
1108
+ /**
1109
+ * The key of a load in flight: what the trigger selected and the route it
1110
+ * came from. A loader receives the route, so a load for another route may
1111
+ * deliver, or throw, what does not fit this one — and one awaiting a
1112
+ * navigation to it would wait on itself.
1113
+ */
1114
+ #inflightKey(locale, route, matching) {
1115
+ const { loaders = [] } = this.#config ?? {};
1116
+ return JSON.stringify([locale, route, ...matching.map(({ loader, signature }) => [loaders.indexOf(loader), signature])]);
1117
+ }
1118
+ /** Joins the load in flight under `key` that delivers what `selected` lacks, or starts one. */
1119
+ #loadSelection(locale, route, key, selected, calls) {
1120
+ const requests = this.#unloaded(selected);
1121
+ const unparked = calls.length ? this.#claimParked(selected) : [];
1122
+ if (!requests.length) {
446
1123
  // Nothing to fetch — the locale still becomes active (its data is
447
- // already present or it has no loaders).
448
- this.#activate(locale);
1124
+ // already present, parked or it has no loaders).
1125
+ if (calls.length) {
1126
+ this.#applyWanted(unparked.map(({ delivery }) => delivery));
1127
+ this.#activate(locale);
1128
+ }
449
1129
  return Promise.resolve();
450
1130
  }
451
- const promise = this.#getTranslationProps(locale, route).then((props) => {
452
- // An `invalidate()` — explicit, via expiry, or via reconfiguration —
453
- // that raced this load severed it from `#inflight`. Its data predates
454
- // the invalidation: applying it would resurrect the dropped bookkeeping
455
- // and permanently suppress the promised refetch.
456
- if (this.#inflight.get(inflightKey) !== promise)
457
- return;
458
- if (props.length)
459
- this.#addTranslations(...props);
460
- this.#activate(locale);
1131
+ // What is parked lands with the load, so a call it fails puts it back.
1132
+ const inflight = this.#joinable(key, requests);
1133
+ if (inflight)
1134
+ return this.#join(inflight, calls, unparked);
1135
+ return this.#start(locale, route, key, requests, calls, unparked);
1136
+ }
1137
+ /** The load in flight under `key` that delivers every one of `requests`. */
1138
+ #joinable(key, requests) {
1139
+ return Array.from(this.#inflight).find(({ key: inflightKey, loaders, severed }) => inflightKey === key
1140
+ && requests.every(({ loader }) => loaders.includes(loader) && !severed.has(loader)));
1141
+ }
1142
+ /**
1143
+ * Joins a load in flight. The activating calls that join make it activate,
1144
+ * and fail with it when a loader of it throws control flow that was not
1145
+ * severed.
1146
+ */
1147
+ #join(entry, calls, unparked) {
1148
+ if (!calls.length)
1149
+ return entry.promise;
1150
+ if (!entry.calls.length)
1151
+ this.#pending = new Set(this.#pending).add(entry.promise);
1152
+ entry.calls = [...entry.calls, ...calls];
1153
+ entry.unparked = [...entry.unparked, ...unparked];
1154
+ return entry.promise;
1155
+ }
1156
+ /**
1157
+ * Whether the next trigger asks `loader` for `signature`: the params its
1158
+ * route yields, none while the route does not select the loader, or any
1159
+ * before a trigger asked.
1160
+ */
1161
+ #isWanted({ loader, signature }) {
1162
+ const wanted = this.#wanted.get(loader);
1163
+ if (wanted === undefined)
1164
+ return true;
1165
+ return (wanted ?? '') === signature;
1166
+ }
1167
+ /**
1168
+ * Applies the deliveries of the params the next trigger asks for, and parks
1169
+ * the rest. A loader no trigger asked for params keeps what it delivered for
1170
+ * others: a warm load parks what would replace it, unless it asks for no
1171
+ * params — `loadNamespace()` off the loader's routes. What an activating load
1172
+ * counts on stays parked until it settles. A loader with `cache: false` runs
1173
+ * on every trigger that selects it, so nothing of it is parked.
1174
+ */
1175
+ #applyWanted(deliveries) {
1176
+ const wanted = deliveries.filter(({ loader, signature }) => {
1177
+ const wanted = this.#wanted.get(loader);
1178
+ if (typeof wanted === 'string')
1179
+ return wanted === signature;
1180
+ const previous = this.#deliveries.get(loader);
1181
+ return !previous || previous.signature === signature || signature === '';
1182
+ });
1183
+ const parked = deliveries.filter((delivery) => !wanted.includes(delivery)
1184
+ && delivery.loader.cache !== false
1185
+ && !this.#claimed(delivery.loader));
1186
+ parked.forEach((delivery) => this.#parked.set(delivery.loader, delivery));
1187
+ this.#stamp(parked.map(({ loader }) => loader.locale));
1188
+ if (wanted.length)
1189
+ this.#applyDeliveries(wanted);
1190
+ }
1191
+ /** Takes the fetches `entry` waited on out of the table, once what they delivered is applied or parked. */
1192
+ #release({ fetches }) {
1193
+ fetches.forEach((fetch) => this.#fetches.delete(fetch));
1194
+ }
1195
+ /** What is parked for `requests`, for the load that applies it once it settles. */
1196
+ #claimParked(requests) {
1197
+ return requests.flatMap((request) => {
1198
+ const delivery = this.#parked.get(request.loader);
1199
+ return delivery?.signature === request.signature ? [{ request, delivery }] : [];
1200
+ });
1201
+ }
1202
+ /** Whether a load in flight counts on what is parked for `loader`. */
1203
+ #claimed(loader) {
1204
+ const delivery = this.#parked.get(loader);
1205
+ return delivery !== undefined && Array.from(this.#inflight).some(({ unparked }) => unparked.some((claim) => claim.delivery === delivery));
1206
+ }
1207
+ /**
1208
+ * The control flow a settled load rejects with: the first its locale's
1209
+ * loaders threw, in `loaders` order, then the fallback locale's. What a
1210
+ * severed loader threw predates the invalidation, and an activating load
1211
+ * counts control flow only while it serves the request the next trigger
1212
+ * loads — not once another locale or other params of that loader were asked
1213
+ * for.
1214
+ */
1215
+ #rejection({ severed, calls }, locale, controlFlow) {
1216
+ const discarded = (flow) => {
1217
+ if (severed.has(flow.loader))
1218
+ return 'an invalidation or destroy() severed it';
1219
+ if (!calls.length)
1220
+ return undefined;
1221
+ if (this.#superseded(locale))
1222
+ return 'another locale was asked for';
1223
+ return this.#isWanted(flow) ? undefined : 'other params were asked for';
1224
+ };
1225
+ const counted = controlFlow.filter((flow) => {
1226
+ const reason = discarded(flow);
1227
+ if (reason)
1228
+ logger.debug(`Discarding what the ${loaderName(flow.loader)} loader threw: ${reason}.`, flow.value);
1229
+ return !reason;
1230
+ });
1231
+ const [first, ...rest] = [
1232
+ ...counted.filter(({ loader }) => loader.locale === locale),
1233
+ ...counted.filter(({ loader }) => loader.locale !== locale),
1234
+ ];
1235
+ if (!first)
1236
+ return undefined;
1237
+ rest.forEach(({ loader, value }) => logger.debug(`Discarding what the ${loaderName(loader)} loader threw: the load rejects with what the ${loaderName(first.loader)} loader threw.`, value));
1238
+ return first;
1239
+ }
1240
+ /** Fetches `requests` as a load in flight under `key`, and settles it. */
1241
+ #start(locale, route, key, requests, calls, unparked) {
1242
+ const onRoute = route ? ` and '${route}' route` : '';
1243
+ let rejection;
1244
+ let outcome;
1245
+ const promise = new Promise((resolve, reject) => { outcome = { resolve, reject }; });
1246
+ const { fetches, run } = this.#fetch(requests, route);
1247
+ const entry = { key, config: this.#config, promise, loaders: requests.map(({ loader }) => loader), fetches, severed: new Set(), calls, unparked };
1248
+ // Registered before any loader is called, so one that invalidates or
1249
+ // destroys the instance before its first `await` severs its own load too.
1250
+ this.#inflight.add(entry);
1251
+ if (calls.length)
1252
+ this.#pending = new Set(this.#pending).add(promise);
1253
+ run();
1254
+ const settled = Promise.all(fetches.map(({ outcome }) => outcome)).then(mergeFetched).then(({ deliveries, controlFlow }) => {
1255
+ // Released before the load settles: a trigger arriving in between must
1256
+ // not join a load whose activation step has already run — it finds the
1257
+ // data recorded and activates at once.
1258
+ this.#inflight.delete(entry);
1259
+ // An `invalidate()` — explicit, via expiry, via reconfiguration or via
1260
+ // `destroy()` — that raced this load severed some of its loaders. Their
1261
+ // data predates the invalidation: applying it would resurrect the
1262
+ // dropped bookkeeping and permanently suppress the promised refetch.
1263
+ const current = deliveries.filter(({ loader }) => !entry.severed.has(loader));
1264
+ // What it counts on applies while still parked: a newer record of the
1265
+ // same params dropped it, and an invalidation severed it.
1266
+ const held = entry.unparked
1267
+ .filter(({ request, delivery }) => !entry.severed.has(request.loader) && this.#parked.get(request.loader) === delivery)
1268
+ .map(({ delivery }) => delivery);
1269
+ const served = [...requests, ...entry.unparked.map(({ request }) => request)];
1270
+ rejection = this.#rejection(entry, locale, controlFlow);
1271
+ // The calls that share the load failed, whether its control flow rejects
1272
+ // them or a later call superseded it: the locale does not advance, and
1273
+ // what they replaced is put back unless a later call that has not failed
1274
+ // came since.
1275
+ const thrown = controlFlow.filter(({ loader }) => !entry.severed.has(loader));
1276
+ const undone = thrown.length ? this.#fail(entry.calls, thrown) : undefined;
1277
+ // With control flow, what the other loaders delivered lands as a warm
1278
+ // load's does. Should applying it fail, that is only logged: the caller
1279
+ // gets the control flow.
1280
+ let failure;
1281
+ try {
1282
+ this.#applyWanted([...held, ...current]);
1283
+ }
1284
+ catch (error) {
1285
+ if (rejection)
1286
+ logError(`Failed to load translations for '${locale}' locale${onRoute}.`, error);
1287
+ else
1288
+ failure = { error };
1289
+ }
1290
+ this.#release(entry);
1291
+ if (undone)
1292
+ this.#settleUndo(undone);
1293
+ if (failure)
1294
+ throw failure.error;
1295
+ if (rejection)
1296
+ throw rejection.value;
1297
+ // A load of params the route no longer asks for, whatever it returned,
1298
+ // leaves the activation to the load of the params it asks for. One whose
1299
+ // control flow a later call superseded failed: an undo that puts its
1300
+ // request back loads it, unless that control flow still covers it.
1301
+ const replaced = served.some((request) => !this.#isWanted(request));
1302
+ if (!entry.calls.length || replaced || thrown.length)
1303
+ return [];
1304
+ if (!entry.severed.size)
1305
+ this.#activate(locale);
1306
+ return served.filter(({ loader }) => entry.severed.has(loader));
461
1307
  });
462
- this.#inflight.set(inflightKey, promise);
463
- this.#pending = new Set(this.#pending).add(promise);
1308
+ // Reported here so a load nobody awaits is still visible. What a resume
1309
+ // adopts is a load of its own, which reports itself; control flow a
1310
+ // shared fetch threw is reported by the first load it rejects.
1311
+ settled.catch((error) => {
1312
+ if (rejection) {
1313
+ if (this.#reported.has(rejection))
1314
+ return;
1315
+ this.#reported.add(rejection);
1316
+ }
1317
+ logError(rejection
1318
+ ? `Rejecting the load of '${locale}' locale${onRoute} with what the ${loaderName(rejection.loader)} loader threw.`
1319
+ : `Failed to load translations for '${locale}' locale${onRoute}.`, error);
1320
+ });
1321
+ // Resumed once per call: a loader that invalidates what it loads each time
1322
+ // it runs would otherwise be fetched again for as long as it keeps doing
1323
+ // so, while a call that joined a resumed load still gets its own.
1324
+ settled
1325
+ .then((severed) => (severed.length && entry.calls.some(({ resumed }) => !resumed) ? this.#resume(entry, locale, route, severed) : undefined))
1326
+ .then(outcome.resolve, outcome.reject);
464
1327
  const settle = () => {
465
- // Guarded by identity — a later load under the same key must not be
466
- // evicted by this one settling.
467
- if (this.#inflight.get(inflightKey) === promise)
468
- this.#inflight.delete(inflightKey);
1328
+ // Only a settle step that threw before its release skipped the one above.
1329
+ this.#inflight.delete(entry);
1330
+ this.#release(entry);
1331
+ if (!this.#pending.has(promise))
1332
+ return;
469
1333
  const next = new Set(this.#pending);
470
1334
  next.delete(promise);
471
1335
  this.#pending = next;
472
1336
  };
1337
+ // Also marks the load handled, so one nobody awaits cannot terminate the
1338
+ // process; an awaiting caller still receives the rejection.
473
1339
  promise.then(settle, settle);
474
- // Reported here so a discarded load is still visible, and marked handled so
475
- // it cannot terminate the process; an awaiting caller still receives the
476
- // rejection from the same promise.
477
- promise.catch((error) => logError(`Failed to load translations for '${locale}' locale and '${route}' route.`, error));
478
1340
  return promise;
479
1341
  }
1342
+ /**
1343
+ * Finishes an activating load an invalidation cut `severed` off: it fetches
1344
+ * them again, so a trigger's promise that resolves still means its locale
1345
+ * is loaded, and control flow the refetch throws rejects it as any load's
1346
+ * does. It runs once per call: what severs the refetch of every call it
1347
+ * serves is left to the next trigger, as is the locale when the instance
1348
+ * was destroyed or reconfigured. Once a
1349
+ * later call asked for another locale or route, it waits for the calls since
1350
+ * its own to settle: it stands down should the request stay replaced, and
1351
+ * resumes should an undo put it back. A load whose params a later request
1352
+ * replaced, or whose control flow it superseded, does not get here: an undo
1353
+ * that puts it back loads it itself.
1354
+ */
1355
+ #resume(entry, locale, route, severed) {
1356
+ if (this.#destroyed || entry.config !== this.#config)
1357
+ return undefined;
1358
+ if (this.#superseded(locale) || this.#route !== route) {
1359
+ // A resumed call joins a load after the calls it started, so the latest
1360
+ // of them is found by its place in the chain, not in the load.
1361
+ const last = Math.max(-1, ...entry.calls.map((call) => this.#calls.indexOf(call)));
1362
+ const later = last === -1 ? [] : this.#calls.slice(last + 1);
1363
+ if (!later.length)
1364
+ return undefined;
1365
+ return Promise.all(later.flatMap(({ settled }) => settled ?? [])).then(() => this.#resume(entry, locale, route, severed));
1366
+ }
1367
+ entry.calls.forEach((call) => { call.resumed = true; });
1368
+ return this.#loadSelection(locale, route, entry.key, severed, entry.calls);
1369
+ }
480
1370
  }
481
1371
  // The raw class is deliberately not exported — every consumer constructs
482
1372
  // through the extension-aware signature. The exported name carries both