@jskit-ai/shell-web 0.1.85 → 0.1.86

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,14 +1,15 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/shell-web",
4
- version: "0.1.85",
4
+ version: "0.1.86",
5
5
  kind: "runtime",
6
6
  description: "Web shell layout runtime with outlet-based placement contributions.",
7
7
  dependsOn: [],
8
8
  capabilities: {
9
9
  provides: [
10
10
  "runtime.web-placement",
11
- "runtime.web-error"
11
+ "runtime.web-error",
12
+ "runtime.web-async-module-recovery"
12
13
  ],
13
14
  requires: []
14
15
  },
@@ -54,6 +55,8 @@ export default Object.freeze({
54
55
  client: [
55
56
  "runtime.web-placement.client",
56
57
  "runtime.web-bootstrap.client",
58
+ "runtime.web-refresh.client",
59
+ "runtime.web-async-module-recovery.client",
57
60
  "runtime.web-error.client",
58
61
  "runtime.web-error.presentation-store.client"
59
62
  ]
@@ -291,7 +294,7 @@ export default Object.freeze({
291
294
  dependencies: {
292
295
  runtime: {
293
296
  "@mdi/js": "^7.4.47",
294
- "@jskit-ai/kernel": "0.1.86"
297
+ "@jskit-ai/kernel": "0.1.87"
295
298
  },
296
299
  dev: {}
297
300
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/shell-web",
3
- "version": "0.1.85",
3
+ "version": "0.1.86",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@mdi/js": "^7.4.47",
29
- "@jskit-ai/kernel": "0.1.86"
29
+ "@jskit-ai/kernel": "0.1.87"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "pinia": "^3.0.4",
@@ -1,4 +1,15 @@
1
1
  import { getClientAppConfig } from "@jskit-ai/kernel/client";
2
+ import {
3
+ createAsyncModuleRecoveryState,
4
+ guardedReloadApp,
5
+ installAsyncModuleRecoveryHandlers,
6
+ isDynamicImportError,
7
+ notifyAsyncModuleLoadError
8
+ } from "@jskit-ai/kernel/client/asyncModuleRecovery";
9
+ import {
10
+ isMissingDynamicModule,
11
+ notifyDynamicImportFailure
12
+ } from "./appModuleLoadFailure.js";
2
13
  import {
3
14
  isRecord
4
15
  } from "@jskit-ai/kernel/shared/support";
@@ -29,18 +40,10 @@ import { resolveBootstrapErrorStatusCode } from "../bootstrap/bootstrapErrorStat
29
40
  const APP_PLACEMENT_MODULE_SPECIFIER = "/src/placement.js";
30
41
  const APP_PLACEMENT_TOPOLOGY_MODULE_SPECIFIER = "/src/placementTopology.js";
31
42
  const APP_ERROR_MODULE_SPECIFIER = "/src/error.js";
43
+ const ASYNC_MODULE_RELOAD_FAILURE_MESSAGE =
44
+ "The app cannot reload because the app server is not reachable. Restart the server, then click Reload.";
32
45
 
33
- function isMissingDynamicModule(error, moduleSpecifier) {
34
- const message = String(error?.message || error || "");
35
- return (
36
- message.includes(moduleSpecifier) &&
37
- (message.includes("Cannot find module") ||
38
- message.includes("Failed to fetch dynamically imported module") ||
39
- message.includes("ERR_MODULE_NOT_FOUND"))
40
- );
41
- }
42
-
43
- async function loadAppPlacementDefinitions(logger) {
46
+ async function loadAppPlacementDefinitions(logger, asyncModuleRecoveryRuntime = null) {
44
47
  try {
45
48
  const moduleNamespace = await import("/src/placement.js");
46
49
  const exported = moduleNamespace?.default;
@@ -60,6 +63,9 @@ async function loadAppPlacementDefinitions(logger) {
60
63
  if (isMissingDynamicModule(error, APP_PLACEMENT_MODULE_SPECIFIER)) {
61
64
  return [];
62
65
  }
66
+ notifyDynamicImportFailure(asyncModuleRecoveryRuntime, error, {
67
+ label: "Placement config"
68
+ });
63
69
 
64
70
  logger.warn(
65
71
  {
@@ -73,7 +79,7 @@ async function loadAppPlacementDefinitions(logger) {
73
79
  return [];
74
80
  }
75
81
 
76
- async function loadAppPlacementTopology(logger) {
82
+ async function loadAppPlacementTopology(logger, asyncModuleRecoveryRuntime = null) {
77
83
  try {
78
84
  const moduleNamespace = await import("/src/placementTopology.js");
79
85
  const exported = moduleNamespace?.default;
@@ -82,6 +88,9 @@ async function loadAppPlacementTopology(logger) {
82
88
  if (isMissingDynamicModule(error, APP_PLACEMENT_TOPOLOGY_MODULE_SPECIFIER)) {
83
89
  return [];
84
90
  }
91
+ notifyDynamicImportFailure(asyncModuleRecoveryRuntime, error, {
92
+ label: "Placement topology"
93
+ });
85
94
 
86
95
  logger.warn(
87
96
  {
@@ -127,7 +136,7 @@ function createErrorConfigToolkit(errorRuntime) {
127
136
  });
128
137
  }
129
138
 
130
- async function loadAppErrorConfig(logger, errorRuntime) {
139
+ async function loadAppErrorConfig(logger, errorRuntime, asyncModuleRecoveryRuntime = null) {
131
140
  try {
132
141
  const moduleNamespace = await import("/src/error.js");
133
142
  const exported = moduleNamespace?.default;
@@ -149,6 +158,9 @@ async function loadAppErrorConfig(logger, errorRuntime) {
149
158
  if (isMissingDynamicModule(error, APP_ERROR_MODULE_SPECIFIER)) {
150
159
  return {};
151
160
  }
161
+ notifyDynamicImportFailure(asyncModuleRecoveryRuntime, error, {
162
+ label: "Error config"
163
+ });
152
164
 
153
165
  logger.warn(
154
166
  {
@@ -362,6 +374,10 @@ function installRouterErrorBridge(app, errorRuntime, logger) {
362
374
  }
363
375
 
364
376
  router.onError((error) => {
377
+ if (isDynamicImportError(error)) {
378
+ return;
379
+ }
380
+
365
381
  try {
366
382
  errorRuntime.report({
367
383
  source: "shell-web.router.on-error",
@@ -384,6 +400,121 @@ function installRouterErrorBridge(app, errorRuntime, logger) {
384
400
  });
385
401
  }
386
402
 
403
+ function createShellAsyncModuleRecoveryRuntime({
404
+ app,
405
+ logger = null
406
+ } = {}) {
407
+ if (!app || typeof app.has !== "function" || typeof app.make !== "function") {
408
+ throw new Error("createShellAsyncModuleRecoveryRuntime requires application has()/make().");
409
+ }
410
+
411
+ const runtimeLogger = logger || createSharedProviderLogger(app);
412
+ const state = createAsyncModuleRecoveryState();
413
+ let installedRecovery = null;
414
+
415
+ function errorRuntime() {
416
+ if (!app.has("runtime.web-error.client")) {
417
+ return null;
418
+ }
419
+ const runtime = app.make("runtime.web-error.client");
420
+ return runtime && typeof runtime.report === "function" ? runtime : null;
421
+ }
422
+
423
+ function report(nextState = state) {
424
+ const runtime = errorRuntime();
425
+ if (!runtime) {
426
+ runtimeLogger.warn(
427
+ {
428
+ label: String(nextState?.label || "")
429
+ },
430
+ "Async module recovery could not report because the shell error runtime is unavailable."
431
+ );
432
+ return null;
433
+ }
434
+
435
+ try {
436
+ return runtime.report({
437
+ source: "shell-web.async-module-recovery",
438
+ message: String(nextState?.message || "A required app module could not load."),
439
+ cause: nextState?.error || null,
440
+ intent: "app-recoverable",
441
+ severity: "warning",
442
+ dedupeKey: `shell-web.async-module-recovery.${Number(nextState?.attempt || 0)}`,
443
+ action: {
444
+ label: "Reload",
445
+ dismissOnRun: true,
446
+ handler() {
447
+ return reload();
448
+ }
449
+ }
450
+ });
451
+ } catch (error) {
452
+ runtimeLogger.error(
453
+ {
454
+ label: String(nextState?.label || ""),
455
+ error: String(error?.message || error || "unknown error")
456
+ },
457
+ "Async module recovery could not report through the shell error runtime."
458
+ );
459
+ return null;
460
+ }
461
+ }
462
+
463
+ async function reload() {
464
+ const reloaded = await guardedReloadApp({
465
+ state,
466
+ label: "App",
467
+ message: ASYNC_MODULE_RELOAD_FAILURE_MESSAGE
468
+ });
469
+ if (!reloaded) {
470
+ report(state);
471
+ }
472
+ return reloaded;
473
+ }
474
+
475
+ function notify(error = null, {
476
+ label = "App module",
477
+ message = "",
478
+ stale = isDynamicImportError(error)
479
+ } = {}) {
480
+ notifyAsyncModuleLoadError(state, error, {
481
+ label,
482
+ message,
483
+ retry: state.retry,
484
+ stale
485
+ });
486
+ return report(state);
487
+ }
488
+
489
+ function install() {
490
+ if (installedRecovery) {
491
+ return installedRecovery;
492
+ }
493
+
494
+ installedRecovery = installAsyncModuleRecoveryHandlers({
495
+ state,
496
+ label: "App module",
497
+ router: app.has("jskit.client.router") ? app.make("jskit.client.router") : null,
498
+ onNotify: report
499
+ });
500
+ return installedRecovery;
501
+ }
502
+
503
+ function dispose() {
504
+ installedRecovery?.dispose?.();
505
+ installedRecovery = null;
506
+ }
507
+
508
+ return Object.freeze({
509
+ dispose,
510
+ install,
511
+ notify,
512
+ reload,
513
+ report,
514
+ state
515
+ });
516
+ }
517
+
387
518
  class ShellWebClientProvider {
388
519
  static id = "shell.web.client";
389
520
 
@@ -437,6 +568,12 @@ class ShellWebClientProvider {
437
568
  logger
438
569
  })
439
570
  );
571
+ app.singleton("runtime.web-async-module-recovery.client", (scope) =>
572
+ createShellAsyncModuleRecoveryRuntime({
573
+ app: scope,
574
+ logger
575
+ })
576
+ );
440
577
  app.singleton("runtime.web-error.presentation-store.client", () => createErrorPresentationStore());
441
578
  app.singleton("runtime.web-error.client", (scope) =>
442
579
  createErrorRuntime({
@@ -456,13 +593,17 @@ class ShellWebClientProvider {
456
593
  }
457
594
 
458
595
  const logger = createSharedProviderLogger(isRecord(app) ? app : null);
596
+ const errorRuntime = app.make("runtime.web-error.client");
597
+ const asyncModuleRecoveryRuntime = app.make("runtime.web-async-module-recovery.client");
598
+ asyncModuleRecoveryRuntime.install();
599
+
459
600
  const placementRuntime = app.make("runtime.web-placement.client");
460
601
  if (placementRuntime && typeof placementRuntime.replacePlacements === "function") {
461
- const placementTopology = await loadAppPlacementTopology(logger);
602
+ const placementTopology = await loadAppPlacementTopology(logger, asyncModuleRecoveryRuntime);
462
603
  if (typeof placementRuntime.replacePlacementTopology === "function") {
463
604
  placementRuntime.replacePlacementTopology(placementTopology, { source: APP_PLACEMENT_TOPOLOGY_MODULE_SPECIFIER });
464
605
  }
465
- const placements = await loadAppPlacementDefinitions(logger);
606
+ const placements = await loadAppPlacementDefinitions(logger, asyncModuleRecoveryRuntime);
466
607
  placementRuntime.replacePlacements(placements, { source: APP_PLACEMENT_MODULE_SPECIFIER });
467
608
  const appConfig = getClientAppConfig();
468
609
  const surfaceRuntime = app.has("jskit.client.surface.runtime")
@@ -486,8 +627,7 @@ class ShellWebClientProvider {
486
627
  );
487
628
  }
488
629
 
489
- const errorRuntime = app.make("runtime.web-error.client");
490
- const errorConfig = await loadAppErrorConfig(logger, errorRuntime);
630
+ const errorConfig = await loadAppErrorConfig(logger, errorRuntime, asyncModuleRecoveryRuntime);
491
631
  applyAppErrorConfig(errorRuntime, errorConfig);
492
632
 
493
633
  const bootstrapRuntime = app.make("runtime.web-bootstrap.client");
@@ -514,6 +654,7 @@ class ShellWebClientProvider {
514
654
  vueApp.provide("jskit.shell-web.runtime.web-placement.client", placementRuntime);
515
655
  vueApp.provide("jskit.shell-web.runtime.web-refresh.client", refreshRuntime);
516
656
  vueApp.provide("jskit.shell-web.runtime.web-error.client", errorRuntime);
657
+ vueApp.provide("jskit.shell-web.runtime.web-async-module-recovery.client", asyncModuleRecoveryRuntime);
517
658
  vueApp.provide(
518
659
  "jskit.shell-web.runtime.web-error.presentation-store.client",
519
660
  errorPresentationStore
@@ -0,0 +1,29 @@
1
+ import {
2
+ isDynamicImportError
3
+ } from "@jskit-ai/kernel/client/asyncModuleRecovery";
4
+
5
+ function isMissingDynamicModule(error, moduleSpecifier) {
6
+ const message = String(error?.message || error || "");
7
+ return (
8
+ message.includes(moduleSpecifier) &&
9
+ (message.includes("Cannot find module") ||
10
+ message.includes("ERR_MODULE_NOT_FOUND"))
11
+ );
12
+ }
13
+
14
+ function notifyDynamicImportFailure(asyncModuleRecoveryRuntime, error, {
15
+ label = "App module"
16
+ } = {}) {
17
+ if (!isDynamicImportError(error) || typeof asyncModuleRecoveryRuntime?.notify !== "function") {
18
+ return;
19
+ }
20
+
21
+ asyncModuleRecoveryRuntime.notify(error, {
22
+ label
23
+ });
24
+ }
25
+
26
+ export {
27
+ isMissingDynamicModule,
28
+ notifyDynamicImportFailure
29
+ };
@@ -19,7 +19,7 @@
19
19
 
20
20
  <style scoped>
21
21
  .generated-ui-screen {
22
- --generated-ui-screen-title-size: clamp(1.5rem, 3vw, 2.5rem);
22
+ --generated-ui-screen-title-size: 2rem;
23
23
  --generated-ui-screen-panel-padding: 1rem;
24
24
  }
25
25
 
@@ -31,7 +31,7 @@
31
31
  .home-start-screen__title {
32
32
  font-size: var(--generated-ui-screen-title-size);
33
33
  font-weight: 700;
34
- letter-spacing: -0.03em;
34
+ letter-spacing: 0;
35
35
  line-height: 1.08;
36
36
  margin: 0 0 0.45rem;
37
37
  }
@@ -39,4 +39,10 @@
39
39
  .home-start-screen__panel {
40
40
  padding: var(--generated-ui-screen-panel-padding);
41
41
  }
42
+
43
+ @media (max-width: 640px) {
44
+ .generated-ui-screen {
45
+ --generated-ui-screen-title-size: 1.5rem;
46
+ }
47
+ }
42
48
  </style>
@@ -56,7 +56,7 @@ const health = computed(() => {
56
56
 
57
57
  <style scoped>
58
58
  .generated-ui-screen {
59
- --generated-ui-screen-title-size: clamp(1.5rem, 2.5vw, 2.25rem);
59
+ --generated-ui-screen-title-size: 2rem;
60
60
  --generated-ui-screen-panel-padding: 1rem;
61
61
  }
62
62
 
@@ -70,7 +70,7 @@ const health = computed(() => {
70
70
  .home-surface-screen__title {
71
71
  font-size: var(--generated-ui-screen-title-size);
72
72
  font-weight: 700;
73
- letter-spacing: -0.03em;
73
+ letter-spacing: 0;
74
74
  line-height: 1.1;
75
75
  margin: 0 0 0.4rem;
76
76
  }
@@ -90,6 +90,10 @@ const health = computed(() => {
90
90
  }
91
91
 
92
92
  @media (max-width: 640px) {
93
+ .generated-ui-screen {
94
+ --generated-ui-screen-title-size: 1.5rem;
95
+ }
96
+
93
97
  .home-surface-screen__header {
94
98
  flex-direction: column;
95
99
  }
@@ -29,14 +29,14 @@ import { RouterView } from "vue-router";
29
29
 
30
30
  <style scoped>
31
31
  .generated-ui-screen {
32
- --generated-ui-screen-title-size: clamp(1.35rem, 2vw, 1.85rem);
32
+ --generated-ui-screen-title-size: 1.85rem;
33
33
  --generated-ui-screen-panel-padding: 1rem;
34
34
  }
35
35
 
36
36
  .settings-shell__title {
37
37
  font-size: var(--generated-ui-screen-title-size);
38
38
  font-weight: 650;
39
- letter-spacing: -0.02em;
39
+ letter-spacing: 0;
40
40
  line-height: 1.15;
41
41
  margin: 0 0 0.35rem;
42
42
  }
@@ -81,6 +81,10 @@ import { RouterView } from "vue-router";
81
81
  }
82
82
 
83
83
  @media (max-width: 960px) {
84
+ .generated-ui-screen {
85
+ --generated-ui-screen-title-size: 1.35rem;
86
+ }
87
+
84
88
  .settings-shell__body {
85
89
  grid-template-columns: 1fr;
86
90
  }
@@ -7,7 +7,7 @@ import descriptor from "../package.descriptor.mjs";
7
7
 
8
8
  const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
9
9
  const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
10
- const CREATE_APP_TEMPLATE_DIR = path.resolve(PACKAGE_DIR, "..", "..", "tooling", "create-app", "templates", "base-shell");
10
+ const CREATE_APP_TEMPLATE_DIR = path.resolve(PACKAGE_DIR, "..", "..", "tooling", "create-app", "templates", "minimal-shell");
11
11
 
12
12
  function findFileMutation(id) {
13
13
  const files = descriptor?.mutations?.files;
@@ -48,7 +48,7 @@ test("shell-web claims starter shell files as app-owned scaffolds", () => {
48
48
  });
49
49
  });
50
50
 
51
- test("shell-web expected-existing starter files stay aligned with create-app base-shell", async () => {
51
+ test("shell-web expected-existing starter files stay aligned with create-app minimal-shell", async () => {
52
52
  const comparedFiles = [
53
53
  "src/App.vue",
54
54
  "src/pages/home.vue",
@@ -5,9 +5,28 @@ import {
5
5
  ShellWebClientProvider,
6
6
  resolveAppPlacementTopologyExport
7
7
  } from "../src/client/providers/ShellWebClientProvider.js";
8
+ import {
9
+ isMissingDynamicModule
10
+ } from "../src/client/providers/appModuleLoadFailure.js";
8
11
  import { useShellErrorPresentationStore } from "../src/client/stores/useShellErrorPresentationStore.js";
9
12
  const CLIENT_APP_CONFIG_GLOBAL_KEY = "__JSKIT_CLIENT_APP_CONFIG__";
10
13
 
14
+ async function withTemporaryGlobal(name, value, callback) {
15
+ const hadPrevious = Object.prototype.hasOwnProperty.call(globalThis, name);
16
+ const previousValue = globalThis[name];
17
+ globalThis[name] = value;
18
+
19
+ try {
20
+ return await callback();
21
+ } finally {
22
+ if (hadPrevious) {
23
+ globalThis[name] = previousValue;
24
+ } else {
25
+ delete globalThis[name];
26
+ }
27
+ }
28
+ }
29
+
11
30
  function setClientAppConfig(source = {}) {
12
31
  const normalized =
13
32
  source && typeof source === "object" && !Array.isArray(source) ? Object.freeze({ ...source }) : Object.freeze({});
@@ -17,7 +36,7 @@ function setClientAppConfig(source = {}) {
17
36
  return normalized;
18
37
  }
19
38
 
20
- function createAppDouble({ surfaceRuntime = null, queryClient = null } = {}) {
39
+ function createAppDouble({ surfaceRuntime = null, queryClient = null, router = null } = {}) {
21
40
  const singletons = new Map();
22
41
  const singletonInstances = new Map();
23
42
  const provided = [];
@@ -67,6 +86,9 @@ function createAppDouble({ surfaceRuntime = null, queryClient = null } = {}) {
67
86
  if (token === "jskit.client.query-client") {
68
87
  return Boolean(queryClient);
69
88
  }
89
+ if (token === "jskit.client.router") {
90
+ return Boolean(router);
91
+ }
70
92
  return singletons.has(token) || singletonInstances.has(token);
71
93
  },
72
94
  make(token) {
@@ -82,6 +104,9 @@ function createAppDouble({ surfaceRuntime = null, queryClient = null } = {}) {
82
104
  if (token === "jskit.client.query-client" && queryClient) {
83
105
  return queryClient;
84
106
  }
107
+ if (token === "jskit.client.router" && router) {
108
+ return router;
109
+ }
85
110
  if (singletonInstances.has(token)) {
86
111
  return singletonInstances.get(token);
87
112
  }
@@ -100,38 +125,45 @@ function createAppDouble({ surfaceRuntime = null, queryClient = null } = {}) {
100
125
  }
101
126
 
102
127
  async function withFetchStub(responsePayload, callback) {
103
- const previousFetch = globalThis.fetch;
104
- globalThis.fetch = async () => ({
128
+ return withFetchImplementation(async () => ({
105
129
  ok: true,
106
130
  async json() {
107
131
  return responsePayload;
108
132
  }
109
- });
110
-
111
- try {
112
- return await callback();
113
- } finally {
114
- if (previousFetch === undefined) {
115
- delete globalThis.fetch;
116
- } else {
117
- globalThis.fetch = previousFetch;
118
- }
119
- }
133
+ }), callback);
120
134
  }
121
135
 
122
136
  async function withFetchImplementation(fetchImplementation, callback) {
123
- const previousFetch = globalThis.fetch;
124
- globalThis.fetch = fetchImplementation;
137
+ return withTemporaryGlobal("fetch", fetchImplementation, callback);
138
+ }
125
139
 
126
- try {
127
- return await callback();
128
- } finally {
129
- if (previousFetch === undefined) {
130
- delete globalThis.fetch;
131
- } else {
132
- globalThis.fetch = previousFetch;
140
+ async function withWindowDouble(windowObject, callback) {
141
+ return withTemporaryGlobal("window", windowObject, callback);
142
+ }
143
+
144
+ function createWindowDouble({ href = "https://example.test/home" } = {}) {
145
+ const listeners = new Map();
146
+ const reloadCalls = [];
147
+ const location = {
148
+ href,
149
+ reload() {
150
+ reloadCalls.push(location.href);
133
151
  }
134
- }
152
+ };
153
+
154
+ return {
155
+ listeners,
156
+ reloadCalls,
157
+ location,
158
+ addEventListener(type, handler) {
159
+ listeners.set(type, handler);
160
+ },
161
+ removeEventListener(type, handler) {
162
+ if (listeners.get(type) === handler) {
163
+ listeners.delete(type);
164
+ }
165
+ }
166
+ };
135
167
  }
136
168
 
137
169
  test("shell web client provider preserves append-only placement topology object exports", () => {
@@ -160,6 +192,23 @@ test("shell web client provider preserves append-only placement topology object
160
192
  assert.deepEqual(warnings, []);
161
193
  });
162
194
 
195
+ test("shell web boot import classifier does not swallow fetched dynamic module failures", () => {
196
+ assert.equal(
197
+ isMissingDynamicModule(
198
+ new Error("Cannot find module '/src/placement.js' imported from /src/main.js"),
199
+ "/src/placement.js"
200
+ ),
201
+ true
202
+ );
203
+ assert.equal(
204
+ isMissingDynamicModule(
205
+ new Error("Failed to fetch dynamically imported module: http://localhost:5173/src/placement.js?t=123"),
206
+ "/src/placement.js"
207
+ ),
208
+ false
209
+ );
210
+ });
211
+
163
212
  test("shell web client provider binds runtime and injects it into Vue app", async () => {
164
213
  await withFetchStub({ surfaceAccess: {} }, async () => {
165
214
  const app = createAppDouble();
@@ -170,6 +219,7 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
170
219
  assert.equal(app.singletons.has("runtime.web-error.client"), true);
171
220
  assert.equal(app.singletons.has("runtime.web-error.presentation-store.client"), true);
172
221
  assert.equal(app.singletons.has("runtime.web-refresh.client"), true);
222
+ assert.equal(app.singletons.has("runtime.web-async-module-recovery.client"), true);
173
223
 
174
224
  await provider.boot(app);
175
225
  assert.equal(app.plugins.length, 0);
@@ -179,6 +229,7 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
179
229
  assert.equal(providedByKey.has("jskit.shell-web.runtime.web-placement.client"), true);
180
230
  assert.equal(providedByKey.has("jskit.shell-web.runtime.web-refresh.client"), true);
181
231
  assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.client"), true);
232
+ assert.equal(providedByKey.has("jskit.shell-web.runtime.web-async-module-recovery.client"), true);
182
233
  assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.presentation-store.client"), true);
183
234
 
184
235
  const placementRuntime = providedByKey.get("jskit.shell-web.runtime.web-placement.client");
@@ -194,6 +245,11 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
194
245
  const refreshRuntime = providedByKey.get("jskit.shell-web.runtime.web-refresh.client");
195
246
  assert.equal(typeof refreshRuntime.refresh, "function");
196
247
 
248
+ const asyncModuleRecoveryRuntime = providedByKey.get("jskit.shell-web.runtime.web-async-module-recovery.client");
249
+ assert.equal(typeof asyncModuleRecoveryRuntime.install, "function");
250
+ assert.equal(typeof asyncModuleRecoveryRuntime.notify, "function");
251
+ assert.equal(typeof asyncModuleRecoveryRuntime.reload, "function");
252
+
197
253
  const errorStore = providedByKey.get("jskit.shell-web.runtime.web-error.presentation-store.client");
198
254
  assert.equal(typeof errorStore.getState, "function");
199
255
  assert.equal(typeof errorStore.present, "function");
@@ -260,6 +316,127 @@ test("shell refresh runtime reports recoverable retry errors as banners", async
260
316
  });
261
317
  });
262
318
 
319
+ test("shell web client provider reports dynamic import failures through async module recovery", async () => {
320
+ await withFetchStub({ surfaceAccess: {} }, async () => {
321
+ const routerErrorHandlers = [];
322
+ const replacedPaths = [];
323
+ const router = {
324
+ onError(handler) {
325
+ routerErrorHandlers.push(handler);
326
+ return () => null;
327
+ },
328
+ replace(fullPath) {
329
+ replacedPaths.push(fullPath);
330
+ }
331
+ };
332
+ const app = createAppDouble({ router });
333
+ const provider = new ShellWebClientProvider();
334
+ provider.register(app);
335
+ await provider.boot(app);
336
+
337
+ assert.equal(routerErrorHandlers.length, 2);
338
+
339
+ const chunkError = new Error("Failed to fetch dynamically imported module: /assets/page.js");
340
+ for (const handler of routerErrorHandlers) {
341
+ handler(chunkError, {
342
+ fullPath: "/app/dashboard"
343
+ });
344
+ }
345
+
346
+ const errorStore = app.make("runtime.web-error.presentation-store.client");
347
+ const state = errorStore.getState();
348
+ assert.equal(state.channels.banner.length, 1);
349
+ assert.equal(
350
+ state.channels.banner[0].message,
351
+ "Page did not download. The app may have been updated, or the network request failed."
352
+ );
353
+ assert.equal(state.channels.banner[0].severity, "warning");
354
+ assert.equal(state.channels.banner[0].action.label, "Reload");
355
+ assert.deepEqual(replacedPaths, []);
356
+ });
357
+ });
358
+
359
+ test("shell web async module recovery reload action checks the document before reloading", async () => {
360
+ const windowObject = createWindowDouble();
361
+ await withWindowDouble(windowObject, async () => {
362
+ const fetchCalls = [];
363
+ await withFetchImplementation(async (input, init = {}) => {
364
+ fetchCalls.push({ input, init });
365
+ return {
366
+ ok: true,
367
+ status: 200,
368
+ async json() {
369
+ return { surfaceAccess: {} };
370
+ }
371
+ };
372
+ }, async () => {
373
+ const routerErrorHandlers = [];
374
+ const router = {
375
+ onError(handler) {
376
+ routerErrorHandlers.push(handler);
377
+ return () => null;
378
+ },
379
+ replace() {}
380
+ };
381
+ const app = createAppDouble({ router });
382
+ const provider = new ShellWebClientProvider();
383
+ provider.register(app);
384
+ await provider.boot(app);
385
+
386
+ const chunkError = new Error("Failed to fetch dynamically imported module: /assets/page.js");
387
+ routerErrorHandlers[0](chunkError, {
388
+ fullPath: "/home/settings"
389
+ });
390
+
391
+ const errorStore = app.make("runtime.web-error.presentation-store.client");
392
+ const banner = errorStore.getState().channels.banner[0];
393
+ await banner.action.handler();
394
+
395
+ assert.deepEqual(windowObject.reloadCalls, ["https://example.test/home"]);
396
+ const reloadFetch = fetchCalls.find((entry) => entry.input === "https://example.test/home");
397
+ assert.equal(reloadFetch?.init?.cache, "no-store");
398
+ assert.equal(reloadFetch?.init?.credentials, "same-origin");
399
+ });
400
+ });
401
+ });
402
+
403
+ test("shell web async module recovery keeps the page alive when reload check fails", async () => {
404
+ const windowObject = createWindowDouble();
405
+ await withWindowDouble(windowObject, async () => {
406
+ await withFetchImplementation(async (input) => {
407
+ if (input === "https://example.test/home") {
408
+ throw new TypeError("Failed to fetch");
409
+ }
410
+ return {
411
+ ok: true,
412
+ status: 200,
413
+ async json() {
414
+ return { surfaceAccess: {} };
415
+ }
416
+ };
417
+ }, async () => {
418
+ const app = createAppDouble();
419
+ const provider = new ShellWebClientProvider();
420
+ provider.register(app);
421
+ await provider.boot(app);
422
+
423
+ const recoveryRuntime = app.make("runtime.web-async-module-recovery.client");
424
+ const reloaded = await recoveryRuntime.reload();
425
+
426
+ assert.equal(reloaded, false);
427
+ assert.deepEqual(windowObject.reloadCalls, []);
428
+ const errorStore = app.make("runtime.web-error.presentation-store.client");
429
+ const state = errorStore.getState();
430
+ assert.equal(state.channels.banner.length, 1);
431
+ assert.equal(
432
+ state.channels.banner[0].message,
433
+ "The app cannot reload because the app server is not reachable. Restart the server, then click Reload."
434
+ );
435
+ assert.equal(state.channels.banner[0].action.label, "Reload");
436
+ });
437
+ });
438
+ });
439
+
263
440
  test("shell web client provider resolves surface config from client app config", async () => {
264
441
  setClientAppConfig({
265
442
  tenancyMode: "workspaces",
@@ -38,6 +38,11 @@ function readTopology(id = "", owner = "") {
38
38
  : [];
39
39
  }
40
40
 
41
+ function readClientContainerTokens() {
42
+ const tokens = descriptor?.metadata?.apiSummary?.containerTokens?.client;
43
+ return Array.isArray(tokens) ? tokens : [];
44
+ }
45
+
41
46
  function findFileMutation(id) {
42
47
  const files = descriptor?.mutations?.files;
43
48
  return Array.isArray(files)
@@ -265,6 +270,15 @@ test("shell-web placement topology seeds global actions as a semantic shell plac
265
270
  });
266
271
 
267
272
  test("shell-web descriptor metadata advertises adaptive shell outlets, default links, and installs the scaffold page", () => {
273
+ assert.deepEqual(readClientContainerTokens(), [
274
+ "runtime.web-placement.client",
275
+ "runtime.web-bootstrap.client",
276
+ "runtime.web-refresh.client",
277
+ "runtime.web-async-module-recovery.client",
278
+ "runtime.web-error.client",
279
+ "runtime.web-error.presentation-store.client"
280
+ ]);
281
+
268
282
  assert.deepEqual(
269
283
  readOutlets("shell-layout:primary-bottom-nav"),
270
284
  [