@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,4 +1,3 @@
1
- import { PageCache } from "./page-cache.js";
2
1
  import { boot_link, router_link, slots } from "./slots.js";
3
2
  import { dispose_scope } from "../ref.js";
4
3
  import { reconcile_body, stamp_region_keys, swap_body } from "./reconcile.js";
@@ -12,26 +11,31 @@ import { emit } from "../events/bus.js";
12
11
  * intercepted click: page fetch + cache, head merge, the body reconcile / morph, seeds, a11y.
13
12
  * Nothing here runs at boot; a visitor who never leaves the page never downloads it.
14
13
  *
15
- * A navigation is TWO tasks. The first — this module's `navigate` after the fetch — does everything
16
- * that needs no live DOM change: parse the HTML, read the route facts, batch the incoming holes,
17
- * stamp reconcile keys, parse the incoming page seed, drop hints for modules already warm, preload
18
- * stylesheets. Then it yields. The second task is the view transition's callback and does ONLY the
19
- * DOM: head merge, seeds, body morph, title. The old single-task swap ran a whole-document regex,
20
- * two `'*'` walks and a seed parse inside `startViewTransition`, on the frame the transition
21
- * captures.
14
+ * A navigation is a STAGED CHANGE (`stage`, @ogygia/machine), in two tasks. The build — this
15
+ * module's `navigate` up to the commit — does everything that needs no live DOM change: fetch and
16
+ * parse the HTML, read the route facts, batch the incoming holes, stamp reconcile keys, parse the
17
+ * incoming page seed, drop hints for modules already warm, load the stylesheets (inert: they style
18
+ * nothing yet). Then it yields. The commit is one synchronous step, the view transition's callback
19
+ * or a plain call, and does ONLY the DOM: head merge, seeds, body morph, title. A navigation left
20
+ * before its commit gives back what it built; one whose commit throws goes to the browser. The old
21
+ * single-task swap ran a whole-document regex, two `'*'` walks and a seed parse inside
22
+ * `startViewTransition`, on the frame the transition captures. The soft refresh is staged the same
23
+ * way.
22
24
  */
23
25
  const KitBoot = { document_has: (doc) => boot_link().KitBoot.document_has(doc) };
24
26
  const regions_in_shadow = () => boot_link().regions_in_shadow();
27
+ const release_sheets = (unlisted) => boot_link().release_sheets(unlisted);
25
28
  const session = () => boot_link().runtime_session;
29
+ /** devalue first, when the fetched page holds a script in its lane (./devalue-lane.ts) */
30
+ const devalue_for = (doc) => boot_link().needs_devalue(doc) ? boot_link().load_devalue() : void 0;
26
31
  const is_warmed_module = (entry, base) => boot_link().is_warmed_module(entry, base);
27
32
  const warm_island_module = (entry, base) => boot_link().warm_island_module(entry, base);
28
33
  const note_entry_location = (entry, src, base) => boot_link().note_entry_location(entry, src, base);
29
34
  const register_island_graph = (text, base) => boot_link().register_island_graph(text, base);
30
35
  const yield_task = () => boot_link().yield_task();
31
- const document_key = (url) => router_link().document_key(url);
36
+ /** What a machine's task answers once its visit is left (the boot's `@ogygia/machine`). */
37
+ const is_stale = (v) => v === boot_link().STALE;
32
38
  const jump_to_hash = (hash) => router_link().jump_to_hash(hash);
33
- const push_state = (state, url) => router_link().push_state(state, url);
34
- const replace_state = (state, url) => router_link().replace_state(state, url);
35
39
  const DEVTOOLS = typeof __OGYGIA_DEVTOOLS__ !== "undefined" ? __OGYGIA_DEVTOOLS__ : false;
36
40
  /** High-res clock for devtools nav timings (guarded — dead when off). */
37
41
  const dt_now = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
@@ -117,6 +121,8 @@ const PAGE_CACHE_MAX_ENTRIES = 32;
117
121
  const PAGE_CACHE_MAX_BYTES = 4e6;
118
122
  /** Cap on waiting for a destination stylesheet to load before the body swap (cold-cache FOUC guard). */
119
123
  const STYLESHEET_WAIT_MS = 2e3;
124
+ /** A `Content-Disposition` that makes a response a file to save. */
125
+ const ATTACHMENT = /^\s*attachment\b/i;
120
126
  /** Kit remote-function POSTs live under `…/_app/remote/…` (or custom `appDir`). */
121
127
  const REMOTE_MUTATION_PATH = /\/remote(?:\/|$|\?)/;
122
128
  /** An `<ogygia-region>` open tag's attributes (shared `g` regex — reset `lastIndex` per scan). */
@@ -213,20 +219,36 @@ function apply_late_templates(root) {
213
219
  * linger in the tab order. For `<body>` the attribute is removed immediately (body stays the active
214
220
  * element regardless). For a normal element — a `#hash` heading — removing `tabindex` while it is
215
221
  * focused would BLUR it back to `<body>`, so the cleanup is deferred to the element's next `blur`.
222
+ *
223
+ * The `tabindex` is LENT until that blur (`lent`), once per element: a second navigation that
224
+ * focuses it meanwhile (a refresh in place at its `#hash`) finds the loan and leaves it, instead of
225
+ * saving the first one's `-1` as the element's own and putting that back after the first cleanup
226
+ * (#278). The loan is the element's, not the page's: an element kept across a navigation (inside a
227
+ * kept island) still gives its `tabindex` back when focus leaves it on the next page.
216
228
  */
217
229
  function focus_reset(el) {
218
230
  const is_body = el === document.body || el === document.documentElement;
219
- const had = el.hasAttribute("tabindex");
231
+ if (lent.has(el)) {
232
+ el.focus({ preventScroll: true });
233
+ return;
234
+ }
220
235
  const prev = el.getAttribute("tabindex");
221
236
  const restore = () => {
222
- if (had) el.setAttribute("tabindex", prev);
237
+ lent.delete(el);
238
+ if (prev !== null) el.setAttribute("tabindex", prev);
223
239
  else el.removeAttribute("tabindex");
224
240
  };
225
241
  el.tabIndex = -1;
226
242
  el.focus({ preventScroll: true });
227
- if (is_body) restore();
228
- else el.addEventListener("blur", () => restore(), { once: true });
243
+ if (is_body) {
244
+ restore();
245
+ return;
246
+ }
247
+ lent.set(el, prev);
248
+ el.addEventListener("blur", restore, { once: true });
229
249
  }
250
+ /** The elements whose `tabindex` a navigation's focus lent until they blur, with their own value. */
251
+ const lent = /* @__PURE__ */ new WeakMap();
230
252
  /** Head nodes that must never be adopted from SPA HTML (rewrite relative fetches / CSP). */
231
253
  function is_dangerous_head_node(node) {
232
254
  const tag = node.tagName;
@@ -237,38 +259,44 @@ function is_dangerous_head_node(node) {
237
259
  }
238
260
  return false;
239
261
  }
240
- const page_cache = new PageCache({
241
- ttlMs: PREFETCH_TTL_MS,
242
- maxEntries: PAGE_CACHE_MAX_ENTRIES,
243
- maxBytes: PAGE_CACHE_MAX_BYTES
262
+ let page_flights;
263
+ /** The world the cached pages are made in: a mutation moves it (`bust_page_cache`). */
264
+ let page_world;
265
+ /** The page fetches: one flight per address, shared by every navigation and prefetch of it. A
266
+ * cacheable answer is kept for PREFETCH_TTL_MS, within PAGE_CACHE_MAX_BYTES (each entry weighs at
267
+ * least its share of it, so no more than PAGE_CACHE_MAX_ENTRIES small pages are kept). They are made
268
+ * in `page_world`: a move of it drops them all, a fetch in flight is aborted, and a navigation that
269
+ * waited on it fetches again. */
270
+ const pages = () => page_flights ??= boot_link().flights.keyed()({
271
+ name: "router pages",
272
+ key: ["href"],
273
+ ignore: { purpose: "a navigation joins the prefetch out for its address: how, not what" },
274
+ run: (ask, signal) => run_page_fetch(ask, signal),
275
+ world: page_world = boot_link().world(),
276
+ keep: (answer) => answer.page !== null && answer.cacheable ? PREFETCH_TTL_MS : 0,
277
+ max: PAGE_CACHE_MAX_BYTES,
278
+ weigh: (answer) => Math.max((answer.page?.html.length ?? 0) * 2, PAGE_CACHE_MAX_BYTES / PAGE_CACHE_MAX_ENTRIES)
244
279
  });
245
- const inflight = /* @__PURE__ */ new Map();
246
280
  /** Prefetches still waiting in the load scheduler's queue, by href (a click promotes its one). */
247
281
  const queued_prefetch = /* @__PURE__ */ new Map();
248
- /** requested href → the FINAL href a fetch landed on after following server redirects, when it
249
- * differs. One-shot: `navigate()` reads it to correct the address bar (a redirect is invisible to
250
- * `fetch` beyond `response.url`), then deletes it. See {@link fetch_page}. */
251
- const final_url = /* @__PURE__ */ new Map();
252
282
  /** Hrefs whose prefetched HTML was already scanned for island entries — parse once. (URL-level
253
283
  * import dedupe lives in the shared `warm_island_module`.) */
254
284
  const warmed_pages = /* @__PURE__ */ new Set();
255
- /** (Hard SPA navigations cancel through the router's own handle, `r.nav_gen` / `r.nav_abort`, so a
256
- * "same document" decision there can stop one in flight: SpaRouter.supersede.) */
257
- /** Soft invalidate fetches only — aborting these must not cancel a real click nav. */
258
- let soft_gen = 0;
259
- let soft_abort = null;
285
+ /** (Hard SPA navigations are the router's own machine, `r.nav` (./navigation.ts), so a "same
286
+ * document" decision there can stop one in flight: SpaRouter.supersede. The soft refresh is its
287
+ * machine too, `boot_link().soft` (./soft-refresh.ts): the boot holds it, as a page without the
288
+ * router refreshes too.) */
260
289
  /** The visually-hidden `aria-live` region that announces each navigation to screen readers. Created
261
290
  * lazily on the first nav and reused (a live region must persist so assistive tech observes its
262
291
  * mutations). It lives in `<body>` but is DETACHED before each body swap so the reconcile never
263
292
  * removes it, then re-attached. See {@link announce} / {@link reset_focus}. */
264
293
  let announcer = null;
294
+ /** Moves the world the cached pages are made in: what is kept goes, and a fetch that started before
295
+ * the move (a prefetch out when a remote mutation lands) never fills the cache after it with the
296
+ * page as it was (#208), and a navigation that waited on it fetches again. (No pages yet: none to
297
+ * put out of date.) */
265
298
  function bust_page_cache() {
266
- page_cache.clear();
267
- inflight.clear();
268
- final_url.clear();
269
- }
270
- function page_cache_size() {
271
- return page_cache.size;
299
+ page_world?.bump();
272
300
  }
273
301
  /**
274
302
  * Bust the SPA HTML cache after any successful Kit remote mutation (command/form POST).
@@ -292,65 +320,81 @@ function install_remote_mutation_cache_bust() {
292
320
  };
293
321
  }
294
322
  install_remote_mutation_cache_bust();
295
- function fetch_page(href, signal, purpose = "nav") {
296
- const cached = page_cache.get(href);
297
- if (cached != null) return Promise.resolve(cached);
298
- const pending = inflight.get(href);
299
- if (pending) {
300
- if (purpose === "nav") queued_prefetch.get(href)?.promote("user");
301
- return pending;
323
+ /**
324
+ * The page at `href`, as HTML plus the address it belongs to: a server redirect (a Kit `redirect()`
325
+ * in load, a trailing-slash canonicalization) is followed by `fetch` transparently, and
326
+ * `response.url` is its only trace. The landing travels WITH the HTML, through the cache and to the
327
+ * navigation that reads it, so nothing else (a cache clear, another fetch) can take it away.
328
+ */
329
+ async function fetch_page(href, signal, purpose = "nav") {
330
+ const ask = {
331
+ href,
332
+ purpose
333
+ };
334
+ for (;;) {
335
+ if (purpose !== "prefetch") queued_prefetch.get(href)?.promote("user");
336
+ const answer = await pages().get(ask, { signal });
337
+ if (answer !== boot_link().STALE) {
338
+ if (purpose === "prefetch" && answer.page !== null) warm_modules(href, answer.page.html);
339
+ return answer.page;
340
+ }
341
+ if (signal?.aborted || purpose === "prefetch") return null;
302
342
  }
303
- final_url.delete(href);
343
+ }
344
+ /** One page fetch (a flight's work): a prefetch waits for its turn in the load scheduler first. */
345
+ async function run_page_fetch({ href, purpose }, signal) {
304
346
  const go = () => fetch(href, {
305
347
  signal,
306
348
  headers: nav_headers(purpose),
307
349
  ...purpose === "prefetch" ? { priority: "low" } : {}
308
350
  });
309
- let fetched;
310
- if (purpose === "prefetch") {
311
- const slot = boot_link().load_slot({
312
- kind: "content",
313
- cls: "speculative",
314
- label: href
315
- });
316
- queued_prefetch.set(href, slot.ticket);
317
- fetched = slot.ready.then((release) => {
318
- queued_prefetch.delete(href);
319
- return go().finally(release);
320
- });
321
- } else fetched = go();
322
- const settled = fetched.then(async (res) => {
351
+ let res;
352
+ try {
353
+ if (purpose === "prefetch") {
354
+ const { LoadSlot } = boot_link();
355
+ const slot = new LoadSlot({
356
+ kind: "content",
357
+ cls: "speculative",
358
+ label: href
359
+ });
360
+ queued_prefetch.set(href, slot);
361
+ const release = await slot.ready;
362
+ if (queued_prefetch.get(href) === slot) queued_prefetch.delete(href);
363
+ try {
364
+ if (signal.aborted) return {
365
+ page: null,
366
+ cacheable: false
367
+ };
368
+ res = await go();
369
+ } finally {
370
+ release();
371
+ }
372
+ } else res = await go();
323
373
  if (!(res.headers.get("content-type") || "").includes("text/html")) return {
324
- html: null,
374
+ page: null,
325
375
  cacheable: false
326
376
  };
327
- if (res.redirected && res.url && res.url !== href) final_url.set(href, res.url);
377
+ if (ATTACHMENT.test(res.headers.get("content-disposition") || "")) return {
378
+ page: null,
379
+ cacheable: false
380
+ };
381
+ const url = res.redirected && res.url ? res.url : href;
328
382
  const html = await res.text();
329
383
  const cc = res.headers.get("cache-control") || "";
330
384
  return {
331
- html,
385
+ page: {
386
+ html,
387
+ url
388
+ },
332
389
  cacheable: spa_html_cacheable(cc, res.headers.has("set-cookie"))
333
390
  };
334
- }).catch((err) => {
391
+ } catch (err) {
335
392
  if (err && err.name === "AbortError") throw err;
336
393
  return {
337
- html: null,
394
+ page: null,
338
395
  cacheable: false
339
396
  };
340
- });
341
- const html_p = settled.then((r) => r.html);
342
- if (!signal) {
343
- inflight.set(href, html_p);
344
- html_p.finally(() => {
345
- if (inflight.get(href) === html_p) inflight.delete(href);
346
- });
347
- settled.then((r) => {
348
- if (r.html == null) return;
349
- if (r.cacheable) page_cache.set(href, r.html);
350
- warm_modules(href, r.html);
351
- }).catch(() => {});
352
397
  }
353
- return html_p;
354
398
  }
355
399
  /**
356
400
  * Kick off import() for every island module the prefetched page will hydrate, so they are in the
@@ -401,16 +445,18 @@ function build_of(d) {
401
445
  const file = (v) => v ? v.slice(v.lastIndexOf("/") + 1) : "";
402
446
  return file(s.getAttribute("src")) + " " + file(s.getAttribute("data-og-locations"));
403
447
  }
404
- async function navigate(r, url, from, { push = true, pop_scroll = null, type = "link", replace = false } = {}) {
405
- if (!r.run_before(from, url, type)) {
406
- if (r.nav_target === url.href) r.nav_target = null;
407
- return;
408
- }
448
+ /** What matches no screen: a sheet under it loads, and styles nothing. */
449
+ const INERT_MEDIA = "not all";
450
+ /** The next page's sheet goes live: it styles the page as it came. */
451
+ function apply_sheet({ link, media, priority }) {
452
+ if (media === null) link.removeAttribute("media");
453
+ else link.setAttribute("media", media);
454
+ if (priority === null) link.removeAttribute("fetchpriority");
455
+ }
456
+ async function navigate(r, url, from, { push = true, pop, type = "link", replace = false } = {}, claim = r.nav.now) {
457
+ if (r.nav.now !== claim) return;
409
458
  const dt_t0 = DEVTOOLS ? dt_now() : 0;
410
- let dt_reconciled = false;
411
459
  const nav_t0 = performance.now();
412
- let nav_fetched = nav_t0;
413
- let nav_styled = nav_t0;
414
460
  if (DEVTOOLS) emit({
415
461
  domain: "nav",
416
462
  name: "nav.start",
@@ -418,93 +464,80 @@ async function navigate(r, url, from, { push = true, pop_scroll = null, type = "
418
464
  to: url.pathname + url.search,
419
465
  type
420
466
  });
421
- r.nav_abort?.abort();
422
- r.nav_abort = new AbortController();
423
- const { signal } = r.nav_abort;
424
- const gen = ++r.nav_gen;
425
- r.nav_target = url.href;
426
- signal.addEventListener("abort", () => {
427
- if (r.nav_target === url.href) r.nav_target = null;
467
+ r.nav.go("loading", { href: url.href });
468
+ const mine = r.nav.now;
469
+ const attempt = r.nav.scope;
470
+ const next = session().next_visit();
471
+ attempt.defer(() => {
472
+ if (session().visit !== next) next.scope.close();
428
473
  });
429
- if (replace) replace_state({
430
- ...history.state,
431
- ogygia: true
432
- }, url.href);
433
- else if (push) {
434
- replace_state({
435
- ...history.state,
436
- scroll: {
437
- x: scrollX,
438
- y: scrollY
439
- }
440
- });
441
- push_state({ ogygia: true }, url.href);
442
- }
474
+ const move = pop ? "pop" : push && !replace ? "push" : "replace";
475
+ const hand_off = (error, written = false) => r.hand_off(url, mine, written ? "replace" : move, error);
443
476
  const purpose = type === "popstate" ? "history" : "nav";
444
- let html;
445
- try {
446
- html = await fetch_page(url.href, signal, purpose);
447
- } catch (err) {
448
- if (err?.name === "AbortError" || gen !== r.nav_gen) return;
449
- location.href = url.href;
450
- return;
451
- }
452
- if (gen !== r.nav_gen) return;
453
- if (html == null) {
454
- location.href = url.href;
455
- return;
456
- }
457
- nav_fetched = performance.now();
458
- page_cache.delete(url.href);
459
- let dest = url;
460
- const landed = final_url.get(url.href);
461
- final_url.delete(url.href);
462
- if (landed) try {
463
- dest = new URL(landed, url);
464
- } catch {
465
- dest = url;
466
- }
467
- const hand_off = () => {
468
- location.href = url.href;
477
+ const build = async ({ signal, discard }) => {
478
+ const page = await fetch_page(url.href, signal, purpose);
479
+ if (page === null || signal.aborted) return null;
480
+ const { html } = page;
481
+ const fetched = performance.now();
482
+ pages().drop(({ href }, entry) => href === url.href && entry.kind === "kept" && !entry.flying);
483
+ let dest = url;
484
+ if (page.url !== url.href) try {
485
+ dest = new URL(page.url, url);
486
+ } catch {
487
+ dest = url;
488
+ }
489
+ if (dest.origin !== location.origin) return null;
490
+ const doc = new DOMParser().parseFromString(html, "text/html");
491
+ if (KitBoot.document_has(doc)) return null;
492
+ const marker = doc.querySelector("meta[name=\"ogygia-router\"]");
493
+ if (!marker) return null;
494
+ const next_build = build_of(doc);
495
+ const this_build = next_build === null ? null : build_of(document);
496
+ if (this_build !== null && next_build !== this_build) return null;
497
+ if (doc.querySelector("meta[name=\"ogygia-first-load\"]")) return null;
498
+ globalThis.__og_restore?.(doc);
499
+ const prefer_reduced_motion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
500
+ const use_vt = marker.getAttribute("content") !== "plain" && !prefer_reduced_motion;
501
+ for (const el of doc.querySelectorAll("ogygia-region")) pin_region_urls(el, dest.href);
502
+ batch_regions(doc, next.scope);
503
+ await devalue_for(doc);
504
+ if (signal.aborted) return null;
505
+ preflight(doc, dest);
506
+ const sheets = [];
507
+ await preload_stylesheets(doc.head, attempt, sheets, discard);
508
+ if (signal.aborted) return null;
509
+ const styled = performance.now();
510
+ await yield_task();
511
+ return {
512
+ doc,
513
+ dest,
514
+ use_vt,
515
+ sheets,
516
+ fetched,
517
+ styled,
518
+ reconciled: false,
519
+ written: false
520
+ };
469
521
  };
470
- if (dest.origin !== location.origin) return hand_off();
471
- const doc = new DOMParser().parseFromString(html, "text/html");
472
- if (KitBoot.document_has(doc)) return hand_off();
473
- const marker = doc.querySelector("meta[name=\"ogygia-router\"]");
474
- if (!marker) return hand_off();
475
- const next_build = build_of(doc);
476
- const this_build = next_build === null ? null : build_of(document);
477
- if (this_build !== null && next_build !== this_build) return hand_off();
478
- if (doc.querySelector("meta[name=\"ogygia-first-load\"]")) return hand_off();
479
- globalThis.__og_restore?.(doc);
480
- if (dest !== url) replace_state({
481
- ...history.state,
482
- ogygia: true
483
- }, dest.href);
484
- const prefer_reduced_motion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
485
- const use_vt = marker.getAttribute("content") !== "plain" && !prefer_reduced_motion;
486
- for (const el of doc.querySelectorAll("ogygia-region")) pin_region_urls(el, dest.href);
487
- batch_regions(doc, dest.href);
488
- preflight(doc, dest);
489
- await preload_stylesheets(doc.head);
490
- if (gen !== r.nav_gen) return;
491
- nav_styled = performance.now();
492
- await yield_task();
493
- if (gen !== r.nav_gen) return;
494
- const reconcile = !!slots.morph && !regions_in_shadow() && !document.body.querySelector("[data-og-no-reconcile]") && !doc.body.querySelector("[data-og-no-reconcile]");
495
- const swap = () => {
496
- if (gen !== r.nav_gen) return;
522
+ const commit = (built) => {
523
+ if (built === null) return;
524
+ const { doc, dest } = built;
525
+ const { soft } = boot_link();
526
+ if (soft.now.kind === "refreshing") soft.go("idle");
527
+ const left = r.current_url;
528
+ r.history.commit(dest, move, pop);
529
+ built.written = true;
530
+ const reconcile = !!slots.morph && !regions_in_shadow() && !document.body.querySelector("[data-og-no-reconcile]") && !doc.body.querySelector("[data-og-no-reconcile]");
497
531
  announcer?.remove();
498
532
  document.dispatchEvent(new Event("og:before-swap"));
499
- merge_head(doc.head);
500
- if (gen !== r.nav_gen) return;
501
- if (slots.forms.enabled && r.current_url) slots.forms.snapshot(document.body, r.current_url.pathname);
502
- prepare_spa_document();
533
+ const unlisted_sheets = merge_head(doc.head);
534
+ for (const sheet of built.sheets) apply_sheet(sheet);
535
+ if (slots.forms.enabled) slots.forms.snapshot(document.body, left.pathname);
536
+ prepare_spa_document(next);
503
537
  apply_soft_invalidate_doc(doc);
504
- if (gen !== r.nav_gen) return;
505
538
  if (reconcile) {
506
539
  reconcile_body(document.body, doc.body, slots.morph);
507
- dt_reconciled = true;
540
+ built.reconciled = true;
508
541
  document.title = doc.title;
509
542
  session().settle_lakes_in(document.body);
510
543
  } else {
@@ -520,29 +553,53 @@ async function navigate(r, url, from, { push = true, pop_scroll = null, type = "
520
553
  }
521
554
  globalThis.__og_restore_adopt?.();
522
555
  session().retire_page_head_copies();
556
+ release_sheets(unlisted_sheets);
523
557
  apply_late_templates(document.body);
524
558
  finish_spa_document();
525
559
  if (slots.forms.enabled) slots.forms.restore(url.pathname);
526
560
  document.dispatchEvent(new Event("og:after-swap"));
527
561
  };
528
- if (use_vt && document.startViewTransition) {
529
- const restore_vt_names = fold_orphan_vt_names(document.body, doc.body);
562
+ const staged = boot_link().stage(attempt, {
563
+ name: "navigation",
564
+ build,
565
+ commit
566
+ });
567
+ let built;
568
+ try {
569
+ built = await staged.ready;
570
+ } catch (error) {
571
+ return hand_off(error);
572
+ }
573
+ if (is_stale(built) || r.nav.now !== mine) return;
574
+ if (built === null) return hand_off();
575
+ const { dest } = built;
576
+ const out = {};
577
+ const swap = () => {
578
+ try {
579
+ staged.commit();
580
+ } catch (error) {
581
+ out.failed = { error };
582
+ }
583
+ };
584
+ if (built.use_vt && document.startViewTransition) {
585
+ const restore_vt_names = fold_orphan_vt_names(document.body, built.doc.body);
530
586
  const t = document.startViewTransition(swap);
531
587
  t.ready?.catch(() => {});
532
588
  (t.finished ?? Promise.resolve()).then(restore_vt_names, restore_vt_names);
533
589
  await t.updateCallbackDone.catch(() => {});
590
+ if (out.failed) t.skipTransition?.();
534
591
  } else swap();
535
- if (gen !== r.nav_gen) return;
536
- r.doc_key = document_key(dest);
537
- r.current_url = dest;
538
- r.nav_target = null;
592
+ if (out.failed) return hand_off(out.failed.error, built.written);
593
+ if (r.nav.now !== mine) return;
594
+ r.nav.move("loading", "settling", { href: url.href });
595
+ const settled = r.nav.now;
539
596
  boot_link().beacon_nav({
540
597
  from: from.pathname + from.search,
541
598
  to: dest.pathname + dest.search,
542
599
  type,
543
600
  t: nav_t0,
544
- fetched: nav_fetched,
545
- styled: nav_styled,
601
+ fetched: built.fetched,
602
+ styled: built.styled,
546
603
  swapped: performance.now(),
547
604
  href: url.href
548
605
  });
@@ -551,27 +608,25 @@ async function navigate(r, url, from, { push = true, pop_scroll = null, type = "
551
608
  name: "nav.finish",
552
609
  to: dest.pathname + dest.search,
553
610
  ms: dt_now() - dt_t0,
554
- reconciled: dt_reconciled,
555
- vt: !!(use_vt && document.startViewTransition)
611
+ reconciled: built.reconciled,
612
+ vt: !!(built.use_vt && document.startViewTransition)
556
613
  });
557
- if (replace) {} else if (pop_scroll) {
614
+ const scroll = pop?.scroll;
615
+ if (replace) {} else if (scroll) {
558
616
  const html_el = document.documentElement;
559
617
  const prev = html_el.style.scrollBehavior;
560
618
  html_el.style.scrollBehavior = "auto";
561
619
  try {
562
- window.scrollTo(pop_scroll.x, pop_scroll.y);
620
+ window.scrollTo(scroll.x, scroll.y);
563
621
  } finally {
564
622
  html_el.style.scrollBehavior = prev;
565
623
  }
566
624
  } else jump_to_hash(dest.hash);
567
625
  announce(dest);
568
- reset_focus(dest.hash);
626
+ reset_focus(dest.hash, pop?.focus ?? null);
569
627
  r.run_after(from, dest, type);
570
628
  document.dispatchEvent(new Event("og:page-load"));
571
- if (gen === r.nav_gen) {
572
- r.nav_abort = null;
573
- if (r.nav_target === url.href) r.nav_target = null;
574
- }
629
+ if (r.nav.now === settled) r.nav.go("idle");
575
630
  r.scan_preload_links();
576
631
  }
577
632
  /**
@@ -585,29 +640,44 @@ async function navigate(r, url, from, { push = true, pop_scroll = null, type = "
585
640
  * that need query updates use `.refresh()`, or `submit().updates(q)` with server
586
641
  * `requested(q).refreshAll()` (updates alone does not populate response `q`).
587
642
  *
588
- * Uses a separate abort/generation from hard `navigate()` so soft fetches never cancel
589
- * an in-flight click navigation (and vice versa).
643
+ * Its own machine (`soft`, ./soft-refresh.ts, which the boot holds: a page without the router
644
+ * refreshes too) so soft fetches never cancel an in-flight click navigation; a real navigation's
645
+ * commit ends a soft refresh in flight.
646
+ *
647
+ * It refreshes the PAGE ON SCREEN (`on_screen`: the router's, which knows it; the address bar
648
+ * without the router), never the page a navigation is still bringing (#268). The refreshed page is
649
+ * staged like a navigation's (`stage`): fetched and parsed off to the side, merged in one commit,
650
+ * which a navigation that went in first refuses (its commit ended this refresh). A refresh whose
651
+ * devalue fails to load ends, and says so to its caller.
590
652
  */
591
- async function invalidate_all() {
653
+ async function invalidate_all(on_screen) {
592
654
  bust_page_cache();
593
- const url = new URL(location.href);
594
- soft_abort?.abort();
595
- soft_abort = new AbortController();
596
- const { signal } = soft_abort;
597
- const gen = ++soft_gen;
598
- let html;
655
+ const url = on_screen ?? new URL(location.href);
656
+ const { soft, stage } = boot_link();
657
+ soft.go("refreshing", { href: url.href });
658
+ const mine = soft.now;
659
+ const staged = stage(soft.scope, {
660
+ name: "soft refresh",
661
+ build: async ({ signal }) => {
662
+ const fetched = await fetch_page(url.href, signal);
663
+ if (fetched === null || signal.aborted) return null;
664
+ const doc = new DOMParser().parseFromString(fetched.html, "text/html");
665
+ if (KitBoot.document_has(doc) || !doc.querySelector("meta[name=\"ogygia-router\"]")) return null;
666
+ await devalue_for(doc);
667
+ return doc;
668
+ },
669
+ commit: (doc) => {
670
+ if (doc === null) return;
671
+ release_sheets(merge_head(doc.head));
672
+ apply_soft_invalidate_doc(doc);
673
+ }
674
+ });
599
675
  try {
600
- html = await fetch_page(url.href, signal);
601
- } catch (err) {
602
- if (err?.name === "AbortError" || gen !== soft_gen) return;
603
- return;
676
+ if (is_stale(await staged.ready)) return;
677
+ staged.commit();
678
+ } finally {
679
+ if (soft.now === mine) soft.go("idle");
604
680
  }
605
- if (gen !== soft_gen || html == null) return;
606
- const doc = new DOMParser().parseFromString(html, "text/html");
607
- if (KitBoot.document_has(doc)) return;
608
- if (!doc.querySelector("meta[name=\"ogygia-router\"]")) return;
609
- merge_head(doc.head);
610
- apply_soft_invalidate_doc(doc);
611
681
  }
612
682
  /**
613
683
  * ACCESSIBILITY — announce the new page. A body swap is invisible to assistive tech: no document
@@ -636,11 +706,15 @@ function announce(dest) {
636
706
  * does this natively; a body swap does not). SvelteKit's algorithm: a `[autofocus]` element wins;
637
707
  * else a `#hash` target; else focus falls back to `<body>` (made momentarily focusable). The one
638
708
  * ogygia twist: a control INSIDE kept/persisted chrome (`data-ogygia-keep` / `data-persist`) that
639
- * survived the nav is left focused — the visitor is still using that persistent widget.
709
+ * survived the nav is left focused — the visitor is still using that persistent widget. A back or
710
+ * forward step first gives focus back to the element (`kept`, by id) that had it when its entry
711
+ * was left (./router.ts `RouterHistory`).
640
712
  */
641
- function reset_focus(hash) {
713
+ function reset_focus(hash, kept = null) {
642
714
  const active = document.activeElement;
643
715
  if (active && active !== document.body && active.isConnected && active.closest("[data-ogygia-keep],[data-persist]")) return;
716
+ const was = kept ? document.getElementById(kept) : null;
717
+ if (was) return focus_reset(was);
644
718
  if (hash) {
645
719
  const id = decodeURIComponent(hash.slice(1));
646
720
  const target = id ? document.getElementById(id) : null;
@@ -653,7 +727,11 @@ function reset_focus(hash) {
653
727
  }
654
728
  focus_reset(document.body);
655
729
  }
656
- /** Merge <head>: keep nodes present in both, remove stale, add new. Keeps runtime scripts alive. */
730
+ /**
731
+ * Merge <head>: keep nodes present in both, remove stale, add new. Keeps runtime scripts alive.
732
+ * Returns the region sheets the new head does not list: they are for the regions that use them too
733
+ * (./region-sheets.ts), so they go only after the swap, once it is settled which regions stayed.
734
+ */
657
735
  function merge_head(new_head) {
658
736
  const current = document.head;
659
737
  const owned = session().page_head;
@@ -664,36 +742,49 @@ function merge_head(new_head) {
664
742
  if (is_dangerous_head_node(node)) continue;
665
743
  next_keys.add(head_node_key(node));
666
744
  }
745
+ const unlisted = [];
667
746
  for (const [key, node] of current_nodes) {
668
747
  if (next_keys.has(key)) continue;
669
748
  if (keep_head_node_across_spa(node)) continue;
670
- node.remove();
749
+ if (node.hasAttribute("data-ogygia-region-css")) unlisted.push(node);
750
+ else node.remove();
671
751
  }
672
- const anchor = current.firstChild;
752
+ let anchor = current.firstChild;
673
753
  for (const node of Array.from(new_head.children)) {
674
754
  if (is_dangerous_head_node(node)) continue;
675
755
  const key = head_node_key(node);
676
756
  const existing = current_nodes.get(key);
677
757
  if (key === "STYLE:data-sveltekit") {
678
- existing?.remove();
679
758
  install_head_style(node, current, anchor);
759
+ if (existing) {
760
+ if (existing === anchor) anchor = existing.nextSibling;
761
+ existing.remove();
762
+ }
680
763
  continue;
681
764
  }
682
765
  if (existing) continue;
683
766
  if (node.tagName === "STYLE") install_head_style(node, current, anchor);
684
767
  else session().claim_page_head(current.appendChild(node.cloneNode(true)));
685
768
  }
769
+ return unlisted;
686
770
  }
687
771
  /**
688
772
  * Load the destination page's stylesheets into the live `<head>` and resolve once they have
689
- * applied — call this BEFORE the body swap. A freshly appended `<link rel="stylesheet">` loads
773
+ * loaded — call this BEFORE the body swap. A freshly appended `<link rel="stylesheet">` loads
690
774
  * asynchronously, so swapping the body first shows the new route unstyled (e.g. a content column
691
775
  * at full width) until the sheet arrives. That window is invisible on a warm cache but flashes on
692
776
  * the first visit after a deploy, when the route CSS isn't cached yet. Preloading here closes it;
693
777
  * `merge_head` then dedupes these by key so nothing is added twice. Capped so a stalled sheet
694
778
  * can't hang navigation.
779
+ *
780
+ * Each loads INERT: under a media no screen matches (at the fetch priority of a sheet that does),
781
+ * so a sheet that arrived first never styles the page still on screen while the navigation waits
782
+ * for the rest (#267). The commit gives each its own media back (`apply_sheet`). The wait is the
783
+ * navigation's (`attempt`: its listeners and its cap go when it ends); the sheets are the stage's
784
+ * (listed in `sheets`, given back by `discard`): a navigation that ends before its commit takes
785
+ * them out again.
695
786
  */
696
- function preload_stylesheets(new_head) {
787
+ function preload_stylesheets(new_head, attempt, sheets, discard) {
697
788
  const present = /* @__PURE__ */ new Set();
698
789
  for (const node of Array.from(document.head.children)) if (node.tagName === "LINK" && node.getAttribute("rel") === "stylesheet") present.add(head_node_key(node));
699
790
  const pending = [];
@@ -705,15 +796,30 @@ function preload_stylesheets(new_head) {
705
796
  if (present.has(key)) continue;
706
797
  present.add(key);
707
798
  const link = node.cloneNode(true);
799
+ const sheet = {
800
+ link,
801
+ media: link.getAttribute("media"),
802
+ priority: link.getAttribute("fetchpriority")
803
+ };
804
+ link.setAttribute("media", INERT_MEDIA);
805
+ if (sheet.priority === null) link.setAttribute("fetchpriority", "high");
708
806
  pending.push(new Promise((resolve) => {
709
- link.addEventListener("load", () => resolve(), { once: true });
710
- link.addEventListener("error", () => resolve(), { once: true });
807
+ attempt.on(link, "load", () => resolve(), { once: true });
808
+ attempt.on(link, "error", () => resolve(), { once: true });
711
809
  }));
712
810
  session().claim_page_head(link);
713
811
  document.head.insertBefore(link, document.head.firstChild);
812
+ sheets.push(sheet);
813
+ discard(() => link.remove());
714
814
  }
715
815
  if (!pending.length) return Promise.resolve();
716
- return Promise.race([Promise.all(pending), new Promise((resolve) => setTimeout(resolve, STYLESHEET_WAIT_MS))]);
816
+ return new Promise((resolve) => {
817
+ const cancel = attempt.timeout(resolve, STYLESHEET_WAIT_MS);
818
+ Promise.all(pending).then(() => {
819
+ cancel();
820
+ resolve();
821
+ });
822
+ });
717
823
  }
718
824
  /**
719
825
  * SINGLE-FLIGHT NAVIGATION. Collect the incoming page's deferred, load-timed region calls and stream them as
@@ -722,34 +828,20 @@ function preload_stylesheets(new_head) {
722
828
  * irrelevant. Only `when="load"` (or unset) is batched: a region scheduled `visible`/`idle`/media
723
829
  * stays lazy and fetches on its own trigger, so dynamic schedules are preserved, not eagerly pulled.
724
830
  */
725
- /** `base`: the fetched page's own URL (its relative hrefs resolve against it). */
726
- function batch_regions(doc, base) {
831
+ /** `visit`: the scope of the page's visit, the batch's owner (a navigation called off stops it). */
832
+ function batch_regions(doc, visit) {
727
833
  const endpoints = [];
728
- const batched = /* @__PURE__ */ new Set();
729
834
  for (const el of Array.from(doc.querySelectorAll("ogygia-region[render=\"defer\"][endpoint]"))) {
730
835
  if ((el.getAttribute("when") || "load") !== "load") continue;
731
836
  const ep = el.getAttribute("endpoint");
732
- if (ep) {
733
- endpoints.push(ep);
734
- batched.add(ep);
735
- }
837
+ if (ep) endpoints.push(ep);
736
838
  }
737
839
  if (!endpoints.length) return;
738
- const same_url = (href) => {
739
- try {
740
- const u = new URL(href, base);
741
- return u.pathname + u.search;
742
- } catch {
743
- return href;
744
- }
745
- };
746
- const batched_urls = new Set(Array.from(batched, same_url));
747
- for (const link of Array.from(doc.querySelectorAll("link[rel=\"preload\"][as=\"fetch\"]"))) if (batched_urls.has(same_url(link.getAttribute("href") || ""))) link.remove();
748
840
  if (DEVTOOLS) emit({
749
841
  domain: "nav",
750
842
  name: "nav.batch",
751
843
  count: endpoints.length
752
844
  });
753
- slots.frames?.stream?.(endpoints);
845
+ slots.frames?.stream?.(endpoints, visit);
754
846
  }
755
- export { DOCUMENT_ACCEPT, apply_late_templates, bust_page_cache, fetch_page, head_node_key, install_head_style, invalidate_all, keep_head_node_across_spa, navigate, page_cache_size, tag_attr };
847
+ export { DOCUMENT_ACCEPT, apply_late_templates, bust_page_cache, fetch_page, head_node_key, install_head_style, invalidate_all, keep_head_node_across_spa, navigate, tag_attr };