@atolljs/islands 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ import type { IslandHandle, MountIslandOptions } from './island.js';
2
+ /** What a boundary render fn may return — applied via el.replaceChildren/append. */
3
+ export type BoundaryContent = Node | string | Array<Node | string> | null | undefined;
4
+ /** Where the failure happened — 'mount' (handshake), 'runtime' (worker crash). */
5
+ export type IslandBoundaryPhase = 'mount' | 'runtime';
6
+ export interface IslandBoundaryContext {
7
+ /** The mount's app key. */
8
+ app: string;
9
+ /** The island's container element — the boundary's render target. */
10
+ el: HTMLElement;
11
+ /** 1-based mount attempt counter — increments across retries. */
12
+ attempt: number;
13
+ }
14
+ export interface IslandBoundaryErrorContext extends IslandBoundaryContext {
15
+ /** The caught failure (mount rejection, crash error). */
16
+ error: unknown;
17
+ phase: IslandBoundaryPhase;
18
+ /** Manual remount — wire it to a retry button in the error render. */
19
+ retry(): void;
20
+ }
21
+ export interface IslandBoundary {
22
+ /** Rendered while the first mount attempt is in flight. */
23
+ loading?(ctx: IslandBoundaryContext): BoundaryContent;
24
+ /** Rendered when the island fails — mount rejection or runtime crash. */
25
+ error?(ctx: IslandBoundaryErrorContext): BoundaryContent;
26
+ /** Fires on every boundary-entering failure — logging/telemetry. */
27
+ onError?(error: unknown, phase: IslandBoundaryPhase): void;
28
+ /**
29
+ * Auto-remount policy: a number is the max retry count (delay grows
30
+ * ~250ms per attempt, capped at 2s); a function maps (attempt, error)
31
+ * to a delay in ms, `false`/`undefined` to stop. Omit for no auto-retry.
32
+ */
33
+ retry?: number | ((attempt: number, error: unknown) => number | false | undefined | void);
34
+ /**
35
+ * What the error state does to the container: 'clear' (default) replaces
36
+ * its contents with the fallback; 'preserve' appends the fallback over
37
+ * the stale last frame on runtime crashes (mount failures always clear —
38
+ * there is no frame to preserve).
39
+ */
40
+ domOnError?: 'clear' | 'preserve';
41
+ }
42
+ export type IslandBoundaryState = 'loading' | 'mounted' | 'error';
43
+ /**
44
+ * The boundary-aware handle: a normal IslandHandle when mounted, safe
45
+ * no-ops while failed (updateProps stashes into the next attempt's props),
46
+ * plus the boundary's own state.
47
+ */
48
+ export interface IslandBoundaryHandle extends IslandHandle {
49
+ readonly state: IslandBoundaryState;
50
+ /** The error that put the boundary in 'error' state (undefined otherwise). */
51
+ readonly error: unknown;
52
+ /** Remount now — same as ctx.retry() handed to the error render fn. */
53
+ retry(): void;
54
+ }
55
+ export declare function mountIslandWithBoundary(opts: MountIslandOptions & {
56
+ boundary: IslandBoundary;
57
+ }): Promise<IslandBoundaryHandle>;
@@ -450,6 +450,7 @@ async function mountIsland(opts) {
450
450
  app: givenApp,
451
451
  props: givenProps,
452
452
  framework,
453
+ boundary,
453
454
  ...workerConfig
454
455
  } = opts;
455
456
  if (isProxyMountTarget(target)) {
@@ -461,6 +462,9 @@ async function mountIsland(opts) {
461
462
  return subMounter(opts);
462
463
  }
463
464
  const el = target;
465
+ if (boundary !== void 0) {
466
+ return mountIslandWithBoundary(opts);
467
+ }
464
468
  const cbTable = /* @__PURE__ */ new Map();
465
469
  let cbSeq = 0;
466
470
  const registerCb = (fn) => {
@@ -1118,6 +1122,172 @@ async function mountIsland(opts) {
1118
1122
  return handle;
1119
1123
  }
1120
1124
 
1125
+ // src/boundary.ts
1126
+ async function mountIslandWithBoundary(opts) {
1127
+ const {
1128
+ boundary,
1129
+ el: target,
1130
+ client: givenClient,
1131
+ worker,
1132
+ app: givenApp,
1133
+ props: givenProps,
1134
+ mode: initialMode,
1135
+ ...mountOpts
1136
+ } = opts;
1137
+ const client = givenClient ?? connectIslandWorker({
1138
+ name: givenApp,
1139
+ ...mountOpts,
1140
+ worker,
1141
+ doorbell: initialMode !== "poll"
1142
+ });
1143
+ const app = givenApp ?? "main";
1144
+ const el = target;
1145
+ let state = "loading";
1146
+ let lastError;
1147
+ let current = null;
1148
+ let attempt = 0;
1149
+ let destroyed = false;
1150
+ let retryTimer = null;
1151
+ let pendingProps = givenProps;
1152
+ let pendingMode = initialMode;
1153
+ const setContent = (content, how) => {
1154
+ if (content === null || content === void 0) {
1155
+ if (how === "replace") el.replaceChildren();
1156
+ return;
1157
+ }
1158
+ const parts = (Array.isArray(content) ? content : [content]).filter(
1159
+ (p) => p !== null && p !== void 0
1160
+ );
1161
+ if (how === "replace") el.replaceChildren(...parts);
1162
+ else el.append(...parts);
1163
+ };
1164
+ const scheduleRetry = (error) => {
1165
+ const policy = boundary.retry;
1166
+ if (policy === void 0 || retryTimer !== null) return;
1167
+ const delay = typeof policy === "number" ? attempt <= policy ? Math.min(250 * attempt, 2e3) : false : policy(attempt, error);
1168
+ if (delay === false || delay === void 0 || typeof delay !== "number") return;
1169
+ retryTimer = setTimeout(() => {
1170
+ retryTimer = null;
1171
+ void tryMount();
1172
+ }, Math.max(0, delay));
1173
+ };
1174
+ const fail = (error, phase) => {
1175
+ if (destroyed) return;
1176
+ state = "error";
1177
+ lastError = error;
1178
+ boundary.onError?.(error, phase);
1179
+ const content = boundary.error?.({
1180
+ app,
1181
+ el,
1182
+ attempt,
1183
+ error,
1184
+ phase,
1185
+ retry: manualRetry
1186
+ });
1187
+ if (boundary.domOnError === "preserve" && phase === "runtime") {
1188
+ setContent(content, "append");
1189
+ } else {
1190
+ setContent(content, "replace");
1191
+ }
1192
+ scheduleRetry(error);
1193
+ };
1194
+ const tryMount = async () => {
1195
+ if (destroyed) return;
1196
+ attempt++;
1197
+ el.replaceChildren();
1198
+ try {
1199
+ const handle2 = await mountIsland({
1200
+ ...mountOpts,
1201
+ client,
1202
+ el,
1203
+ app: givenApp,
1204
+ props: pendingProps,
1205
+ mode: pendingMode
1206
+ });
1207
+ if (destroyed) {
1208
+ handle2.destroy();
1209
+ return;
1210
+ }
1211
+ current = handle2;
1212
+ state = "mounted";
1213
+ } catch (err) {
1214
+ fail(err, "mount");
1215
+ }
1216
+ };
1217
+ const manualRetry = () => {
1218
+ if (destroyed) return;
1219
+ if (retryTimer !== null) {
1220
+ clearTimeout(retryTimer);
1221
+ retryTimer = null;
1222
+ }
1223
+ void tryMount();
1224
+ };
1225
+ const offLifecycle = client.onWorkerEvent?.((event) => {
1226
+ if (destroyed || state === "error" || event.type !== "error") return;
1227
+ const stale = current;
1228
+ current = null;
1229
+ stale?.destroy();
1230
+ fail(
1231
+ new Error(
1232
+ `[island ${app}] worker crashed` + (event.message !== void 0 ? `: ${event.message}` : "")
1233
+ ),
1234
+ "runtime"
1235
+ );
1236
+ });
1237
+ const handle = {
1238
+ get app() {
1239
+ return current?.app ?? app;
1240
+ },
1241
+ get pid() {
1242
+ return current?.pid ?? "unmounted";
1243
+ },
1244
+ get instance() {
1245
+ return current?.instance ?? `${app}@?`;
1246
+ },
1247
+ get mode() {
1248
+ return current?.mode ?? pendingMode ?? "push";
1249
+ },
1250
+ get opsApplied() {
1251
+ return current?.opsApplied ?? 0;
1252
+ },
1253
+ get flushCalls() {
1254
+ return current?.flushCalls ?? 0;
1255
+ },
1256
+ get state() {
1257
+ return state;
1258
+ },
1259
+ get error() {
1260
+ return lastError;
1261
+ },
1262
+ retry: manualRetry,
1263
+ setMode: (mode) => {
1264
+ pendingMode = mode;
1265
+ current?.setMode(mode);
1266
+ },
1267
+ updateProps: async (next) => {
1268
+ pendingProps = next;
1269
+ await current?.updateProps(next);
1270
+ },
1271
+ flush: async () => {
1272
+ await current?.flush();
1273
+ },
1274
+ destroy: () => {
1275
+ if (destroyed) return;
1276
+ destroyed = true;
1277
+ if (retryTimer !== null) {
1278
+ clearTimeout(retryTimer);
1279
+ retryTimer = null;
1280
+ }
1281
+ offLifecycle?.();
1282
+ current?.destroy();
1283
+ current = null;
1284
+ }
1285
+ };
1286
+ setContent(boundary.loading?.({ app, el, attempt: 1 }), "replace");
1287
+ await tryMount();
1288
+ return handle;
1289
+ }
1290
+
1121
1291
  // src/contract.ts
1122
1292
  var CONTRACT_MARK = "islandContract";
1123
1293
  var APP_CONTRACT = "appContract";
@@ -1171,6 +1341,7 @@ export {
1171
1341
  marshalCallbackProps,
1172
1342
  unmarshalCallbackProps,
1173
1343
  propsPreview,
1344
+ mountIslandWithBoundary,
1174
1345
  isKeepAliveClient,
1175
1346
  connectIslandWorker,
1176
1347
  assertCloneableProps,
package/dist/index.d.ts CHANGED
@@ -13,6 +13,8 @@
13
13
  */
14
14
  export { connectIslandWorker, isKeepAliveClient, mountIsland } from './island.js';
15
15
  export type { ConnectIslandWorkerConfig, IslandClient, IslandHandle, IslandWorkerDefinition, IslandWorkerOptions, Mode, MountIslandOptions, } from './island.js';
16
+ export { mountIslandWithBoundary } from './boundary.js';
17
+ export type { BoundaryContent, IslandBoundary, IslandBoundaryContext, IslandBoundaryErrorContext, IslandBoundaryHandle, IslandBoundaryPhase, IslandBoundaryState, } from './boundary.js';
16
18
  export { makeDoorbell } from './memory.js';
17
19
  export type { DoorbellSpec } from './memory.js';
18
20
  export { islandApp, islandAppNameOf } from './app.js';
package/dist/index.js CHANGED
@@ -11,8 +11,9 @@ import {
11
11
  islandAppNameOf,
12
12
  makeDoorbell,
13
13
  mountIsland,
14
+ mountIslandWithBoundary,
14
15
  withContract
15
- } from "./chunk-TX4FAHRB.js";
16
+ } from "./chunk-P3G2GL3T.js";
16
17
  export {
17
18
  callbackProp,
18
19
  connectIslandWorker,
@@ -26,5 +27,6 @@ export {
26
27
  islandAppNameOf,
27
28
  makeDoorbell,
28
29
  mountIsland,
30
+ mountIslandWithBoundary,
29
31
  withContract
30
32
  };
package/dist/island.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ConnectWorkerConfig, SharedSpec, WorkerClient, WorkerDefinition } from '@atolljs/core';
2
2
  import { type DoorbellSpec } from './memory.js';
3
3
  import { type IslandWorkerMethods, type Op } from './ops.js';
4
+ import type { IslandBoundary, IslandBoundaryHandle } from './boundary.js';
4
5
  import type { ProxyElement } from './worker/dom/element.js';
5
6
  export type Mode = 'push' | 'poll';
6
7
  /** The worker definition every `definePolyWorker` call produces. */
@@ -117,6 +118,15 @@ interface MountIslandBaseOptions {
117
118
  * client warm for this island's teardown.
118
119
  */
119
120
  keepAlive?: boolean;
121
+ /**
122
+ * Error boundary — `loading`/`error` state render functions plus a retry
123
+ * policy, covering mount rejection, mountTimeout, and runtime worker
124
+ * crashes (detected via the client's lifecycle events). When set, the
125
+ * mount resolves to an `IslandBoundaryHandle` after the first attempt
126
+ * settles — errors render the fallback instead of rejecting. Ignored on
127
+ * nested (proxy-element) mounts. See boundary.ts / docs error-boundaries.
128
+ */
129
+ boundary?: IslandBoundary;
120
130
  }
121
131
  /**
122
132
  * The mount needs one worker-side endpoint: either a `client` a
@@ -171,5 +181,8 @@ export declare const assertCloneableProps: (props: Record<string, unknown>, cont
171
181
  * unit semantics when replaying style values into proxy elements. */
172
182
  export declare const cssStyleValue: (key: string, value: unknown) => string;
173
183
  export declare const registerSubIslandMounter: (fn: (opts: MountIslandOptions) => Promise<IslandHandle>) => void;
184
+ export declare function mountIsland(opts: MountIslandOptions & {
185
+ boundary: IslandBoundary;
186
+ }): Promise<IslandBoundaryHandle>;
174
187
  export declare function mountIsland(opts: MountIslandOptions): Promise<IslandHandle>;
175
188
  export {};
@@ -142,6 +142,16 @@ export interface PolyWorkerRegistry {
142
142
  /** Name → island app registry. The shell's `mountIsland({ app: name })`
143
143
  * picks one per island. */
144
144
  apps: Record<string, IslandApp>;
145
+ /**
146
+ * Lazy app loader — consulted by `mount` when the requested registry name
147
+ * isn't present yet. Returning an app registers it under that name (so a
148
+ * second mount is a registry hit); returning `undefined` lets the mount
149
+ * fail as unknown. Federation hosts pass a remote-module importer here —
150
+ * see `@atolljs/federation`. When set, the single-registered-app
151
+ * name-blind fallback is DISABLED: a loader host resolves every mount by
152
+ * its exact registry key, never by "the only app I have".
153
+ */
154
+ loadApp?: (name: string) => IslandApp | undefined | Promise<IslandApp | undefined>;
145
155
  /**
146
156
  * The doorbell contract the worker exposes — defaults to the package's
147
157
  * `renderMemory`. Rarely needed: pass a different doorbell-SPEC instance
@@ -185,6 +195,13 @@ interface RenderedInstance extends InstanceBase {
185
195
  }
186
196
  /** A mounted instance — one per island worker in production. */
187
197
  export type Instance = ImperativeInstance | RenderedInstance;
198
+ /**
199
+ * Register an app into the module-level registry after the definer ran —
200
+ * the manual counterpart to `loadApp` (warm-up/preload outside a mount).
201
+ * Registers under `name` exactly; the mount-time name-blind fallback only
202
+ * applies to single-registry workers without a loader.
203
+ */
204
+ export declare const registerIslandApp: (name: string, app: IslandApp) => void;
188
205
  /**
189
206
  * PolyWorker — one worker script serving a REGISTRY of islands:
190
207
  * `mountIsland({ app: name })` picks one per island, and every island in
@@ -14,7 +14,7 @@
14
14
  * Framework-neutral by construction: this entry imports no framework — React's
15
15
  * reconciler adapter lives in `@atolljs/react-island/worker`.
16
16
  */
17
- export { definePolyWorker, defineMonoWorker } from './defineWorkers.js';
17
+ export { definePolyWorker, defineMonoWorker, registerIslandApp } from './defineWorkers.js';
18
18
  export type { ImperativeIslandApp, Instance, IslandApp, PolyWorkerRegistry, RenderContext, RenderedHandle, RenderedIslandApp, } from './defineWorkers.js';
19
19
  export { islandApp, islandAppNameOf } from '../app.js';
20
20
  export { defineIslandContract, isIslandContract, withContract, contractOf } from '../contract.js';
@@ -17,7 +17,7 @@ import {
17
17
  renderMemory,
18
18
  unmarshalCallbackProps,
19
19
  withContract
20
- } from "../chunk-TX4FAHRB.js";
20
+ } from "../chunk-P3G2GL3T.js";
21
21
 
22
22
  // src/worker/defineWorkers.ts
23
23
  import { defineWorker } from "@atolljs/core";
@@ -2321,7 +2321,7 @@ var isImperativeInstance = (r) => r.imperative !== void 0;
2321
2321
  var isRenderedInstance = (r) => r.rendered !== void 0;
2322
2322
  var mounts = /* @__PURE__ */ new Map();
2323
2323
  var APP_REGISTRY = /* @__PURE__ */ new Map();
2324
- var registerSingleApp = (name, app) => {
2324
+ var registerIslandApp = (name, app) => {
2325
2325
  APP_REGISTRY.set(name, app);
2326
2326
  };
2327
2327
  var resolveApp = (name) => APP_REGISTRY.get(name) ?? (APP_REGISTRY.size === 1 ? APP_REGISTRY.values().next().value : void 0);
@@ -2332,7 +2332,7 @@ var appNameOf = (instance) => {
2332
2332
  const tilde = name.lastIndexOf("~");
2333
2333
  return tilde === -1 ? name : name.slice(tilde + 1);
2334
2334
  };
2335
- function createIslandRuntime(sharedMemory = renderMemory) {
2335
+ function createIslandRuntime(sharedMemory = renderMemory, loadApp) {
2336
2336
  setDoorbellContract(sharedMemory);
2337
2337
  installInstanceDispatcher();
2338
2338
  const parseContractProps = (contract, key, props) => {
@@ -2423,7 +2423,17 @@ function createIslandRuntime(sharedMemory = renderMemory) {
2423
2423
  */
2424
2424
  async mount(instance, props = {}) {
2425
2425
  props = unmarshalCallbackProps(props, callbackFactory(instance));
2426
- const App = resolveApp(appNameOf(instance));
2426
+ const appName = appNameOf(instance);
2427
+ let App = APP_REGISTRY.get(appName);
2428
+ if (App === void 0 && loadApp !== void 0) {
2429
+ const loaded = await loadApp(appName);
2430
+ if (loaded !== void 0) {
2431
+ registerIslandApp(appName, loaded);
2432
+ App = loaded;
2433
+ }
2434
+ } else if (App === void 0) {
2435
+ App = resolveApp(appName);
2436
+ }
2427
2437
  if (App === void 0) {
2428
2438
  throw new Error(
2429
2439
  `mount: unknown app "${instance}" \u2014 registry has: ${[...APP_REGISTRY.keys()].join(", ")}`
@@ -2603,13 +2613,13 @@ function definePolyWorker(registry, options) {
2603
2613
  `[definePolyWorker] app registered as "${key}" but stamped "${stamped}" \u2014 component-reference mounts resolve "${stamped}" and will fail. Fix the key or the stamp.`
2604
2614
  );
2605
2615
  }
2606
- registerSingleApp(key, app);
2616
+ registerIslandApp(key, app);
2607
2617
  }
2608
- return createIslandRuntime(registry.sharedMemory ?? options?.sharedMemory);
2618
+ return createIslandRuntime(registry.sharedMemory ?? options?.sharedMemory, registry.loadApp);
2609
2619
  }
2610
2620
  function defineMonoWorker(app, options) {
2611
2621
  const stamp = app.islandAppName;
2612
- registerSingleApp(typeof stamp === "string" && stamp !== "" ? stamp : "main", app);
2622
+ registerIslandApp(typeof stamp === "string" && stamp !== "" ? stamp : "main", app);
2613
2623
  return createIslandRuntime(options?.sharedMemory);
2614
2624
  }
2615
2625
 
@@ -3147,6 +3157,7 @@ export {
3147
3157
  proxyInstanceStats,
3148
3158
  pushOp,
3149
3159
  registerHandler,
3160
+ registerIslandApp,
3150
3161
  renderMemory,
3151
3162
  runInInstance,
3152
3163
  serializeProps,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolljs/islands",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Atoll islands — MonoWorker/PolyWorker hosts rendering framework islands (React/Vue/Svelte/Solid/Angular) over a proxy-DOM op protocol",
5
5
  "homepage": "https://jwhenry3.github.io/atolljs/consumer/quickstart/",
6
6
  "type": "module",
@@ -29,7 +29,7 @@
29
29
  "htmlparser2": "^12.0.0"
30
30
  },
31
31
  "peerDependencies": {
32
- "@atolljs/core": "0.1.13"
32
+ "@atolljs/core": "^0.1.15"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Island error boundaries — a configurable safety net around every
3
+ * breakable point in a mount's lifecycle:
4
+ *
5
+ * mountIsland({
6
+ * worker, el, app,
7
+ * boundary: {
8
+ * loading: () => spinnerEl, // render fn per state
9
+ * error: ({ error, retry }) => errorEl, // …incl. the retry hook
10
+ * retry: 3, // auto-remount policy
11
+ * onError: (err, phase) => report(err, phase), // telemetry hook
12
+ * },
13
+ * })
14
+ *
15
+ * Breakable points covered:
16
+ * - MOUNT: worker entry fails to load, mountTimeout, contract prop
17
+ * rejection, federation `loadApp` failures (bad remote URL, unsatisfied
18
+ * `requires`) — the mount handshake rejects, the boundary renders the
19
+ * error state instead of propagating an unhandled rejection.
20
+ * - RUNTIME: the worker crashes mid-life. The client's onWorkerEvent
21
+ * 'error' transition detects it (in-flight tasks already rejected as
22
+ * WorkerCrashedError), the stale driver tears down, and the boundary
23
+ * enters the error state — `retry()`/auto-retry remounts into the
24
+ * respawned worker.
25
+ *
26
+ * Render functions are framework-free: they return `Node | string` (or an
27
+ * array), applied to the island's container element. Loading replaces the
28
+ * container's contents; the error state replaces them by default —
29
+ * `domOnError: 'preserve'` appends the fallback over the stale last frame
30
+ * instead (crash-overlay style).
31
+ *
32
+ * The returned handle is an IslandHandle plus `state`/`error`/`retry()` —
33
+ * it resolves after the FIRST attempt settles (mounted or error state),
34
+ * so callers never see an unhandled rejection, and a later retry() can
35
+ * still produce a working island under the same handle.
36
+ */
37
+ import { connectIslandWorker, mountIsland } from './island';
38
+ import type {
39
+ ConnectIslandWorkerConfig,
40
+ IslandClient,
41
+ IslandHandle,
42
+ Mode,
43
+ MountIslandOptions,
44
+ } from './island';
45
+
46
+ /** What a boundary render fn may return — applied via el.replaceChildren/append. */
47
+ export type BoundaryContent = Node | string | Array<Node | string> | null | undefined;
48
+
49
+ /** Where the failure happened — 'mount' (handshake), 'runtime' (worker crash). */
50
+ export type IslandBoundaryPhase = 'mount' | 'runtime';
51
+
52
+ export interface IslandBoundaryContext {
53
+ /** The mount's app key. */
54
+ app: string;
55
+ /** The island's container element — the boundary's render target. */
56
+ el: HTMLElement;
57
+ /** 1-based mount attempt counter — increments across retries. */
58
+ attempt: number;
59
+ }
60
+
61
+ export interface IslandBoundaryErrorContext extends IslandBoundaryContext {
62
+ /** The caught failure (mount rejection, crash error). */
63
+ error: unknown;
64
+ phase: IslandBoundaryPhase;
65
+ /** Manual remount — wire it to a retry button in the error render. */
66
+ retry(): void;
67
+ }
68
+
69
+ export interface IslandBoundary {
70
+ /** Rendered while the first mount attempt is in flight. */
71
+ loading?(ctx: IslandBoundaryContext): BoundaryContent;
72
+ /** Rendered when the island fails — mount rejection or runtime crash. */
73
+ error?(ctx: IslandBoundaryErrorContext): BoundaryContent;
74
+ /** Fires on every boundary-entering failure — logging/telemetry. */
75
+ onError?(error: unknown, phase: IslandBoundaryPhase): void;
76
+ /**
77
+ * Auto-remount policy: a number is the max retry count (delay grows
78
+ * ~250ms per attempt, capped at 2s); a function maps (attempt, error)
79
+ * to a delay in ms, `false`/`undefined` to stop. Omit for no auto-retry.
80
+ */
81
+ retry?: number | ((attempt: number, error: unknown) => number | false | undefined | void);
82
+ /**
83
+ * What the error state does to the container: 'clear' (default) replaces
84
+ * its contents with the fallback; 'preserve' appends the fallback over
85
+ * the stale last frame on runtime crashes (mount failures always clear —
86
+ * there is no frame to preserve).
87
+ */
88
+ domOnError?: 'clear' | 'preserve';
89
+ }
90
+
91
+ export type IslandBoundaryState = 'loading' | 'mounted' | 'error';
92
+
93
+ /**
94
+ * The boundary-aware handle: a normal IslandHandle when mounted, safe
95
+ * no-ops while failed (updateProps stashes into the next attempt's props),
96
+ * plus the boundary's own state.
97
+ */
98
+ export interface IslandBoundaryHandle extends IslandHandle {
99
+ readonly state: IslandBoundaryState;
100
+ /** The error that put the boundary in 'error' state (undefined otherwise). */
101
+ readonly error: unknown;
102
+ /** Remount now — same as ctx.retry() handed to the error render fn. */
103
+ retry(): void;
104
+ }
105
+
106
+ export async function mountIslandWithBoundary(
107
+ opts: MountIslandOptions & { boundary: IslandBoundary },
108
+ ): Promise<IslandBoundaryHandle> {
109
+ const {
110
+ boundary,
111
+ el: target,
112
+ client: givenClient,
113
+ worker,
114
+ app: givenApp,
115
+ props: givenProps,
116
+ mode: initialMode,
117
+ ...mountOpts
118
+ } = opts;
119
+ // The boundary owns the client when the caller used the `worker`
120
+ // shorthand — it needs the lifecycle subscription (and clientMounts
121
+ // accounting keeps last-island-destroy → terminate semantics identical).
122
+ const client: IslandClient =
123
+ givenClient ??
124
+ connectIslandWorker({
125
+ name: givenApp,
126
+ ...mountOpts,
127
+ worker,
128
+ doorbell: initialMode !== 'poll',
129
+ } as ConnectIslandWorkerConfig);
130
+ const app = givenApp ?? 'main';
131
+ // mountIsland only routes here for REAL mount targets — proxy elements
132
+ // take the nested path before the boundary check.
133
+ const el = target as HTMLElement;
134
+
135
+ let state: IslandBoundaryState = 'loading';
136
+ let lastError: unknown;
137
+ let current: IslandHandle | null = null;
138
+ let attempt = 0;
139
+ let destroyed = false;
140
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
141
+ /** Latest props — updateProps while failed stashes here for the remount. */
142
+ let pendingProps = givenProps;
143
+ let pendingMode: Mode | undefined = initialMode;
144
+
145
+ const setContent = (content: BoundaryContent, how: 'replace' | 'append'): void => {
146
+ if (content === null || content === undefined) {
147
+ if (how === 'replace') el.replaceChildren();
148
+ return;
149
+ }
150
+ const parts = (Array.isArray(content) ? content : [content]).filter(
151
+ (p): p is Node | string => p !== null && p !== undefined,
152
+ );
153
+ if (how === 'replace') el.replaceChildren(...parts);
154
+ else el.append(...parts);
155
+ };
156
+
157
+ const scheduleRetry = (error: unknown): void => {
158
+ const policy = boundary.retry;
159
+ if (policy === undefined || retryTimer !== null) return;
160
+ const delay =
161
+ typeof policy === 'number'
162
+ ? attempt <= policy
163
+ ? Math.min(250 * attempt, 2000)
164
+ : false
165
+ : policy(attempt, error);
166
+ if (delay === false || delay === undefined || typeof delay !== 'number') return;
167
+ retryTimer = setTimeout(() => {
168
+ retryTimer = null;
169
+ void tryMount();
170
+ }, Math.max(0, delay));
171
+ };
172
+
173
+ const fail = (error: unknown, phase: IslandBoundaryPhase): void => {
174
+ if (destroyed) return;
175
+ state = 'error';
176
+ lastError = error;
177
+ boundary.onError?.(error, phase);
178
+ const content = boundary.error?.({
179
+ app,
180
+ el,
181
+ attempt,
182
+ error,
183
+ phase,
184
+ retry: manualRetry,
185
+ });
186
+ // Runtime 'preserve' paints over the stale frame; everything else
187
+ // replaces (mount failures never produced a frame worth keeping).
188
+ if (boundary.domOnError === 'preserve' && phase === 'runtime') {
189
+ setContent(content, 'append');
190
+ } else {
191
+ setContent(content, 'replace');
192
+ }
193
+ scheduleRetry(error);
194
+ };
195
+
196
+ const tryMount = async (): Promise<void> => {
197
+ if (destroyed) return;
198
+ attempt++;
199
+ // The worker's mount batch assumes an empty container — stale frames,
200
+ // loading content, and error fallbacks all clear before it replays.
201
+ el.replaceChildren();
202
+ try {
203
+ const handle = await mountIsland({
204
+ ...mountOpts,
205
+ client,
206
+ el,
207
+ app: givenApp,
208
+ props: pendingProps,
209
+ mode: pendingMode,
210
+ } as MountIslandOptions);
211
+ if (destroyed) {
212
+ handle.destroy();
213
+ return;
214
+ }
215
+ current = handle;
216
+ state = 'mounted';
217
+ } catch (err) {
218
+ fail(err, 'mount');
219
+ }
220
+ };
221
+
222
+ const manualRetry = (): void => {
223
+ if (destroyed) return;
224
+ if (retryTimer !== null) {
225
+ clearTimeout(retryTimer);
226
+ retryTimer = null;
227
+ }
228
+ void tryMount();
229
+ };
230
+
231
+ // Worker-death detection: the client's lifecycle events fire on the
232
+ // runner's 'error' transition — every instance on this client just died.
233
+ // Shared clients notify each island's boundary individually.
234
+ const offLifecycle = client.onWorkerEvent?.((event) => {
235
+ if (destroyed || state === 'error' || event.type !== 'error') return;
236
+ const stale = current;
237
+ current = null;
238
+ // Tear the dead driver down — stops doorbell/poll/RO timers and
239
+ // releases instance state (its unmount task rejects, which destroy
240
+ // already swallows; a last-island terminate races the runner's own
241
+ // respawn harmlessly).
242
+ stale?.destroy();
243
+ fail(
244
+ new Error(
245
+ `[island ${app}] worker crashed` + (event.message !== undefined ? `: ${event.message}` : ''),
246
+ ),
247
+ 'runtime',
248
+ );
249
+ });
250
+
251
+ const handle: IslandBoundaryHandle = {
252
+ get app() {
253
+ return current?.app ?? app;
254
+ },
255
+ get pid() {
256
+ return current?.pid ?? 'unmounted';
257
+ },
258
+ get instance() {
259
+ return current?.instance ?? `${app}@?`;
260
+ },
261
+ get mode() {
262
+ return current?.mode ?? pendingMode ?? 'push';
263
+ },
264
+ get opsApplied() {
265
+ return current?.opsApplied ?? 0;
266
+ },
267
+ get flushCalls() {
268
+ return current?.flushCalls ?? 0;
269
+ },
270
+ get state() {
271
+ return state;
272
+ },
273
+ get error() {
274
+ return lastError;
275
+ },
276
+ retry: manualRetry,
277
+ setMode: (mode) => {
278
+ pendingMode = mode;
279
+ current?.setMode(mode);
280
+ },
281
+ updateProps: async (next) => {
282
+ pendingProps = next;
283
+ await current?.updateProps(next);
284
+ },
285
+ flush: async () => {
286
+ await current?.flush();
287
+ },
288
+ destroy: () => {
289
+ if (destroyed) return;
290
+ destroyed = true;
291
+ if (retryTimer !== null) {
292
+ clearTimeout(retryTimer);
293
+ retryTimer = null;
294
+ }
295
+ offLifecycle?.();
296
+ current?.destroy();
297
+ current = null;
298
+ },
299
+ };
300
+
301
+ setContent(boundary.loading?.({ app, el, attempt: 1 }), 'replace');
302
+ await tryMount();
303
+ return handle;
304
+ }
package/src/index.ts CHANGED
@@ -22,6 +22,17 @@ export type {
22
22
  MountIslandOptions,
23
23
  } from './island';
24
24
 
25
+ export { mountIslandWithBoundary } from './boundary';
26
+ export type {
27
+ BoundaryContent,
28
+ IslandBoundary,
29
+ IslandBoundaryContext,
30
+ IslandBoundaryErrorContext,
31
+ IslandBoundaryHandle,
32
+ IslandBoundaryPhase,
33
+ IslandBoundaryState,
34
+ } from './boundary';
35
+
25
36
  export { makeDoorbell } from './memory';
26
37
  export type { DoorbellSpec } from './memory';
27
38
 
package/src/island.ts CHANGED
@@ -44,6 +44,8 @@ import {
44
44
  import { marshalCallbackProps, CALLBACK_EVENT } from './callbackProps';
45
45
  import { trackIslandForDevtools } from './devtoolsCommands';
46
46
  import { propsPreview } from './devtoolsPreview';
47
+ import { mountIslandWithBoundary } from './boundary';
48
+ import type { IslandBoundary, IslandBoundaryHandle } from './boundary';
47
49
  import type { ProxyElement } from './worker/dom/element';
48
50
 
49
51
  export type Mode = 'push' | 'poll';
@@ -189,6 +191,15 @@ interface MountIslandBaseOptions {
189
191
  * client warm for this island's teardown.
190
192
  */
191
193
  keepAlive?: boolean;
194
+ /**
195
+ * Error boundary — `loading`/`error` state render functions plus a retry
196
+ * policy, covering mount rejection, mountTimeout, and runtime worker
197
+ * crashes (detected via the client's lifecycle events). When set, the
198
+ * mount resolves to an `IslandBoundaryHandle` after the first attempt
199
+ * settles — errors render the fallback instead of rejecting. Ignored on
200
+ * nested (proxy-element) mounts. See boundary.ts / docs error-boundaries.
201
+ */
202
+ boundary?: IslandBoundary;
192
203
  }
193
204
 
194
205
  /**
@@ -471,10 +482,15 @@ export const registerSubIslandMounter = (
471
482
  const isProxyMountTarget = (el: unknown): el is ProxyElement =>
472
483
  typeof (el as { doc?: { instance?: unknown } } | null)?.doc?.instance === 'string';
473
484
 
485
+ export function mountIsland(
486
+ opts: MountIslandOptions & { boundary: IslandBoundary },
487
+ ): Promise<IslandBoundaryHandle>;
488
+ export function mountIsland(opts: MountIslandOptions): Promise<IslandHandle>;
474
489
  export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandle> {
475
490
  const {
476
491
  el: target, onEvent, onActivity, onOps, slots, mode: initialMode, mountTimeout,
477
492
  client: givenClient, worker, app: givenApp, props: givenProps, framework,
493
+ boundary,
478
494
  ...workerConfig
479
495
  } = opts;
480
496
  if (isProxyMountTarget(target)) {
@@ -489,6 +505,12 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
489
505
  // Real DOM from here on (the proxy path delegated above) — the declared
490
506
  // type keeps closures honest; the guard did the proving.
491
507
  const el = target as HTMLElement;
508
+ if (boundary !== undefined) {
509
+ // Error-boundary path — mount/handshake failures and runtime worker
510
+ // crashes render state fallbacks instead of rejecting; the returned
511
+ // handle wraps the eventual island. Nested mounts ignore the option.
512
+ return mountIslandWithBoundary(opts as MountIslandOptions & { boundary: IslandBoundary });
513
+ }
492
514
  // Shell→worker callables: callbackProp() markers marshal to {__cb:id}
493
515
  // wire handles; the island's table keeps the real functions and the
494
516
  // driver's emit-case routes worker-side invocations back here.
@@ -170,6 +170,16 @@ export interface PolyWorkerRegistry {
170
170
  /** Name → island app registry. The shell's `mountIsland({ app: name })`
171
171
  * picks one per island. */
172
172
  apps: Record<string, IslandApp>;
173
+ /**
174
+ * Lazy app loader — consulted by `mount` when the requested registry name
175
+ * isn't present yet. Returning an app registers it under that name (so a
176
+ * second mount is a registry hit); returning `undefined` lets the mount
177
+ * fail as unknown. Federation hosts pass a remote-module importer here —
178
+ * see `@atolljs/federation`. When set, the single-registered-app
179
+ * name-blind fallback is DISABLED: a loader host resolves every mount by
180
+ * its exact registry key, never by "the only app I have".
181
+ */
182
+ loadApp?: (name: string) => IslandApp | undefined | Promise<IslandApp | undefined>;
173
183
  /**
174
184
  * The doorbell contract the worker exposes — defaults to the package's
175
185
  * `renderMemory`. Rarely needed: pass a different doorbell-SPEC instance
@@ -259,7 +269,13 @@ const mounts = new Map<string, Instance>();
259
269
  */
260
270
  const APP_REGISTRY = new Map<string, IslandApp>();
261
271
 
262
- const registerSingleApp = (name: string, app: IslandApp): void => {
272
+ /**
273
+ * Register an app into the module-level registry after the definer ran —
274
+ * the manual counterpart to `loadApp` (warm-up/preload outside a mount).
275
+ * Registers under `name` exactly; the mount-time name-blind fallback only
276
+ * applies to single-registry workers without a loader.
277
+ */
278
+ export const registerIslandApp = (name: string, app: IslandApp): void => {
263
279
  APP_REGISTRY.set(name, app);
264
280
  };
265
281
 
@@ -292,6 +308,7 @@ const appNameOf = (instance: string): string => {
292
308
  */
293
309
  function createIslandRuntime(
294
310
  sharedMemory: SharedMemory<DoorbellSpec> = renderMemory,
311
+ loadApp?: PolyWorkerRegistry['loadApp'],
295
312
  ): WorkerDefinition<DoorbellSpec, IslandWorkerMethods> {
296
313
  // Point bumpOpsVersion at the declared contract (it's the same object as
297
314
  // renderMemory unless a custom doorbell instance was passed).
@@ -435,7 +452,20 @@ function createIslandRuntime(
435
452
  // marshalled function (fire-and-forget, doorbell-rung for out-of-
436
453
  // task callers like timers/promise continuations).
437
454
  props = unmarshalCallbackProps(props, callbackFactory(instance)) as Record<string, unknown>;
438
- const App = resolveApp(appNameOf(instance));
455
+ const appName = appNameOf(instance);
456
+ let App = APP_REGISTRY.get(appName);
457
+ if (App === undefined && loadApp !== undefined) {
458
+ // Lazy registration — the loader imports a remote module (federation)
459
+ // or otherwise supplies the app; a defined result registers under
460
+ // the requested name so subsequent mounts resolve directly.
461
+ const loaded = await loadApp(appName);
462
+ if (loaded !== undefined) {
463
+ registerIslandApp(appName, loaded);
464
+ App = loaded;
465
+ }
466
+ } else if (App === undefined) {
467
+ App = resolveApp(appName); // sole-app fallback (no loader present)
468
+ }
439
469
  if (App === undefined) {
440
470
  throw new Error(
441
471
  `mount: unknown app "${instance}" — registry has: ${[...APP_REGISTRY.keys()].join(', ')}`,
@@ -684,9 +714,9 @@ export function definePolyWorker(
684
714
  `component-reference mounts resolve "${stamped}" and will fail. Fix the key or the stamp.`,
685
715
  );
686
716
  }
687
- registerSingleApp(key, app);
717
+ registerIslandApp(key, app);
688
718
  }
689
- return createIslandRuntime(registry.sharedMemory ?? options?.sharedMemory);
719
+ return createIslandRuntime(registry.sharedMemory ?? options?.sharedMemory, registry.loadApp);
690
720
  }
691
721
 
692
722
  /**
@@ -700,6 +730,6 @@ export function defineMonoWorker(
700
730
  options?: { sharedMemory?: SharedMemory<DoorbellSpec> },
701
731
  ): WorkerDefinition<DoorbellSpec, IslandWorkerMethods> {
702
732
  const stamp = (app as { islandAppName?: unknown }).islandAppName;
703
- registerSingleApp(typeof stamp === 'string' && stamp !== '' ? stamp : 'main', app);
733
+ registerIslandApp(typeof stamp === 'string' && stamp !== '' ? stamp : 'main', app);
704
734
  return createIslandRuntime(options?.sharedMemory);
705
735
  }
@@ -14,7 +14,7 @@
14
14
  * Framework-neutral by construction: this entry imports no framework — React's
15
15
  * reconciler adapter lives in `@atolljs/react-island/worker`.
16
16
  */
17
- export { definePolyWorker, defineMonoWorker } from './defineWorkers';
17
+ export { definePolyWorker, defineMonoWorker, registerIslandApp } from './defineWorkers';
18
18
  export type {
19
19
  ImperativeIslandApp,
20
20
  Instance,