@openmrs/esm-implementer-tools-app 11.0.1-pre.6006 → 11.0.1-pre.6014

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.
@@ -0,0 +1,473 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import i18next, { type BackendModule } from 'i18next';
3
+ import { temporaryConfigStore } from '@openmrs/esm-framework/src/internal';
4
+ import {
5
+ findChangedOverrides,
6
+ reloadTranslations,
7
+ resetTranslationOverride,
8
+ setTranslationOverride,
9
+ watchTranslationOverrides,
10
+ } from './translation-overrides';
11
+
12
+ const loginApp = '@openmrs/esm-login-app';
13
+
14
+ const mockReloadResources = vi.fn((_lngs: unknown, _ns: unknown, done?: (error?: unknown) => void) => done?.());
15
+ const mockHasResourceBundle = vi.fn(() => true);
16
+ const connectorState: Record<string, number> = {};
17
+
18
+ beforeEach(() => {
19
+ for (const key of Object.keys(connectorState)) {
20
+ delete connectorState[key];
21
+ }
22
+
23
+ window.i18next = {
24
+ ...window.i18next,
25
+ language: 'en',
26
+ store: { data: { en: { [loginApp]: {} } } },
27
+ reloadResources: mockReloadResources,
28
+ hasResourceBundle: mockHasResourceBundle,
29
+ getResourceBundle: vi.fn(() => ({})),
30
+ removeResourceBundle: vi.fn(),
31
+ addResourceBundle: vi.fn(),
32
+ options: { ns: [] },
33
+ services: { backendConnector: { state: connectorState } },
34
+ emit: vi.fn(),
35
+ } as unknown as typeof window.i18next;
36
+ });
37
+
38
+ afterEach(() => {
39
+ temporaryConfigStore.setState({ config: {} });
40
+ });
41
+
42
+ describe('setTranslationOverride', () => {
43
+ it('writes the override where the config system expects it', () => {
44
+ setTranslationOverride(loginApp, 'en', 'loginButton', 'Sign in');
45
+
46
+ expect(temporaryConfigStore.getState().config).toEqual({
47
+ [loginApp]: { 'Translation overrides': { en: { loginButton: 'Sign in' } } },
48
+ });
49
+ });
50
+
51
+ it('leaves every other pending config edit alone', () => {
52
+ temporaryConfigStore.setState({
53
+ config: {
54
+ '@openmrs/esm-other-app': { someSetting: 'untouched' },
55
+ [loginApp]: {
56
+ anotherSetting: 'also untouched',
57
+ 'Translation overrides': {
58
+ en: { locationsHeader: 'Facilities' },
59
+ fr: { loginButton: 'Connexion' },
60
+ },
61
+ },
62
+ },
63
+ });
64
+
65
+ setTranslationOverride(loginApp, 'en', 'loginButton', 'Sign in');
66
+
67
+ expect(temporaryConfigStore.getState().config).toEqual({
68
+ '@openmrs/esm-other-app': { someSetting: 'untouched' },
69
+ [loginApp]: {
70
+ anotherSetting: 'also untouched',
71
+ 'Translation overrides': {
72
+ en: { locationsHeader: 'Facilities', loginButton: 'Sign in' },
73
+ fr: { loginButton: 'Connexion' },
74
+ },
75
+ },
76
+ });
77
+ });
78
+
79
+ it('writes a region-qualified language tag under that tag', () => {
80
+ setTranslationOverride(loginApp, 'km-KH', 'loginButton', 'ចូល');
81
+
82
+ expect(temporaryConfigStore.getState().config[loginApp]['Translation overrides']).toEqual({
83
+ 'km-KH': { loginButton: 'ចូល' },
84
+ });
85
+ });
86
+ });
87
+
88
+ describe('resetTranslationOverride', () => {
89
+ it('removes one override and keeps its siblings', () => {
90
+ temporaryConfigStore.setState({
91
+ config: {
92
+ [loginApp]: {
93
+ 'Translation overrides': {
94
+ en: { loginButton: 'Sign in', locationsHeader: 'Facilities' },
95
+ fr: { loginButton: 'Connexion' },
96
+ },
97
+ },
98
+ },
99
+ });
100
+
101
+ resetTranslationOverride(loginApp, 'en', 'loginButton');
102
+
103
+ expect(temporaryConfigStore.getState().config[loginApp]['Translation overrides']).toEqual({
104
+ en: { locationsHeader: 'Facilities' },
105
+ fr: { loginButton: 'Connexion' },
106
+ });
107
+ });
108
+
109
+ it('does nothing for a key that was never overridden', () => {
110
+ temporaryConfigStore.setState({ config: { [loginApp]: { 'Translation overrides': { en: {} } } } });
111
+
112
+ expect(() => resetTranslationOverride(loginApp, 'en', 'loginButton')).not.toThrow();
113
+ expect(temporaryConfigStore.getState().config).toEqual({
114
+ [loginApp]: { 'Translation overrides': { en: {} } },
115
+ });
116
+ });
117
+ });
118
+
119
+ describe('reloadTranslations', () => {
120
+ it('reloads the module and every extension-scoped bundle derived from it', async () => {
121
+ window.i18next.store.data.en = {
122
+ [loginApp]: {},
123
+ [`${loginApp}___some-slot___some-extension`]: {},
124
+ [`${loginApp}___other-slot___other-extension`]: {},
125
+ '@openmrs/esm-other-app___slot___ext': {},
126
+ };
127
+
128
+ await reloadTranslations(loginApp, 'en');
129
+
130
+ expect(mockReloadResources).toHaveBeenCalledWith(
131
+ ['en'],
132
+ [loginApp, `${loginApp}___some-slot___some-extension`, `${loginApp}___other-slot___other-extension`],
133
+ expect.any(Function),
134
+ );
135
+ });
136
+
137
+ it('re-renders the application once the reload lands', async () => {
138
+ await reloadTranslations(loginApp, 'en');
139
+
140
+ expect(window.i18next.emit).toHaveBeenCalledWith('languageChanged', 'en');
141
+ });
142
+
143
+ it('reports a reload i18next skipped, even though the old bundle is still there', async () => {
144
+ // A failed load leaves the previous bundle in place and marks the pair -1, and i18next then
145
+ // calls back as a success, so an absent bundle is not evidence either way.
146
+ mockReloadResources.mockImplementationOnce((_lngs, _ns, done) => {
147
+ connectorState[`en|${loginApp}`] = -1;
148
+ return done?.();
149
+ });
150
+
151
+ await expect(reloadTranslations(loginApp, 'en')).rejects.toThrow(/try again/);
152
+ expect(window.i18next.emit).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it('clears a previous failure so the next attempt actually runs', async () => {
156
+ connectorState[`en|${loginApp}`] = -1;
157
+
158
+ await reloadTranslations(loginApp, 'en');
159
+
160
+ expect(connectorState[`en|${loginApp}`]).toBe(0);
161
+ expect(window.i18next.emit).toHaveBeenCalledWith('languageChanged', 'en');
162
+ });
163
+
164
+ it('drops a reset key from the bundle, which a reload alone would leave behind', async () => {
165
+ // i18next's reload merges over the existing bundle, so a key the fresh payload no longer carries
166
+ // survives — which is what resetting an override produces for a language whose file lacks it.
167
+ const bundle: Record<string, string> = { loginButton: 'Sign in', locationsHeader: 'Locations' };
168
+ window.i18next.getResourceBundle = vi.fn(() => bundle);
169
+
170
+ await reloadTranslations(loginApp, 'en', 'loginButton');
171
+
172
+ expect(bundle).toEqual({ locationsHeader: 'Locations' });
173
+ });
174
+
175
+ it('leaves the bundle alone when no key is being reset', async () => {
176
+ const bundle: Record<string, string> = { loginButton: 'Sign in' };
177
+ window.i18next.getResourceBundle = vi.fn(() => bundle);
178
+
179
+ await reloadTranslations(loginApp, 'en');
180
+
181
+ expect(bundle).toEqual({ loginButton: 'Sign in' });
182
+ });
183
+
184
+ it('reports a reload that quietly did nothing rather than reporting success', async () => {
185
+ // i18next skips a language/namespace pair whose first load failed and calls back as though it
186
+ // had reloaded it, so an absent bundle is the only evidence the reload did nothing.
187
+ mockHasResourceBundle.mockReturnValue(false);
188
+
189
+ await expect(reloadTranslations(loginApp, 'en')).rejects.toThrow(/holds no/);
190
+ expect(window.i18next.emit).not.toHaveBeenCalled();
191
+ });
192
+
193
+ it('reports a failed reload rather than reporting success', async () => {
194
+ mockReloadResources.mockImplementationOnce((_lngs, _ns, done) => {
195
+ connectorState[`en|${loginApp}`] = -1;
196
+ return done?.(new Error('chunk 404'));
197
+ });
198
+
199
+ await expect(reloadTranslations(loginApp, 'en')).rejects.toThrow('chunk 404');
200
+ expect(window.i18next.emit).not.toHaveBeenCalled();
201
+ });
202
+
203
+ it('does not fail over a load error that belongs to another namespace', async () => {
204
+ // i18next hands every waiting request every load error, whichever pair it came from.
205
+ mockReloadResources.mockImplementationOnce((_lngs, _ns, done) => {
206
+ connectorState['en|core'] = -1;
207
+ return done?.([new Error('core failed')]);
208
+ });
209
+
210
+ await expect(reloadTranslations(loginApp, 'en')).resolves.toBeUndefined();
211
+ expect(window.i18next.emit).toHaveBeenCalledWith('languageChanged', 'en');
212
+ });
213
+
214
+ it('reports a failure in an extension-scoped bundle reloaded alongside the module', async () => {
215
+ const extension = `${loginApp}___some-slot___some-extension`;
216
+ window.i18next.store.data.en = { [loginApp]: {}, [extension]: {} };
217
+ mockReloadResources.mockImplementationOnce((_lngs, _ns, done) => {
218
+ connectorState[`en|${extension}`] = -1;
219
+ return done?.();
220
+ });
221
+
222
+ await expect(reloadTranslations(loginApp, 'en')).rejects.toThrow(extension);
223
+ expect(window.i18next.emit).not.toHaveBeenCalled();
224
+ });
225
+
226
+ it('drops a reset key from the extension-scoped bundles too', async () => {
227
+ const extension = `${loginApp}___some-slot___some-extension`;
228
+ const bundles: Record<string, Record<string, string>> = {
229
+ [loginApp]: { loginButton: 'Sign in' },
230
+ [extension]: { loginButton: 'Sign in', other: 'kept' },
231
+ };
232
+ window.i18next.store.data.en = { [loginApp]: {}, [extension]: {} };
233
+ window.i18next.getResourceBundle = vi.fn((_lng: string, ns: string) => bundles[ns]);
234
+
235
+ await reloadTranslations(loginApp, 'en', 'loginButton');
236
+
237
+ expect(bundles).toEqual({ [loginApp]: {}, [extension]: { other: 'kept' } });
238
+ });
239
+
240
+ describe('when a read never finishes', () => {
241
+ beforeEach(() => vi.useFakeTimers());
242
+ afterEach(() => vi.useRealTimers());
243
+
244
+ it('gives up, and lets the next save start a read of its own', async () => {
245
+ mockReloadResources.mockImplementationOnce(() => {
246
+ connectorState[`en|${loginApp}`] = 1;
247
+ });
248
+
249
+ const hung = reloadTranslations(loginApp, 'en');
250
+ const assertion = expect(hung).rejects.toThrow(/did not finish/);
251
+ await vi.advanceTimersByTimeAsync(15_000);
252
+ await assertion;
253
+
254
+ // Left marked as loading, i18next would attach the next request to the read that never ended.
255
+ expect(connectorState[`en|${loginApp}`]).toBeUndefined();
256
+
257
+ const next = reloadTranslations(loginApp, 'en');
258
+ await vi.advanceTimersByTimeAsync(0);
259
+
260
+ await expect(next).resolves.toBeUndefined();
261
+ expect(mockReloadResources).toHaveBeenCalledTimes(2);
262
+ });
263
+
264
+ it('does not reload later for a save that already timed out waiting on an earlier read', async () => {
265
+ const listeners = new Set<() => void>();
266
+ Object.assign(window.i18next.services.backendConnector, {
267
+ on: (_event: string, listener: () => void) => listeners.add(listener),
268
+ off: (_event: string, listener: () => void) => listeners.delete(listener),
269
+ });
270
+ connectorState[`en|${loginApp}`] = 1;
271
+
272
+ const hung = reloadTranslations(loginApp, 'en');
273
+ const assertion = expect(hung).rejects.toThrow(/did not finish/);
274
+ await vi.advanceTimersByTimeAsync(15_000);
275
+ await assertion;
276
+
277
+ // Some other namespace finishing its load.
278
+ listeners.forEach((listener) => listener());
279
+ await vi.advanceTimersByTimeAsync(0);
280
+
281
+ expect(mockReloadResources).not.toHaveBeenCalled();
282
+ expect(listeners.size).toBe(0);
283
+ });
284
+ });
285
+ });
286
+
287
+ describe('findChangedOverrides', () => {
288
+ const overrides = (byLanguage: Record<string, Record<string, string>>) => ({
289
+ [loginApp]: { 'Translation overrides': byLanguage },
290
+ });
291
+
292
+ it('reports the languages whose overrides changed, with the keys that were removed', () => {
293
+ const previous = overrides({ en: { loginButton: 'Sign in', other: 'Kept' }, fr: { loginButton: 'Connexion' } });
294
+ const next = overrides({ en: { other: 'Kept' }, fr: { loginButton: 'Connexion' } });
295
+
296
+ expect(findChangedOverrides(previous, next)).toEqual([
297
+ { namespace: loginApp, language: 'en', droppedKeys: ['loginButton'] },
298
+ ]);
299
+ });
300
+
301
+ it('reports every override when the whole config is cleared', () => {
302
+ expect(findChangedOverrides(overrides({ en: { loginButton: 'Sign in' } }), {})).toEqual([
303
+ { namespace: loginApp, language: 'en', droppedKeys: ['loginButton'] },
304
+ ]);
305
+ });
306
+
307
+ it('reports an added or changed override without dropping anything', () => {
308
+ expect(
309
+ findChangedOverrides(overrides({ en: { loginButton: 'Sign in' } }), overrides({ en: { loginButton: 'Enter' } })),
310
+ ).toEqual([{ namespace: loginApp, language: 'en', droppedKeys: [] }]);
311
+ });
312
+
313
+ it('ignores config changes that leave the overrides alone', () => {
314
+ expect(findChangedOverrides({ [loginApp]: { someSetting: 1 } }, { [loginApp]: { someSetting: 2 } })).toEqual([]);
315
+ });
316
+ });
317
+
318
+ describe('watchTranslationOverrides', () => {
319
+ let stop: () => void;
320
+
321
+ beforeEach(() => {
322
+ stop = watchTranslationOverrides();
323
+ });
324
+
325
+ afterEach(() => {
326
+ stop();
327
+ });
328
+
329
+ it('reloads a loaded namespace whose override is removed from outside the Translations tab', async () => {
330
+ const bundle = { loginButton: 'Sign in' };
331
+ vi.mocked(window.i18next.getResourceBundle).mockReturnValue(bundle);
332
+ temporaryConfigStore.setState({ config: { [loginApp]: { 'Translation overrides': { en: bundle } } } });
333
+ mockReloadResources.mockClear();
334
+
335
+ temporaryConfigStore.setState({ config: {} });
336
+ await vi.waitFor(() => expect(mockReloadResources).toHaveBeenCalledWith(['en'], [loginApp], expect.any(Function)));
337
+
338
+ expect(bundle).toEqual({});
339
+ });
340
+
341
+ it('leaves the reload to the caller of setTranslationOverride and resetTranslationOverride', async () => {
342
+ setTranslationOverride(loginApp, 'en', 'loginButton', 'Sign in');
343
+ resetTranslationOverride(loginApp, 'en', 'loginButton');
344
+ await new Promise((resolve) => setTimeout(resolve, 0));
345
+
346
+ expect(mockReloadResources).not.toHaveBeenCalled();
347
+ });
348
+
349
+ it('does not load a namespace that is not on screen', async () => {
350
+ mockHasResourceBundle.mockReturnValueOnce(false);
351
+ temporaryConfigStore.setState({
352
+ config: { '@openmrs/esm-other-app': { 'Translation overrides': { en: { title: 'Other' } } } },
353
+ });
354
+ await new Promise((resolve) => setTimeout(resolve, 0));
355
+
356
+ expect(mockReloadResources).not.toHaveBeenCalled();
357
+ });
358
+ });
359
+
360
+ describe('reloadTranslations against i18next', () => {
361
+ /** Reads that have started, each settled by calling it. Overrides are collected as a read starts. */
362
+ let pendingReads: Array<(error?: Error) => void>;
363
+ let i18n: ReturnType<typeof i18next.createInstance>;
364
+
365
+ beforeEach(async () => {
366
+ pendingReads = [];
367
+
368
+ const backend: BackendModule = {
369
+ type: 'backend',
370
+ init() {},
371
+ read(language, namespace, callback) {
372
+ // Like the app shell, an extension-scoped namespace takes its module's overrides.
373
+ const moduleName = namespace.split('___')[0];
374
+ const overrides =
375
+ temporaryConfigStore.getState().config?.[moduleName]?.['Translation overrides']?.[language] ?? {};
376
+ pendingReads.push((error) =>
377
+ error ? callback(error, null) : callback(null, { loginButton: 'Log in', ...overrides }),
378
+ );
379
+ },
380
+ };
381
+
382
+ i18n = i18next.createInstance();
383
+ await i18n.use(backend).init({ lng: 'en', fallbackLng: 'en', ns: [], initAsync: false });
384
+ i18n.addResourceBundle('en', loginApp, { loginButton: 'Log in' });
385
+ window.i18next = i18n as unknown as typeof window.i18next;
386
+ });
387
+
388
+ /** Settles every read, including those started in response to an earlier one settling. */
389
+ async function settleReads() {
390
+ let settled: Array<() => void>;
391
+
392
+ do {
393
+ await new Promise((resolve) => setTimeout(resolve, 0));
394
+ settled = pendingReads.splice(0);
395
+ settled.forEach((settle) => settle());
396
+ } while (settled.length > 0);
397
+ }
398
+
399
+ it('applies an override saved while the application is already reading the namespace', async () => {
400
+ i18n.reloadResources(['en'], [loginApp]);
401
+ expect(pendingReads).toHaveLength(1);
402
+
403
+ setTranslationOverride(loginApp, 'en', 'loginButton', 'Sign in');
404
+ const saved = reloadTranslations(loginApp, 'en');
405
+ await settleReads();
406
+
407
+ await expect(saved).resolves.toBeUndefined();
408
+ expect(i18n.getResource('en', loginApp, 'loginButton')).toBe('Sign in');
409
+ });
410
+
411
+ it('applies both of two saves made in quick succession', async () => {
412
+ setTranslationOverride(loginApp, 'en', 'loginButton', 'Sign in');
413
+ const first = reloadTranslations(loginApp, 'en');
414
+ setTranslationOverride(loginApp, 'en', 'locationsHeader', 'Facilities');
415
+ const second = reloadTranslations(loginApp, 'en');
416
+ await settleReads();
417
+
418
+ await Promise.all([first, second]);
419
+ expect(i18n.getResource('en', loginApp, 'locationsHeader')).toBe('Facilities');
420
+ });
421
+
422
+ it('still applies a save made while an earlier read fails', async () => {
423
+ i18n.reloadResources(['en'], [loginApp]);
424
+ setTranslationOverride(loginApp, 'en', 'loginButton', 'Sign in');
425
+ const saved = reloadTranslations(loginApp, 'en');
426
+
427
+ pendingReads.shift()?.(new Error('chunk 404'));
428
+ await settleReads();
429
+
430
+ await expect(saved).resolves.toBeUndefined();
431
+ expect(i18n.getResource('en', loginApp, 'loginButton')).toBe('Sign in');
432
+ });
433
+
434
+ it('applies an override to an extension whose first read is still in flight', async () => {
435
+ const extension = `${loginApp}___some-slot___some-extension`;
436
+ i18n.reloadResources(['en'], [extension]);
437
+
438
+ setTranslationOverride(loginApp, 'en', 'loginButton', 'Sign in');
439
+ const saved = reloadTranslations(loginApp, 'en');
440
+ await settleReads();
441
+
442
+ await expect(saved).resolves.toBeUndefined();
443
+ expect(i18n.getResource('en', extension, 'loginButton')).toBe('Sign in');
444
+ });
445
+
446
+ it('removes a reset key even when an earlier read that still had it lands first', async () => {
447
+ setTranslationOverride(loginApp, 'en', 'extraKey', 'Only an override');
448
+ i18n.reloadResources(['en'], [loginApp]);
449
+
450
+ resetTranslationOverride(loginApp, 'en', 'extraKey');
451
+ const reset = reloadTranslations(loginApp, 'en', 'extraKey');
452
+ await settleReads();
453
+
454
+ await expect(reset).resolves.toBeUndefined();
455
+ expect(i18n.getResource('en', loginApp, 'extraKey')).toBeUndefined();
456
+ });
457
+
458
+ it('restores the translation file when local config is cleared', async () => {
459
+ const stop = watchTranslationOverrides();
460
+ setTranslationOverride(loginApp, 'en', 'loginButton', 'Sign in');
461
+ setTranslationOverride(loginApp, 'en', 'extraKey', 'Only an override');
462
+ const saved = reloadTranslations(loginApp, 'en');
463
+ await settleReads();
464
+ await saved;
465
+
466
+ temporaryConfigStore.setState({ config: {} });
467
+ await settleReads();
468
+ stop();
469
+
470
+ await vi.waitFor(() => expect(i18n.getResource('en', loginApp, 'loginButton')).toBe('Log in'));
471
+ expect(i18n.getResource('en', loginApp, 'extraKey')).toBeUndefined();
472
+ });
473
+ });
@@ -0,0 +1,197 @@
1
+ import { cloneDeep, isEqual, set, unset } from 'lodash-es';
2
+ import {
3
+ clearConfigErrors,
4
+ extensionNamespaceSeparator,
5
+ temporaryConfigStore,
6
+ } from '@openmrs/esm-framework/src/internal';
7
+ import {
8
+ clearFailedLoads,
9
+ findLoadFailure,
10
+ getNamespacesLoading,
11
+ releaseStalledLoads,
12
+ waitForLoadsInFlight,
13
+ withTimeout,
14
+ } from './i18next-loads';
15
+ import { translationOverridesKey } from './translation-catalog';
16
+
17
+ /**
18
+ * Overrides a translation by writing to the temporary config, which is the same path the rest of
19
+ * the implementer tools take. The override therefore behaves exactly as it will in a distribution's
20
+ * config file, and is picked up by `Download config` along with every other local edit.
21
+ *
22
+ * The change only reaches the screen once `reloadTranslations` has re-read the namespace.
23
+ */
24
+ export function setTranslationOverride(namespace: string, language: string, key: string, value: string) {
25
+ const path = [namespace, translationOverridesKey, language, key];
26
+ clearConfigErrors(path.join('.'));
27
+ writeOverrides(set(cloneDeep(temporaryConfigStore.getState()), ['config', ...path], value));
28
+ }
29
+
30
+ /** Removes a locally-made override, restoring whatever the distribution's config supplies. */
31
+ export function resetTranslationOverride(namespace: string, language: string, key: string) {
32
+ const path = [namespace, translationOverridesKey, language, key];
33
+ clearConfigErrors(path.join('.'));
34
+
35
+ const state = cloneDeep(temporaryConfigStore.getState());
36
+ unset(state, ['config', ...path]);
37
+ writeOverrides(state);
38
+ }
39
+
40
+ /** Set while this module writes an override, whose caller reloads and reports on it. */
41
+ let isWritingOverride = false;
42
+
43
+ function writeOverrides(state: ReturnType<typeof temporaryConfigStore.getState>) {
44
+ isWritingOverride = true;
45
+
46
+ try {
47
+ temporaryConfigStore.setState(state);
48
+ } finally {
49
+ isWritingOverride = false;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Reloads the translations whose overrides change in the temporary config through anything other
55
+ * than `setTranslationOverride` and `resetTranslationOverride`, such as "Clear local config" or the
56
+ * configuration editor. Without it, the old override stays on screen until the page is reloaded.
57
+ *
58
+ * Only namespaces already loaded for the language are reloaded; any other picks up the current
59
+ * overrides whenever it is first read.
60
+ *
61
+ * @returns A function that stops watching.
62
+ */
63
+ export function watchTranslationOverrides(): () => void {
64
+ return temporaryConfigStore.subscribe((state, previous) => {
65
+ if (isWritingOverride) {
66
+ return;
67
+ }
68
+
69
+ for (const { namespace, language, droppedKeys } of findChangedOverrides(previous.config, state.config)) {
70
+ const isOnScreen =
71
+ window.i18next?.hasResourceBundle(language, namespace) ||
72
+ getExtensionNamespaces(namespace, language).length > 0;
73
+
74
+ if (isOnScreen) {
75
+ reloadTranslations(namespace, language, ...droppedKeys).catch((error) =>
76
+ console.error(`Could not apply the changed translation overrides for ${namespace} in ${language}`, error),
77
+ );
78
+ }
79
+ }
80
+ });
81
+ }
82
+
83
+ interface ChangedOverrides {
84
+ namespace: string;
85
+ language: string;
86
+ /** The keys whose override was removed. */
87
+ droppedKeys: Array<string>;
88
+ }
89
+
90
+ /** Lists the namespace and language pairs whose overrides differ between two temporary configs. */
91
+ export function findChangedOverrides(previous: unknown, next: unknown): Array<ChangedOverrides> {
92
+ const namespaces = new Set([...Object.keys(asRecord(previous)), ...Object.keys(asRecord(next))]);
93
+
94
+ return Array.from(namespaces).flatMap((namespace) => {
95
+ const before = asRecord(asRecord(asRecord(previous)[namespace])[translationOverridesKey]);
96
+ const after = asRecord(asRecord(asRecord(next)[namespace])[translationOverridesKey]);
97
+ const languages = new Set([...Object.keys(before), ...Object.keys(after)]);
98
+
99
+ return Array.from(languages)
100
+ .filter((language) => !isEqual(before[language], after[language]))
101
+ .map((language) => {
102
+ const kept = asRecord(after[language]);
103
+
104
+ return {
105
+ namespace,
106
+ language,
107
+ droppedKeys: Object.keys(asRecord(before[language])).filter((key) => !Object.hasOwn(kept, key)),
108
+ };
109
+ });
110
+ });
111
+ }
112
+
113
+ function asRecord(value: unknown): Record<string, unknown> {
114
+ return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
115
+ }
116
+
117
+ /**
118
+ * Re-reads a namespace's translations so that a changed override takes effect without a page
119
+ * reload. The app shell's i18next backend merges the config's overrides in as it reads a namespace,
120
+ * so reloading is what applies an override for real rather than faking it into the resource store.
121
+ *
122
+ * @param droppedKeys Keys whose override was removed. A reload alone would leave their old value.
123
+ */
124
+ export async function reloadTranslations(
125
+ namespace: string,
126
+ language: string,
127
+ ...droppedKeys: Array<string>
128
+ ): Promise<void> {
129
+ const i18n = window.i18next;
130
+ const namespaces = [namespace, ...getExtensionNamespaces(namespace, language)];
131
+ const abandon = new AbortController();
132
+
133
+ try {
134
+ await withTimeout(
135
+ // A read already in flight, whether an earlier save's or the application's own, collected its
136
+ // overrides before this one was written, and would land over anything done to the bundle now.
137
+ waitForLoadsInFlight([language], namespaces, abandon.signal).then(
138
+ () =>
139
+ new Promise<void>((resolve, reject) => {
140
+ clearFailedLoads([language], namespaces);
141
+
142
+ dropKeys(language, namespaces, droppedKeys);
143
+
144
+ const done = (error?: unknown) => {
145
+ const failure = findLoadFailure([language], namespaces, error);
146
+ return failure ? reject(failure) : resolve();
147
+ };
148
+
149
+ // i18next declares this callback as taking no arguments but calls it with the load error.
150
+ i18n.reloadResources([language], namespaces, done as () => void);
151
+ }),
152
+ ),
153
+ `Reloading ${namespace} for ${language} did not finish`,
154
+ );
155
+ } catch (error) {
156
+ abandon.abort();
157
+ releaseStalledLoads([language], namespaces);
158
+ throw error;
159
+ }
160
+
161
+ // Mounted components only re-read their translations on `languageChanged`; react-i18next leaves
162
+ // the resource store's own `added` event unbound (`bindI18nStore` defaults to '').
163
+ i18n.emit('languageChanged', i18n.language);
164
+ }
165
+
166
+ /**
167
+ * Removes keys from the loaded bundles ahead of a reload.
168
+ *
169
+ * A reload merges its payload over the existing bundle rather than replacing it, so a key the fresh
170
+ * payload no longer carries survives — which is exactly what resetting an override produces for a
171
+ * language whose own file lacks that key. Deleting just these keys leaves the rest of the bundle
172
+ * intact; each is missing until the reload lands, and stays missing until the page is reloaded if
173
+ * it fails, which is what the failure message tells the user.
174
+ */
175
+ function dropKeys(language: string, namespaces: Array<string>, keys: Array<string>) {
176
+ for (const namespace of namespaces) {
177
+ const bundle = window.i18next.getResourceBundle(language, namespace);
178
+
179
+ for (const key of keys) {
180
+ delete bundle?.[key];
181
+ }
182
+ }
183
+ }
184
+
185
+ /**
186
+ * The extension-scoped bundles derived from a namespace. They are loaded separately from the
187
+ * module's own bundle but merge the module's overrides in, so they go stale unless reloaded
188
+ * alongside it. One whose first read is still in flight has no bundle yet, but that read collected
189
+ * the old overrides, so it counts too.
190
+ */
191
+ function getExtensionNamespaces(namespace: string, language: string): Array<string> {
192
+ const loaded = Object.keys(window.i18next?.store?.data?.[language] ?? {});
193
+
194
+ return Array.from(new Set([...loaded, ...getNamespacesLoading(language)])).filter((candidate) =>
195
+ candidate.startsWith(`${namespace}${extensionNamespaceSeparator}`),
196
+ );
197
+ }