@ogygia/core 0.12.3 → 0.13.0

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 (226) hide show
  1. package/dist/LateIsland.svelte +3 -2
  2. package/dist/LateIslandAwait.svelte +6 -2
  3. package/dist/Region.svelte +22 -33
  4. package/dist/boundary.d.ts +0 -2
  5. package/dist/boundary.js +8 -6
  6. package/dist/compiler/debarrel/barrel.d.ts +15 -8
  7. package/dist/compiler/debarrel/barrel.js +92 -45
  8. package/dist/compiler/debarrel/plugin.js +41 -17
  9. package/dist/compiler/dev/dev-hmr.d.ts +3 -0
  10. package/dist/compiler/dev/dev-hmr.js +3 -0
  11. package/dist/compiler/driver.js +11 -4
  12. package/dist/compiler/flags.js +21 -7
  13. package/dist/compiler/fouc-css.js +5 -1
  14. package/dist/compiler/kit.js +5 -1
  15. package/dist/compiler/link/build-output.js +12 -3
  16. package/dist/compiler/macros/bake.js +18 -4
  17. package/dist/compiler/macros/dollar.js +6 -1
  18. package/dist/compiler/macros/source.js +6 -1
  19. package/dist/compiler/macros/store.js +10 -2
  20. package/dist/compiler/macros/wire.js +6 -1
  21. package/dist/compiler/parse/scan.js +10 -3
  22. package/dist/compiler/region/transform.js +188 -74
  23. package/dist/context-bridge.js +13 -5
  24. package/dist/context.d.ts +9 -0
  25. package/dist/context.js +30 -1
  26. package/dist/document.d.ts +1 -1
  27. package/dist/document.js +7 -1
  28. package/dist/early-holes-head.d.ts +5 -0
  29. package/dist/early-holes-head.js +20 -0
  30. package/dist/early-holes.d.ts +34 -0
  31. package/dist/early-holes.js +51 -0
  32. package/dist/entry-hydrate.js +9 -12
  33. package/dist/events/bus.js +7 -2
  34. package/dist/events/schema.d.ts +2 -2
  35. package/dist/fn-transport.js +4 -3
  36. package/dist/hooks.js +94 -163
  37. package/dist/insights/page-assets.js +11 -3
  38. package/dist/insights/page-insights.d.ts +0 -2
  39. package/dist/insights/page-insights.js +14 -10
  40. package/dist/insights/session-insights.js +2 -2
  41. package/dist/internal-instruments.d.ts +3 -1
  42. package/dist/internal-instruments.js +3 -1
  43. package/dist/internal-server.d.ts +3 -2
  44. package/dist/internal-server.js +3 -2
  45. package/dist/internal.d.ts +1 -2
  46. package/dist/internal.js +1 -2
  47. package/dist/keep-fallback.d.ts +0 -2
  48. package/dist/keep-fallback.js +4 -5
  49. package/dist/live-transport.js +3 -2
  50. package/dist/messages/build.d.ts +585 -0
  51. package/dist/messages/build.js +800 -0
  52. package/dist/messages/core.d.ts +464 -0
  53. package/dist/messages/core.js +843 -0
  54. package/dist/messages/define.d.ts +62 -0
  55. package/dist/messages/define.js +49 -0
  56. package/dist/messages/index.d.ts +6 -0
  57. package/dist/messages/index.js +6 -0
  58. package/dist/messages/island.d.ts +4 -0
  59. package/dist/messages/island.js +34 -0
  60. package/dist/messages/lazy.d.ts +3 -0
  61. package/dist/messages/lazy.js +22 -0
  62. package/dist/messages/prose.d.ts +15 -0
  63. package/dist/messages/prose.js +43 -0
  64. package/dist/messages/server.d.ts +142 -0
  65. package/dist/messages/server.js +246 -0
  66. package/dist/nested.d.ts +3 -0
  67. package/dist/nested.js +8 -1
  68. package/dist/plugin/css.js +14 -13
  69. package/dist/plugin/facts.d.ts +2 -0
  70. package/dist/plugin/facts.js +10 -6
  71. package/dist/plugin/hooks.d.ts +7 -17
  72. package/dist/plugin/hooks.js +32 -4
  73. package/dist/plugin/index.d.ts +9 -2
  74. package/dist/plugin/request-scope.d.ts +20 -0
  75. package/dist/plugin/request-scope.js +149 -0
  76. package/dist/plugin/server.d.ts +20 -2
  77. package/dist/plugin/server.js +98 -28
  78. package/dist/plugin/testing.d.ts +6 -0
  79. package/dist/plugin/testing.js +14 -2
  80. package/dist/policies.d.ts +21 -0
  81. package/dist/policies.js +66 -0
  82. package/dist/preference.js +6 -1
  83. package/dist/preload.js +6 -9
  84. package/dist/ref-watch.d.ts +0 -10
  85. package/dist/ref-watch.js +14 -1
  86. package/dist/ref.js +17 -5
  87. package/dist/region-snippet.js +9 -3
  88. package/dist/region.js +2 -1
  89. package/dist/report-thrown.d.ts +9 -0
  90. package/dist/report-thrown.js +15 -0
  91. package/dist/rules.d.ts +17 -0
  92. package/dist/rules.js +57 -0
  93. package/dist/runtime/beacon.d.ts +0 -4
  94. package/dist/runtime/beacon.js +58 -43
  95. package/dist/runtime/boot-link.js +19 -2
  96. package/dist/runtime/client-hooks.js +2 -1
  97. package/dist/runtime/concurrency.d.ts +16 -5
  98. package/dist/runtime/concurrency.js +123 -7
  99. package/dist/runtime/core.d.ts +3 -1
  100. package/dist/runtime/core.js +718 -384
  101. package/dist/runtime/devalue-lane.d.ts +13 -0
  102. package/dist/runtime/devalue-lane.js +49 -0
  103. package/dist/runtime/disclose-version.d.ts +1 -0
  104. package/dist/runtime/disclose-version.js +14 -0
  105. package/dist/runtime/early-holes.d.ts +4 -0
  106. package/dist/runtime/early-holes.js +40 -0
  107. package/dist/runtime/entry-locations.js +40 -49
  108. package/dist/runtime/foreign-props.d.ts +0 -7
  109. package/dist/runtime/foreign-props.js +2 -1
  110. package/dist/runtime/form-continuity.d.ts +2 -2
  111. package/dist/runtime/form-continuity.js +30 -8
  112. package/dist/runtime/frame-nav.d.ts +7 -1
  113. package/dist/runtime/frame-nav.js +84 -18
  114. package/dist/runtime/frame-store.d.ts +25 -16
  115. package/dist/runtime/frame-store.js +124 -98
  116. package/dist/runtime/frames.js +8 -4
  117. package/dist/runtime/hole-facts.js +6 -7
  118. package/dist/runtime/hole-fetch.d.ts +17 -0
  119. package/dist/runtime/hole-fetch.js +47 -0
  120. package/dist/runtime/hydrate-core.d.ts +4 -4
  121. package/dist/runtime/hydrate-core.js +80 -64
  122. package/dist/runtime/import-again.d.ts +33 -0
  123. package/dist/runtime/import-again.js +217 -0
  124. package/dist/runtime/interaction-replay.d.ts +5 -3
  125. package/dist/runtime/interaction-replay.js +6 -6
  126. package/dist/runtime/interaction.d.ts +14 -3
  127. package/dist/runtime/interaction.js +46 -23
  128. package/dist/runtime/kit-page-thread.svelte.d.ts +2 -1
  129. package/dist/runtime/kit-page-thread.svelte.js +44 -20
  130. package/dist/runtime/lakes.d.ts +1 -1
  131. package/dist/runtime/lakes.js +18 -5
  132. package/dist/runtime/live.d.ts +1 -2
  133. package/dist/runtime/live.js +6 -8
  134. package/dist/runtime/load-scheduler.d.ts +29 -18
  135. package/dist/runtime/load-scheduler.js +236 -136
  136. package/dist/runtime/nav-handle.js +25 -6
  137. package/dist/runtime/navigation.d.ts +24 -0
  138. package/dist/runtime/navigation.js +24 -0
  139. package/dist/runtime/ownership.d.ts +1 -1
  140. package/dist/runtime/page-defer.js +3 -3
  141. package/dist/runtime/parse-html.d.ts +15 -4
  142. package/dist/runtime/parse-html.js +24 -5
  143. package/dist/runtime/pushable-props.svelte.d.ts +21 -0
  144. package/dist/runtime/pushable-props.svelte.js +37 -0
  145. package/dist/runtime/region-endpoint-url.js +4 -6
  146. package/dist/runtime/region-machines.d.ts +111 -0
  147. package/dist/runtime/region-machines.js +97 -0
  148. package/dist/runtime/region-sheets.d.ts +31 -0
  149. package/dist/runtime/region-sheets.js +49 -0
  150. package/dist/runtime/restore-report.d.ts +1 -0
  151. package/dist/runtime/restore-report.js +36 -0
  152. package/dist/runtime/restore.d.ts +5 -2
  153. package/dist/runtime/restore.js +33 -14
  154. package/dist/runtime/router-nav.d.ts +32 -12
  155. package/dist/runtime/router-nav.js +320 -228
  156. package/dist/runtime/router.d.ts +83 -19
  157. package/dist/runtime/router.js +297 -129
  158. package/dist/runtime/rules.d.ts +35 -0
  159. package/dist/runtime/rules.js +137 -0
  160. package/dist/runtime/schedule.d.ts +6 -3
  161. package/dist/runtime/schedule.js +172 -57
  162. package/dist/runtime/seeds.d.ts +5 -1
  163. package/dist/runtime/seeds.js +11 -5
  164. package/dist/runtime/session.d.ts +46 -7
  165. package/dist/runtime/session.js +78 -10
  166. package/dist/runtime/slots.d.ts +53 -20
  167. package/dist/runtime/slots.js +2 -3
  168. package/dist/runtime/soft-refresh.d.ts +16 -0
  169. package/dist/runtime/soft-refresh.js +14 -0
  170. package/dist/runtime/svelte-warnings.d.ts +4 -10
  171. package/dist/runtime/svelte-warnings.js +7 -4
  172. package/dist/runtime/wire-classes.js +21 -8
  173. package/dist/runtime/wire-format.js +6 -3
  174. package/dist/script.js +8 -7
  175. package/dist/seed-ref-reviver.d.ts +0 -9
  176. package/dist/seed-ref-reviver.js +2 -1
  177. package/dist/seed-refs.d.ts +46 -36
  178. package/dist/seed-refs.js +131 -92
  179. package/dist/server/document-tail.d.ts +6 -0
  180. package/dist/server/document-tail.js +7 -1
  181. package/dist/server/early-holes-csp.d.ts +14 -0
  182. package/dist/server/early-holes-csp.js +73 -0
  183. package/dist/server/page-facts.d.ts +4 -1
  184. package/dist/server/page-facts.js +4 -1
  185. package/dist/server/page-seed.d.ts +2 -2
  186. package/dist/server/page-seed.js +104 -11
  187. package/dist/server/page-stream.d.ts +14 -1
  188. package/dist/server/page-stream.js +119 -2
  189. package/dist/server/personal-response.d.ts +0 -5
  190. package/dist/server/personal-response.js +6 -2
  191. package/dist/server/props-wire.js +5 -1
  192. package/dist/server/region-endpoint.js +10 -2
  193. package/dist/server/render-cache.d.ts +24 -20
  194. package/dist/server/render-cache.js +99 -40
  195. package/dist/server/render-page.js +46 -15
  196. package/dist/server/request-stats.d.ts +10 -8
  197. package/dist/server/request-stats.js +20 -12
  198. package/dist/server/rules.d.ts +11 -0
  199. package/dist/server/rules.js +64 -0
  200. package/dist/server/signing-key.js +31 -18
  201. package/dist/server/stream-regions.d.ts +14 -1
  202. package/dist/server/stream-regions.js +38 -2
  203. package/dist/shared-state.js +5 -1
  204. package/dist/shims/app-navigation.js +3 -2
  205. package/dist/shims/page-store.svelte.js +16 -2
  206. package/dist/source.js +7 -2
  207. package/dist/store-transport.js +12 -5
  208. package/dist/transport.js +3 -2
  209. package/dist/vite/index.js +17 -10
  210. package/dist/vite/kit-dirs.js +7 -1
  211. package/dist/vite/options.d.ts +2 -1
  212. package/dist/vite/options.js +11 -20
  213. package/dist/vite/package-files.js +13 -3
  214. package/dist/vite/paths.d.ts +2 -0
  215. package/dist/vite/paths.js +4 -4
  216. package/dist/vite/plugin-api.js +42 -14
  217. package/dist/vite/style-preprocess.js +10 -3
  218. package/package.json +8 -1
  219. package/dist/KeepHost.svelte +0 -19
  220. package/dist/KeepHost.svelte.d.ts +0 -10
  221. package/dist/LiveHost.svelte +0 -19
  222. package/dist/LiveHost.svelte.d.ts +0 -10
  223. package/dist/NestedProvider.svelte +0 -13
  224. package/dist/NestedProvider.svelte.d.ts +0 -8
  225. package/dist/runtime/page-cache.d.ts +0 -23
  226. package/dist/runtime/page-cache.js +0 -74
@@ -1,31 +1,43 @@
1
1
  import { frameAddress } from "../frame.js";
2
+ import { import_again, is_failed_import, on_reload_needed } from "./import-again.js";
2
3
  import { kit_hydrates_page } from "./kit-boot.js";
3
4
  import { init_shadow_registries, parse_region_html, restore_adopt_sheets, restore_markup } from "./parse-html.js";
4
5
  import { mark_region_changed, region_changed, unwatch_region, watch_region } from "./drift-watch.js";
5
6
  import { runtime_session } from "./session.js";
6
- import { RegionAnswerRefused, capability_expired, deny_of, is_allowed_region_endpoint, is_document_answer, is_redirected_answer, is_same_origin_response, island_module_url, renewal_url } from "./region-endpoint-url.js";
7
+ import { RegionAnswerRefused, capability_expired, is_allowed_region_endpoint, island_module_url } from "./region-endpoint-url.js";
8
+ import { fetch_hole } from "./hole-fetch.js";
7
9
  import { import_entry, island_entry_of, on_entry_fallback, pin_build_map, pin_region_urls } from "./entry-locations.js";
10
+ import { load_devalue, needs_devalue } from "./devalue-lane.js";
8
11
  import { is_awake, is_deferred, is_frozen, ours_on_kit_document, phase2_hydrate_schedule, region_hydrate_schedule, region_schedule } from "./region-attrs.js";
9
12
  import { slots } from "./slots.js";
10
- import { KEEP_FALLBACK_HTML } from "../keep-fallback-marker.js";
13
+ import { create_answer, create_hole, create_live, create_paint, create_wake } from "./region-machines.js";
14
+ import "../keep-fallback-marker.js";
15
+ import { download, region_fetch } from "../policies.js";
16
+ import { install_rules, note_mount, note_unmount } from "./rules.js";
11
17
  import { PAGE_FACTS_SELECTOR } from "../page-facts-script.js";
12
18
  import { link_boot } from "./boot-link.js";
13
19
  import { background_start, hydrate_settled, hydrate_started, hydrate_turn, register_region, unregister_region, yield_task } from "./schedule.js";
14
20
  import { once_visible } from "./observe.js";
21
+ import { sheet_of, use_sheets } from "./region-sheets.js";
15
22
  import { preload_island_graph } from "./island-graph-preload.js";
16
- import { fetch_priority_of, load_slot, viewport_class } from "./load-scheduler.js";
23
+ import { LoadSlot, fetch_priority_of, viewport_class } from "./load-scheduler.js";
17
24
  import { connected_regions } from "./connected.js";
18
25
  import { restore_props_sidecar } from "./sidecar.js";
19
26
  import { hole_facts_of } from "./hole-facts.js";
20
- import { beacon_entry_fallback, beacon_failed, beacon_hole_answered, beacon_hole_failed, beacon_hole_fetched, beacon_hydrated, beacon_warning, beacon_watch } from "./beacon.js";
27
+ import { beacon_entry_fallback, beacon_failed, beacon_hole_answered, beacon_hole_failed, beacon_hydrated, beacon_warning, beacon_watch } from "./beacon.js";
21
28
  import { on_svelte_warning, set_hydrating, tap_svelte_warnings } from "./svelte-warnings.js";
22
29
  import { emit } from "../events/bus.js";
23
30
  import { ingest_server_events, install_window_sink } from "../events/sinks.js";
24
31
  import { CLIENT_PLUGINS, boot_client_plugins, client_region_mount, client_region_unmount } from "./client-plugins.js";
32
+ import { OgygiaError, line, warn } from "../messages/core.js";
33
+ import { report_restores } from "./restore-report.js";
34
+ import { disclose_version } from "./disclose-version.js";
35
+ import { STALE, backoffAfter, flights, mailbox, scope } from "@ogygia/machine";
25
36
  import { FRAMES } from "virtual:ogygia/runtime-flags";
26
37
  // src/runtime/core.ts
27
38
  const DEVTOOLS = typeof __OGYGIA_DEVTOOLS__ !== "undefined" ? __OGYGIA_DEVTOOLS__ : false;
28
39
  const BEACON = typeof __OGYGIA_BEACON__ !== "undefined" ? __OGYGIA_BEACON__ : true;
40
+ const RULES = import.meta.env?.DEV || (typeof __OGYGIA_RULES__ !== "undefined" ? __OGYGIA_RULES__ : false);
29
41
  /** Above this many characters an island keeps no server copy (its hydration falls back to
30
42
  * Svelte's own recovery on a mismatch) — a bound on memory, not a behaviour anyone tunes. */
31
43
  const SSR_SNAPSHOT_MAX = 1 << 19;
@@ -33,9 +45,6 @@ const SSR_SNAPSHOT_MAX = 1 << 19;
33
45
  * is still disconnected — nothing has upgraded it — so the island repairs against its true server bytes
34
46
  * instead of a DOM a swapped-in foreign runtime may have already mutated. See region_fragment. */
35
47
  const PRISTINE_SSR = Symbol("ogygia.pristine-ssr");
36
- /** A dynamic-import failure of the island ENTRY (network/stale), not a hydration throw. The browser
37
- * message is `Failed to fetch dynamically imported module: <url>` across engines. */
38
- const ENTRY_FETCH_FAILED_RE = /failed to fetch dynamically imported module|error loading dynamically imported module/i;
39
48
  const CSS_ASSET_HREF_RE = /\.css(\?|$)/;
40
49
  /** The island's server markup to hydrate against later (#ssr_html), or `null` when there is
41
50
  * nothing worth keeping: no children, or more than SSR_SNAPSHOT_MAX characters. */
@@ -56,7 +65,6 @@ function fragment_markup(frag) {
56
65
  frag.append(...Array.from(box.childNodes));
57
66
  return html.length > SSR_SNAPSHOT_MAX ? null : html;
58
67
  }
59
- const HOLE_WITHOUT_ADDRESS_WARNING = "[ogygia] deferred region %s was rendered in the browser with no server-minted address, and the document records none for it (a client-side navigation mounted it, or its props differ from the server render) — its fallback stands. A hole is server HTML: render it on the server.";
60
68
  /** Read the identity fields devtools events correlate on, off a region element. Cheap — attributes
61
69
  * already in hand. Only ever called from behind an `if (DEVTOOLS)` guard, so it costs nothing off. */
62
70
  function dt_ids(el) {
@@ -69,17 +77,47 @@ function dt_ids(el) {
69
77
  function now_ms() {
70
78
  return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
71
79
  }
72
- let core_promise = null;
73
80
  let loaded_core = null;
74
- function hydrate_core() {
75
- if (!core_promise) core_promise = yield_task().then(() => import("./hydrate-core.js")).then((m) => loaded_core = m);
76
- return core_promise;
77
- }
81
+ const import_core = import_again(() => import("./hydrate-core.js"));
82
+ const hydrate_core = flights({
83
+ name: "hydrate core",
84
+ run: () => yield_task().then(import_core).then((m) => loaded_core = m),
85
+ retry: download
86
+ });
78
87
  /** An answer just went in carrying the page facts it rendered with (server/render-page.ts): once
79
88
  * islands are awake (the hydrate core loaded), they join the page store NOW, so a KEPT island reading
80
89
  * those keys shows the new values; before any island woke, the first hydrate absorbs them. */
81
90
  function absorb_page_facts_now() {
82
- if (runtime_session.page_facts.length) loaded_core?.seed_page_once();
91
+ if (runtime_session.visit.facts.length && loaded_core) load_devalue().then(() => loaded_core?.seed_page_once(), () => {});
92
+ }
93
+ /** devalue, when this wake's reads need it (./devalue-lane.ts): a devalue-lane script in the
94
+ * document (a props sidecar, the page seed) or page facts an answer queued. Started beside the
95
+ * hydrate core, so it adds no hop; a page of JSON-lane scripts never downloads it. */
96
+ function devalue_for_wake() {
97
+ if (runtime_session.visit.facts.length || document_needs_devalue()) return load_devalue();
98
+ }
99
+ /**
100
+ * The document's `needs_devalue` answer (null: ask). One query of the whole document, not one per
101
+ * wake (that was islands × the page's elements). Asked again only after markup came in: a region
102
+ * connecting (every later render root, a hole answer, a router document, a fragment, carries its
103
+ * sidecars beside its regions), a fetched region's HTML parsed, a router swap. A yes stays yes: devalue
104
+ * is loading from then on.
105
+ */
106
+ let doc_needs_devalue = null;
107
+ let devalue_swap_armed = false;
108
+ function document_needs_devalue() {
109
+ if (doc_needs_devalue === null) {
110
+ if (!devalue_swap_armed) {
111
+ devalue_swap_armed = true;
112
+ document.addEventListener("og:after-swap", devalue_lane_stale);
113
+ }
114
+ doc_needs_devalue = needs_devalue(document);
115
+ }
116
+ return doc_needs_devalue;
117
+ }
118
+ /** New markup may hold a devalue-lane script: the next wake asks the document again. */
119
+ function devalue_lane_stale() {
120
+ if (doc_needs_devalue === false) doc_needs_devalue = null;
83
121
  }
84
122
  /** What counts as intent for an ON-DEMAND hole (`render: 'deferred'` + `fetch: 'interaction'`).
85
123
  * `pointerover` (not `pointerenter`) so it fires on hover AND bubbles through the boxless
@@ -92,6 +130,8 @@ const ON_DEMAND_EVENTS = [
92
130
  "keydown"
93
131
  ];
94
132
  const ON_DEMAND_SELECTOR = "ogygia-region[render=\"defer\"][when=\"interaction\"]";
133
+ /** A visitor's wake with nothing to replay (a hole's on-demand fetch). */
134
+ const NO_REPLAY = Promise.resolve();
95
135
  /**
96
136
  * ON-DEMAND holes, delegated: ONE set of capture listeners on the document for every armed hole
97
137
  * (five listeners per hole used to be five hundred on a page of nav-menus). Intent lands anywhere
@@ -153,26 +193,36 @@ function dom_ready() {
153
193
  if (typeof document === "undefined" || document.readyState !== "loading") return Promise.resolve();
154
194
  return new Promise((r) => document.addEventListener("DOMContentLoaded", r, { once: true }));
155
195
  }
196
+ /** What a region element holds before its first connect and after it left the page: nothing.
197
+ * Anything given to it is let go at once. */
198
+ const NOT_CONNECTED = scope();
199
+ NOT_CONNECTED.close();
200
+ /** Its mailbox then: closed (every post dropped, every wait `STALE`). */
201
+ const CLOSED_BOX = mailbox(NOT_CONNECTED);
202
+ /** A download slot (load-scheduler.ts) asked for by a step whose element then left: its turn still
203
+ * comes, and is handed straight back. */
204
+ const hand_back = (release) => release();
205
+ const nothing = () => {};
156
206
  /** Where a live snippet's pending hydrate waits on its host region (region-snippet.ts `hold_host`). */
157
207
  const SNIPPET_BOOTS = Symbol.for("ogygia.snippet-boots");
158
208
  /** A snippet whose entry never arrives must not hold its host's signal forever. */
159
209
  const SNIPPET_BOOT_CAP_MS = 1e4;
160
210
  /** Settle the live snippets a just-hydrated region renders — and the snippets those render, which
161
- * register as they boot — or the cap, whichever is first. A failed boot settles too. */
162
- async function snippets_settled(region) {
211
+ * register as they boot — or the cap, whichever is first. A failed boot settles too. The wait is
212
+ * `visit`'s (the wake's): it ends when the region leaves, too. */
213
+ async function snippets_settled(region, visit) {
163
214
  const host = region;
164
- let timer;
165
- const cap = new Promise((r) => timer = setTimeout(r, SNIPPET_BOOT_CAP_MS));
166
- let capped = false;
167
- cap.then(() => capped = true);
215
+ const wait = visit.child();
216
+ const cap = new Promise((r) => wait.defer(r));
217
+ wait.timeout(() => wait.close(), SNIPPET_BOOT_CAP_MS);
168
218
  try {
169
- for (let batch = host[SNIPPET_BOOTS]; batch?.length && !capped; batch = host[SNIPPET_BOOTS]) {
219
+ for (let batch = host[SNIPPET_BOOTS]; batch?.length && !wait.closed; batch = host[SNIPPET_BOOTS]) {
170
220
  host[SNIPPET_BOOTS] = [];
171
221
  await Promise.race([Promise.allSettled(batch), cap]);
172
222
  await Promise.resolve();
173
223
  }
174
224
  } finally {
175
- clearTimeout(timer);
225
+ wait.close();
176
226
  delete host[SNIPPET_BOOTS];
177
227
  }
178
228
  }
@@ -181,16 +231,20 @@ async function snippets_settled(region) {
181
231
  * inlined `<style data-ogygia-region-css="href">`) it carries into `<head>` (deduped by href). A held / server-picked region's component was never
182
232
  * imported by the page, so its scoped CSS is in no stylesheet the page loaded; the region response
183
233
  * ships the links and the runtime lifts them to the head — where they load once and stick. (A link
184
- * left in the body would also fail to load inside a `<template>` batch parcel.)
234
+ * left in the body would also fail to load inside a `<template>` batch parcel.) `sheets`: the head
235
+ * nodes the answer uses, for the region that paints it (./region-sheets.ts `use_sheets`).
185
236
  */
186
237
  function region_fragment(html) {
187
238
  const frag = parse_region_html(html);
239
+ devalue_lane_stale();
240
+ const { facts: queued, scope: page } = runtime_session.visit;
188
241
  for (const facts of frag.querySelectorAll(PAGE_FACTS_SELECTOR)) {
189
242
  facts.remove();
190
- runtime_session.page_facts.push(facts.textContent ?? "");
243
+ queued.push(facts.textContent ?? "");
191
244
  }
192
245
  const links = frag.querySelectorAll("link[data-ogygia-region-css]");
193
246
  const pending = [];
247
+ const sheets = [];
194
248
  if (links.length && import.meta.env.DEV) {
195
249
  const seen = /* @__PURE__ */ new Set();
196
250
  for (const link of links) {
@@ -207,55 +261,70 @@ function region_fragment(html) {
207
261
  }
208
262
  } else if (links.length) {
209
263
  const existing = new Map(Array.from(document.querySelectorAll("link[rel=\"stylesheet\"]"), (l) => [l.getAttribute("href"), l]));
210
- const until_loaded = (l) => new Promise((resolve) => {
211
- l.addEventListener("load", () => resolve(), { once: true });
212
- l.addEventListener("error", () => resolve(), { once: true });
213
- if (l.sheet) resolve();
214
- });
264
+ const until_settled = (sheet) => sheet.wait("loaded").then(() => void 0);
215
265
  for (const link of links) {
216
266
  const href = link.getAttribute("href");
217
267
  link.remove();
218
268
  if (!href) continue;
219
269
  const present = existing.get(href);
220
270
  if (present) {
221
- if (!present.sheet) pending.push(until_loaded(present));
222
- continue;
271
+ const sheet = sheet_of(present);
272
+ if (sheet.now.kind === "loading") pending.push(until_settled(sheet));
273
+ if (sheet.now.kind !== "failed") {
274
+ sheets.push(present);
275
+ continue;
276
+ }
277
+ present.remove();
223
278
  }
224
279
  const clone = document.createElement("link");
225
280
  clone.rel = "stylesheet";
226
281
  clone.href = href;
227
282
  clone.setAttribute("data-ogygia-region-css", "");
228
- pending.push(until_loaded(clone));
283
+ pending.push(until_settled(sheet_of(clone)));
229
284
  existing.set(href, clone);
285
+ sheets.push(clone);
230
286
  runtime_session.claim_page_head(clone);
231
287
  document.head.appendChild(clone);
232
288
  }
233
289
  }
234
290
  const styles = frag.querySelectorAll("style[data-ogygia-region-css]");
235
291
  if (styles.length) {
236
- const present = /* @__PURE__ */ new Set();
292
+ const present = /* @__PURE__ */ new Map();
237
293
  for (const n of document.head.querySelectorAll("[data-ogygia-region-css], link[rel=\"stylesheet\"]")) {
238
294
  const id = n.tagName === "STYLE" ? n.getAttribute("data-ogygia-region-css") : n.getAttribute("href");
239
- if (id) present.add(id);
295
+ if (id) present.set(id, n);
240
296
  }
241
297
  for (const style of styles) {
242
298
  const id = style.getAttribute("data-ogygia-region-css") || "";
243
299
  style.remove();
244
- if (!id || present.has(id)) continue;
245
- present.add(id);
300
+ if (!id) continue;
301
+ const found = present.get(id);
302
+ if (found !== void 0) {
303
+ sheets.push(found);
304
+ continue;
305
+ }
246
306
  const el = document.createElement("style");
307
+ present.set(id, el);
308
+ sheets.push(el);
247
309
  el.setAttribute("data-ogygia-region-css", id);
248
310
  el.textContent = style.textContent || "";
249
311
  runtime_session.claim_page_head(el);
250
312
  document.head.insertBefore(el, document.head.firstChild);
251
313
  }
252
314
  }
253
- const ready = pending.length ? Promise.race([Promise.all(pending).then(() => void 0), new Promise((r) => setTimeout(r, 5e3))]) : Promise.resolve();
315
+ const ready = pending.length ? page.task(() => new Promise((resolve) => {
316
+ const cancel = page.timeout(resolve, 5e3);
317
+ Promise.all(pending).then(() => {
318
+ cancel();
319
+ resolve();
320
+ });
321
+ })).then(() => void 0) : Promise.resolve();
254
322
  restore_markup(frag);
255
323
  for (const el of frag.querySelectorAll("ogygia-region")) if (el.getAttribute("entry") && !is_deferred(el) && !is_frozen(el)) el[PRISTINE_SSR] = el.innerHTML;
256
324
  return {
257
325
  frag,
258
- ready
326
+ ready,
327
+ sheets
259
328
  };
260
329
  }
261
330
  /** TEST: the pristine server markup {@link region_fragment} stashed on a nested island, if any. */
@@ -270,71 +339,107 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
270
339
  * The fetch reads this copy and restores the attribute (lakes, devtools and the router's
271
340
  * next-page warm all read the DOM). */
272
341
  #minted_endpoint = null;
273
- /** A fresh capability a renewal answered (#frame_fetcher), adopted once the fetch that got it is
274
- * applied (#adopt_renewed) — moving addresses mid-fetch would orphan that answer. */
275
- #renewed_endpoint = null;
276
342
  /** THE HYDRATION SOURCE OF TRUTH: this island's server markup as it connected (or, for a
277
343
  * hydrating hole, as its answer was swapped in). An island can sleep a long time and other
278
344
  * scripts edit the page meanwhile; on wake, hydrate-core hydrates against THIS when the live
279
345
  * DOM drifted, instead of letting Svelte re-render the island client-side. Dropped once the
280
346
  * island is awake. `null` for a nested region (rides its parent) and above SSR_SNAPSHOT_MAX. */
281
347
  #ssr_html = null;
282
- /** Counted in drift-watch.ts's sleeping islands (paired: one watch, one unwatch). */
283
- #watching = false;
348
+ /**
349
+ * THE ELEMENT'S LIFETIME IN THE PAGE: made at its connect, closed at its teardown (a move — out and
350
+ * back in one task — is no teardown, so it keeps it). What the element holds across its machines'
351
+ * visits is this scope's: its sleep in drift-watch.ts, its viewport snapshot for the scheduler,
352
+ * its binding to its frame-store address, the frame that replays a visitor's clicks. What one
353
+ * visit holds stays on that visit (`m.scope`). Closed before the first connect and after the
354
+ * teardown: anything given to it then is let go at once.
355
+ */
356
+ #life = NOT_CONNECTED;
357
+ /**
358
+ * ITS EVENTS, ONE AT A TIME (@ogygia/machine's `mailbox()`, #322): a connect, a schedule firing, a
359
+ * frame landing, a live tick, its fetch's end and its retry, its wake's steps — each a turn, in the
360
+ * order they came, so no two see each other's half-done step, whichever of its machines they drive.
361
+ * Its lifetime's:
362
+ * the element leaving drops what is queued, and a step waiting gets `STALE` at once. Every callback
363
+ * that comes back to the element later posts here (the box of the lifetime it started in).
364
+ */
365
+ #box = CLOSED_BOX;
366
+ /** The answer it painted last: what a hole put back on the page shows again when the frame store
367
+ * let it go meanwhile (#336). */
368
+ #shown = null;
369
+ /** Frames its binding was handed (a binding that replays one counts it at once). */
370
+ #frames_in = 0;
371
+ /** A live region's last tick: what it goes on with when it is put back on the page (#337). */
372
+ #live_last = null;
373
+ /** While it sleeps with a copy: counted in drift-watch.ts's sleeping islands, until it wakes (or
374
+ * leaves the page). */
375
+ #asleep = null;
376
+ get #watching() {
377
+ return this.#asleep !== null && !this.#asleep.closed;
378
+ }
284
379
  #watch() {
285
380
  if (this.#watching) return;
286
- this.#watching = true;
381
+ this.#asleep = this.#life.child();
287
382
  watch_region();
383
+ this.#asleep.defer(unwatch_region);
288
384
  }
289
385
  #unwatch() {
290
- if (!this.#watching) return;
291
- this.#watching = false;
292
- unwatch_region();
386
+ this.#asleep?.close();
387
+ this.#asleep = null;
293
388
  }
294
- /** True after a successful HTML swap — failures leave this false so a later schedule can retry. */
295
- #done = false;
296
- /** In-flight `#apply` run. `#apply` awaits the region's stylesheet before swapping, so anyone
297
- * who needs the post-swap state (`#done`, phase-2 hydrate arming) must await this first —
298
- * sampling `#done` right after the fetch races the CSS wait and reads stale `false`. */
299
- #applying;
300
- /** In-flight fetch guard (separate from `#done` so a failed fetch does not one-shot the region). */
301
- #fetching = false;
302
- /** Bounded automatic retries after a failed defer/SWR fetch. */
303
- #fetch_attempts = 0;
389
+ /** A deferred region's own fetch of its answer: armed, fetching, waiting to retry, given up
390
+ * (./region-machines.ts). Its `armed` state owns the `when` and `prefetch` listeners. */
391
+ #hole = create_hole();
392
+ /** What the hole's answers put on screen: nothing yet, an answer, or the kept fallback. Any
393
+ * answer counts, whoever fetched it (a twin, a batch, a write at its address). */
394
+ #answer = create_answer();
395
+ /** The island's hydration: cold, armed (its schedule's listeners), waking, awake, failed. */
396
+ #wake = create_wake();
304
397
  /** when this hole's first fetch started (devtools + profiler: how long its fallback showed) */
305
398
  #fetch_started = 0;
306
- /** Frame-store address (endpoint call) this region is fetching, so disconnect can release it. */
399
+ /** Frame-store address (endpoint call) this region is bound to. */
307
400
  #frame_address = null;
308
- /** Unsubscribe from the frame store (a defer region binds to its address). */
309
- #frame_unsub = null;
310
- /** Set while an SWR revalidate is in flight, so the next apply marks `data-revalidated`. */
311
- #revalidating = false;
312
- #hydrating = false;
313
- /** A visitor's gesture woke this region (`interaction`): its downloads are the `user` class. */
314
- #user_woken = false;
401
+ /** Its subscription at that address (a defer region binds to its address), a child of its
402
+ * lifetime in the page: closing it unsubscribes. */
403
+ #binding = null;
404
+ /** A visitor's gesture woke this region (`interaction`, or the capture on a scheduled island): its
405
+ * downloads are the `user` class, and this is what replays their input (the capture's replay half,
406
+ * loading) — its island goes live once it is in, so the input lands AT wake (#260). */
407
+ #visitor = null;
315
408
  /** The hydrated island (hydrate core handle): dispose, and for a kept island, props push. */
316
409
  #app = null;
317
- /** Stops the shared `visible` observation (set while armed, cold). */
318
- #stop_visible = null;
319
- /** Removes the `wake="interaction"` wake listeners (set while armed, cold). */
320
- #disarm_interaction = null;
321
- #mql = null;
322
- /** The hole answered `keepFallback()`: the page's fallback stands, no phase-2 wake. */
323
- #kept = false;
324
- /** Abort in-flight region HTML fetch on disconnect (P-ABORT). */
325
- #fetch_abort = null;
326
- /** Cancel idle schedule when disconnected. */
327
- #idle_handle = null;
328
410
  /** Disconnected, teardown waiting a microtask: a reconnect before it is a move. */
329
411
  #leaving = false;
330
412
  /** Live region (`<ogygia-region live>`): driven imperatively by Region.svelte's applyLive. */
331
413
  #live_ready = false;
332
- #live_app = null;
333
- #live_module = "";
414
+ /** Its mode, tick by tick: static (subscribed to the store), hydrating, live (./region-machines.ts). */
415
+ #live = create_live();
416
+ /** The frame that paints next: each frame waits for its sheets in a visit of its own, and only
417
+ * the newest paints (./region-machines.ts). */
418
+ #paint = create_paint();
419
+ /** The props of the latest same-module tick that came while its island hydrated: pushed once live. */
420
+ #live_props = null;
421
+ /** A static live region's subscription to its address, while it is static. */
422
+ #live_unsub = null;
334
423
  #hydrate_run = null;
335
- #fire_hydrate = () => this.#hydrate_run ??= this.#hydrate().finally(() => this.#hydrate_run = null);
336
- #fire_server = () => this.#server();
337
- #fire_prefetch = () => this.#prefetch_html();
424
+ #fire_hydrate = () => {
425
+ const held = this.#hydrate_run;
426
+ if (held && (held.visit === this.#wake.now || this.#wake.now.kind === "waking")) return held.run;
427
+ const run = this.#box.ask((turn) => this.#hydrate(turn)).then(nothing);
428
+ const entry = {
429
+ visit: this.#wake.now,
430
+ run
431
+ };
432
+ this.#hydrate_run = entry;
433
+ const ran = () => this.#hydrate_ran(entry);
434
+ run.then(ran, ran);
435
+ return run;
436
+ };
437
+ /** A hydration run ended: forget it, unless a newer one took its place. */
438
+ #hydrate_ran(entry) {
439
+ if (this.#hydrate_run === entry) this.#hydrate_run = null;
440
+ }
441
+ #fire_server = () => this.#box.post((turn) => this.#server(turn));
442
+ #fire_prefetch = () => this.#box.post(() => this.#prefetch_html());
338
443
  /**
339
444
  * CONTINUITY: this persisted island is relocating onto `next` (the incoming page's SSR region).
340
445
  * Push the new page's props into the live app so a `persist`ed component reflects the new route
@@ -349,9 +454,18 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
349
454
  } catch {}
350
455
  }
351
456
  connectedCallback() {
457
+ if (this.#life.closed && this.isConnected) {
458
+ this.#life = scope();
459
+ this.#box = mailbox(this.#life);
460
+ }
352
461
  connected_regions.add(this);
462
+ devalue_lane_stale();
353
463
  pin_region_urls(this);
354
- if (this.hasAttribute("live")) return;
464
+ this.#box.post((turn) => this.#connect(turn));
465
+ }
466
+ /** The connect, in its turn: where the region stands, and what it arms. */
467
+ #connect(turn) {
468
+ if (this.hasAttribute("live")) return this.#live_put_back(turn);
355
469
  if (FRAMES && this.#minted_endpoint === null && is_deferred(this)) {
356
470
  const minted = this.getAttribute("endpoint");
357
471
  if (minted) this.#minted_endpoint = minted;
@@ -363,7 +477,7 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
363
477
  pin_region_urls(this);
364
478
  this.#minted_endpoint = this.getAttribute("endpoint");
365
479
  if (facts.sidecar) restore_props_sidecar(this, facts.sidecar.cloneNode(true));
366
- } else if (import.meta.env.DEV && identity) console.warn(HOLE_WITHOUT_ADDRESS_WARNING, this.getAttribute("entry") || identity);
480
+ } else if (import.meta.env.DEV && identity) warn("hole_without_address", { region: this.getAttribute("entry") || identity });
367
481
  }
368
482
  }
369
483
  if (is_frozen(this) && slots.lakes.on_frozen_connect(this, this.#lake_arm())) return;
@@ -383,7 +497,10 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
383
497
  deferred: false,
384
498
  nested: true
385
499
  });
386
- if (import.meta.env.DEV) console.warn(`[ogygia] nested region "${this.getAttribute("entry")}" skipped self-run; the nearest region above it is awake, so it rides that hydration (inner hydrate "${this.getAttribute("wake") || "load"}" ignored).`);
500
+ if (import.meta.env.DEV) warn("nested_region_rides_parent", {
501
+ entry: this.getAttribute("entry") || "",
502
+ wake: this.getAttribute("wake") || "load"
503
+ });
387
504
  return;
388
505
  }
389
506
  const deferred = is_deferred(this);
@@ -395,6 +512,10 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
395
512
  if (this.#ssr_html !== null) this.#watch();
396
513
  if (slots.lakes.wait_for_boundary(this, boundary)) return;
397
514
  this.#scheduled = true;
515
+ if (deferred && this.#answer.now.kind !== "none") {
516
+ this.#put_back();
517
+ return;
518
+ }
398
519
  const when = region_schedule(this);
399
520
  if (DEVTOOLS) {
400
521
  emit({
@@ -412,13 +533,44 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
412
533
  when
413
534
  });
414
535
  }
415
- if (!deferred) register_region(this);
416
- if (deferred) this.#arm(when, this.#fire_server);
417
- else this.#arm(when, this.#fire_hydrate);
418
- const prefetch = deferred ? this.getAttribute("prefetch") : null;
536
+ if (!deferred) register_region(this, this.#life);
537
+ if (!deferred) {
538
+ if (this.#wake.now.kind === "cold") this.#wake.go("armed");
539
+ this.#arm(when, this.#fire_hydrate);
540
+ return;
541
+ }
542
+ if (!this.#hole_armed()) return;
543
+ this.#arm(when, this.#fire_server);
544
+ const prefetch = this.getAttribute("prefetch");
419
545
  if (prefetch && prefetch !== when && prefetch !== "interaction") this.#arm(prefetch, this.#fire_prefetch, this.getAttribute("margin") || void 0);
420
546
  }
421
547
  /**
548
+ * Put the hole's fetch in `armed`, the state its schedules' listeners belong to: from idle, from a
549
+ * retry it waited for, or after it gave up (a lake's or a connect's new try). A fetch in flight
550
+ * arms nothing — it is already doing what a schedule would start.
551
+ */
552
+ #hole_armed() {
553
+ const s = this.#hole.now;
554
+ if (s.kind === "armed") return true;
555
+ if (s.kind === "fetching") return false;
556
+ return this.#hole.go("armed", { attempts: s.kind === "retrying" ? s.attempts : 0 });
557
+ }
558
+ /**
559
+ * A hole that had its answer is back on the page (#336): it binds to its address again, and the
560
+ * store hands it the frame there, as it does any late binder — its island wakes on it, whoever's
561
+ * the answer. A minute off the page with nothing bound, the store let the frame go: the hole's own
562
+ * last answer is painted again (its island, unmounted as it left, took its markup with it). Still
563
+ * bound (a move, a failed refresh), it has nothing to catch up on.
564
+ */
565
+ #put_back() {
566
+ const endpoint = this.#endpoint();
567
+ if (!endpoint || this.#bound()) return;
568
+ const handed = this.#frames_in;
569
+ this.#bind(endpoint);
570
+ const shown = this.#shown;
571
+ if (this.#frames_in === handed && shown !== null && this.#answer.now.kind === "answered") this.#box.post((turn) => this.#apply(shown, turn));
572
+ }
573
+ /**
422
574
  * Warm this hole's HTML into the frame store ahead of its `when` — the `prefetch` schedule. No
423
575
  * subscription is taken, so nothing is applied now; the later wake (`#server`) subscribes, the
424
576
  * store replays the warm frame at once, and its `ensure` joins instead of fetching. A failed
@@ -427,7 +579,7 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
427
579
  */
428
580
  #prefetch_html() {
429
581
  if (!FRAMES) return;
430
- if (this.#done || this.#fetching || !this.isConnected) return;
582
+ if (this.#answer.now.kind !== "none" || this.#hole.now.kind === "fetching" || !this.isConnected) return;
431
583
  const endpoint = this.#endpoint();
432
584
  if (!endpoint || !is_allowed_region_endpoint(endpoint)) return;
433
585
  const address = frameAddress(endpoint);
@@ -447,7 +599,7 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
447
599
  #frame_fetcher(endpoint, revalidate, cls) {
448
600
  const class_now = typeof cls === "function" ? cls : () => cls;
449
601
  return async (signal) => {
450
- const release = await load_slot({
602
+ const release = await new LoadSlot({
451
603
  kind: "content",
452
604
  cls,
453
605
  label: endpoint
@@ -460,45 +612,41 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
460
612
  };
461
613
  }
462
614
  #fetch_region(endpoint, revalidate, priority, signal) {
463
- return runtime_session.server_gate.run(async () => {
464
- if (BEACON) beacon_hole_fetched(endpoint);
465
- let res = await fetch(endpoint, {
466
- credentials: "same-origin",
467
- cache: revalidate ? "no-store" : "default",
615
+ return runtime_session.server_gate.run(() => {
616
+ const address = frameAddress(endpoint);
617
+ return fetch_hole(endpoint, {
618
+ revalidate,
468
619
  priority,
469
- signal
620
+ signal,
621
+ renewed: (fresh) => slots.frames?.renew(address, fresh)
470
622
  });
471
- if (res.status === 403 && capability_expired(endpoint)) {
472
- res = await fetch(renewal_url(endpoint), {
473
- credentials: "same-origin",
474
- cache: "no-store",
475
- signal
476
- });
477
- const fresh = res.ok ? res.headers.get("x-ogygia-capability") : null;
478
- if (fresh && is_allowed_region_endpoint(fresh)) this.#renewed_endpoint = fresh;
479
- }
480
- if (!is_same_origin_response(res)) throw new Error("cross-origin redirect");
481
- if (is_redirected_answer(res)) throw new RegionAnswerRefused("redirected", res.url);
482
- if (res.status === 403) throw new RegionAnswerRefused("forbidden", res.url || endpoint, deny_of(res, endpoint));
483
- if (!res.ok) throw new Error("status " + res.status);
484
- if (res.status === 204) return KEEP_FALLBACK_HTML;
485
- const text = await res.text();
486
- if (is_document_answer(text)) throw new RegionAnswerRefused("document", res.url);
487
- return text;
488
623
  });
489
624
  }
490
- /** The schedule hooks a frozen region (lake) drives its revalidation through. */
625
+ /** The schedule hooks a frozen region (lake) drives its revalidation through: their listeners
626
+ * belong to the hole's `armed` state, like a hole's own schedule's. */
491
627
  #lake_arm() {
492
628
  return {
493
- idle: (fire) => this.#on_idle(fire),
494
- visible: (fire, margin) => this.#on_visible(fire, margin),
495
- media: (when, fire) => this.#on_media(when, fire),
496
- fetch_revalidate: () => void this.#fetch_html({ revalidate: true }),
629
+ idle: (fire) => {
630
+ if (this.#hole_armed()) this.#on_idle(fire, this.#hole);
631
+ },
632
+ visible: (fire, margin) => {
633
+ if (this.#hole_armed()) this.#on_visible(fire, margin, this.#hole);
634
+ },
635
+ media: (when, fire) => {
636
+ if (this.#hole_armed()) this.#on_media(when, fire, this.#hole);
637
+ },
638
+ fetch_revalidate: () => this.#box.post((turn) => this.#fetch_html(turn, true)),
497
639
  wake_children: () => this.#wake_waiting_regions()
498
640
  };
499
641
  }
500
- /** Arm idle / visible / load / interaction / media for a schedule callback. */
642
+ /**
643
+ * Arm idle / visible / load / interaction / media for a schedule callback. The listeners belong to
644
+ * the `armed` state of the machine the callback drives — the island's wake, or the hole's fetch —
645
+ * which must be in it: leaving it (the schedule fired, an answer came another way, the region left
646
+ * the page) drops every one, however many schedules armed.
647
+ */
501
648
  #arm(when, fire, visible_margin) {
649
+ const owner = fire === this.#fire_hydrate ? this.#wake : this.#hole;
502
650
  if (fire === this.#fire_hydrate && when !== "interaction") this.#catch_early_input(fire);
503
651
  if (DEVTOOLS) {
504
652
  const raw = fire;
@@ -512,18 +660,20 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
512
660
  return raw();
513
661
  };
514
662
  }
515
- const start = () => background_start(fire);
516
- if (when === "idle") this.#on_idle(fire);
517
- else if (when === "visible") this.#on_visible(start, visible_margin);
663
+ const armed = owner.scope;
664
+ const start = () => background_start(() => void (armed.closed || fire()));
665
+ if (when === "idle") this.#on_idle(fire, owner);
666
+ else if (when === "visible") this.#on_visible(start, visible_margin, owner);
518
667
  else if (when === "load") start();
519
668
  else if (when === "interaction") {
520
- const by_user = () => {
521
- this.#user_woken = true;
522
- return fire();
523
- };
524
- if (is_deferred(this)) arm_on_demand(this, by_user);
525
- else this.#on_interaction(by_user);
526
- } else this.#on_media(when, start);
669
+ if (owner === this.#hole) {
670
+ arm_on_demand(this, () => {
671
+ this.#visitor = NO_REPLAY;
672
+ return fire();
673
+ });
674
+ owner.scope.defer(() => on_demand_armed.delete(this));
675
+ } else this.#on_interaction(fire);
676
+ } else this.#on_media(when, start, owner);
527
677
  }
528
678
  /**
529
679
  * `wake="interaction"`: sleep until a pointer/key/focus/click lands inside the region, then
@@ -543,8 +693,7 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
543
693
  fire();
544
694
  return;
545
695
  }
546
- const disarm = arm(this, fire);
547
- this.#disarm_interaction = typeof disarm === "function" ? disarm : null;
696
+ this.#hold_capture(arm(this, this.#by_visitor(fire), this.#life));
548
697
  }
549
698
  /**
550
699
  * A scheduled island (`load` / `idle` / `visible` / media) also catches input until it is live — the
@@ -555,28 +704,81 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
555
704
  #catch_early_input(fire) {
556
705
  const arm = slots.interaction;
557
706
  if (!arm || this.#app || kit_hydrates_page() && !ours_on_kit_document(this)) return;
558
- const disarm = arm(this, () => {
559
- this.#user_woken = true;
560
- return fire();
561
- });
562
- this.#disarm_interaction = typeof disarm === "function" ? disarm : null;
707
+ this.#hold_capture(arm(this, this.#by_visitor(fire), this.#life));
708
+ }
709
+ /**
710
+ * The wake as the interaction capture awaits it: a visitor's gesture (its downloads are the `user`
711
+ * class from here on), and a promise that REJECTS when the island failed to wake — the capture then
712
+ * replays its clicks onto the server markup and says so (interaction.ts). Only the capture gets this:
713
+ * a schedule's own wake has nobody to tell.
714
+ */
715
+ #by_visitor(fire) {
716
+ return (replayed) => {
717
+ this.#visitor = replayed;
718
+ return Promise.resolve(fire()).then(() => {
719
+ const s = this.#wake.now;
720
+ if (s.kind === "failed") throw s.error;
721
+ });
722
+ };
723
+ }
724
+ /**
725
+ * The capture outlives the schedule it was armed with: it stays while the island wakes (a click
726
+ * meanwhile is queued, and replayed once the island is live), so leaving `armed` for `waking` hands
727
+ * its disarm on to `waking`, and leaving that (awake, failed, or the region gone) disarms it. Gone
728
+ * (out of the document), its visit is over: the clicks it queued go with it.
729
+ */
730
+ #hold_capture(disarm) {
731
+ if (typeof disarm !== "function") return;
732
+ const wake = this.#wake;
733
+ const hold = () => {
734
+ if (wake.now.kind === "waking") wake.scope.defer(hold);
735
+ else disarm(!this.isConnected);
736
+ };
737
+ wake.scope.defer(hold);
563
738
  }
564
739
  /**
565
- * Move this hole onto the fresh capability a renewal answered: the attribute (every DOM reader —
566
- * lakes, devtools, the router's warm — agrees), the minted copy, and the frame-store address its
567
- * later wakes and revalidates fetch and apply at. Called after the answer that carried it landed.
740
+ * Move this hole onto the fresh capability a renewal at its address answered (frame-store
741
+ * `renew`, whichever copy's fetch got it): the attribute (every DOM reader — lakes, devtools, the
742
+ * router's warm — agrees), the minted copy, and, should the address differ, the frame-store
743
+ * address its later wakes and revalidates fetch and apply at. Called once an answer landed — moving
744
+ * addresses mid-fetch would orphan that answer. A copy whose own capability has not expired keeps
745
+ * it (it can be newer than the renewal: a page rendered since).
568
746
  */
569
747
  #adopt_renewed() {
570
- const fresh = this.#renewed_endpoint;
571
- if (!fresh) return;
572
- this.#renewed_endpoint = null;
748
+ const address = this.#frame_address;
749
+ const fresh = address ? slots.frames?.renewal(address) : null;
750
+ const current = this.getAttribute("endpoint");
751
+ if (!fresh || fresh === current || current && !capability_expired(current)) return;
573
752
  this.#minted_endpoint = fresh;
574
753
  this.setAttribute("endpoint", fresh);
575
- if (this.#frame_unsub) {
576
- this.#frame_unsub();
577
- const address = this.#frame_address = frameAddress(fresh);
578
- this.#frame_unsub = slots.frames?.subscribe(address, (f) => void (this.#applying = this.#apply(f.html))) ?? null;
579
- }
754
+ if (this.#bound()) this.#bind(fresh);
755
+ }
756
+ /**
757
+ * Bind to the store at `endpoint`'s address: this region applies whatever frame lands there (#apply)
758
+ * — from its own fetch, a twin's, a navigation batch stream, or a mutation. subscribe() replays
759
+ * current content immediately, so a late-mounting twin catches up free. A re-minted endpoint (new
760
+ * props, new page facts) moves the binding to its new address. Returns the address.
761
+ */
762
+ #bind(endpoint) {
763
+ const address = frameAddress(endpoint);
764
+ if (this.#bound() && this.#frame_address === address) return address;
765
+ this.#binding?.close();
766
+ const binding = this.#binding = this.#life.child();
767
+ this.#frame_address = address;
768
+ binding.defer(() => {
769
+ if (this.#binding === binding) this.#frame_address = null;
770
+ });
771
+ const box = this.#box;
772
+ const off = slots.frames?.subscribe(address, (f) => {
773
+ this.#frames_in++;
774
+ box.post((turn) => this.#apply(f.html, turn));
775
+ });
776
+ if (off) binding.defer(off);
777
+ return address;
778
+ }
779
+ /** Bound to a frame-store address now. */
780
+ #bound() {
781
+ return this.#binding !== null && !this.#binding.closed;
580
782
  }
581
783
  /** The hole's endpoint: the attribute, or the server-minted copy when Kit's hydration wiped it
582
784
  * (restored on the element so every other DOM reader agrees). */
@@ -588,28 +790,53 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
588
790
  return this.#minted_endpoint;
589
791
  }
590
792
  /**
591
- * Deferred hole: get its HTML and swap it in. When `hydrate` is also set (deferred client
592
- * island), schedule phase-2 hydrate — coalescing matching schedules to immediate load.
793
+ * Deferred hole, its `when` fired: bind to its address and get its HTML. The swap — and, for a
794
+ * deferred client island, the phase-2 wake — happen where every answer lands (#apply), whoever
795
+ * fetched it.
593
796
  */
594
- async #server() {
797
+ #server(turn) {
595
798
  if (!FRAMES) return;
596
799
  const endpoint = this.#endpoint();
597
- if (endpoint && !this.#frame_unsub) {
598
- const address = this.#frame_address = frameAddress(endpoint);
599
- this.#frame_unsub = slots.frames?.subscribe(address, (f) => void (this.#applying = this.#apply(f.html))) ?? null;
600
- }
601
- await this.#fetch_html();
602
- await this.#applying;
603
- if (!this.#done || !this.isConnected) return;
604
- if (this.#kept) return;
800
+ const handed = this.#frames_in;
801
+ if (endpoint) this.#bind(endpoint);
802
+ const run = this.#frames_in === handed ? this.#fetch_html(turn) : void 0;
803
+ if (this.#hole.now.kind === "armed") this.#hole.go("idle");
804
+ return run;
805
+ }
806
+ /**
807
+ * A deferred client island's answer is in: arm its phase-2 wake — coalescing a schedule that
808
+ * matches the hole's own `when` to an immediate load. Once: an island already armed, waking, awake
809
+ * or failed is not armed again (a refresh morphs a live island in place).
810
+ */
811
+ #arm_phase2() {
812
+ if (this.#wake.now.kind !== "cold" || this.#app || !this.isConnected) return;
605
813
  const hydrate = region_hydrate_schedule(this);
606
814
  if (!hydrate) return;
607
815
  const defer_when = this.getAttribute("when") || "load";
608
816
  const phase2 = phase2_hydrate_schedule(defer_when, hydrate);
609
817
  const margin = phase2 === "visible" ? this.getAttribute("hydrate-margin") || this.getAttribute("margin") || void 0 : void 0;
610
- register_region(this);
818
+ register_region(this, this.#life);
819
+ this.#wake.go("armed");
611
820
  this.#arm(phase2, this.#fire_hydrate, margin);
612
821
  }
822
+ /** An answer landed: the hole's own fetch has nothing left to wait for — a schedule still armed
823
+ * (a retry's) or a retry it waited for is called off. A fetch in flight runs to its end. */
824
+ #answered(kind) {
825
+ if (this.#answer.now.kind !== kind) this.#answer.go(kind);
826
+ const hole = this.#hole.now.kind;
827
+ if (hole === "armed" || hole === "retrying" || hole === "failed") this.#hole.go("idle");
828
+ }
829
+ /** A frame landed: it starts its paint visit (a frame still waiting for its sheets is older, and
830
+ * its visit is over). Returns the visit, to check after the wait. */
831
+ #paint_next() {
832
+ this.#paint.go("painting");
833
+ return this.#paint.now;
834
+ }
835
+ /** The frame of visit `painting` paints now: nothing is waiting any more, unless a newer frame
836
+ * already started (a listener of this paint wrote one). */
837
+ #painted(painting) {
838
+ if (this.#paint.now === painting) this.#paint.go("idle");
839
+ }
613
840
  /**
614
841
  * THE single apply path. A frame's HTML lands here from any source (own fetch, stream parcel,
615
842
  * navigation batch, mutation). First arrival hydrates the hole; a later arrival at a newer
@@ -617,22 +844,25 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
617
844
  * offline before custom elements connect, then swap, mark, event. HOLE-TRUST: the HTML is our own
618
845
  * signed same-origin SSR.
619
846
  */
620
- async #apply(html) {
847
+ async #apply(html, turn) {
621
848
  if (!this.isConnected) return;
849
+ const painting = this.#paint_next();
622
850
  if (html === "<!--ogygia:keep-fallback-->") {
623
- this.#revalidating = false;
624
- this.#done = true;
625
- this.#kept = true;
851
+ this.#painted(painting);
852
+ use_sheets(this, []);
853
+ this.#answered("kept");
626
854
  if (!is_awake(this)) this.setAttribute("data-hydrated", "");
627
855
  this.setAttribute("data-og-kept", "");
856
+ this.#adopt_renewed();
628
857
  this.dispatchEvent(new CustomEvent("ogygia:server", { bubbles: true }));
629
858
  return;
630
859
  }
631
- const revalidate = this.#revalidating || this.#done;
632
- this.#revalidating = false;
633
- const { frag, ready } = region_fragment(html);
634
- await ready;
635
- if (!this.isConnected) return;
860
+ const revalidate = is_frozen(this) || this.#answer.now.kind === "answered";
861
+ const { frag, ready, sheets } = region_fragment(html);
862
+ if (await turn.wait(ready) === STALE || this.#paint.now !== painting) return;
863
+ this.#painted(painting);
864
+ use_sheets(this, sheets);
865
+ this.#shown = html;
636
866
  slots.lakes.settle_in(frag);
637
867
  slots.lakes.mark_frozen_settled(this);
638
868
  const morph = slots.morph;
@@ -645,10 +875,11 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
645
875
  restore_adopt_sheets();
646
876
  init_shadow_registries(this);
647
877
  absorb_page_facts_now();
648
- this.#done = true;
878
+ this.#answered("answered");
879
+ this.removeAttribute("data-og-kept");
649
880
  if (revalidate) this.setAttribute("data-revalidated", "");
650
881
  else if (!is_awake(this)) this.setAttribute("data-hydrated", "");
651
- slots.lakes.after_html_swap(this, { revalidate });
882
+ slots.lakes.after_html_swap(this, { revalidate }, () => this.#wake_waiting_regions());
652
883
  if (BEACON && !revalidate && this.#fetch_started) {
653
884
  const ep = this.getAttribute("endpoint");
654
885
  beacon_hole_answered(this, this.#fetch_started, ep ? slots.frames?.batch_times?.(ep) : void 0);
@@ -662,7 +893,9 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
662
893
  revalidate,
663
894
  ...!revalidate && this.#fetch_started ? { wait_ms: Math.round(performance.now() - this.#fetch_started) } : {}
664
895
  });
896
+ this.#adopt_renewed();
665
897
  this.dispatchEvent(new CustomEvent("ogygia:server", { bubbles: true }));
898
+ this.#arm_phase2();
666
899
  }
667
900
  /**
668
901
  * Store applicator for a STATIC live/held region (`<ogygia-region live>` with no interactive
@@ -671,7 +904,7 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
671
904
  * survives — the breathing update live partials rely on). Interactive live regions never reach
672
905
  * here: they keep the imperative keep-alive path in {@link applyLive}.
673
906
  */
674
- async #morph_live(html) {
907
+ async #morph_live(html, turn) {
675
908
  if (!this.isConnected) return;
676
909
  if (!this.#live_ready && this.hasAttribute("data-og-baked")) {
677
910
  this.#live_ready = true;
@@ -679,9 +912,11 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
679
912
  this.dispatchEvent(new CustomEvent("ogygia:live", { bubbles: true }));
680
913
  return;
681
914
  }
682
- const { frag, ready } = region_fragment(html);
683
- await ready;
684
- if (!this.isConnected) return;
915
+ const painting = this.#paint_next();
916
+ const { frag, ready, sheets } = region_fragment(html);
917
+ if (await turn.wait(ready) === STALE || this.#paint.now !== painting) return;
918
+ this.#painted(painting);
919
+ use_sheets(this, sheets);
685
920
  slots.lakes.settle_in(frag);
686
921
  if (!this.#live_ready) {
687
922
  this.replaceChildren(frag);
@@ -692,7 +927,7 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
692
927
  const morph = slots.morph;
693
928
  if (morph) morph(this, nodes);
694
929
  else this.replaceChildren(...nodes);
695
- for (const el of this.querySelectorAll("ogygia-region")) if (el instanceof OgygiaRegion) el.#renew();
930
+ for (const el of this.querySelectorAll("ogygia-region")) if (el instanceof OgygiaRegion) el.#box.post((t) => el.#renew(t));
696
931
  }
697
932
  restore_adopt_sheets();
698
933
  init_shadow_registries(this);
@@ -704,154 +939,188 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
704
939
  * new page facts (`pv`) — it fetches its new answer, the current one on screen until that lands.
705
940
  * A hole that never fetched yet fetches the new address on its own schedule.
706
941
  */
707
- #renew() {
942
+ #renew(turn) {
708
943
  if (!this.#frame_address || !is_deferred(this)) return;
709
944
  const endpoint = this.getAttribute("endpoint");
710
945
  if (!endpoint || frameAddress(endpoint) === this.#frame_address) return;
711
- this.#fetch_html({ revalidate: true });
946
+ return this.#fetch_html(turn, true);
712
947
  }
713
948
  /**
714
- * Fetch `endpoint` HTML and swap it in.
715
- * Success sets `#done` (at most one successful swap per element). Failure leaves `#done` false
716
- * so a reconnect, remount, or deferred retry can try again — previously `#done` was set up front
717
- * and a transient network error permanently killed the hole.
949
+ * Fetch `endpoint` HTML into the store; the store's write swaps it in (#apply). One visit to the
950
+ * hole's `fetching`: it ends when the fetch does (idle), when it failed (`retrying`, `failed`), or
951
+ * when the region leaves the page (it leaves the shared fetch). A failure leaves the
952
+ * answer as it was, so a reconnect, a remount or the retry can try again — a transient network
953
+ * error never kills the hole for good.
718
954
  * A deferred hole reuses the browser's `<link rel="preload">` response; an swr REVALIDATE must
719
955
  * not (the endpoint answers `cache-control: private, max-age=30`, and stale is the whole point).
720
956
  */
721
- async #fetch_html(opts = {}) {
957
+ async #fetch_html(turn, revalidate = false) {
722
958
  if (!FRAMES) return;
723
- if (this.#fetching || this.#done && !opts.revalidate) return;
959
+ const s = this.#hole.now;
960
+ if (s.kind === "fetching" || !revalidate && this.#answer.now.kind !== "none") return;
724
961
  const endpoint = this.#endpoint();
725
962
  if (!endpoint) return;
726
963
  if (!is_allowed_region_endpoint(endpoint)) {
727
- if (import.meta.env.DEV) console.warn("[ogygia] refused non-same-origin region endpoint", endpoint);
964
+ if (import.meta.env.DEV) warn("region_endpoint_foreign", { endpoint });
728
965
  return;
729
966
  }
730
- this.#fetching = true;
967
+ const frames = slots.frames;
968
+ if (!frames) return;
731
969
  if (!this.#fetch_started) this.#fetch_started = performance.now();
732
- this.#fetch_abort?.abort();
733
- this.#fetch_abort = new AbortController();
734
- const outer = this.#fetch_abort.signal;
735
- const previous = this.#frame_address;
736
- const address = this.#frame_address = frameAddress(endpoint);
737
- if (this.#frame_unsub && previous !== address) {
738
- this.#frame_unsub();
739
- this.#frame_unsub = null;
970
+ const address = this.#bind(endpoint);
971
+ const attempts = s.kind === "armed" || s.kind === "retrying" ? s.attempts : 0;
972
+ this.#hole.go("fetching", {
973
+ attempts,
974
+ revalidate
975
+ });
976
+ const mine = this.#hole.now;
977
+ let html;
978
+ try {
979
+ html = await turn.wait(this.#hole.scope.task((signal) => this.#ensure_frame(frames, endpoint, address, revalidate, signal)));
980
+ } catch (err) {
981
+ if (this.#hole.now !== mine) return;
982
+ return this.#fetch_failed(turn, err, endpoint, revalidate, attempts + 1);
740
983
  }
741
- if (!this.#frame_unsub) this.#frame_unsub = slots.frames?.subscribe(address, (f) => void (this.#applying = this.#apply(f.html))) ?? null;
742
- if (opts.revalidate) this.#revalidating = true;
984
+ if (html !== STALE && this.#hole.now === mine) this.#hole.go("idle");
985
+ }
986
+ /**
987
+ * The network side of one visit to `fetching`, through the frame store: N regions with the same
988
+ * address ⇒ one request, and a stale response can't overwrite a newer one (the store tickets at
989
+ * request time). `signal` is the visit's: leaving it (the region left the page) lets THIS region
990
+ * go (`STALE`), never a twin — the shared fetch is aborted only once its last caller left.
991
+ */
992
+ async #ensure_frame(frames, endpoint, address, revalidate, signal) {
993
+ if (!revalidate && frames.join && !endpoint.includes("&ttl=") && !capability_expired(endpoint)) await frames.join(endpoint);
994
+ const fetcher = this.#frame_fetcher(endpoint, revalidate, () => this.#visitor ? "user" : viewport_class(this));
995
+ const run = () => frames.ensure(address, fetcher, {
996
+ force: revalidate,
997
+ signal
998
+ });
743
999
  try {
744
- const frames = slots.frames;
745
- if (!opts.revalidate && frames?.join && !endpoint.includes("&ttl=") && !capability_expired(endpoint)) await frames.join(endpoint);
746
- const run = () => frames?.ensure(address, this.#frame_fetcher(endpoint, !!opts.revalidate, () => this.#user_woken ? "user" : viewport_class(this)), { force: opts.revalidate });
747
- try {
748
- await run();
749
- } catch (err) {
750
- if (err?.name !== "OgygiaBatchMiss" || outer.aborted) throw err;
751
- await run();
752
- }
753
- this.#adopt_renewed();
1000
+ return await run();
754
1001
  } catch (err) {
755
- if (err?.name === "AbortError" || outer.aborted) return;
756
- const refused = err instanceof RegionAnswerRefused;
757
- const deny = refused && err.reason === "forbidden" ? err.deny || "no reason given" : void 0;
758
- if (import.meta.env.DEV) {
759
- if (deny !== void 0) console.warn(`[ogygia] region answer refused for ${endpoint}: ${deny.startsWith("not ogygia") ? deny : `the islands endpoint refused its capability (403: ${deny})`}. ` + (deny.startsWith("foreign-key") ? "The page and its islands endpoint came from different deployments (two keys): route the endpoint to the deployment that served the page, or give every deployment one OGYGIA_SECRET. " : "") + "The fallback stands.");
760
- else if (refused) console.warn(`[ogygia] region answer refused for ${endpoint}: the request was ${err.reason === "redirected" ? "redirected to" : "answered with a whole document at"} ${err.final_url}. A handle in front of ogygia.handle() (an auth redirect, a locale bounce, a 404 handler) took the region request — exempt the islands endpoint there. The fallback stands.`);
761
- else console.warn("[ogygia] region fetch failed for", endpoint, err);
762
- }
763
- this.#fetch_attempts++;
764
- const final = refused || !this.isConnected || this.#fetch_attempts >= 3;
765
- if (final) {
766
- let id = "";
767
- let final_path;
768
- try {
769
- id = new URL(endpoint, location.href).searchParams.get("id") ?? "";
770
- if (refused) final_path = new URL(err.final_url, location.href).pathname;
771
- } catch {}
772
- if (BEACON && id) beacon_hole_failed({
773
- id,
774
- reason: refused ? err.reason : "error",
775
- ...final_path ? { final_path } : {},
776
- ...refused ? {} : { message: String(err?.message ?? err).split("\n")[0].slice(0, 200) },
777
- ...deny !== void 0 ? { message: deny.slice(0, 200) } : {},
778
- attempts: this.#fetch_attempts,
779
- t: Math.round(performance.now())
780
- });
781
- }
782
- if (DEVTOOLS) emit({
783
- domain: "runtime",
784
- name: "region.server.failed",
785
- entry: this.getAttribute("entry") || void 0,
1002
+ if (err?.name !== "OgygiaBatchMiss" || signal.aborted) throw err;
1003
+ return await run();
1004
+ }
1005
+ }
1006
+ /**
1007
+ * A fetch of this hole failed (never its own leaving: that is no failure). Said in dev, told to the
1008
+ * profiler and the devtools; then a retry waits in `retrying` (called off by an answer that comes
1009
+ * another way, a new schedule, or the region leaving), or, refused or out of tries, it gives up.
1010
+ */
1011
+ async #fetch_failed(turn, err, endpoint, revalidate, attempts) {
1012
+ const refused = err instanceof RegionAnswerRefused;
1013
+ const deny = refused && err.reason === "forbidden" ? err.deny || "no reason given" : void 0;
1014
+ if (import.meta.env.DEV) {
1015
+ if (deny !== void 0) warn("region_answer_denied", {
786
1016
  endpoint,
1017
+ deny
1018
+ });
1019
+ else if (refused) warn("region_answer_redirected", {
1020
+ endpoint,
1021
+ how: err.reason === "redirected" ? "redirected" : "document",
1022
+ final_url: err.final_url
1023
+ });
1024
+ else warn("region_fetch_failed", { endpoint }, err);
1025
+ }
1026
+ const final = refused || !this.isConnected || attempts >= region_fetch.attempts;
1027
+ if (final) {
1028
+ let id = "";
1029
+ let final_path;
1030
+ try {
1031
+ id = new URL(endpoint, location.href).searchParams.get("id") ?? "";
1032
+ if (refused) final_path = new URL(err.final_url, location.href).pathname;
1033
+ } catch {}
1034
+ if (BEACON && id) beacon_hole_failed({
1035
+ id,
787
1036
  reason: refused ? err.reason : "error",
788
- ...refused ? { final_url: err.final_url } : { message: String(err?.message ?? err).split("\n")[0].slice(0, 200) },
1037
+ ...final_path ? { final_path } : {},
1038
+ ...refused ? {} : { message: String(err?.message ?? err).split("\n")[0].slice(0, 200) },
789
1039
  ...deny !== void 0 ? { message: deny.slice(0, 200) } : {},
790
- attempt: this.#fetch_attempts,
791
- final
1040
+ attempts,
1041
+ t: Math.round(performance.now())
792
1042
  });
793
- this.#scheduled = false;
794
- if (!refused && this.isConnected && this.#fetch_attempts < 3) {
795
- const delay = 500 * this.#fetch_attempts;
796
- setTimeout(() => {
797
- if (!this.isConnected || this.#done || this.#fetching) return;
798
- if (opts.revalidate) this.#fetch_html({ revalidate: true });
799
- else this.connectedCallback();
800
- }, delay);
1043
+ }
1044
+ if (DEVTOOLS) emit({
1045
+ domain: "runtime",
1046
+ name: "region.server.failed",
1047
+ entry: this.getAttribute("entry") || void 0,
1048
+ endpoint,
1049
+ reason: refused ? err.reason : "error",
1050
+ ...refused ? { final_url: err.final_url } : { message: String(err?.message ?? err).split("\n")[0].slice(0, 200) },
1051
+ ...deny !== void 0 ? { message: deny.slice(0, 200) } : {},
1052
+ attempt: attempts,
1053
+ final
1054
+ });
1055
+ this.#scheduled = false;
1056
+ if (!final) {
1057
+ if (this.#answer.now.kind !== "none") {
1058
+ this.#hole.go("idle");
801
1059
  return;
802
1060
  }
803
- slots.lakes.after_fetch_exhausted(this, opts, () => this.#wake_waiting_regions());
804
- } finally {
805
- this.#fetching = false;
1061
+ this.#hole.go("retrying", {
1062
+ attempts,
1063
+ revalidate
1064
+ });
1065
+ const retrying = this.#hole.scope;
1066
+ const wait = backoffAfter(region_fetch, attempts);
1067
+ if (await turn.wait(retrying.task(() => new Promise((resolve) => retrying.timeout(resolve, wait)))) === STALE) return;
1068
+ if (revalidate) return this.#fetch_html(turn, true);
1069
+ return this.#connect(turn);
806
1070
  }
1071
+ this.#hole.go("failed");
1072
+ slots.lakes.after_fetch_exhausted(this, { revalidate }, () => this.#wake_waiting_regions());
807
1073
  }
808
1074
  /** Re-enter connectedCallback for descendants that early-returned on an unsettled lake. */
809
1075
  #wake_waiting_regions() {
810
1076
  for (const el of this.querySelectorAll("ogygia-region")) if (el instanceof OgygiaRegion) el.connectedCallback();
811
1077
  }
812
- #on_idle(fire) {
813
- if ("requestIdleCallback" in window) this.#idle_handle = requestIdleCallback(() => {
814
- this.#idle_handle = null;
815
- fire();
816
- }, { timeout: 2e3 });
817
- else this.#idle_handle = setTimeout(() => {
818
- this.#idle_handle = null;
1078
+ #on_idle(fire, owner) {
1079
+ const s = owner.scope;
1080
+ if (!("requestIdleCallback" in window)) {
1081
+ s.timeout(() => fire(), 200);
1082
+ return;
1083
+ }
1084
+ const first = () => {
1085
+ off_idle();
1086
+ off_cap();
819
1087
  fire();
820
- }, 200);
1088
+ };
1089
+ const off_idle = s.timeout(first, "idle");
1090
+ const off_cap = s.timeout(first, 2e3);
821
1091
  }
822
1092
  /** `visible`: the shared observer for this margin (observe.ts) — fires once, on first entry. */
823
- #on_visible(fire, root_margin) {
1093
+ #on_visible(fire, root_margin, owner) {
824
1094
  const rootMargin = root_margin || this.getAttribute("margin") || "0px";
825
- this.#stop_visible = once_visible(this, rootMargin, () => {
826
- this.#stop_visible = null;
827
- fire();
828
- });
1095
+ owner.scope.defer(once_visible(this, rootMargin, () => void fire()));
829
1096
  }
830
- #on_media(q, fire) {
831
- if (!q) return fire();
1097
+ #on_media(q, fire, owner) {
1098
+ if (!q) return void fire();
832
1099
  const mql = matchMedia(q);
833
- if (mql.matches) return fire();
834
- const on = (e) => {
835
- if (e.matches) {
836
- mql.removeEventListener("change", on);
837
- this.#mql = null;
838
- fire();
839
- }
840
- };
841
- mql.addEventListener("change", on);
842
- this.#mql = {
843
- mql,
844
- on
845
- };
1100
+ if (mql.matches) return void fire();
1101
+ const off = owner.scope.on(mql, "change", (e) => {
1102
+ if (!e.matches) return;
1103
+ off();
1104
+ fire();
1105
+ });
846
1106
  }
847
1107
  /**
848
1108
  * Wake this island: load its module and the hydrate core in parallel, then take a SCHEDULER
849
1109
  * TURN (one island per task, viewport first — schedule.ts) and run the synchronous hydrate step
850
1110
  * (hydrate-core.ts) inside it.
851
1111
  */
852
- async #hydrate() {
853
- if (this.#app || this.#hydrating) return;
854
- this.#hydrating = true;
1112
+ async #hydrate(turn) {
1113
+ const from = this.#wake.now.kind;
1114
+ if (this.#app || from !== "cold" && from !== "armed") return;
1115
+ this.#wake.go("waking");
1116
+ const mine = this.#wake.now;
1117
+ /** What this wake waits on is its visit's: leaving `waking` ends it. */
1118
+ const visit = this.#wake.scope;
1119
+ /** After each wait, back in its turn: the region left the page (`STALE`: its mailbox closed, the
1120
+ * one it came back with wakes it), or is on its way out (its teardown a microtask away), or the
1121
+ * visit is over. Checked after every wait before the turn. */
1122
+ const gone = (back) => back === STALE || !this.isConnected || this.#wake.now !== mine;
1123
+ let failure = null;
855
1124
  const t0 = now_ms();
856
1125
  const dt_t0 = DEVTOOLS ? t0 : 0;
857
1126
  let t_loaded = t0;
@@ -864,49 +1133,60 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
864
1133
  });
865
1134
  if (CLIENT_PLUGINS) client_region_mount(this);
866
1135
  try {
867
- if (typeof document !== "undefined" && document.readyState === "loading") await dom_ready();
868
- if (!this.isConnected) return;
1136
+ if (typeof document !== "undefined" && document.readyState === "loading") {
1137
+ if (gone(await turn.wait(dom_ready()))) return;
1138
+ }
1139
+ if (gone()) return;
869
1140
  const entry = island_entry_of(this);
870
1141
  if (!entry) return;
871
- const cls = () => this.#user_woken ? "user" : viewport_class(this);
872
- const release = await load_slot({
1142
+ const cls = () => this.#visitor ? "user" : viewport_class(this);
1143
+ const slot = new LoadSlot({
873
1144
  kind: "code",
874
1145
  cls,
875
1146
  label: entry
876
- }).ready;
1147
+ });
1148
+ const release = await turn.wait(slot.ready);
1149
+ if (release === STALE) return void slot.ready.then(hand_back);
877
1150
  let mod, core;
878
1151
  try {
879
- if (!this.isConnected) return;
1152
+ if (gone()) return;
880
1153
  hydrate_started(this);
881
- [mod, core] = await Promise.all([
1154
+ const loaded = await turn.wait(Promise.all([
882
1155
  load_island(entry, cls()),
883
- hydrate_core(),
884
- slots.wire_classes?.(this)
885
- ]);
1156
+ hydrate_core.get(),
1157
+ slots.wire_classes?.(this),
1158
+ devalue_for_wake()
1159
+ ]));
1160
+ if (loaded === STALE) return;
1161
+ [mod, core] = loaded;
886
1162
  } finally {
887
1163
  release();
888
1164
  }
889
1165
  t_loaded = now_ms();
890
- if (!this.isConnected) return;
891
- await this.#kit_page_ready(core);
892
- if (!this.isConnected) return;
1166
+ if (gone()) return;
1167
+ if (gone(await turn.wait(this.#kit_page_ready(core, visit)))) return;
893
1168
  core.seed_remote_once();
894
1169
  core.seed_page_once();
895
- await hydrate_turn(this);
896
- if (!this.isConnected || this.#app) return;
1170
+ let visitor;
1171
+ do {
1172
+ visitor = this.#visitor;
1173
+ if (visitor && gone(await turn.wait(visitor.catch(nothing)))) return;
1174
+ if (gone()) return;
1175
+ if (gone(await turn.wait(hydrate_turn(this))) || this.#app) return;
1176
+ } while (this.#visitor !== visitor);
897
1177
  t_turn = now_ms();
898
1178
  const ssr_html = this.#ssr_html;
899
1179
  const touched = ssr_html === null || region_changed(this);
900
- if (import.meta.env.DEV) set_hydrating(this);
1180
+ if (import.meta.env.DEV) set_hydrating(this, this.#life);
901
1181
  this.#app = core.hydrate_island(this, entry, mod, ssr_html, touched);
902
1182
  this.#ssr_html = null;
903
1183
  this.#unwatch();
904
1184
  if (!this.#app) return;
1185
+ if (RULES) note_mount(this);
905
1186
  const t_done = now_ms();
906
1187
  await Promise.resolve();
907
1188
  const t_mounted = now_ms();
908
- await snippets_settled(this);
909
- if (!this.isConnected || !this.#app) return;
1189
+ if (gone(await turn.wait(snippets_settled(this, visit))) || !this.#app) return;
910
1190
  this.setAttribute("data-hydrated", "");
911
1191
  if (BEACON) beacon_hydrated(this, t0, t_loaded, t_done, ssr_html, t_turn);
912
1192
  if (DEVTOOLS) emit({
@@ -917,6 +1197,8 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
917
1197
  });
918
1198
  this.dispatchEvent(new CustomEvent("ogygia:hydrated", { bubbles: true }));
919
1199
  } catch (err) {
1200
+ if (this.#wake.now !== mine) return;
1201
+ failure = { error: err };
920
1202
  if (this.#watching) mark_region_changed(this);
921
1203
  this.#unwatch();
922
1204
  if (BEACON) beacon_failed(this, err?.message ?? String(err), t0, t_turn);
@@ -927,19 +1209,19 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
927
1209
  message: err?.message ?? String(err),
928
1210
  ...t_turn !== void 0 ? { turn: t_turn } : {}
929
1211
  });
930
- const msg = err?.message ?? "";
931
1212
  const entry = this.getAttribute("entry");
932
- if (ENTRY_FETCH_FAILED_RE.test(msg)) {
1213
+ if (is_failed_import(err)) {
933
1214
  if (import.meta.env.DEV) {
934
- console.error(`[ogygia] island entry failed to load: ${entry}\nThe module could not be FETCHED (not a hydration error). In dev this almost always means Vite re-optimized dependencies and rotated its optimizer hash while this tab was open, so its dep imports 404. Recovering… Original error:`, err);
1215
+ console.error(new OgygiaError("island_load_failed_stale_deps", { entry }), err);
935
1216
  recover_from_stale_deps(entry);
936
- } else console.error("[ogygia] island entry failed to load:", entry, err);
937
- } else console.error("[ogygia] hydration failed for", entry, err);
1217
+ } else console.error(new OgygiaError("island_load_failed", { entry }), err);
1218
+ } else console.error(new OgygiaError("island_hydrate_failed", { entry }), err);
938
1219
  } finally {
939
- this.#hydrating = false;
940
- hydrate_settled(this);
941
- this.#disarm_interaction?.();
942
- this.#disarm_interaction = null;
1220
+ if (this.#wake.now === mine) {
1221
+ hydrate_settled(this);
1222
+ if (failure) this.#wake.go("failed", failure);
1223
+ else this.#wake.go(this.#app ? "awake" : "cold");
1224
+ }
943
1225
  }
944
1226
  }
945
1227
  /**
@@ -947,7 +1229,7 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
947
1229
  * carries server-rendered HTML). No fetch — the HTML is already here.
948
1230
  *
949
1231
  * - **First tick:** parse + swap the HTML in. Interactive (`hydrate` + `module`) → hydrate a
950
- * {@link LiveHost} so later ticks can push props. Static → done (no JS ever ships).
1232
+ * pushable props so later ticks can push them. Static → done (no JS ever ships).
951
1233
  * - **Later tick, same interactive module:** keep-alive — push the new props to the mounted
952
1234
  * component (focus + local state survive; Svelte reconciles). No swap, no re-hydrate.
953
1235
  * - **Later tick, static:** morph the new HTML in place (see {@link morph_children}).
@@ -955,82 +1237,145 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
955
1237
  */
956
1238
  async applyLive(desc) {
957
1239
  if (!FRAMES) return;
1240
+ this.#live_last = desc;
1241
+ await this.#box.ask((turn) => this.#apply_live(desc, turn));
1242
+ }
1243
+ /**
1244
+ * A live region put back on the page (#337) goes on with its last tick: leaving it disposed its
1245
+ * island or dropped its subscription. A static one binds to its address again, where the store
1246
+ * hands it the newest frame (or, when it let that go, the last tick's HTML is painted again); an
1247
+ * interactive one swaps and hydrates its island again. Still in its mode (a move), it has nothing
1248
+ * to catch up on.
1249
+ */
1250
+ #live_put_back(turn) {
1251
+ const last = this.#live_last;
1252
+ if (last === null || this.#live.now.kind !== "idle") return;
1253
+ return this.#apply_live(last, turn, true);
1254
+ }
1255
+ /** One live tick, in its turn. `again`: its last tick, gone on with as it is put back. */
1256
+ async #apply_live(desc, turn, again = false) {
958
1257
  this.setAttribute("render", "defer");
959
1258
  this.setAttribute("entry", desc.module || desc.id);
960
1259
  if (desc.url) this.setAttribute("endpoint", desc.url);
961
1260
  if (desc.hydrate) this.setAttribute("wake", desc.hydrate);
962
1261
  else this.removeAttribute("wake");
963
1262
  if (desc.hydrateMargin) this.setAttribute("hydrate-margin", desc.hydrateMargin);
964
- const interactive = !!desc.hydrate && !!desc.module;
965
- if (!interactive) {
966
- if (desc.url && !this.#frame_unsub) {
967
- this.#frame_address = frameAddress(desc.url);
968
- this.#frame_unsub = slots.frames?.subscribe(this.#frame_address, (f) => this.#morph_live(f.html)) ?? null;
1263
+ if (!(!!desc.hydrate && !!desc.module)) {
1264
+ if (this.#live.now.kind !== "static") this.#live.go("static");
1265
+ let replayed = false;
1266
+ if (desc.url && !this.#live_unsub) {
1267
+ const box = this.#box;
1268
+ let binding = true;
1269
+ const off = slots.frames?.subscribe(frameAddress(desc.url), (f) => {
1270
+ if (binding && !again) return;
1271
+ replayed ||= binding;
1272
+ box.post((t) => this.#morph_live(f.html, t));
1273
+ });
1274
+ binding = false;
1275
+ if (off) {
1276
+ this.#live_unsub = off;
1277
+ this.#live.scope.defer(() => {
1278
+ off();
1279
+ this.#live_unsub = null;
1280
+ });
1281
+ }
969
1282
  }
970
- this.#morph_live(desc.html);
1283
+ if (!replayed) await this.#morph_live(desc.html, turn);
971
1284
  return;
972
1285
  }
973
- if (this.#live_ready && interactive && this.#live_app && desc.module === this.#live_module) {
974
- this.#live_app.set_props?.(desc.props);
1286
+ const s = this.#live.now;
1287
+ if (s.kind === "live" && s.module === desc.module) {
1288
+ s.app.set_props?.(desc.props);
975
1289
  this.dispatchEvent(new CustomEvent("ogygia:live", { bubbles: true }));
976
1290
  return;
977
1291
  }
978
- if (this.#live_app) {
979
- this.#live_app.dispose();
980
- this.#live_app = null;
1292
+ if (s.kind === "hydrating" && s.module === desc.module) {
1293
+ this.#live_props = desc.props;
1294
+ return;
981
1295
  }
1296
+ this.#live.go("hydrating", { module: desc.module });
1297
+ const mine = this.#live.now;
1298
+ const painting = this.#paint_next();
1299
+ this.#live_props = null;
982
1300
  this.#live_ready = true;
983
- this.#live_module = desc.module;
984
- const { frag, ready } = region_fragment(desc.html);
985
- await ready;
986
- if (!this.isConnected) return;
1301
+ const { frag, ready, sheets } = region_fragment(desc.html);
1302
+ if (await turn.wait(ready) === STALE || this.#live.now !== mine) return;
1303
+ this.#painted(painting);
1304
+ use_sheets(this, sheets);
987
1305
  slots.lakes.settle_in(frag);
988
1306
  this.replaceChildren(frag);
989
1307
  restore_adopt_sheets();
990
1308
  init_shadow_registries(this);
991
- if (interactive) await this.#live_hydrate(desc.props);
992
- else {
993
- this.setAttribute("data-hydrated", "");
994
- this.dispatchEvent(new CustomEvent("ogygia:live", { bubbles: true }));
995
- }
1309
+ await this.#live_hydrate(desc.props, mine, desc.module, turn);
996
1310
  }
997
1311
  /**
998
1312
  * On a Kit-hydrated document, a region of ours (a lake's inside, a hole's answer) waits for Kit to
999
1313
  * apply its page before it hydrates — `null` (no await) once applied. The wait lives in the lazy
1000
1314
  * hydrate core (it observes Kit's reactive page, i.e. it needs Svelte), so it never reaches the boot.
1315
+ * It waits for `visit` (the wake's, or the live tick's): a region that left stops waiting, and the
1316
+ * wait ends with the last region in it.
1001
1317
  */
1002
- #kit_page_ready(core) {
1318
+ #kit_page_ready(core, visit) {
1003
1319
  if (!kit_hydrates_page() || !ours_on_kit_document(this)) return null;
1004
- return core.kit_page_thread();
1320
+ return core.kit_page_thread(visit.signal);
1005
1321
  }
1006
- /** Hydrate a live region's swapped-in HTML through the hydrate core's LiveHost path. */
1007
- async #live_hydrate(props) {
1008
- await dom_ready();
1009
- if (!this.isConnected) return;
1010
- const entry = island_entry_of(this);
1011
- if (!entry) return;
1012
- const cls = () => viewport_class(this);
1013
- const release = await load_slot({
1014
- kind: "code",
1015
- cls,
1016
- label: entry
1017
- }).ready;
1018
- let mod, core;
1322
+ /**
1323
+ * Hydrate a live region's swapped-in HTML through the hydrate core's live path — for the visit to
1324
+ * `hydrating` that swapped it (`mine`): a later tick that took over (another module, a static
1325
+ * tick) or the region leaving ends it before it mounts. One that could not mount goes back to
1326
+ * `idle`, so the next tick starts over.
1327
+ */
1328
+ async #live_hydrate(props, mine, module, turn) {
1329
+ /** Back in its turn after a wait: the region left the page (`STALE`), is on its way out, or
1330
+ * another tick took over. */
1331
+ const ours = (back) => back !== STALE && this.isConnected && this.#live.now === mine;
1019
1332
  try {
1020
- if (!this.isConnected) return;
1021
- [mod, core] = await Promise.all([load_island(entry, cls()), hydrate_core()]);
1333
+ if (!ours(await turn.wait(dom_ready()))) return;
1334
+ const entry = island_entry_of(this);
1335
+ if (!entry) return;
1336
+ const cls = () => viewport_class(this);
1337
+ const slot = new LoadSlot({
1338
+ kind: "code",
1339
+ cls,
1340
+ label: entry
1341
+ });
1342
+ const release = await turn.wait(slot.ready);
1343
+ if (release === STALE) return void slot.ready.then(hand_back);
1344
+ let mod, core;
1345
+ try {
1346
+ if (!ours()) return;
1347
+ const loaded = await turn.wait(Promise.all([
1348
+ load_island(entry, cls()),
1349
+ hydrate_core.get(),
1350
+ devalue_for_wake()
1351
+ ]));
1352
+ if (loaded === STALE) return;
1353
+ [mod, core] = loaded;
1354
+ } finally {
1355
+ release();
1356
+ }
1357
+ if (!ours()) return;
1358
+ if (!ours(await turn.wait(this.#kit_page_ready(core, this.#live.scope)))) return;
1359
+ if (CLIENT_PLUGINS) client_region_mount(this);
1360
+ const app = core.hydrate_live(this, entry, mod, props);
1361
+ if (!app) return;
1362
+ this.#live.go("live", {
1363
+ module,
1364
+ app
1365
+ });
1366
+ this.#live.scope.defer(() => {
1367
+ if (CLIENT_PLUGINS) client_region_unmount(this, this.getAttribute("entry") ?? "");
1368
+ app.dispose();
1369
+ });
1370
+ const pending = this.#live_props;
1371
+ this.#live_props = null;
1372
+ if (pending) app.set_props?.(pending);
1373
+ this.setAttribute("data-hydrated", "");
1374
+ this.dispatchEvent(new CustomEvent("ogygia:hydrated", { bubbles: true }));
1375
+ this.dispatchEvent(new CustomEvent("ogygia:live", { bubbles: true }));
1022
1376
  } finally {
1023
- release();
1377
+ if (this.#live.now === mine) this.#live.go("idle");
1024
1378
  }
1025
- if (!this.isConnected) return;
1026
- await this.#kit_page_ready(core);
1027
- if (!this.isConnected) return;
1028
- if (CLIENT_PLUGINS) client_region_mount(this);
1029
- this.#live_app = core.hydrate_live(this, entry, mod, props);
1030
- if (!this.#live_app) return;
1031
- this.setAttribute("data-hydrated", "");
1032
- this.dispatchEvent(new CustomEvent("ogygia:hydrated", { bubbles: true }));
1033
- this.dispatchEvent(new CustomEvent("ogygia:live", { bubbles: true }));
1034
1379
  }
1035
1380
  /**
1036
1381
  * A MOVE IS NOT A REMOVAL. Another script relocating the element (a custom element that, at its
@@ -1054,37 +1399,17 @@ var OgygiaRegion = class OgygiaRegion extends HTMLElement {
1054
1399
  connected_regions.delete(this);
1055
1400
  unregister_region(this);
1056
1401
  if (this.#watching) mark_region_changed(this);
1057
- this.#unwatch();
1058
- if (CLIENT_PLUGINS && (this.#live_app || this.#app)) client_region_unmount(this, this.getAttribute("entry") ?? "");
1059
- if (this.#live_app) {
1060
- this.#live_app.dispose();
1061
- this.#live_app = null;
1062
- }
1063
- this.#fetch_abort?.abort();
1064
- this.#fetch_abort = null;
1065
- this.#frame_unsub?.();
1066
- this.#frame_unsub = null;
1067
- if (this.#frame_address) {
1068
- slots.frames?.abandon(this.#frame_address);
1069
- this.#frame_address = null;
1070
- }
1071
- if (this.#idle_handle != null) {
1072
- if ("cancelIdleCallback" in window) cancelIdleCallback(this.#idle_handle);
1073
- else clearTimeout(this.#idle_handle);
1074
- this.#idle_handle = null;
1075
- }
1076
- this.#stop_visible?.();
1077
- this.#stop_visible = null;
1078
- on_demand_armed.delete(this);
1079
- this.#disarm_interaction?.();
1080
- this.#disarm_interaction = null;
1081
- if (this.#mql) {
1082
- this.#mql.mql.removeEventListener("change", this.#mql.on);
1083
- this.#mql = null;
1084
- }
1402
+ if (CLIENT_PLUGINS && this.#app) client_region_unmount(this, this.getAttribute("entry") ?? "");
1403
+ if (this.#live.now.kind !== "idle") this.#live.go("idle");
1404
+ if (this.#hole.now.kind !== "idle") this.#hole.go("idle");
1405
+ if (this.#wake.now.kind !== "cold") this.#wake.go("cold");
1406
+ this.#life.close();
1407
+ this.#life = NOT_CONNECTED;
1408
+ this.#box = CLOSED_BOX;
1085
1409
  if (this.#app) {
1086
1410
  this.#app.dispose();
1087
1411
  this.#app = null;
1412
+ if (RULES) note_unmount(this);
1088
1413
  }
1089
1414
  this.#scheduled = false;
1090
1415
  }
@@ -1125,7 +1450,15 @@ function apply_dev_head_region_css() {
1125
1450
  });
1126
1451
  }
1127
1452
  function boot(installers = [], client_plugins = []) {
1453
+ disclose_version();
1454
+ if (RULES) install_rules(import.meta.env.DEV);
1128
1455
  runtime_session.adopt_document_head();
1456
+ globalThis[Symbol.for("ogygia.messages")] ??= {
1457
+ OgygiaError,
1458
+ warn,
1459
+ line
1460
+ };
1461
+ report_restores();
1129
1462
  link_boot();
1130
1463
  for (const install of installers) install();
1131
1464
  pin_build_map();
@@ -1137,6 +1470,7 @@ function boot(installers = [], client_plugins = []) {
1137
1470
  });
1138
1471
  if (BEACON) beacon_entry_fallback(report);
1139
1472
  });
1473
+ on_reload_needed((url, error) => console.error(new OgygiaError("chunk_reload_needed", { url }), error));
1140
1474
  if (import.meta.env.DEV) apply_dev_head_region_css();
1141
1475
  if (BEACON) beacon_watch();
1142
1476
  if (import.meta.env.DEV) {