@openmrs/esm-dynamic-loading 10.0.1-pre.5240 → 10.0.1-pre.5252
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/.turbo/turbo-build.log +1 -1
- package/dist/import-maps.d.ts.map +1 -1
- package/dist/import-maps.js +24 -1
- package/dist/route-maps.d.ts.map +1 -1
- package/dist/route-maps.js +25 -1
- package/package.json +5 -5
- package/src/import-maps.test.ts +88 -0
- package/src/import-maps.ts +24 -1
- package/src/route-maps.test.ts +90 -0
- package/src/route-maps.ts +25 -1
package/.turbo/turbo-build.log
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/import-maps.js
CHANGED
|
@@ -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
|
|
49
|
+
return {
|
|
50
|
+
map: mergeMaps(maps),
|
|
51
|
+
complete
|
|
52
|
+
};
|
|
30
53
|
}
|
|
31
54
|
function mergeMaps(maps) {
|
|
32
55
|
const merged = {
|
package/dist/route-maps.d.ts.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/route-maps.js
CHANGED
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "10.0.1-pre.5252",
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
5
|
"description": "Utilities for dynamically loading code in OpenMRS",
|
|
6
6
|
"type": "module",
|
|
@@ -53,12 +53,12 @@
|
|
|
53
53
|
"access": "public"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
|
-
"@openmrs/esm-globals": "^10.0.1-pre.
|
|
57
|
-
"@openmrs/esm-translations": "^10.0.1-pre.
|
|
56
|
+
"@openmrs/esm-globals": "^10.0.1-pre.5252",
|
|
57
|
+
"@openmrs/esm-translations": "^10.0.1-pre.5252"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"@openmrs/esm-globals": "10.0.1-pre.
|
|
61
|
-
"@openmrs/esm-translations": "10.0.1-pre.
|
|
60
|
+
"@openmrs/esm-globals": "10.0.1-pre.5252",
|
|
61
|
+
"@openmrs/esm-translations": "10.0.1-pre.5252",
|
|
62
62
|
"@swc/cli": "0.8.1",
|
|
63
63
|
"@swc/core": "1.15.21",
|
|
64
64
|
"@vitest/coverage-v8": "^4.1.2",
|
package/src/import-maps.test.ts
CHANGED
|
@@ -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';
|
package/src/import-maps.ts
CHANGED
|
@@ -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 {
|
package/src/route-maps.test.ts
CHANGED
|
@@ -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 {
|