@openmrs/esm-dynamic-loading 9.0.3-pre.4533 → 9.0.3-pre.4537

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmrs/esm-dynamic-loading",
3
- "version": "9.0.3-pre.4533",
3
+ "version": "9.0.3-pre.4537",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Utilities for dynamically loading code in OpenMRS",
6
6
  "type": "module",
@@ -57,14 +57,17 @@
57
57
  "@openmrs/esm-translations": "9.x"
58
58
  },
59
59
  "devDependencies": {
60
- "@openmrs/esm-globals": "9.0.3-pre.4533",
61
- "@openmrs/esm-translations": "9.0.3-pre.4533",
60
+ "@openmrs/esm-globals": "9.0.3-pre.4537",
61
+ "@openmrs/esm-translations": "9.0.3-pre.4537",
62
62
  "@swc/cli": "0.8.1",
63
63
  "@swc/core": "1.15.21",
64
64
  "@vitest/coverage-v8": "^4.1.2",
65
65
  "concurrently": "^9.2.1",
66
+ "happy-dom": "^20.6.0",
67
+ "jsdom": "^29.0.2",
66
68
  "rimraf": "^6.1.3",
67
- "vitest": "^4.1.2"
69
+ "vitest": "^4.1.2",
70
+ "vitest-fetch-mock": "^0.4.5"
68
71
  },
69
72
  "stableVersion": "9.0.2"
70
73
  }
@@ -0,0 +1,426 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const {
4
+ mockGetCurrentPageMap,
5
+ mockGetImportMapOverrideMap,
6
+ mockResetImportMapOverrides,
7
+ mockDispatchToastShown,
8
+ mockGetCoreTranslation,
9
+ } = vi.hoisted(() => ({
10
+ mockGetCurrentPageMap: vi.fn(),
11
+ mockGetImportMapOverrideMap: vi.fn().mockReturnValue({ imports: {} }),
12
+ mockResetImportMapOverrides: vi.fn(),
13
+ mockDispatchToastShown: vi.fn(),
14
+ mockGetCoreTranslation: vi.fn((_key: string, fallback: string) => fallback),
15
+ }));
16
+
17
+ vi.mock('./import-maps', () => ({
18
+ getCurrentPageMap: mockGetCurrentPageMap,
19
+ getImportMapOverrideMap: mockGetImportMapOverrideMap,
20
+ resetImportMapOverrides: mockResetImportMapOverrides,
21
+ }));
22
+
23
+ vi.mock('@openmrs/esm-globals', () => ({
24
+ dispatchToastShown: mockDispatchToastShown,
25
+ }));
26
+
27
+ vi.mock('@openmrs/esm-translations', () => ({
28
+ getCoreTranslation: mockGetCoreTranslation,
29
+ }));
30
+
31
+ import { importDynamic, preloadImport, slugify } from './dynamic-loading';
32
+
33
+ /**
34
+ * Flushes pending microtasks so that async code that has been awaiting
35
+ * a resolved promise can continue executing.
36
+ */
37
+ async function flushMicrotasks() {
38
+ for (let i = 0; i < 10; i++) {
39
+ await Promise.resolve();
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Waits until a `<script>` element with the given `src` appears in
45
+ * `document.head`, then returns it. Flushes microtasks repeatedly
46
+ * to allow async production code to run.
47
+ */
48
+ async function waitForScript(src: string): Promise<HTMLScriptElement> {
49
+ for (let i = 0; i < 20; i++) {
50
+ await Promise.resolve();
51
+ const el = document.head.querySelector(`script[src="${src}"]`) as HTMLScriptElement | null;
52
+ if (el) return el;
53
+ }
54
+ throw new Error(`Script with src="${src}" was not appended to <head>`);
55
+ }
56
+
57
+ describe('dynamic-loading', () => {
58
+ beforeEach(() => {
59
+ localStorage.clear();
60
+ document.head.querySelectorAll('script').forEach((el) => el.remove());
61
+ (globalThis as any).__webpack_share_scopes__ = { default: {} };
62
+ (window as any).spaBase = '/openmrs/spa';
63
+ mockGetImportMapOverrideMap.mockReturnValue({ imports: {} });
64
+ });
65
+
66
+ afterEach(() => {
67
+ vi.restoreAllMocks();
68
+ delete (globalThis as any).__webpack_share_scopes__;
69
+ });
70
+
71
+ describe('slugify', () => {
72
+ it('replaces slashes with underscores', () => {
73
+ expect(slugify('a/b/c')).toBe('a_b_c');
74
+ });
75
+
76
+ it('replaces hyphens with underscores', () => {
77
+ expect(slugify('esm-foo-bar')).toBe('esm_foo_bar');
78
+ });
79
+
80
+ it('replaces @ with underscores', () => {
81
+ expect(slugify('@openmrs/esm-foo')).toBe('_openmrs_esm_foo');
82
+ });
83
+
84
+ it('handles a typical module name', () => {
85
+ expect(slugify('@openmrs/esm-patient-chart-app')).toBe('_openmrs_esm_patient_chart_app');
86
+ });
87
+
88
+ it('returns the input unchanged when there are no special characters', () => {
89
+ expect(slugify('simple')).toBe('simple');
90
+ });
91
+ });
92
+
93
+ describe('preloadImport', () => {
94
+ it('throws when called with an empty string', async () => {
95
+ await expect(preloadImport('')).rejects.toThrow('without supplying a package');
96
+ });
97
+
98
+ it('throws when called with a whitespace-only string', async () => {
99
+ await expect(preloadImport(' ')).rejects.toThrow('without supplying a package');
100
+ });
101
+
102
+ it('throws when the package is not in the import map', async () => {
103
+ mockGetCurrentPageMap.mockResolvedValue({ imports: {} });
104
+ await expect(preloadImport('@openmrs/esm-missing')).rejects.toThrow(
105
+ 'Could not find the package @openmrs/esm-missing',
106
+ );
107
+ });
108
+
109
+ it('resolves immediately if the package is already loaded on window', async () => {
110
+ const slug = '_openmrs_esm_foo';
111
+ (window as any)[slug] = { init: vi.fn(), get: vi.fn() };
112
+
113
+ await expect(preloadImport('@openmrs/esm-foo')).resolves.toBeUndefined();
114
+
115
+ delete (window as any)[slug];
116
+ });
117
+
118
+ it('creates a script element and resolves on load', async () => {
119
+ mockGetCurrentPageMap.mockResolvedValue({
120
+ imports: { '@openmrs/esm-foo': 'http://localhost/foo.js' },
121
+ });
122
+
123
+ const promise = preloadImport('@openmrs/esm-foo');
124
+ const script = await waitForScript('http://localhost/foo.js');
125
+
126
+ expect(script.type).toBe('text/javascript');
127
+ expect(script.async).toBe(true);
128
+
129
+ script.dispatchEvent(new Event('load'));
130
+
131
+ await expect(promise).resolves.toBeNull();
132
+ });
133
+
134
+ it('rejects when the script fails to load', async () => {
135
+ mockGetCurrentPageMap.mockResolvedValue({
136
+ imports: { '@openmrs/esm-foo': 'http://localhost/foo.js' },
137
+ });
138
+
139
+ const promise = preloadImport('@openmrs/esm-foo');
140
+ const script = await waitForScript('http://localhost/foo.js');
141
+
142
+ script.dispatchEvent(new ErrorEvent('error', { message: 'net::ERR_CONNECTION_REFUSED' }));
143
+
144
+ await expect(promise).rejects.toBe('net::ERR_CONNECTION_REFUSED');
145
+ });
146
+
147
+ it('shows a toast when an overridden script fails to load', async () => {
148
+ mockGetCurrentPageMap.mockResolvedValue({
149
+ imports: { '@openmrs/esm-foo': 'http://localhost:8080/foo.js' },
150
+ });
151
+ mockGetImportMapOverrideMap.mockReturnValue({
152
+ imports: { '@openmrs/esm-foo': 'http://localhost:8080/foo.js' },
153
+ });
154
+
155
+ const promise = preloadImport('@openmrs/esm-foo');
156
+ const script = await waitForScript('http://localhost:8080/foo.js');
157
+
158
+ script.dispatchEvent(new ErrorEvent('error', { message: 'net::ERR_CONNECTION_REFUSED' }));
159
+
160
+ await expect(promise).rejects.toBe('net::ERR_CONNECTION_REFUSED');
161
+ expect(mockDispatchToastShown).toHaveBeenCalledWith(
162
+ expect.objectContaining({
163
+ kind: 'error',
164
+ }),
165
+ );
166
+ });
167
+
168
+ it('calls resetImportMapOverrides when the toast action button is clicked', async () => {
169
+ mockGetCurrentPageMap.mockResolvedValue({
170
+ imports: { '@openmrs/esm-foo': 'http://localhost:8080/foo.js' },
171
+ });
172
+ mockGetImportMapOverrideMap.mockReturnValue({
173
+ imports: { '@openmrs/esm-foo': 'http://localhost:8080/foo.js' },
174
+ });
175
+
176
+ const reloadMock = vi.fn();
177
+ Object.defineProperty(window, 'location', {
178
+ value: { ...window.location, reload: reloadMock },
179
+ writable: true,
180
+ configurable: true,
181
+ });
182
+
183
+ const promise = preloadImport('@openmrs/esm-foo');
184
+ const script = await waitForScript('http://localhost:8080/foo.js');
185
+
186
+ script.dispatchEvent(new ErrorEvent('error', { message: 'fail' }));
187
+ await promise.catch(() => {});
188
+
189
+ const toastArg = mockDispatchToastShown.mock.calls[0][0];
190
+ toastArg.onActionButtonClick();
191
+
192
+ expect(mockResetImportMapOverrides).toHaveBeenCalled();
193
+ expect(reloadMock).toHaveBeenCalled();
194
+ });
195
+
196
+ it('uses the provided import map instead of fetching one', async () => {
197
+ const importMap = { imports: { '@openmrs/esm-foo': 'http://localhost/foo.js' } };
198
+
199
+ const promise = preloadImport('@openmrs/esm-foo', importMap);
200
+ const script = await waitForScript('http://localhost/foo.js');
201
+
202
+ script.dispatchEvent(new Event('load'));
203
+
204
+ await expect(promise).resolves.toBeNull();
205
+ expect(mockGetCurrentPageMap).not.toHaveBeenCalled();
206
+ });
207
+
208
+ it('prepends spaBase to relative URLs starting with ./', async () => {
209
+ mockGetCurrentPageMap.mockResolvedValue({
210
+ imports: { '@openmrs/esm-foo': './foo.js' },
211
+ });
212
+
213
+ const promise = preloadImport('@openmrs/esm-foo');
214
+ const script = await waitForScript('/openmrs/spa/foo.js');
215
+
216
+ expect(script).not.toBeNull();
217
+ script.dispatchEvent(new Event('load'));
218
+
219
+ await expect(promise).resolves.toBeNull();
220
+ });
221
+
222
+ it('does not reload a script that is already in the DOM and finished loading', async () => {
223
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
224
+ mockGetCurrentPageMap.mockResolvedValue({
225
+ imports: { '@openmrs/esm-foo': 'http://localhost/foo.js' },
226
+ });
227
+
228
+ // First load
229
+ const firstPromise = preloadImport('@openmrs/esm-foo');
230
+ const script = await waitForScript('http://localhost/foo.js');
231
+ script.dispatchEvent(new Event('load'));
232
+ await firstPromise;
233
+
234
+ // Second load — script is in DOM but slug not on window, so it resolves with a warning
235
+ await expect(preloadImport('@openmrs/esm-foo')).resolves.toBeNull();
236
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('already loaded'));
237
+ });
238
+
239
+ it('resolves both callers when the same script is preloaded concurrently', async () => {
240
+ mockGetCurrentPageMap.mockResolvedValue({
241
+ imports: { '@openmrs/esm-foo': 'http://localhost/foo.js' },
242
+ });
243
+
244
+ // Two concurrent preloads for the same package
245
+ const first = preloadImport('@openmrs/esm-foo');
246
+ const second = preloadImport('@openmrs/esm-foo');
247
+
248
+ const script = await waitForScript('http://localhost/foo.js');
249
+ script.dispatchEvent(new Event('load'));
250
+
251
+ await expect(first).resolves.toBeNull();
252
+ await expect(second).resolves.toBeNull();
253
+ });
254
+
255
+ it('rejects both callers when a concurrently-loaded script fails', async () => {
256
+ mockGetCurrentPageMap.mockResolvedValue({
257
+ imports: { '@openmrs/esm-foo': 'http://localhost/foo.js' },
258
+ });
259
+
260
+ const first = preloadImport('@openmrs/esm-foo');
261
+ const second = preloadImport('@openmrs/esm-foo');
262
+
263
+ const script = await waitForScript('http://localhost/foo.js');
264
+ script.dispatchEvent(new ErrorEvent('error', { message: 'net::ERR_FAILED' }));
265
+
266
+ await expect(first).rejects.toBe('net::ERR_FAILED');
267
+ await expect(second).rejects.toBe('net::ERR_FAILED');
268
+ });
269
+
270
+ it('logs an error when a script takes longer than 5 seconds to load', async () => {
271
+ vi.useFakeTimers();
272
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
273
+ mockGetCurrentPageMap.mockResolvedValue({
274
+ imports: { '@openmrs/esm-foo': 'http://localhost/foo.js' },
275
+ });
276
+
277
+ preloadImport('@openmrs/esm-foo');
278
+ await vi.advanceTimersByTimeAsync(1);
279
+
280
+ expect(errorSpy).not.toHaveBeenCalled();
281
+
282
+ vi.advanceTimersByTime(5_000);
283
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('did not load within 5 seconds'));
284
+
285
+ vi.useRealTimers();
286
+ });
287
+
288
+ it('rejects with an empty string when the error event has no message', async () => {
289
+ mockGetCurrentPageMap.mockResolvedValue({
290
+ imports: { '@openmrs/esm-foo': 'http://localhost/foo.js' },
291
+ });
292
+ vi.spyOn(console, 'error').mockImplementation(() => {});
293
+
294
+ const promise = preloadImport('@openmrs/esm-foo');
295
+ const script = await waitForScript('http://localhost/foo.js');
296
+
297
+ script.dispatchEvent(new ErrorEvent('error'));
298
+
299
+ // ErrorEvent.message defaults to '' which is not nullish, so ?? doesn't trigger
300
+ await expect(promise).rejects.toBe('');
301
+ });
302
+ });
303
+
304
+ describe('importDynamic', () => {
305
+ function setupFederatedModule(slug: string, moduleExports: Record<string, unknown>) {
306
+ (window as any)[slug] = {
307
+ init: vi.fn(),
308
+ get: vi.fn().mockResolvedValue(() => moduleExports),
309
+ };
310
+ }
311
+
312
+ afterEach(() => {
313
+ delete (window as any)['_openmrs_esm_foo'];
314
+ });
315
+
316
+ it('returns the module exports from a federated module', async () => {
317
+ const moduleExports = { default: 'hello', namedExport: 42 };
318
+ setupFederatedModule('_openmrs_esm_foo', moduleExports);
319
+
320
+ const result = await importDynamic('@openmrs/esm-foo');
321
+ expect(result).toEqual(moduleExports);
322
+ });
323
+
324
+ it('calls container.init with the default webpack share scope', async () => {
325
+ const initFn = vi.fn();
326
+ (window as any)['_openmrs_esm_foo'] = {
327
+ init: initFn,
328
+ get: vi.fn().mockResolvedValue(() => ({ default: true })),
329
+ };
330
+
331
+ await importDynamic('@openmrs/esm-foo');
332
+ expect(initFn).toHaveBeenCalledWith(__webpack_share_scopes__.default);
333
+ });
334
+
335
+ it('calls container.get with the specified share', async () => {
336
+ const getFn = vi.fn().mockResolvedValue(() => ({ default: true }));
337
+ (window as any)['_openmrs_esm_foo'] = { init: vi.fn(), get: getFn };
338
+
339
+ await importDynamic('@openmrs/esm-foo', './custom-share');
340
+ expect(getFn).toHaveBeenCalledWith('./custom-share');
341
+ });
342
+
343
+ it('uses ./start as the default share', async () => {
344
+ const getFn = vi.fn().mockResolvedValue(() => ({ default: true }));
345
+ (window as any)['_openmrs_esm_foo'] = { init: vi.fn(), get: getFn };
346
+
347
+ await importDynamic('@openmrs/esm-foo');
348
+ expect(getFn).toHaveBeenCalledWith('./start');
349
+ });
350
+
351
+ it('throws when the global is not a federated module', async () => {
352
+ (window as any)['_openmrs_esm_foo'] = 'not a module';
353
+
354
+ await expect(importDynamic('@openmrs/esm-foo')).rejects.toThrow('does not refer to a federated module');
355
+ });
356
+
357
+ it('throws when the factory returns null', async () => {
358
+ (window as any)['_openmrs_esm_foo'] = {
359
+ init: vi.fn(),
360
+ get: vi.fn().mockResolvedValue(() => null),
361
+ };
362
+
363
+ await expect(importDynamic('@openmrs/esm-foo')).rejects.toThrow('did not return an ESM module');
364
+ });
365
+
366
+ it('throws when the factory returns a string', async () => {
367
+ (window as any)['_openmrs_esm_foo'] = {
368
+ init: vi.fn(),
369
+ get: vi.fn().mockResolvedValue(() => 'not a module'),
370
+ };
371
+
372
+ await expect(importDynamic('@openmrs/esm-foo')).rejects.toThrow('did not return an ESM module');
373
+ });
374
+
375
+ it('rejects if preloading exceeds maxLoadingTime', async () => {
376
+ vi.useFakeTimers();
377
+ mockGetCurrentPageMap.mockReturnValue(new Promise(() => {}));
378
+
379
+ const promise = importDynamic('@openmrs/esm-foo', './start', { maxLoadingTime: 100 });
380
+ // Attach a no-op handler so the rejection is tracked before the timer fires
381
+ promise.catch(() => {});
382
+
383
+ vi.advanceTimersByTime(100);
384
+ await flushMicrotasks();
385
+
386
+ await expect(promise).rejects.toThrow('Could not resolve requested script');
387
+
388
+ vi.useRealTimers();
389
+ });
390
+
391
+ it('defaults maxLoadingTime to 10 minutes', async () => {
392
+ vi.useFakeTimers();
393
+ mockGetCurrentPageMap.mockReturnValue(new Promise(() => {}));
394
+
395
+ const promise = importDynamic('@openmrs/esm-foo');
396
+ promise.catch(() => {});
397
+
398
+ // Just under 10 minutes — should not have rejected yet
399
+ vi.advanceTimersByTime(599_999);
400
+ await flushMicrotasks();
401
+
402
+ // Advance the remaining 1ms
403
+ vi.advanceTimersByTime(1);
404
+ await flushMicrotasks();
405
+
406
+ await expect(promise).rejects.toThrow('10 minutes');
407
+
408
+ vi.useRealTimers();
409
+ });
410
+
411
+ it('treats non-positive maxLoadingTime as the default', async () => {
412
+ vi.useFakeTimers();
413
+ mockGetCurrentPageMap.mockReturnValue(new Promise(() => {}));
414
+
415
+ const promise = importDynamic('@openmrs/esm-foo', './start', { maxLoadingTime: -1 });
416
+ promise.catch(() => {});
417
+
418
+ vi.advanceTimersByTime(600_000);
419
+ await flushMicrotasks();
420
+
421
+ await expect(promise).rejects.toThrow('10 minutes');
422
+
423
+ vi.useRealTimers();
424
+ });
425
+ });
426
+ });
@@ -1,8 +1,8 @@
1
1
  /** @module @category Dynamic Loading */
2
2
  'use strict';
3
- // hack to make the types defined in esm-globals available here
4
3
  import { dispatchToastShown, type ImportMap } from '@openmrs/esm-globals';
5
4
  import { getCoreTranslation } from '@openmrs/esm-translations';
5
+ import { getCurrentPageMap, getImportMapOverrideMap, resetImportMapOverrides } from './import-maps';
6
6
 
7
7
  /**
8
8
  * @internal
@@ -138,7 +138,7 @@ export async function preloadImport(jsPackage: string, importMap?: ImportMap) {
138
138
  url = window.spaBase + url.substring(1);
139
139
  }
140
140
 
141
- const isOverridden = !!window.localStorage.getItem(`import-map-override:${jsPackage}`);
141
+ const isOverridden = jsPackage in getImportMapOverrideMap().imports;
142
142
  try {
143
143
  return await new Promise<void>((resolve, reject) => {
144
144
  loadScript(url, resolve, reject);
@@ -157,7 +157,7 @@ export async function preloadImport(jsPackage: string, importMap?: ImportMap) {
157
157
  ),
158
158
  actionButtonLabel: getCoreTranslation('resetOverrides', 'Reset overrides'),
159
159
  onActionButtonClick() {
160
- window.importMapOverrides.resetOverrides();
160
+ resetImportMapOverrides();
161
161
  window.location.reload();
162
162
  },
163
163
  });
@@ -178,7 +178,7 @@ export async function preloadImport(jsPackage: string, importMap?: ImportMap) {
178
178
  * @returns The current import map.
179
179
  */
180
180
  export async function getCurrentImportMap() {
181
- return window.importMapOverrides.getCurrentPageMap();
181
+ return getCurrentPageMap();
182
182
  }
183
183
 
184
184
  interface FederatedModule {