@braidlabs/core 0.6.0 → 0.7.1

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.
Files changed (75) hide show
  1. package/package.json +1 -1
  2. package/src/adapters/adapter.d.ts +11 -0
  3. package/src/adapters/adapter.js.map +1 -1
  4. package/src/adapters/compat-adapter.js +2 -2
  5. package/src/adapters/compat-adapter.js.map +1 -1
  6. package/src/compat/born-inert-scripts.d.ts +2 -0
  7. package/src/compat/born-inert-scripts.js +39 -0
  8. package/src/compat/born-inert-scripts.js.map +1 -1
  9. package/src/compat/cookie-partition.d.ts +25 -0
  10. package/src/compat/cookie-partition.js +79 -0
  11. package/src/compat/cookie-partition.js.map +1 -0
  12. package/src/compat/document-member-classification.js +3 -2
  13. package/src/compat/document-member-classification.js.map +1 -1
  14. package/src/compat/document-overrides.d.ts +6 -6
  15. package/src/compat/document-overrides.js +23 -0
  16. package/src/compat/document-overrides.js.map +1 -1
  17. package/src/compat/fragment-boundary.d.ts +5 -0
  18. package/src/compat/fragment-boundary.js +291 -36
  19. package/src/compat/fragment-boundary.js.map +1 -1
  20. package/src/compat/html-policy.d.ts +50 -0
  21. package/src/compat/html-policy.js +155 -0
  22. package/src/compat/html-policy.js.map +1 -0
  23. package/src/compat/realm-patches.d.ts +2 -1
  24. package/src/compat/realm-patches.js +117 -3
  25. package/src/compat/realm-patches.js.map +1 -1
  26. package/src/compat/script-execution.d.ts +24 -0
  27. package/src/compat/script-execution.js +177 -10
  28. package/src/compat/script-execution.js.map +1 -1
  29. package/src/compat/storage-partition.d.ts +47 -0
  30. package/src/compat/storage-partition.js +291 -0
  31. package/src/compat/storage-partition.js.map +1 -0
  32. package/src/compat/subresource-urls.d.ts +41 -0
  33. package/src/compat/subresource-urls.js +133 -0
  34. package/src/compat/subresource-urls.js.map +1 -0
  35. package/src/config.d.ts +28 -0
  36. package/src/config.js +24 -0
  37. package/src/config.js.map +1 -1
  38. package/src/elements/boot-gate.d.ts +47 -0
  39. package/src/elements/boot-gate.js +207 -0
  40. package/src/elements/boot-gate.js.map +1 -0
  41. package/src/elements/fragment-slot.d.ts +62 -15
  42. package/src/elements/fragment-slot.js +388 -26
  43. package/src/elements/fragment-slot.js.map +1 -1
  44. package/src/elements/late-content.d.ts +44 -0
  45. package/src/elements/late-content.js +107 -0
  46. package/src/elements/late-content.js.map +1 -0
  47. package/src/elements/prefetch.d.ts +27 -0
  48. package/src/elements/prefetch.js +100 -0
  49. package/src/elements/prefetch.js.map +1 -0
  50. package/src/elements/prerequisites.d.ts +42 -0
  51. package/src/elements/prerequisites.js +133 -0
  52. package/src/elements/prerequisites.js.map +1 -0
  53. package/src/elements/recovery.d.ts +46 -0
  54. package/src/elements/recovery.js +59 -0
  55. package/src/elements/recovery.js.map +1 -0
  56. package/src/env/create-env.js +2 -1
  57. package/src/env/create-env.js.map +1 -1
  58. package/src/env/fragment-env.d.ts +6 -0
  59. package/src/errors.d.ts +4 -1
  60. package/src/errors.js +2 -0
  61. package/src/errors.js.map +1 -1
  62. package/src/index.d.ts +5 -1
  63. package/src/index.js +11 -1
  64. package/src/index.js.map +1 -1
  65. package/src/realm/realm-manager.js +23 -0
  66. package/src/realm/realm-manager.js.map +1 -1
  67. package/src/weave/capabilities.d.ts +35 -7
  68. package/src/weave/capabilities.js +17 -0
  69. package/src/weave/capabilities.js.map +1 -1
  70. package/src/weave/guest.d.ts +2 -0
  71. package/src/weave/guest.js +2 -1
  72. package/src/weave/guest.js.map +1 -1
  73. package/src/weave/messages.d.ts +24 -0
  74. package/src/weave/messages.js +22 -0
  75. package/src/weave/messages.js.map +1 -1
@@ -13,17 +13,61 @@ import { createSameRealmBackingPair } from '../weave/same-realm-backing.js';
13
13
  import { weaveId } from '../weave/envelope.js';
14
14
  import { performHostHandshake } from '../weave/handshake.js';
15
15
  import { attachContextRouter } from '../weave/context-bridge.js';
16
- import { FRAGMENT_EVENT, PROPS_CHANGED } from '../weave/messages.js';
16
+ import { BRAID_FRAGMENT_REPORTER, FRAGMENT_EVENT, PROPS_CHANGED, SETTLED } from '../weave/messages.js';
17
17
  import { startFragmentBeats, startHostLiveness } from '../weave/liveness.js';
18
18
  import { DIRTY, closeAndDispose } from '../weave/closing.js';
19
19
  import { negotiateContract } from '../weave/contract.js';
20
20
  import { createSandboxRealm } from '../realm/sandbox-realm.js';
21
21
  import { createPortBacking } from '../weave/port-backing.js';
22
- import { readCapabilities, resolveCapabilities, resolveSharedGlobals, } from '../weave/capabilities.js';
22
+ import { readCapabilities, resolveCapabilities, resolveSharedGlobals, resolveStoragePartition, } from '../weave/capabilities.js';
23
+ import { awaitPrerequisites, NotEntitledError, parseRequires } from './prerequisites.js';
24
+ import { createBootGate, parseBootMode } from './boot-gate.js';
25
+ import { DEFERRED_ATTRIBUTE, lateContentArrival } from './late-content.js';
26
+ import { startPrefetch, takePrefetchedDocument } from './prefetch.js';
27
+ import { createRecovery } from './recovery.js';
28
+ import { resolveHtmlPolicy } from '../compat/html-policy.js';
29
+ /**
30
+ * What the slot reports about its fragment.
31
+ *
32
+ * The original four are all still here and still mean what they meant — `ready` in particular is
33
+ * kept as the name for "mounted, first beat not yet observed", which the liveness machine calls
34
+ * `connecting`. Renaming it would have broken every host checking `slot.state === 'ready'` in
35
+ * exchange for a word.
36
+ *
37
+ * The new three are the ones `ready` used to have to cover on its own:
38
+ *
39
+ * - `healthy` — beating.
40
+ * - `unobservable` — the tab is hidden, so the host has stopped being able to tell. Not a fault.
41
+ * - `suspect` → `gone` — beats stopped while the fragment should have been running.
42
+ */
43
+ /** How long after `ready` a slot waits for its fragment to report settled before saying so itself. */
44
+ const DEFAULT_SETTLE_TIMEOUT_MS = 10_000;
45
+ /** The longest delay `setTimeout` honors; anything longer fires at once. */
46
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
47
+ /** The `settle-timeout` attribute as a delay: the default when absent, empty or not a number. */
48
+ export function settleTimeoutMs(attribute) {
49
+ const value = attribute?.trim() ? Number(attribute) : NaN;
50
+ return Number.isFinite(value) && value >= 0 ? Math.min(value, MAX_TIMER_DELAY_MS) : DEFAULT_SETTLE_TIMEOUT_MS;
51
+ }
23
52
  /** `connecting` is reported as `ready`; every other liveness state carries its own name. */
24
53
  export function slotStateFor(liveness) {
25
54
  return liveness === 'connecting' ? 'ready' : liveness;
26
55
  }
56
+ /**
57
+ * Whether a slot sits in another fragment's DOM: some shadow root above it belongs to a slot. A
58
+ * host's own components may keep slots in their shadow roots; those are the host's.
59
+ */
60
+ export function insideAnotherFragment(slot) {
61
+ // the native method: a slot a compat fragment wrote is stamped, and its own getRootNode() answers
62
+ // with the fragment's virtual document instead of the shadow root it is really in
63
+ const rootOf = (node) => nativeGetRootNode.call(node);
64
+ for (let root = rootOf(slot); root instanceof ShadowRoot; root = rootOf(root.host)) {
65
+ if (root.host.localName === 'fragment-slot')
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+ const nativeGetRootNode = Node.prototype.getRootNode;
27
71
  /**
28
72
  * Finds server-rendered fragment content the gateway pierced into a slot.
29
73
  *
@@ -52,7 +96,26 @@ export function findPiercedContentRoot(shadowRoot) {
52
96
  export const SLOT_STYLES = ':host, braid-document, braid-html, braid-body { display: block; } braid-head { display: none; }' +
53
97
  'slot[name="fallback"] { display: none; }' +
54
98
  ':host([state="gone"]) braid-document, :host([state="error"]) braid-document { display: none; }' +
55
- ':host([state="gone"]) slot[name="fallback"], :host([state="error"]) slot[name="fallback"] { display: block; }';
99
+ ':host([state="gone"]) slot[name="fallback"], :host([state="error"]) slot[name="fallback"] { display: block; }' +
100
+ // A host's `slot="loading"` child shows while the fragment boots and nothing of it is on screen
101
+ // yet — never for a pierced slot, whose root has no loading slot. After --braid-loading-delay,
102
+ // so a fast boot never flashes it: a zero-length animation that holds it hidden until then.
103
+ 'slot[name="loading"] { display: none; }' +
104
+ ':host([state="loading"]) braid-document:empty ~ slot[name="loading"] {' +
105
+ ' display: block; animation: braid-loading-in 0s linear var(--braid-loading-delay, 200ms) backwards; }' +
106
+ '@keyframes braid-loading-in { from, to { visibility: hidden; } }';
107
+ /**
108
+ * Adds the slot a host's `slot="loading"` child projects into. Only on roots the client builds: a
109
+ * pierced fragment already has its content on screen. (After `reload()` a pierced slot's root is
110
+ * rebuilt by the client, and can show its loading state like any other.)
111
+ */
112
+ export function ensureLoadingSlot(shadowRoot) {
113
+ if (shadowRoot.querySelector('slot[name="loading"]'))
114
+ return;
115
+ const loading = document.createElement('slot');
116
+ loading.name = 'loading';
117
+ shadowRoot.append(loading);
118
+ }
56
119
  /**
57
120
  * Ensures the shadow root can present host-provided fallback content:
58
121
  *
@@ -88,9 +151,14 @@ export function ensureFallbackSlot(shadowRoot) {
88
151
  * - `src` — optional route url for the fragment. When absent, the fragment is *bound*: it
89
152
  * follows the host page's location and participates in host navigation.
90
153
  * - `props` — JSON attribute (or the `props` property) passed to the fragment instance.
154
+ * - `boot` — `eager` (default), `idle`, `visible`, or `interaction`: when to boot. Read when the
155
+ * slot connects, or when a deferred slot's late markup arrives. See `boot-gate.ts`.
156
+ *
157
+ * - `settle-timeout` — milliseconds after `ready` before the slot reports `settled` on its own, for a
158
+ * fragment that never says it has (default 10000). See {@link FragmentSlot.settled}.
91
159
  *
92
160
  * Events: `braid:ready`, `braid:error` (detail includes stage + fix hint), `braid:event`
93
- * (fragment → host).
161
+ * (fragment → host), `braid:settled` (detail: `{ fragmentId, timedOut }`).
94
162
  *
95
163
  * The slot uses only its own shadow root and its own elements — the host page is never patched.
96
164
  */
@@ -99,6 +167,8 @@ export class FragmentSlot extends HTMLElement {
99
167
  return ['name', 'src', 'props'];
100
168
  }
101
169
  #state = 'idle';
170
+ #settled = false;
171
+ #settleTimer;
102
172
  #props = {};
103
173
  /** Host end of this instance's boundary channel; undefined between teardown and the next boot. */
104
174
  #channel;
@@ -118,11 +188,55 @@ export class FragmentSlot extends HTMLElement {
118
188
  #dirty = null;
119
189
  #booted = false;
120
190
  #bootScheduled = false;
191
+ /**
192
+ * A deferred boot's gate and the listeners that replay or release it; undefined for an eager slot
193
+ * and once the slot leaves the DOM. Kept after the trigger fires, for the replay.
194
+ */
195
+ #bootGate;
196
+ /** Which scheduled boot is current: a disconnect or reload makes an older one's trigger moot. */
197
+ #scheduleGeneration = 0;
198
+ /** Reboots a fragment that failed for a fixable reason, when the host opted in. */
199
+ #recovery = createRecovery({
200
+ policy: () => getBraidConfig().recover,
201
+ announce: (event) => this.dispatchEvent(new CustomEvent('braid:recovering', { detail: { fragmentId: this.name, ...event } })),
202
+ // only if it still needs it: a host may have reloaded it, or it may have come back on its own
203
+ reboot: () => {
204
+ if (this.isConnected && (this.#state === 'error' || this.#state === 'gone')) {
205
+ this.#reload().catch((error) => console.error(`[braid:${this.name}] recovery failed`, error));
206
+ }
207
+ },
208
+ });
121
209
  /** Set by reload(): a reload must re-fetch, never re-adopt the pierced content. */
122
210
  #forceFetch = false;
211
+ /** Which boot is current: a teardown or a newer boot makes an older one's outcome moot. */
212
+ #bootGeneration = 0;
123
213
  get state() {
124
214
  return this.#state;
125
215
  }
216
+ /**
217
+ * The slot's own `aria-busy` before {@link #markBusy} set it — `null` for none — or `undefined`
218
+ * when the attribute is not the slot's doing, so whatever the host wrote is left as it is.
219
+ */
220
+ #busyRestore = undefined;
221
+ /**
222
+ * Busy while a host-provided loading state may be showing: a client-built root with a
223
+ * `slot="loading"` child, until first paint or the slot leaves `loading`.
224
+ */
225
+ #markBusy() {
226
+ if (this.#busyRestore !== undefined || !this.querySelector(':scope > [slot="loading"]'))
227
+ return;
228
+ this.#busyRestore = this.getAttribute('aria-busy');
229
+ this.setAttribute('aria-busy', 'true');
230
+ }
231
+ #clearBusy() {
232
+ if (this.#busyRestore === undefined)
233
+ return;
234
+ if (this.#busyRestore === null)
235
+ this.removeAttribute('aria-busy');
236
+ else
237
+ this.setAttribute('aria-busy', this.#busyRestore);
238
+ this.#busyRestore = undefined;
239
+ }
126
240
  /**
127
241
  * Sets the state, reflects it to the `state` attribute, and announces the transition.
128
242
  *
@@ -136,11 +250,81 @@ export class FragmentSlot extends HTMLElement {
136
250
  const previous = this.#state;
137
251
  this.#state = next;
138
252
  this.setAttribute('state', next);
253
+ if (next !== 'loading')
254
+ this.#clearBusy();
255
+ // a new boot, or none, has not settled; a failed or closing one never will
256
+ if (next === 'loading' || next === 'idle')
257
+ this.#resetSettled();
258
+ else if (next === 'error' || next === 'closing')
259
+ this.#cancelSettleFallback();
139
260
  this.dispatchEvent(new CustomEvent('braid:state', { detail: { state: next, previous, fragmentId: this.name } }));
261
+ // not over unsaved work: a fragment that declared some may yet come back with it
262
+ if (next === 'gone' && this.#dirty === null)
263
+ this.#recovery.onGone();
140
264
  }
141
265
  get name() {
142
266
  return this.getAttribute('name') ?? '';
143
267
  }
268
+ /**
269
+ * Whether the fragment's first content has settled, reflected as the `settled` attribute.
270
+ *
271
+ * Not a slot state, because it answers a different question. `healthy` says the fragment's realm
272
+ * is running, which is true from its first beat — while the application inside may still be
273
+ * showing its own spinner. `settled` says the fragment has finished its own initial loading,
274
+ * which only the fragment can know: it reports it (`env.settled()`, `provideBraidFragment()` in
275
+ * Angular), and a host keeps its skeleton up until then. Not through `slot="loading"`, which is
276
+ * the slot's own boot-time state and gives way at first paint, but as a sibling the host styles:
277
+ *
278
+ * ```html
279
+ * <fragment-slot name="contact"></fragment-slot><div class="skeleton" hidden></div>
280
+ * ```
281
+ * ```css
282
+ * fragment-slot:not([settled], [state="error"]) + .skeleton { display: block; }
283
+ * ```
284
+ *
285
+ * A fragment that never reports is settled anyway `settle-timeout` milliseconds after `ready`, with
286
+ * `timedOut: true` on the event, so a skeleton cannot stick forever. One that fails never settles —
287
+ * it has nothing to show — so a skeleton keyed on `settled` gives way to `[state="error"]`, as above.
288
+ */
289
+ get settled() {
290
+ return this.#settled;
291
+ }
292
+ #listenForSettled(channel, fragmentId, signal) {
293
+ // a boot that has been torn down speaks for nothing: a newer one may already be on screen
294
+ channel.on(SETTLED, () => {
295
+ if (!signal.aborted)
296
+ this.#markSettled(fragmentId, false);
297
+ });
298
+ }
299
+ #armSettleFallback(fragmentId, signal) {
300
+ if (this.#settled)
301
+ return;
302
+ this.#cancelSettleFallback();
303
+ this.#settleTimer = setTimeout(() => {
304
+ this.#settleTimer = undefined;
305
+ if (!signal.aborted)
306
+ this.#markSettled(fragmentId, true);
307
+ }, settleTimeoutMs(this.getAttribute('settle-timeout')));
308
+ }
309
+ #markSettled(fragmentId, timedOut) {
310
+ if (this.#settled)
311
+ return;
312
+ this.#cancelSettleFallback();
313
+ this.#settled = true;
314
+ this.setAttribute('settled', '');
315
+ this.dispatchEvent(new CustomEvent('braid:settled', { detail: { fragmentId, timedOut } }));
316
+ }
317
+ #cancelSettleFallback() {
318
+ if (this.#settleTimer === undefined)
319
+ return;
320
+ clearTimeout(this.#settleTimer);
321
+ this.#settleTimer = undefined;
322
+ }
323
+ #resetSettled() {
324
+ this.#cancelSettleFallback();
325
+ this.#settled = false;
326
+ this.removeAttribute('settled');
327
+ }
144
328
  /**
145
329
  * The fragment's unsaved-work declaration, or null.
146
330
  *
@@ -180,14 +364,7 @@ export class FragmentSlot extends HTMLElement {
180
364
  void this.reload();
181
365
  }
182
366
  else if (this.isConnected && !this.#booted && !this.#bootScheduled) {
183
- this.#bootScheduled = true;
184
- queueMicrotask(() => {
185
- this.#bootScheduled = false;
186
- if (this.isConnected && !this.#booted) {
187
- this.#booted = true;
188
- void this.#boot();
189
- }
190
- });
367
+ this.#scheduleBoot();
191
368
  }
192
369
  }
193
370
  }
@@ -201,25 +378,109 @@ export class FragmentSlot extends HTMLElement {
201
378
  if (!this.name && (this.hasAttribute('outlet') || this.hasAttribute('data-braid-in-outlet'))) {
202
379
  return;
203
380
  }
381
+ this.#scheduleBoot();
382
+ }
383
+ /**
384
+ * Boots once the slot's `boot` trigger fires — on the next microtask for an eager slot.
385
+ *
386
+ * The microtask is what lets a host set `props` or attributes in the same task that inserted the
387
+ * slot; a deferred slot waits longer still, and boots with whatever name it has by then.
388
+ */
389
+ #scheduleBoot() {
204
390
  this.#bootScheduled = true;
205
- queueMicrotask(() => {
391
+ const schedule = ++this.#scheduleGeneration;
392
+ const current = () => schedule === this.#scheduleGeneration && this.isConnected && !this.#booted;
393
+ // A slot the gateway streamed past gets its markup at the end of the document; until then it
394
+ // cannot know whether it is pierced, which is what every boot mode decides on.
395
+ if (this.hasAttribute(DEFERRED_ATTRIBUTE)) {
396
+ void lateContentArrival(this).then(() => {
397
+ if (current())
398
+ this.#gateBoot(current);
399
+ });
400
+ return;
401
+ }
402
+ this.#gateBoot(current);
403
+ }
404
+ #gateBoot(current) {
405
+ const start = () => {
206
406
  this.#bootScheduled = false;
207
- if (!this.isConnected || this.#booted) {
407
+ if (!current())
208
408
  return;
209
- }
210
409
  this.#booted = true;
211
410
  void this.#boot();
411
+ };
412
+ const mode = parseBootMode(this.getAttribute('boot'), this.name);
413
+ if (mode === 'eager') {
414
+ queueMicrotask(start);
415
+ return;
416
+ }
417
+ this.#releaseBootGate();
418
+ const gate = createBootGate(this, mode, {
419
+ pierced: findPiercedContentRoot(this.shadowRoot) !== null,
420
+ fragmentId: this.name,
212
421
  });
422
+ const listening = new AbortController();
423
+ this.#bootGate = { gate, listening };
424
+ // Replayed once the fragment can handle what was held. A fragment that fails, or is gone, never
425
+ // will: the gate lets go, so its content and the host's fallback are clickable again.
426
+ this.addEventListener('braid:ready', () => setTimeout(() => gate.replay(), 0), { once: true, signal: listening.signal });
427
+ this.addEventListener('braid:state', (event) => {
428
+ const { state } = event.detail;
429
+ if (state === 'error' || state === 'gone')
430
+ this.#releaseBootGate();
431
+ }, { signal: listening.signal });
432
+ void gate.triggered.then(start);
433
+ }
434
+ /** Drops the slot's boot gate, its held actions, and the listeners waiting to replay them. */
435
+ #releaseBootGate() {
436
+ this.#bootGate?.gate.dispose();
437
+ this.#bootGate?.listening.abort();
438
+ this.#bootGate = undefined;
213
439
  }
214
440
  disconnectedCallback() {
215
441
  this.#bootScheduled = false;
442
+ this.#scheduleGeneration++;
443
+ this.#recovery.reset();
444
+ this.#releaseBootGate();
216
445
  // Not awaited, because `disconnectedCallback` cannot be. The close runs on after the element
217
446
  // has left the DOM — which is exactly the window a flush needs, and the reason the fragment's
218
447
  // realm is not disposed until the negotiation finishes.
219
448
  void this.#teardown('unmount');
220
449
  this.#booted = false;
221
450
  }
451
+ /**
452
+ * Tears down and boots afresh. A host's reload also restores the recovery budget and cancels a
453
+ * scheduled recovery reboot: the host has taken over.
454
+ */
455
+ /**
456
+ * A slot's `entry`, or its untrusted `src`, names the code a fragment runs — so only the host may
457
+ * give one. A slot inside another fragment's DOM was written by that fragment, perhaps from data it
458
+ * rendered, and may compose registered fragments, whose code the registry names, but not bring
459
+ * its own. An untrusted slot would also be handed whatever capabilities the host granted its name.
460
+ */
461
+ #assertHostDeclaresCode(fragmentId) {
462
+ const bringsCode = this.hasAttribute('entry') || this.getAttribute('trust') === 'untrusted';
463
+ if (!bringsCode || !insideAnotherFragment(this))
464
+ return;
465
+ throw new BraidError(`a <fragment-slot> inside another fragment may not declare an entry or an untrusted src`, {
466
+ fragmentId,
467
+ stage: 'slot-config',
468
+ fixHint: `register fragment "${fragmentId}" in the gateway and compose it by name, or move the slot into ` +
469
+ `the host page's own DOM`,
470
+ });
471
+ }
222
472
  async reload() {
473
+ this.#recovery.reset();
474
+ await this.#reload();
475
+ }
476
+ async #reload() {
477
+ // A reload boots now, whatever the slot was waiting for: its trigger, or late markup, which
478
+ // would otherwise arrive later and replace the content this boot fetched.
479
+ this.#scheduleGeneration++;
480
+ this.#bootScheduled = false;
481
+ this.#releaseBootGate();
482
+ this.removeAttribute(DEFERRED_ATTRIBUTE);
483
+ this.#booted = true;
223
484
  await this.#teardown('reload');
224
485
  this.#forceFetch = true;
225
486
  await this.#boot();
@@ -236,6 +497,7 @@ export class FragmentSlot extends HTMLElement {
236
497
  this.#liveness = undefined;
237
498
  this.#channel = undefined;
238
499
  this.#abortController = undefined;
500
+ this.#bootGeneration++;
239
501
  // Nothing mounted: the pre-Weave synchronous path, still the right one for an idle slot.
240
502
  if (!channel || !abortController) {
241
503
  abortController?.abort();
@@ -286,6 +548,7 @@ export class FragmentSlot extends HTMLElement {
286
548
  // A close still negotiating owns the shadow root until it finishes. Booting into it first
287
549
  // would render content that the completing close would then clear.
288
550
  await this.#closing;
551
+ const generation = ++this.#bootGeneration;
289
552
  try {
290
553
  if (!fragmentId) {
291
554
  /**
@@ -307,13 +570,17 @@ export class FragmentSlot extends HTMLElement {
307
570
  'capability grants — it is not looked up in a registry on this tier',
308
571
  });
309
572
  }
573
+ this.#assertHostDeclaresCode(fragmentId);
310
574
  this.#setState('loading');
575
+ // Prerequisites the host's markup declares, on every tier: a gate that silently did not apply
576
+ // to some fragments would be worse than none.
577
+ const markupRequires = parseRequires(this.getAttribute('requires'));
311
578
  if (this.getAttribute('trust') === 'untrusted') {
312
- await this.#bootUntrusted(fragmentId);
579
+ await this.#bootUntrusted(fragmentId, markupRequires);
313
580
  return;
314
581
  }
315
582
  if (this.getAttribute('adapter') === 'contract') {
316
- await this.#bootContract(fragmentId);
583
+ await this.#bootContract(fragmentId, markupRequires);
317
584
  return;
318
585
  }
319
586
  const abortController = new AbortController();
@@ -346,6 +613,18 @@ export class FragmentSlot extends HTMLElement {
346
613
  shadowRoot.replaceChildren(styleSheet, contentRoot);
347
614
  }
348
615
  ensureFallbackSlot(shadowRoot);
616
+ if (!piercedContentRoot) {
617
+ ensureLoadingSlot(shadowRoot);
618
+ this.#markBusy();
619
+ }
620
+ // Prerequisites the host's markup declares start now, alongside the realm and the document;
621
+ // the manifest's arrive with the realm stub. Either way the fragment's code waits for them.
622
+ const earlyPrerequisites = markupRequires.length ? awaitPrerequisites(markupRequires, fragmentId, signal) : null;
623
+ // awaited below; until then a failure must not surface as an unhandled rejection
624
+ earlyPrerequisites?.catch(() => undefined);
625
+ // A failing prerequisite ends the boot at once, not after the realm and the handshake: the
626
+ // fallback should not wait a round trip on a fragment the user was never going to see.
627
+ const failFast = (step) => earlyPrerequisites ? Promise.race([step, earlyPrerequisites.then(() => step)]) : step;
349
628
  /**
350
629
  * Boot the realm and fetch the fragment's document at the same time, unless it was
351
630
  * already pierced in. Both go through the gateway's namespaces, addressed by id.
@@ -368,20 +647,29 @@ export class FragmentSlot extends HTMLElement {
368
647
  if (signal.aborted || !isHtml || !html.trim())
369
648
  return;
370
649
  contentRoot.appendChild(parseFragmentContent(html, document));
650
+ // on screen now: the loading state has given way
651
+ this.#clearBusy();
371
652
  painted = true;
372
653
  };
373
- const [htmlResult, realm] = await Promise.all([
654
+ const [htmlResult, realm] = await failFast(Promise.all([
374
655
  piercedContentRoot
375
656
  ? Promise.resolve({ ok: true, html: null })
376
- : fetchFragmentHtml(fragmentId, routeSrcUrl, signal).then(({ html, isHtml }) => {
377
- paint(html, isHtml);
657
+ : (takePrefetchedDocument(fragmentId, routeSrcUrl, signal) ?? fetchFragmentHtml(fragmentId, routeSrcUrl, signal)).then(({ html, isHtml }) => {
658
+ // nothing of a gated fragment goes on screen before its markup prerequisites hold
659
+ if (earlyPrerequisites)
660
+ earlyPrerequisites.then(() => paint(html, isHtml), () => undefined);
661
+ else
662
+ paint(html, isHtml);
378
663
  return { ok: true, html };
379
664
  }, (error) => ({ ok: false, error })),
380
665
  createRealm('compat-http', { fragmentId, routeUrl, bound, signal }),
381
- ]);
666
+ ]));
382
667
  // The gateway stamps the manifest-declared adapter onto the realm stub; unknown adapters
383
668
  // fail as a named error, and an undeclared adapter resolves to the default (compat).
384
669
  const adapter = resolveAdapter(realm.manifestAdapter, fragmentId);
670
+ const manifestRequires = parseRequires(realm.adapterOptions['requires']).filter((key) => !markupRequires.includes(key));
671
+ const latePrerequisites = manifestRequires.length ? awaitPrerequisites(manifestRequires, fragmentId, signal) : null;
672
+ latePrerequisites?.catch(() => undefined);
385
673
  if (!htmlResult.ok && adapter.needsDocument !== false) {
386
674
  // The fragment sent the user somewhere else — a login, almost always — and the gateway has
387
675
  // said the host allows following it. The slot stays `loading`: the page is about to be
@@ -424,6 +712,7 @@ export class FragmentSlot extends HTMLElement {
424
712
  const { type, detail } = (payload ?? {});
425
713
  this.dispatchEvent(new CustomEvent('braid:event', { detail: { type, detail }, bubbles: true }));
426
714
  });
715
+ this.#listenForSettled(hostChannel, fragmentId, signal);
427
716
  const { env, opened } = createFragmentEnv({
428
717
  contentRoot,
429
718
  shadowRoot,
@@ -477,12 +766,23 @@ export class FragmentSlot extends HTMLElement {
477
766
  props: structuredClone(this.#props),
478
767
  }),
479
768
  });
480
- await opened;
769
+ await failFast(opened);
481
770
  if (signal.aborted)
482
771
  return;
483
772
  const realmGlobals = adapter.installsRealmGlobals ? await resolveRealmGlobals(fragmentId, signal) : undefined;
484
773
  if (signal.aborted)
485
774
  return;
775
+ const storagePartition = resolveStoragePartition(capabilities, fragmentId);
776
+ const htmlPolicy = resolveHtmlPolicy(capabilities, fragmentId);
777
+ // nothing of the fragment's has run yet; its content may already be painted, inert
778
+ await Promise.all([earlyPrerequisites, latePrerequisites]);
779
+ if (signal.aborted)
780
+ return;
781
+ // before any fragment code: an unmodified app has no env, so it reports through its global
782
+ Object.defineProperty(realm.window, BRAID_FRAGMENT_REPORTER, {
783
+ value: Object.freeze({ settled: () => env.settled() }),
784
+ configurable: true,
785
+ });
486
786
  await adapter.boot({
487
787
  fragmentId,
488
788
  shadowRoot,
@@ -495,6 +795,8 @@ export class FragmentSlot extends HTMLElement {
495
795
  bound,
496
796
  env,
497
797
  sharedGlobals: resolveSharedGlobals(capabilities),
798
+ ...(storagePartition === null ? {} : { storagePartition }),
799
+ ...(htmlPolicy === null ? {} : { htmlPolicy }),
498
800
  ...(realmGlobals ? { realmGlobals } : {}),
499
801
  signal,
500
802
  });
@@ -502,6 +804,7 @@ export class FragmentSlot extends HTMLElement {
502
804
  return;
503
805
  this.#setState('ready');
504
806
  this.dispatchEvent(new CustomEvent('braid:ready', { detail: { fragmentId } }));
807
+ this.#armSettleFallback(fragmentId, signal);
505
808
  /**
506
809
  * Liveness starts only once the adapter has mounted.
507
810
  *
@@ -527,7 +830,8 @@ export class FragmentSlot extends HTMLElement {
527
830
  }
528
831
  }
529
832
  catch (error) {
530
- if (this.#abortController?.signal.aborted)
833
+ // a boot a teardown or a newer boot overtook has nothing to report — its abort is not a failure
834
+ if (generation !== this.#bootGeneration || this.#abortController?.signal.aborted)
531
835
  return;
532
836
  this.#setState('error');
533
837
  const braidError = error instanceof BraidError
@@ -537,15 +841,28 @@ export class FragmentSlot extends HTMLElement {
537
841
  stage: 'adapter-mount',
538
842
  cause: error,
539
843
  });
540
- console.error(braidError);
844
+ // a user without access to a prerequisite is an outcome, not a failure: fallback, no alarm,
845
+ // and none of the fragment's content left in the slot
846
+ const notEntitled = braidError instanceof NotEntitledError;
847
+ if (notEntitled) {
848
+ this.shadowRoot?.querySelector('braid-document')?.replaceChildren();
849
+ if (isDevMode())
850
+ console.debug(`[braid:${fragmentId}] ${braidError.message}; showing the fallback`);
851
+ }
852
+ else {
853
+ console.error(braidError);
854
+ }
541
855
  this.dispatchEvent(new CustomEvent('braid:error', {
542
856
  detail: {
543
857
  fragmentId: braidError.fragmentId,
544
858
  stage: braidError.stage,
545
859
  fixHint: braidError.fixHint,
546
860
  error: braidError,
861
+ // an outcome, not a failure: a host routing braid:error to monitoring can skip it
862
+ ...(notEntitled ? { notEntitled: true } : {}),
547
863
  },
548
864
  }));
865
+ this.#recovery.onError(braidError, notEntitled);
549
866
  }
550
867
  }
551
868
  /**
@@ -564,7 +881,9 @@ export class FragmentSlot extends HTMLElement {
564
881
  * negotiation, capabilities, liveness and teardown — because all of it was built against the
565
882
  * boundary rather than against compat's machinery.
566
883
  */
567
- async #bootContract(fragmentId) {
884
+ async #bootContract(fragmentId, requires) {
885
+ // again where it is read: a `braid:state` listener runs between #boot's check and here
886
+ this.#assertHostDeclaresCode(fragmentId);
568
887
  const entry = this.getAttribute('entry');
569
888
  if (!entry) {
570
889
  throw new BraidError('a contract fragment needs an entry module', {
@@ -584,6 +903,13 @@ export class FragmentSlot extends HTMLElement {
584
903
  const contentRoot = document.createElement('braid-document');
585
904
  shadowRoot.replaceChildren(styleSheet, contentRoot);
586
905
  ensureFallbackSlot(shadowRoot);
906
+ ensureLoadingSlot(shadowRoot);
907
+ this.#markBusy();
908
+ // before anything of the fragment exists: no realm, nothing mounted
909
+ if (requires.length)
910
+ await awaitPrerequisites(requires, fragmentId, signal);
911
+ if (signal.aborted)
912
+ return;
587
913
  // The realm's base is the entry's own directory, not a gateway namespace: a contract fragment
588
914
  // fetches its assets from wherever it is hosted, directly.
589
915
  const entryUrl = new URL(entry, document.baseURI);
@@ -604,6 +930,7 @@ export class FragmentSlot extends HTMLElement {
604
930
  const { type, detail } = (payload ?? {});
605
931
  this.dispatchEvent(new CustomEvent('braid:event', { detail: { type, detail }, bubbles: true }));
606
932
  });
933
+ this.#listenForSettled(hostChannel, fragmentId, signal);
607
934
  const { env, opened } = createFragmentEnv({
608
935
  contentRoot,
609
936
  shadowRoot,
@@ -657,6 +984,7 @@ export class FragmentSlot extends HTMLElement {
657
984
  return;
658
985
  this.#setState('ready');
659
986
  this.dispatchEvent(new CustomEvent('braid:ready', { detail: { fragmentId } }));
987
+ this.#armSettleFallback(fragmentId, signal);
660
988
  this.#liveness = startHostLiveness({
661
989
  channel: hostChannel,
662
990
  instance,
@@ -677,7 +1005,9 @@ export class FragmentSlot extends HTMLElement {
677
1005
  * same contract negotiation, the same liveness, the same negotiated teardown. That is the return
678
1006
  * on having built the transport before the tier that needed it.
679
1007
  */
680
- async #bootUntrusted(fragmentId) {
1008
+ async #bootUntrusted(fragmentId, requires) {
1009
+ // again where it is read: a `braid:state` listener runs between #boot's check and here
1010
+ this.#assertHostDeclaresCode(fragmentId);
681
1011
  const src = this.getAttribute('src');
682
1012
  if (!src) {
683
1013
  throw new BraidError('an untrusted fragment needs an explicit cross-origin src', {
@@ -694,6 +1024,11 @@ export class FragmentSlot extends HTMLElement {
694
1024
  styleSheet.textContent = SLOT_STYLES;
695
1025
  shadowRoot.replaceChildren(styleSheet);
696
1026
  ensureFallbackSlot(shadowRoot);
1027
+ // before anything of the fragment exists: no frame, no connection
1028
+ if (requires.length)
1029
+ await awaitPrerequisites(requires, fragmentId, signal);
1030
+ if (signal.aborted)
1031
+ return;
697
1032
  /**
698
1033
  * On this tier the capability block is not advisory: `sandbox` and `permissions` become real
699
1034
  * iframe attributes that the browser enforces, and `context.read` decides what enters the
@@ -729,6 +1064,7 @@ export class FragmentSlot extends HTMLElement {
729
1064
  const { type, detail } = (payload ?? {});
730
1065
  this.dispatchEvent(new CustomEvent('braid:event', { detail: { type, detail }, bubbles: true }));
731
1066
  });
1067
+ this.#listenForSettled(hostChannel, fragmentId, signal);
732
1068
  /**
733
1069
  * Listening starts *before* the handshake; judging starts at `mounted`.
734
1070
  *
@@ -774,6 +1110,7 @@ export class FragmentSlot extends HTMLElement {
774
1110
  return;
775
1111
  this.#setState('ready');
776
1112
  this.dispatchEvent(new CustomEvent('braid:ready', { detail: { fragmentId } }));
1113
+ this.#armSettleFallback(fragmentId, signal);
777
1114
  /**
778
1115
  * No `startFragmentBeats` here. The guest schedules its own, in its own document, on its own
779
1116
  * event loop — which is stronger evidence than the trusted tier can offer, where the beat comes
@@ -812,6 +1149,29 @@ function redactedLocation(location, base) {
812
1149
  return null;
813
1150
  }
814
1151
  }
1152
+ /**
1153
+ * Starts loading a fragment before its slot exists — from a link's hover, or a router's preload
1154
+ * hook — so a navigation that is about to mount it finds the work already in flight.
1155
+ *
1156
+ * ```ts
1157
+ * link.addEventListener('pointerenter', () => prefetchFragment('billing', '/billing/invoices'));
1158
+ * ```
1159
+ *
1160
+ * `route` is what the slot will render: the destination page's path for a routed fragment, or the
1161
+ * slot's `src` for a widget. Two things start:
1162
+ *
1163
+ * - the fragment's document, held for the next slot of that fragment and route to mount within
1164
+ * ten seconds (`PREFETCH_TTL_MS`) — so that slot makes no document request of its own;
1165
+ * - the scripts the document names, in its own namespace, as `<link rel="prefetch">` the realm
1166
+ * then loads from the HTTP cache.
1167
+ *
1168
+ * Compat fragments only: contract and untrusted fragments load no Braid document. Calling it again
1169
+ * for the same fragment and route while a prefetch is held does nothing. Call
1170
+ * `clearPrefetchedFragments()` when the user signs out.
1171
+ */
1172
+ export function prefetchFragment(fragmentId, route) {
1173
+ startPrefetch(fragmentId, route, (url, signal) => fetchFragmentHtml(fragmentId, url, signal));
1174
+ }
815
1175
  /**
816
1176
  * Fetches the fragment's document from the gateway's document namespace.
817
1177
  *
@@ -862,6 +1222,7 @@ export async function fetchFragmentHtml(fragmentId, routeSrcUrl, signal) {
862
1222
  (location ? ` to "${location}"` : ' (the target is not visible to the browser)'), {
863
1223
  fragmentId,
864
1224
  stage: 'fragment-fetch',
1225
+ httpStatus: response.status || 302,
865
1226
  fixHint: `a fragment's document must not redirect — it is fetched, not navigated to. Usually the request is ` +
866
1227
  `missing authentication and the fragment is sending it to a login page: handle auth inside ` +
867
1228
  `fragment "${fragmentId}" instead of redirecting, and check the Network tab for the redirect's target`,
@@ -880,6 +1241,7 @@ export async function fetchFragmentHtml(fragmentId, routeSrcUrl, signal) {
880
1241
  throw new BraidError(`the gateway responded with HTTP ${response.status} for "${documentUrl}"`, {
881
1242
  fragmentId,
882
1243
  stage: 'fragment-fetch',
1244
+ httpStatus: response.status,
883
1245
  fixHint: response.status === 404
884
1246
  ? `register a manifest for fragment id "${fragmentId}" in the gateway registry`
885
1247
  : `check the gateway logs for fragment id "${fragmentId}"`,