@openmrs/esm-dynamic-loading 10.0.1-pre.5251 → 10.0.1-pre.5255

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,3 +1,3 @@
1
- [0] Successfully compiled: 5 files with swc (250.51ms)
1
+ [0] Successfully compiled: 5 files with swc (229.8ms)
2
2
  [0] swc --strip-leading-paths src -d dist exited with code 0
3
3
  [1] tsc --project tsconfig.build.json exited with code 0
@@ -1 +1 @@
1
- {"version":3,"file":"import-maps.d.ts","sourceRoot":"","sources":["../src/import-maps.ts"],"names":[],"mappings":"AAAA,mCAAmC;AACnC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AA2EtD;;;GAGG;AACH,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,SAAS,CAAC,CAO5D;AAED;;GAEG;AACH,wBAAsB,sBAAsB,IAAI,OAAO,CAAC,SAAS,CAAC,CAEjE;AAED;;;;GAIG;AACH,wBAAsB,uBAAuB,IAAI,OAAO,CAAC,SAAS,CAAC,CAMlE;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,eAAe,CAAC,EAAE,OAAO,GAAG,SAAS,CAmB5E;AAED;;;GAGG;AACH,wBAAgB,6BAA6B,IAAI,MAAM,EAAE,CAiBxD;AAED;;;GAGG;AACH,wBAAgB,2BAA2B,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAKvE;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAWpE;AAED;;GAEG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAiB1D;AAED;;GAEG;AACH,wBAAgB,uBAAuB,IAAI,IAAI,CAmB9C;AAED;;GAEG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAgB1D;AAaD,wBAAgB,uBAAuB,SAWtC"}
1
+ {"version":3,"file":"import-maps.d.ts","sourceRoot":"","sources":["../src/import-maps.ts"],"names":[],"mappings":"AAAA,mCAAmC;AACnC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAkGtD;;;GAGG;AACH,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,SAAS,CAAC,CAO5D;AAED;;GAEG;AACH,wBAAsB,sBAAsB,IAAI,OAAO,CAAC,SAAS,CAAC,CAEjE;AAED;;;;GAIG;AACH,wBAAsB,uBAAuB,IAAI,OAAO,CAAC,SAAS,CAAC,CAMlE;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,eAAe,CAAC,EAAE,OAAO,GAAG,SAAS,CAmB5E;AAED;;;GAGG;AACH,wBAAgB,6BAA6B,IAAI,MAAM,EAAE,CAiBxD;AAED;;;GAGG;AACH,wBAAgB,2BAA2B,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAKvE;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAWpE;AAED;;GAEG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAiB1D;AAED;;GAEG;AACH,wBAAgB,uBAAuB,IAAI,IAAI,CAmB9C;AAED;;GAEG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAgB1D;AAaD,wBAAgB,uBAAuB,SAWtC"}
@@ -6,27 +6,50 @@ let devMode = false;
6
6
  // Snapshot of overrides at setup time (matches import-map-overrides library behavior:
7
7
  // getCurrentPageMap returns the overrides as they were when the page loaded).
8
8
  let initialOverrideSnapshot = null;
9
+ // Memoizes the base map so that the import map is fetched at most once per page load.
10
+ let baseMapPromise = null;
9
11
  /**
10
12
  * Reads all `<script type="systemjs-importmap">` tags from the DOM and merges
11
13
  * them into a single {@link ImportMap}. Tags with a `src` attribute are fetched;
12
14
  * inline tags have their `textContent` parsed as JSON.
15
+ *
16
+ * A read in which every tag was parsed successfully is cached and reused by all later
17
+ * callers. A read that lost one or more tags to an error is returned as-is but not cached, so
18
+ * that a transient network failure doesn't leave the page with a permanently incomplete map.
13
19
  */ async function readBaseMap() {
20
+ baseMapPromise ?? (baseMapPromise = loadBaseMap().then(({ map, complete })=>{
21
+ if (!complete) {
22
+ baseMapPromise = null;
23
+ }
24
+ return map;
25
+ }));
26
+ return baseMapPromise;
27
+ }
28
+ async function loadBaseMap() {
14
29
  const scripts = document.querySelectorAll('script[type="systemjs-importmap"]');
15
30
  const maps = [];
31
+ let complete = true;
16
32
  for(let i = 0; i < scripts.length; i++){
17
33
  const script = scripts[i];
18
34
  try {
19
35
  if (script.src) {
20
36
  const response = await fetch(script.src);
37
+ if (!response.ok) {
38
+ throw new Error(`Request for ${script.src} returned ${response.status} ${response.statusText}`);
39
+ }
21
40
  maps.push(await response.json());
22
41
  } else if (script.textContent) {
23
42
  maps.push(JSON.parse(script.textContent));
24
43
  }
25
44
  } catch (e) {
45
+ complete = false;
26
46
  console.warn(`[import-maps] Failed to parse import map from script tag at index ${i}`, e);
27
47
  }
28
48
  }
29
- return mergeMaps(maps);
49
+ return {
50
+ map: mergeMaps(maps),
51
+ complete
52
+ };
30
53
  }
31
54
  function mergeMaps(maps) {
32
55
  const merged = {
@@ -1 +1 @@
1
- {"version":3,"file":"route-maps.d.ts","sourceRoot":"","sources":["../src/route-maps.ts"],"names":[],"mappings":"AAAA,kCAAkC;AAClC,OAAO,EAAuC,KAAK,gBAAgB,EAAE,KAAK,aAAa,EAAE,MAAM,sBAAsB,CAAC;AA+GtH;;;GAGG;AACH,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,aAAa,CAAC,CAOjE;AAED;;GAEG;AACH,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,aAAa,CAAC,CAEpE;AAED;;;;GAIG;AACH,wBAAsB,sBAAsB,IAAI,OAAO,CAAC,aAAa,CAAC,CAMrE;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAoB/D;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,gBAAgB,GAAG,MAAM,GAAG,GAAG,QAkC9F;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,QAYxD;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,SAmBrC;AAaD,wBAAgB,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC,CAgBtD"}
1
+ {"version":3,"file":"route-maps.d.ts","sourceRoot":"","sources":["../src/route-maps.ts"],"names":[],"mappings":"AAAA,kCAAkC;AAClC,OAAO,EAAuC,KAAK,gBAAgB,EAAE,KAAK,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAuItH;;;GAGG;AACH,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,aAAa,CAAC,CAOjE;AAED;;GAEG;AACH,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,aAAa,CAAC,CAEpE;AAED;;;;GAIG;AACH,wBAAsB,sBAAsB,IAAI,OAAO,CAAC,aAAa,CAAC,CAMrE;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAoB/D;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,gBAAgB,GAAG,MAAM,GAAG,GAAG,QAkC9F;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,QAYxD;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,SAmBrC;AAaD,wBAAgB,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC,CAgBtD"}
@@ -6,19 +6,39 @@ let devMode = false;
6
6
  // Snapshot of overrides at setup time (mirrors the import-map-overrides pattern:
7
7
  // getCurrentRouteMap returns the overrides as they were when the page loaded).
8
8
  let initialOverrideSnapshot = null;
9
+ // Memoizes the base map so that the routes registry is fetched at most once per page load.
10
+ let baseMapPromise = null;
9
11
  /**
10
12
  * Reads all `<script type="openmrs-routes">` tags from the DOM and merges
11
13
  * them into a single {@link OpenmrsRoutes} object. Tags with a `src` attribute
12
14
  * are fetched; inline tags have their `textContent` parsed as JSON.
15
+ *
16
+ * A read in which no tag threw is cached and reused by all later callers. A read that lost a
17
+ * tag to an error is returned as-is but not cached, so that a transient network failure doesn't
18
+ * leave the page with a permanently incomplete map. A tag that loads but fails validation is
19
+ * dropped without invalidating the cache, since re-reading it would only fail the same way.
13
20
  */ async function readBaseMap() {
21
+ baseMapPromise ?? (baseMapPromise = loadBaseMap().then(({ map, complete })=>{
22
+ if (!complete) {
23
+ baseMapPromise = null;
24
+ }
25
+ return map;
26
+ }));
27
+ return baseMapPromise;
28
+ }
29
+ async function loadBaseMap() {
14
30
  const scripts = document.querySelectorAll("script[type='openmrs-routes']");
15
31
  const maps = [];
32
+ let complete = true;
16
33
  for(let i = 0; i < scripts.length; i++){
17
34
  const script = scripts[i];
18
35
  try {
19
36
  let parsed;
20
37
  if (script.src) {
21
38
  const response = await fetch(script.src);
39
+ if (!response.ok) {
40
+ throw new Error(`Request for ${script.src} returned ${response.status} ${response.statusText}`);
41
+ }
22
42
  parsed = await response.json();
23
43
  } else if (script.textContent) {
24
44
  parsed = JSON.parse(script.textContent);
@@ -27,10 +47,14 @@ let initialOverrideSnapshot = null;
27
47
  maps.push(parsed);
28
48
  }
29
49
  } catch (e) {
50
+ complete = false;
30
51
  console.warn(`[route-maps] Failed to parse routes from script tag at index ${i}`, e);
31
52
  }
32
53
  }
33
- return mergeRouteMaps(maps);
54
+ return {
55
+ map: mergeRouteMaps(maps),
56
+ complete
57
+ };
34
58
  }
35
59
  function mergeRouteMaps(maps) {
36
60
  const merged = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmrs/esm-dynamic-loading",
3
- "version": "10.0.1-pre.5251",
3
+ "version": "10.0.1-pre.5255",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Utilities for dynamically loading code in OpenMRS",
6
6
  "type": "module",
@@ -25,6 +25,7 @@
25
25
  "test": "cross-env TZ=UTC vitest run --passWithNoTests",
26
26
  "test:watch": "cross-env TZ=UTC vitest watch --passWithNoTests",
27
27
  "build": "rimraf dist && concurrently \"swc --strip-leading-paths src -d dist\" \"tsc --project tsconfig.build.json\"",
28
+ "clean": "git clean -fdX -- dist",
28
29
  "build:development": "rimraf dist && concurrently \"swc --strip-leading-paths src -d dist\" \"tsc --project tsconfig.build.json\"",
29
30
  "typescript": "tsc --project tsconfig.build.json",
30
31
  "lint": "eslint src",
@@ -53,12 +54,12 @@
53
54
  "access": "public"
54
55
  },
55
56
  "peerDependencies": {
56
- "@openmrs/esm-globals": "^10.0.1-pre.5251",
57
- "@openmrs/esm-translations": "^10.0.1-pre.5251"
57
+ "@openmrs/esm-globals": "^10.0.1-pre.5255",
58
+ "@openmrs/esm-translations": "^10.0.1-pre.5255"
58
59
  },
59
60
  "devDependencies": {
60
- "@openmrs/esm-globals": "10.0.1-pre.5251",
61
- "@openmrs/esm-translations": "10.0.1-pre.5251",
61
+ "@openmrs/esm-globals": "10.0.1-pre.5255",
62
+ "@openmrs/esm-translations": "10.0.1-pre.5255",
62
63
  "@swc/cli": "0.8.1",
63
64
  "@swc/core": "1.15.21",
64
65
  "@vitest/coverage-v8": "^4.1.2",
@@ -334,6 +334,94 @@ describe('import-maps', () => {
334
334
  });
335
335
  });
336
336
 
337
+ describe('remote import map caching', () => {
338
+ function setRemoteImportMap(src = 'http://localhost/importmap.json') {
339
+ const script = document.createElement('script');
340
+ script.type = 'systemjs-importmap';
341
+ Object.defineProperty(script, 'src', { value: src, writable: false });
342
+ document.head.appendChild(script);
343
+ }
344
+
345
+ beforeEach(() => {
346
+ (window as any).spaEnv = 'production';
347
+ vi.resetModules();
348
+ });
349
+
350
+ it('fetches the import map with the default credentials mode so the preloaded response is reused', async () => {
351
+ setRemoteImportMap();
352
+ fetchMock.mockResponse(JSON.stringify({ imports: { '@openmrs/esm-remote': '/remote.js' } }));
353
+
354
+ const { setupImportMapOverrides, getCurrentPageMap } = await import('./import-maps');
355
+ setupImportMapOverrides();
356
+
357
+ await getCurrentPageMap();
358
+
359
+ // `crossorigin="anonymous"` on the preload link gives it a `same-origin` credentials mode,
360
+ // which is also `fetch()`'s default; passing anything else here misses the preload cache.
361
+ expect(fetchMock).toHaveBeenCalledWith('http://localhost/importmap.json');
362
+ });
363
+
364
+ it('fetches the import map only once across repeated reads', async () => {
365
+ setRemoteImportMap();
366
+ fetchMock.mockResponse(JSON.stringify({ imports: { '@openmrs/esm-remote': '/remote.js' } }));
367
+
368
+ const { setupImportMapOverrides, getCurrentPageMap, getImportMapDefaultMap, getImportMapNextPageMap } =
369
+ await import('./import-maps');
370
+ setupImportMapOverrides();
371
+
372
+ const [first, second, third] = await Promise.all([
373
+ getCurrentPageMap(),
374
+ getImportMapDefaultMap(),
375
+ getImportMapNextPageMap(),
376
+ ]);
377
+ const fourth = await getCurrentPageMap();
378
+
379
+ for (const map of [first, second, third, fourth]) {
380
+ expect(map.imports['@openmrs/esm-remote']).toBe('/remote.js');
381
+ }
382
+ expect(fetchMock).toHaveBeenCalledTimes(1);
383
+ });
384
+
385
+ it('does not cache a map whose fetch failed', async () => {
386
+ setRemoteImportMap();
387
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
388
+
389
+ fetchMock.mockRejectOnce(new Error('network is down'));
390
+ fetchMock.mockResponse(JSON.stringify({ imports: { '@openmrs/esm-remote': '/remote.js' } }));
391
+
392
+ const { setupImportMapOverrides, getCurrentPageMap } = await import('./import-maps');
393
+ setupImportMapOverrides();
394
+
395
+ const failed = await getCurrentPageMap();
396
+ expect(failed.imports).toEqual({});
397
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to parse import map'), expect.anything());
398
+
399
+ // The failed read was not cached, so the next call retries and succeeds
400
+ const retried = await getCurrentPageMap();
401
+ expect(retried.imports['@openmrs/esm-remote']).toBe('/remote.js');
402
+ expect(fetchMock).toHaveBeenCalledTimes(2);
403
+ });
404
+
405
+ it('does not cache a map whose fetch returned an error status', async () => {
406
+ setRemoteImportMap();
407
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
408
+
409
+ fetchMock.mockResponseOnce(JSON.stringify({ error: 'service unavailable' }), { status: 503 });
410
+ fetchMock.mockResponse(JSON.stringify({ imports: { '@openmrs/esm-remote': '/remote.js' } }));
411
+
412
+ const { setupImportMapOverrides, getCurrentPageMap } = await import('./import-maps');
413
+ setupImportMapOverrides();
414
+
415
+ const failed = await getCurrentPageMap();
416
+ expect(failed.imports).toEqual({});
417
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to parse import map'), expect.anything());
418
+
419
+ const retried = await getCurrentPageMap();
420
+ expect(retried.imports['@openmrs/esm-remote']).toBe('/remote.js');
421
+ expect(fetchMock).toHaveBeenCalledTimes(2);
422
+ });
423
+ });
424
+
337
425
  describe('error handling', () => {
338
426
  it('skips malformed inline import map script tags', async () => {
339
427
  (window as any).spaEnv = 'production';
@@ -12,30 +12,53 @@ let devMode = false;
12
12
  // getCurrentPageMap returns the overrides as they were when the page loaded).
13
13
  let initialOverrideSnapshot: ImportMap | null = null;
14
14
 
15
+ // Memoizes the base map so that the import map is fetched at most once per page load.
16
+ let baseMapPromise: Promise<ImportMap> | null = null;
17
+
15
18
  /**
16
19
  * Reads all `<script type="systemjs-importmap">` tags from the DOM and merges
17
20
  * them into a single {@link ImportMap}. Tags with a `src` attribute are fetched;
18
21
  * inline tags have their `textContent` parsed as JSON.
22
+ *
23
+ * A read in which every tag was parsed successfully is cached and reused by all later
24
+ * callers. A read that lost one or more tags to an error is returned as-is but not cached, so
25
+ * that a transient network failure doesn't leave the page with a permanently incomplete map.
19
26
  */
20
27
  async function readBaseMap(): Promise<ImportMap> {
28
+ baseMapPromise ??= loadBaseMap().then(({ map, complete }) => {
29
+ if (!complete) {
30
+ baseMapPromise = null;
31
+ }
32
+ return map;
33
+ });
34
+
35
+ return baseMapPromise;
36
+ }
37
+
38
+ async function loadBaseMap(): Promise<{ map: ImportMap; complete: boolean }> {
21
39
  const scripts = document.querySelectorAll<HTMLScriptElement>('script[type="systemjs-importmap"]');
22
40
  const maps: ImportMap[] = [];
41
+ let complete = true;
23
42
 
24
43
  for (let i = 0; i < scripts.length; i++) {
25
44
  const script = scripts[i];
26
45
  try {
27
46
  if (script.src) {
28
47
  const response = await fetch(script.src);
48
+ if (!response.ok) {
49
+ throw new Error(`Request for ${script.src} returned ${response.status} ${response.statusText}`);
50
+ }
29
51
  maps.push(await response.json());
30
52
  } else if (script.textContent) {
31
53
  maps.push(JSON.parse(script.textContent));
32
54
  }
33
55
  } catch (e) {
56
+ complete = false;
34
57
  console.warn(`[import-maps] Failed to parse import map from script tag at index ${i}`, e);
35
58
  }
36
59
  }
37
60
 
38
- return mergeMaps(maps);
61
+ return { map: mergeMaps(maps), complete };
39
62
  }
40
63
 
41
64
  function mergeMaps(maps: ImportMap[]): ImportMap {
@@ -375,6 +375,94 @@ describe('route-maps', () => {
375
375
  });
376
376
  });
377
377
 
378
+ describe('remote route map caching', () => {
379
+ function setRemoteRouteMap(src = 'http://localhost/routes.registry.json') {
380
+ const script = document.createElement('script');
381
+ script.type = 'openmrs-routes';
382
+ Object.defineProperty(script, 'src', { value: src, writable: false });
383
+ document.head.appendChild(script);
384
+ }
385
+
386
+ beforeEach(() => {
387
+ (window as any).spaEnv = 'production';
388
+ vi.resetModules();
389
+ });
390
+
391
+ it('fetches the routes registry only once across repeated reads', async () => {
392
+ setRemoteRouteMap();
393
+ fetchMock.mockResponse(JSON.stringify({ routes: { '@openmrs/esm-remote': { pages: [] } } }));
394
+
395
+ const { setupRouteMapOverrides, getCurrentRouteMap, getRouteMapDefaultMap, getRouteMapNextPageMap } =
396
+ await import('./route-maps');
397
+ await setupRouteMapOverrides();
398
+
399
+ const [first, second, third] = await Promise.all([
400
+ getCurrentRouteMap(),
401
+ getRouteMapDefaultMap(),
402
+ getRouteMapNextPageMap(),
403
+ ]);
404
+ const fourth = await getCurrentRouteMap();
405
+
406
+ for (const map of [first, second, third, fourth]) {
407
+ expect(map.routes['@openmrs/esm-remote']).toEqual({ pages: [] });
408
+ }
409
+ expect(fetchMock).toHaveBeenCalledTimes(1);
410
+ });
411
+
412
+ it('does not cache a map whose fetch failed', async () => {
413
+ setRemoteRouteMap();
414
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
415
+
416
+ fetchMock.mockRejectOnce(new Error('network is down'));
417
+ fetchMock.mockResponse(JSON.stringify({ routes: { '@openmrs/esm-remote': { pages: [] } } }));
418
+
419
+ const { setupRouteMapOverrides, getCurrentRouteMap } = await import('./route-maps');
420
+ await setupRouteMapOverrides();
421
+
422
+ const failed = await getCurrentRouteMap();
423
+ expect(Object.keys(failed.routes)).toHaveLength(0);
424
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to parse routes'), expect.anything());
425
+
426
+ // The failed read was not cached, so the next call retries and succeeds
427
+ const retried = await getCurrentRouteMap();
428
+ expect(retried.routes['@openmrs/esm-remote']).toEqual({ pages: [] });
429
+ expect(fetchMock).toHaveBeenCalledTimes(2);
430
+ });
431
+
432
+ it('does not cache a map whose fetch returned an error status', async () => {
433
+ setRemoteRouteMap();
434
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
435
+
436
+ fetchMock.mockResponseOnce(JSON.stringify({ error: 'service unavailable' }), { status: 503 });
437
+ fetchMock.mockResponse(JSON.stringify({ routes: { '@openmrs/esm-remote': { pages: [] } } }));
438
+
439
+ const { setupRouteMapOverrides, getCurrentRouteMap } = await import('./route-maps');
440
+ await setupRouteMapOverrides();
441
+
442
+ const failed = await getCurrentRouteMap();
443
+ expect(Object.keys(failed.routes)).toHaveLength(0);
444
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to parse routes'), expect.anything());
445
+
446
+ const retried = await getCurrentRouteMap();
447
+ expect(retried.routes['@openmrs/esm-remote']).toEqual({ pages: [] });
448
+ expect(fetchMock).toHaveBeenCalledTimes(2);
449
+ });
450
+
451
+ it('caches a map whose tags loaded but failed validation', async () => {
452
+ setRemoteRouteMap();
453
+ // Valid JSON that isn't an OpenmrsRoutes object is dropped without throwing. Re-fetching
454
+ // would fail identically, so this must not defeat the cache.
455
+ fetchMock.mockResponse(JSON.stringify({ something: 'unexpected' }));
456
+
457
+ const { setupRouteMapOverrides, getCurrentRouteMap } = await import('./route-maps');
458
+ await setupRouteMapOverrides();
459
+
460
+ expect(Object.keys((await getCurrentRouteMap()).routes)).toHaveLength(0);
461
+ expect(Object.keys((await getCurrentRouteMap()).routes)).toHaveLength(0);
462
+ expect(fetchMock).toHaveBeenCalledTimes(1);
463
+ });
464
+ });
465
+
378
466
  describe('error handling', () => {
379
467
  it('reads route maps from remote script src attributes', async () => {
380
468
  (window as any).spaEnv = 'production';
@@ -394,6 +482,8 @@ describe('route-maps', () => {
394
482
 
395
483
  const map = await getCurrentRouteMap();
396
484
  expect(map.routes['@openmrs/esm-remote']).toEqual({ pages: [{ component: 'root', route: '/remote' }] });
485
+ // `crossorigin="anonymous"` on the preload link gives it a `same-origin` credentials mode,
486
+ // which is also `fetch()`'s default; passing anything else here misses the preload cache.
397
487
  expect(fetchMock).toHaveBeenCalledWith('http://localhost/routes.json');
398
488
  });
399
489
 
package/src/route-maps.ts CHANGED
@@ -11,14 +11,34 @@ let devMode = false;
11
11
  // getCurrentRouteMap returns the overrides as they were when the page loaded).
12
12
  let initialOverrideSnapshot: OpenmrsRoutes | null = null;
13
13
 
14
+ // Memoizes the base map so that the routes registry is fetched at most once per page load.
15
+ let baseMapPromise: Promise<OpenmrsRoutes> | null = null;
16
+
14
17
  /**
15
18
  * Reads all `<script type="openmrs-routes">` tags from the DOM and merges
16
19
  * them into a single {@link OpenmrsRoutes} object. Tags with a `src` attribute
17
20
  * are fetched; inline tags have their `textContent` parsed as JSON.
21
+ *
22
+ * A read in which no tag threw is cached and reused by all later callers. A read that lost a
23
+ * tag to an error is returned as-is but not cached, so that a transient network failure doesn't
24
+ * leave the page with a permanently incomplete map. A tag that loads but fails validation is
25
+ * dropped without invalidating the cache, since re-reading it would only fail the same way.
18
26
  */
19
27
  async function readBaseMap(): Promise<OpenmrsRoutes> {
28
+ baseMapPromise ??= loadBaseMap().then(({ map, complete }) => {
29
+ if (!complete) {
30
+ baseMapPromise = null;
31
+ }
32
+ return map;
33
+ });
34
+
35
+ return baseMapPromise;
36
+ }
37
+
38
+ async function loadBaseMap(): Promise<{ map: OpenmrsRoutes; complete: boolean }> {
20
39
  const scripts = document.querySelectorAll<HTMLScriptElement>("script[type='openmrs-routes']");
21
40
  const maps: OpenmrsRoutes[] = [];
41
+ let complete = true;
22
42
 
23
43
  for (let i = 0; i < scripts.length; i++) {
24
44
  const script = scripts[i];
@@ -26,6 +46,9 @@ async function readBaseMap(): Promise<OpenmrsRoutes> {
26
46
  let parsed: unknown;
27
47
  if (script.src) {
28
48
  const response = await fetch(script.src);
49
+ if (!response.ok) {
50
+ throw new Error(`Request for ${script.src} returned ${response.status} ${response.statusText}`);
51
+ }
29
52
  parsed = await response.json();
30
53
  } else if (script.textContent) {
31
54
  parsed = JSON.parse(script.textContent);
@@ -35,11 +58,12 @@ async function readBaseMap(): Promise<OpenmrsRoutes> {
35
58
  maps.push(parsed);
36
59
  }
37
60
  } catch (e) {
61
+ complete = false;
38
62
  console.warn(`[route-maps] Failed to parse routes from script tag at index ${i}`, e);
39
63
  }
40
64
  }
41
65
 
42
- return mergeRouteMaps(maps);
66
+ return { map: mergeRouteMaps(maps), complete };
43
67
  }
44
68
 
45
69
  function mergeRouteMaps(maps: OpenmrsRoutes[]): OpenmrsRoutes {