@ogygia/core 0.11.5 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/Region.svelte +28 -21
  2. package/dist/Region.svelte.d.ts +0 -2
  3. package/dist/compiler/bom.js +1 -1
  4. package/dist/compiler/browser.d.ts +1 -1
  5. package/dist/compiler/browser.js +1 -1
  6. package/dist/compiler/ctx.d.ts +1 -1
  7. package/dist/compiler/ctx.js +3 -3
  8. package/dist/compiler/{macros/dedent.d.ts → dedent.d.ts} +12 -1
  9. package/dist/compiler/dedent.js +43 -0
  10. package/dist/compiler/driver.d.ts +9 -6
  11. package/dist/compiler/driver.js +16 -24
  12. package/dist/compiler/index.d.ts +2 -2
  13. package/dist/compiler/index.js +2 -2
  14. package/dist/compiler/link/caps.js +59 -9
  15. package/dist/compiler/link/island-deps.js +225 -22
  16. package/dist/compiler/link/location-map.d.ts +10 -0
  17. package/dist/compiler/link/location-map.js +53 -0
  18. package/dist/compiler/link/manifest.js +5 -1
  19. package/dist/compiler/link/server-manifest.js +43 -7
  20. package/dist/compiler/link/transport.js +39 -7
  21. package/dist/compiler/macros/source.js +2 -2
  22. package/dist/compiler/region/transform.d.ts +13 -4
  23. package/dist/compiler/region/transform.js +25 -9
  24. package/dist/hooks.d.ts +16 -0
  25. package/dist/hooks.js +4 -2
  26. package/dist/insights/page-insights.js +2 -2
  27. package/dist/runtime/beacon.js +1 -1
  28. package/dist/runtime/boot-link.js +4 -1
  29. package/dist/runtime/core.js +3 -2
  30. package/dist/runtime/entry-locations.d.ts +10 -3
  31. package/dist/runtime/entry-locations.js +132 -21
  32. package/dist/runtime/morph.d.ts +7 -0
  33. package/dist/runtime/morph.js +21 -15
  34. package/dist/runtime/ownership.d.ts +10 -1
  35. package/dist/runtime/ownership.js +19 -1
  36. package/dist/runtime/reconcile.d.ts +5 -2
  37. package/dist/runtime/reconcile.js +52 -16
  38. package/dist/runtime/region-endpoint-url.js +1 -0
  39. package/dist/runtime/router-nav.js +15 -0
  40. package/dist/runtime/router.js +3 -0
  41. package/dist/runtime/slots.d.ts +8 -2
  42. package/dist/server/document-tail.d.ts +3 -2
  43. package/dist/server/document-tail.js +4 -3
  44. package/dist/server/entry-location.js +11 -8
  45. package/dist/server/props-wire.js +2 -2
  46. package/dist/server/signing-key.d.ts +10 -2
  47. package/dist/server/signing-key.js +49 -15
  48. package/dist/vite/index.js +5 -4
  49. package/dist/vite/kit-dirs.js +1 -1
  50. package/package.json +1 -1
  51. package/dist/compiler/link/entry-shim.d.ts +0 -4
  52. package/dist/compiler/link/entry-shim.js +0 -29
  53. package/dist/compiler/macros/dedent.js +0 -36
package/dist/hooks.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { REGION_BRAND } from "./region-brand.js";
2
- import { signing_key } from "./server/signing-key.js";
2
+ import { configure_signing_secret, signing_key } from "./server/signing-key.js";
3
3
  import { mpaSpeculationRules } from "./compiler/link/speculation.js";
4
4
  import { set_page_route_config } from "./plugin/routes.js";
5
5
  import { kit_page_facts_tail, kit_page_ready, kit_render_context, set_kit_event_reader, set_kit_page_reader, set_render_page_port } from "./server/kit-context.js";
@@ -9,7 +9,7 @@ import { absolutize_hole_html } from "./server/hole-urls.js";
9
9
  import { is_keep_fallback } from "./keep-fallback.js";
10
10
  import { set_source_recorder } from "./source.js";
11
11
  import { kit_asset } from "./kit-paths.js";
12
- import { region_mac_message, sign, verify } from "./server/hmac.js";
12
+ import { region_mac_message, secret_has_min_entropy, sign, verify } from "./server/hmac.js";
13
13
  import { expiry_refusal, note_cache_control } from "./server/shared-cache.js";
14
14
  import { app_lifetime } from "./server/region-endpoint.js";
15
15
  import { cached_render, render_cache_key } from "./server/render-cache.js";
@@ -435,6 +435,8 @@ var OgygiaHandle = class {
435
435
  this.#transform = options.transform;
436
436
  configure_islands_endpoint(options.endpoint);
437
437
  this.#endpoint = islands_endpoint();
438
+ if (options.secret?.trim() && !import.meta.env.DEV && !secret_has_min_entropy(options.secret)) throw new Error(`[ogygia] handle({ secret }) is too short for production (need ≥16 UTF-8 bytes).`);
439
+ configure_signing_secret(options.secret);
438
440
  this.#speculation_rules = enabled ? "" : mpaSpeculationRules(this.#endpoint);
439
441
  this.render_rate = new RateLimiter({
440
442
  max: rateLimit.max,
@@ -1267,8 +1267,8 @@ function analyze_page(page, regions, failures = [], now = Infinity, cpu = null)
1267
1267
  const woke = gone.filter((f) => f.recovered);
1268
1268
  const dead = gone.filter((f) => !f.recovered);
1269
1269
  const parts = [];
1270
- if (woke.length) parts.push(`${list(woke.map(name))} woke on the current build's code through ${woke.length === 1 ? "its" : "their"} stable name${woke.length === 1 ? "" : "s"}: the page's HTML and ${woke.length === 1 ? "that island" : "those islands"} now come from different builds`);
1271
- if (dead.length) parts.push(`${list(dead.map(name))} stayed asleep: ${dead.length === 1 ? "its" : "their"} stable name failed too`);
1270
+ if (woke.length) parts.push(`${list(woke.map(name))} woke on the current build's code, found in its \`_app/ogygia.json\`: the page's HTML and ${woke.length === 1 ? "that island" : "those islands"} now come from different builds`);
1271
+ if (dead.length) parts.push(`${list(dead.map(name))} stayed asleep: the current build's file failed too, or \`_app/ogygia.json\` did not name ${dead.length === 1 ? "it" : "them"}`);
1272
1272
  findings.push({
1273
1273
  code: "island-file-gone",
1274
1274
  severity: dead.length ? "error" : "warn",
@@ -108,7 +108,7 @@ function tag_attrs(tag) {
108
108
  }
109
109
  return out;
110
110
  }
111
- /** The tag's name, lowercase (`<qds-button …>` → `qds-button`). */
111
+ /** The tag's name, lowercase (`<ds-button …>` → `ds-button`). */
112
112
  function tag_name(tag) {
113
113
  let i = 1;
114
114
  while (i < tag.length && tag[i] !== " " && tag[i] !== ">" && tag[i] !== "/" && tag[i] !== "\n") i++;
@@ -9,7 +9,7 @@ import { runtime_session } from "./session.js";
9
9
  import { regions_in_shadow } from "./connected.js";
10
10
  import { yield_task } from "./schedule.js";
11
11
  import { beacon_nav } from "./beacon.js";
12
- import { is_self_owned, walk_enters } from "./ownership.js";
12
+ import { is_self_owned, record_server_body, server_body_attributes, server_body_child, walk_enters } from "./ownership.js";
13
13
  import { load_slot } from "./load-scheduler.js";
14
14
  // src/runtime/boot-link.ts
15
15
  /**
@@ -38,6 +38,9 @@ function link_boot() {
38
38
  beacon_nav: BEACON ? beacon_nav : no_beacon_nav,
39
39
  walk_enters,
40
40
  is_self_owned,
41
+ record_server_body,
42
+ server_body_attributes,
43
+ server_body_child,
41
44
  load_slot
42
45
  };
43
46
  }
@@ -4,7 +4,7 @@ import { init_shadow_registries, parse_region_html, restore_adopt_sheets, restor
4
4
  import { mark_region_changed, region_changed, unwatch_region, watch_region } from "./drift-watch.js";
5
5
  import { runtime_session } from "./session.js";
6
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 { import_entry, island_entry_of, on_entry_fallback, pin_region_urls } from "./entry-locations.js";
7
+ import { import_entry, island_entry_of, on_entry_fallback, pin_build_map, pin_region_urls } from "./entry-locations.js";
8
8
  import { is_awake, is_deferred, is_frozen, ours_on_kit_document, phase2_hydrate_schedule, region_hydrate_schedule, region_schedule } from "./region-attrs.js";
9
9
  import { slots } from "./slots.js";
10
10
  import { KEEP_FALLBACK_HTML } from "../keep-fallback-marker.js";
@@ -94,7 +94,7 @@ const ON_DEMAND_EVENTS = [
94
94
  const ON_DEMAND_SELECTOR = "ogygia-region[render=\"defer\"][when=\"interaction\"]";
95
95
  /**
96
96
  * ON-DEMAND holes, delegated: ONE set of capture listeners on the document for every armed hole
97
- * (five listeners per hole used to be five hundred on a page of mega-menus). Intent lands anywhere
97
+ * (five listeners per hole used to be five hundred on a page of nav-menus). Intent lands anywhere
98
98
  * inside a hole; the nearest ARMED hole above the target fires once and is forgotten.
99
99
  */
100
100
  const on_demand_armed = /* @__PURE__ */ new Map();
@@ -1128,6 +1128,7 @@ function boot(installers = [], client_plugins = []) {
1128
1128
  runtime_session.adopt_document_head();
1129
1129
  link_boot();
1130
1130
  for (const install of installers) install();
1131
+ pin_build_map();
1131
1132
  on_entry_fallback((report) => {
1132
1133
  if (DEVTOOLS) emit({
1133
1134
  domain: "runtime",
@@ -11,6 +11,13 @@ export declare function on_entry_fallback(reporter: (report: EntryFallbackReport
11
11
  export declare function note_entry_location(entry: string | null | undefined, src: string | null | undefined, base?: string): void;
12
12
  /** The known location of an entry, or undefined. */
13
13
  export declare function entry_location(entry: string, base?: string): string | undefined;
14
+ /**
15
+ * Pin the build map's address to the page that wrote it, as `pin_region_urls` pins a region's: with
16
+ * Kit's default `paths.relative` it is written relative to that page, and it is read lazily — after a
17
+ * client navigation to another depth, resolved against the new address, it named a file that is not
18
+ * there. The runtime calls this at boot, before any navigation.
19
+ */
20
+ export declare function pin_build_map(): void;
14
21
  /**
15
22
  * PIN A REGION'S URLS TO THE PAGE THAT WROTE THEM. With Kit's default `paths.relative`, the server
16
23
  * writes `entry` / `src` / `endpoint` relative to the page it rendered (`../../_app/immutable/…`,
@@ -26,8 +33,8 @@ export declare function pin_region_urls(el: Element, base?: string): void;
26
33
  /** An island element's identity, its location noted on the way (the one read every loader uses). */
27
34
  export declare function island_entry_of(el: Element): string | null;
28
35
  /**
29
- * Import an entry: its location when known, else `url` (what the caller resolved for the identity);
30
- * the location failing, once, the identity fetched fresh — so an island on a page that outlived its
31
- * build still wakes, on the current code. No location known → the error stands.
36
+ * Import an entry: its location when known (else this build's map names it, once read); failing that,
37
+ * or with none anywhere, the CURRENT build's location — so an island on a page that outlived its build
38
+ * still wakes, on the current code. `url`: what the caller resolved for the identity.
32
39
  */
33
40
  export declare function import_entry<T>(entry: string, url?: string): Promise<T>;
@@ -6,9 +6,9 @@
6
6
  * fingerprint, the devtools' names). The file to import is its LOCATION — content-hashed, so an
7
7
  * `immutable` cache never serves a stale one. The server writes it beside the identity: the region
8
8
  * element's `src`, and the island graph's `s` map for an entry no element carries (a portable
9
- * snippet's). Every loader resolves through `island_module_url` (region-endpoint-url.ts), which asks
10
- * here first: the location once one is known, else the identity (the dev server, or a page from
11
- * before hashing — whose stable name is a shim that reaches the current build).
9
+ * snippet's), and the build's location maps for the rest (`import_entry`). Every loader resolves
10
+ * through `island_module_url` (region-endpoint-url.ts), which asks here first: the location once one
11
+ * is known, else the identity (on the dev server, the module itself; in a build, a name, no file).
12
12
  */
13
13
  /** absolute identity → absolute location */
14
14
  const locations = /* @__PURE__ */ new Map();
@@ -43,6 +43,48 @@ function note_entry_location(entry, src, base) {
43
43
  function entry_location(entry, base) {
44
44
  return locations.size ? locations.get(absolute(entry, base)) : void 0;
45
45
  }
46
+ /**
47
+ * THIS BUILD'S LOCATION MAP (compiler/link/location-map.ts, the content-hashed one): every island entry's
48
+ * location by identity, for a load that arrives with an identity alone — a region the browser
49
+ * rendered, a held region from the wire, a snippet whose host carried no graph. The runtime's script
50
+ * names it (`data-og-locations`); it is read once, on the first such load. Content-hashed, so no
51
+ * cache serves it stale. It only fills gaps: a location a page wrote (a later build's, after a
52
+ * navigation) always wins over the map of the build the runtime came from. No map, or it failed
53
+ * (the dev server, a page that outlived its build): the identity is loaded, as before. A failed read
54
+ * is not kept: the next identity-only load asks again (a blip must not cost the page every later one).
55
+ */
56
+ const BUILD_MAP_ATTR = "data-og-locations";
57
+ let build_map = null;
58
+ /**
59
+ * Pin the build map's address to the page that wrote it, as `pin_region_urls` pins a region's: with
60
+ * Kit's default `paths.relative` it is written relative to that page, and it is read lazily — after a
61
+ * client navigation to another depth, resolved against the new address, it named a file that is not
62
+ * there. The runtime calls this at boot, before any navigation.
63
+ */
64
+ function pin_build_map() {
65
+ if (typeof document === "undefined") return;
66
+ const script = document.querySelector(`script[${BUILD_MAP_ATTR}]`);
67
+ const href = script?.getAttribute(BUILD_MAP_ATTR);
68
+ if (script && href) script.setAttribute(BUILD_MAP_ATTR, absolute(href));
69
+ }
70
+ function read_build_map() {
71
+ if (build_map) return build_map;
72
+ if (typeof document === "undefined") return null;
73
+ const href = document.querySelector(`script[${BUILD_MAP_ATTR}]`)?.getAttribute(BUILD_MAP_ATTR);
74
+ if (!href) return null;
75
+ const url = absolute(href);
76
+ const read = fetch(url).then((res) => res.ok ? res.json() : null).catch(() => null).then((map) => {
77
+ if (!map) {
78
+ if (build_map === read) build_map = null;
79
+ return;
80
+ }
81
+ for (const stable in map) {
82
+ const identity = absolute(stable, url);
83
+ if (!locations.has(identity)) locations.set(identity, absolute(map[stable], url));
84
+ }
85
+ });
86
+ return build_map = read;
87
+ }
46
88
  /** The region attributes that hold a URL: its identity, its location, its hole's endpoint. */
47
89
  const REGION_URL_ATTRS = [
48
90
  "entry",
@@ -80,32 +122,101 @@ function island_entry_of(el) {
80
122
  return entry;
81
123
  }
82
124
  /**
83
- * The identity itself, past every cache: what a load falls back to when its location failed (a page
84
- * a cache kept outlived the build that made it, and that build's files are gone). The stable name
85
- * is a shim of the CURRENT build's file; the query makes the browser fetch it fresh rather than the
86
- * copy an `immutable` cache may hold. Null when no location was known (nothing to fall back to).
125
+ * THE CURRENT BUILD'S MAP (compiler/link/location-map.ts): the one file at a fixed name, in the app
126
+ * dir of the identities it maps (`<appDir>/ogygia.json`), so any app's is found from an identity alone.
127
+ * A page that outlived its build reads it when its own files are gone (or it never named a map) and
128
+ * wakes on the current code. Revalidated (a host serves it as any changing file; `no-cache` past one
129
+ * that kept it anyway), read once per document per app. Only an ogygia identity has one: the dev
130
+ * server's module URLs never ask.
87
131
  */
88
- function fresh_identity_url(entry, base) {
89
- if (!entry_location(entry, base)) return null;
90
- const identity = absolute(entry, base);
91
- return identity + (identity.includes("?") ? "&" : "?") + "og-fresh=" + Date.now().toString(36);
132
+ const current_maps = /* @__PURE__ */ new Map();
133
+ /** A build's island identity (`…/<appDir>/ogygia/og-region.<id>.js`): a name, no file. */
134
+ function is_build_identity(entry) {
135
+ return absolute(entry).includes("/ogygia/og-region.");
92
136
  }
137
+ /** The current map's file for an identity (`<appDir>/ogygia.json`), or null: not a build's identity. */
138
+ function current_map_file(identity) {
139
+ const dir = identity.lastIndexOf("/ogygia/og-region.");
140
+ return dir === -1 ? null : identity.slice(0, dir) + "/ogygia.json";
141
+ }
142
+ /** The current build's location for `entry`: null when its map has none, undefined when the map
143
+ * could not be read (that read is not kept: the next ask reads again). */
144
+ async function current_location(entry) {
145
+ const identity = absolute(entry);
146
+ const file = current_map_file(identity);
147
+ if (!file) return null;
148
+ let map = current_maps.get(file);
149
+ if (!map) {
150
+ map = fetch(file, { cache: "no-cache" }).then((res) => res.ok ? res.json() : null).catch(() => null);
151
+ current_maps.set(file, map);
152
+ }
153
+ const pairs = await map;
154
+ if (!pairs) {
155
+ forget_current_map(file, map);
156
+ return;
157
+ }
158
+ for (const name in pairs) if (absolute(name, file) === identity) return absolute(pairs[name], file);
159
+ return null;
160
+ }
161
+ function forget_current_map(file, map = current_maps.get(file)) {
162
+ if (current_maps.get(file) === map) current_maps.delete(file);
163
+ }
164
+ /** The host says this file is not there (it does not say so of a file that failed another way). */
165
+ function is_gone(url) {
166
+ return fetch(url, {
167
+ method: "HEAD",
168
+ cache: "no-store"
169
+ }).then((res) => res.status === 404 || res.status === 410, () => false);
170
+ }
171
+ /** How long a recovery waits before each ask of the current map: a rollover settles in seconds. */
172
+ const RECOVERY_WAITS = [
173
+ 0,
174
+ 1e3,
175
+ 3e3,
176
+ 9e3
177
+ ];
93
178
  /**
94
- * Import an entry: its location when known, else `url` (what the caller resolved for the identity);
95
- * the location failing, once, the identity fetched fresh — so an island on a page that outlived its
96
- * build still wakes, on the current code. No location known → the error stands.
179
+ * The current build's file for a build's identity whose file just failed to load. A deploy's rollover
180
+ * is exactly when this is asked, so an answer that cannot be the current one is asked again while it
181
+ * settles: a map that could not be read (a 404, a 5xx), and a map that names the very file that just
182
+ * failed when the host says that file is gone — an edge still answering with the old build's map. A
183
+ * file that failed some other way (the network, a broken module) is not asked about again.
184
+ */
185
+ async function recover_location(entry, failed) {
186
+ let current;
187
+ for (const wait of RECOVERY_WAITS) {
188
+ if (wait) await new Promise((resolve) => setTimeout(resolve, wait));
189
+ current = await current_location(entry);
190
+ if (current === void 0) continue;
191
+ if (current !== failed || !await is_gone(failed)) break;
192
+ forget_current_map(current_map_file(absolute(entry)));
193
+ }
194
+ return current ?? null;
195
+ }
196
+ /**
197
+ * Import an entry: its location when known (else this build's map names it, once read); failing that,
198
+ * or with none anywhere, the CURRENT build's location — so an island on a page that outlived its build
199
+ * still wakes, on the current code. `url`: what the caller resolved for the identity.
97
200
  */
98
201
  function import_entry(entry, url = entry_location(entry) ?? entry) {
99
- const loaded = import(
202
+ if (entry_location(entry)) return load_entry(entry, url);
203
+ return (read_build_map() ?? Promise.resolve()).then(async () => {
204
+ const located = entry_location(entry);
205
+ if (located) return load_entry(entry, located);
206
+ return load_entry(entry, await current_location(entry) || url);
207
+ });
208
+ }
209
+ function load_entry(entry, url) {
210
+ return import(
100
211
  /* @vite-ignore */
101
212
  url
102
- );
103
- const fresh = fresh_identity_url(entry);
104
- if (!fresh) return loaded;
105
- return loaded.catch(() => {
213
+ ).catch(async (error) => {
214
+ const failed = absolute(url);
215
+ const current = is_build_identity(entry) ? await recover_location(entry, failed) : absolute(entry);
216
+ if (!current || current === failed) throw error;
106
217
  const retry = import(
107
218
  /* @vite-ignore */
108
- fresh
219
+ current
109
220
  );
110
221
  const report = (recovered) => fallback_reporter?.({
111
222
  entry,
@@ -116,4 +227,4 @@ function import_entry(entry, url = entry_location(entry) ?? entry) {
116
227
  return retry;
117
228
  });
118
229
  }
119
- export { entry_location, import_entry, island_entry_of, note_entry_location, on_entry_fallback, pin_region_urls };
230
+ export { entry_location, import_entry, island_entry_of, note_entry_location, on_entry_fallback, pin_build_map, pin_region_urls };
@@ -35,6 +35,13 @@ export declare function morph_children(parent: Element, new_nodes: ArrayLike<Nod
35
35
  */
36
36
  export interface MorphOptions {
37
37
  target?: "live" | "walk";
38
+ /**
39
+ * A child of the ROOT that is not the render's (the router's body reconcile: a node a script put
40
+ * in `<body>`, such as a portal's mount anchor; ownership.ts `server_body_child`). The morph steps
41
+ * over it: never matched, never moved, never removed. The render's children around it reconcile
42
+ * as usual. Root level only; below the root the ownership rules decide.
43
+ */
44
+ stays?: (node: Node) => boolean;
38
45
  }
39
46
  /** Add + update + remove attributes so `from` matches `to` exactly. Boolean attrs are attr presence.
40
47
  * `keep_extra` (a self-owned element, {@link is_self_owned}): add + update only — an attribute the
@@ -95,7 +95,7 @@ function morph_children(parent, new_nodes, options) {
95
95
  const owner = owner_of(parent);
96
96
  if (owner !== "walk" && owner !== "region") return;
97
97
  }
98
- reconcile(parent, new_nodes, build_id_sets(parent, new_nodes), walk);
98
+ reconcile(parent, new_nodes, build_id_sets(parent, new_nodes), walk, options?.stays);
99
99
  clear_aria_hidden_over_focus(parent);
100
100
  }
101
101
  /**
@@ -191,18 +191,24 @@ function reserved_for_later(sets, new_inner, cursor, idx) {
191
191
  * ({@link reconcile_by_identity}). Toward Svelte's walk, positions are always the render's (the walk
192
192
  * binds by position).
193
193
  */
194
- function reconcile(parent, new_nodes, sets, walk) {
194
+ function reconcile(parent, new_nodes, sets, walk, stays) {
195
195
  if (!walk && order_owner(parent) === "element") reconcile_by_identity(parent, new_nodes, sets);
196
- else reconcile_children(parent, new_nodes, sets, walk);
196
+ else reconcile_children(parent, new_nodes, sets, walk, stays);
197
197
  }
198
198
  /**
199
199
  * The render's sequence, by position.
200
200
  * @param walk the {@link MorphOptions.target} is Svelte's walk (the repair), threaded unchanged down
201
201
  * the recursion — a subtree the walk never enters is skipped.
202
+ * @param stays the root's children that are not the render's ({@link MorphOptions.stays}): the
203
+ * cursor steps over them, so they are never matched, moved or removed. Not threaded down.
202
204
  */
203
- function reconcile_children(parent, new_nodes, sets, walk = false) {
205
+ function reconcile_children(parent, new_nodes, sets, walk = false, stays) {
204
206
  const count = new_nodes.length;
205
- let cursor = parent.firstChild;
207
+ const step = (n) => {
208
+ if (stays) while (n && stays(n)) n = n.nextSibling;
209
+ return n;
210
+ };
211
+ let cursor = step(parent.firstChild);
206
212
  let idx = 0;
207
213
  while (idx < count && cursor) {
208
214
  const next = new_nodes[idx];
@@ -213,11 +219,11 @@ function reconcile_children(parent, new_nodes, sets, walk = false) {
213
219
  if (!same_node(cursor, next)) break;
214
220
  if (sets !== null && !id_sets_agree(sets, cursor, next)) break;
215
221
  const here = cursor;
216
- cursor = cursor.nextSibling;
222
+ cursor = step(cursor.nextSibling);
217
223
  morph_same(here, next, sets, walk);
218
224
  } else {
219
225
  const here = cursor;
220
- cursor = cursor.nextSibling;
226
+ cursor = step(cursor.nextSibling);
221
227
  morph_node(here, next, sets, walk);
222
228
  }
223
229
  idx++;
@@ -225,7 +231,7 @@ function reconcile_children(parent, new_nodes, sets, walk = false) {
225
231
  if (idx >= count) {
226
232
  while (cursor) {
227
233
  const gone = cursor;
228
- cursor = cursor.nextSibling;
234
+ cursor = step(cursor.nextSibling);
229
235
  parent.removeChild(gone);
230
236
  }
231
237
  return;
@@ -236,7 +242,7 @@ function reconcile_children(parent, new_nodes, sets, walk = false) {
236
242
  }
237
243
  let old_keys = null;
238
244
  let old_inner = null;
239
- for (let n = cursor; n; n = n.nextSibling) {
245
+ for (let n = cursor; n; n = step(n.nextSibling)) {
240
246
  const k = key_of(n);
241
247
  if (k !== null) (old_keys ??= /* @__PURE__ */ new Map()).set(k, n);
242
248
  else if (sets !== null && n.nodeType === ELEMENT) {
@@ -260,7 +266,7 @@ function reconcile_children(parent, new_nodes, sets, walk = false) {
260
266
  const next = new_nodes[idx];
261
267
  if (cursor && key_of(next) === null && same_node(cursor, next)) {
262
268
  const here = cursor;
263
- cursor = cursor.nextSibling;
269
+ cursor = step(cursor.nextSibling);
264
270
  morph_same(here, next, sets, walk);
265
271
  } else parent.insertBefore(clone(next), cursor);
266
272
  }
@@ -271,7 +277,7 @@ function reconcile_children(parent, new_nodes, sets, walk = false) {
271
277
  const matched = old_keys?.get(next_key);
272
278
  if (matched !== void 0) {
273
279
  old_keys.delete(next_key);
274
- if (matched === cursor) cursor = cursor.nextSibling;
280
+ if (matched === cursor) cursor = step(cursor.nextSibling);
275
281
  else parent.insertBefore(matched, cursor);
276
282
  morph_node(matched, next, sets, walk);
277
283
  continue;
@@ -281,7 +287,7 @@ function reconcile_children(parent, new_nodes, sets, walk = false) {
281
287
  const holder = wanted === void 0 ? null : find_holder(old_inner, wanted, next);
282
288
  if (holder !== null) {
283
289
  release_holder(old_inner, sets, holder);
284
- if (holder === cursor) cursor = cursor.nextSibling;
290
+ if (holder === cursor) cursor = step(cursor.nextSibling);
285
291
  else parent.insertBefore(holder, cursor);
286
292
  morph_same(holder, next, sets, walk);
287
293
  continue;
@@ -291,11 +297,11 @@ function reconcile_children(parent, new_nodes, sets, walk = false) {
291
297
  while (cursor) {
292
298
  cur_key = key_of(cursor);
293
299
  if (cur_key === null || old_keys === null || !old_keys.has(cur_key)) break;
294
- cursor = cursor.nextSibling;
300
+ cursor = step(cursor.nextSibling);
295
301
  }
296
302
  if (cursor && next_key === null && cur_key === null && same_node(cursor, next) && (old_inner === null || !reserved_for_later(sets, new_inner, cursor, idx))) {
297
303
  const here = cursor;
298
- cursor = cursor.nextSibling;
304
+ cursor = step(cursor.nextSibling);
299
305
  if (old_inner !== null) release_holder(old_inner, sets, here);
300
306
  morph_same(here, next, sets, walk);
301
307
  continue;
@@ -304,7 +310,7 @@ function reconcile_children(parent, new_nodes, sets, walk = false) {
304
310
  }
305
311
  while (cursor) {
306
312
  const gone = cursor;
307
- cursor = cursor.nextSibling;
313
+ cursor = step(cursor.nextSibling);
308
314
  parent.removeChild(gone);
309
315
  }
310
316
  if (old_keys) {
@@ -98,4 +98,13 @@ export declare function mark_render_made(node: Node): void;
98
98
  * produces at this level (`render_tags`) is render output — a runtime does not make the render's
99
99
  * elements. Anything else is the element's own: never matched, never removed.
100
100
  */
101
- export declare function render_made(node: Node, render_tags: ReadonlySet<string>): boolean;
101
+ export declare function render_made(node: Node, render_tags: ReadonlySet<string>): boolean;
102
+ /** Record what the server sent `root` (`<body>`): the attribute names on `sent` (the root as the
103
+ * server rendered it), and `children` (the live root's nodes that are the server's) as render output. */
104
+ export declare function record_server_body(root: Element, sent: Element, children: Iterable<Node>): void;
105
+ /** The attribute names the server last sent `root`, or null for a root with no record yet (all of
106
+ * it is then taken to be the server's). */
107
+ export declare function server_body_attributes(root: Element): ReadonlySet<string> | null;
108
+ /** Is `node`, a child of `<body>`, the server's? Recorded, or an `<ogygia-region>`. Anything else
109
+ * the page's scripts put there: a navigation neither matches nor removes it. */
110
+ export declare function server_body_child(node: Node): boolean;
@@ -112,4 +112,22 @@ function render_made(node, render_tags) {
112
112
  const name = node.localName;
113
113
  return name === REGION || render_tags.has(name);
114
114
  }
115
- export { BAKED_ATTR, HTML_PARENT_ATTR, OPAQUE_ATTR, REGION_RENDER_ATTRS, attributes_writer, is_declared_foreign, is_region_owned, is_self_owned, is_upgraded_ce, mark_render_made, order_owner, owner_of, render_made, slots_light_dom, walk_enters };
115
+ const NO_TAGS = /* @__PURE__ */ new Set();
116
+ const server_attrs = /* @__PURE__ */ new WeakMap();
117
+ /** Record what the server sent `root` (`<body>`): the attribute names on `sent` (the root as the
118
+ * server rendered it), and `children` (the live root's nodes that are the server's) as render output. */
119
+ function record_server_body(root, sent, children) {
120
+ server_attrs.set(root, new Set(Array.from(sent.attributes, (a) => a.name)));
121
+ for (const n of children) mark_render_made(n);
122
+ }
123
+ /** The attribute names the server last sent `root`, or null for a root with no record yet (all of
124
+ * it is then taken to be the server's). */
125
+ function server_body_attributes(root) {
126
+ return server_attrs.get(root) ?? null;
127
+ }
128
+ /** Is `node`, a child of `<body>`, the server's? Recorded, or an `<ogygia-region>`. Anything else
129
+ * the page's scripts put there: a navigation neither matches nor removes it. */
130
+ function server_body_child(node) {
131
+ return render_made(node, NO_TAGS);
132
+ }
133
+ export { BAKED_ATTR, HTML_PARENT_ATTR, OPAQUE_ATTR, REGION_RENDER_ATTRS, attributes_writer, is_declared_foreign, is_region_owned, is_self_owned, is_upgraded_ce, mark_render_made, order_owner, owner_of, record_server_body, render_made, server_body_attributes, server_body_child, slots_light_dom, walk_enters };
@@ -27,7 +27,9 @@ export declare function stamp_region_keys(root: ParentNode): void;
27
27
  * here, on either document.
28
28
  */
29
29
  export declare const NO_RECONCILE_SELECTOR = "[data-og-no-reconcile]";
30
- export declare function reconcile_body(live: Element, next: Element, morph: (parent: Element, nodes: ArrayLike<Node>) => void): void;
30
+ export declare function reconcile_body(live: Element, next: Element, morph: (parent: Element, nodes: ArrayLike<Node>, options?: {
31
+ stays?: (node: Node) => boolean;
32
+ }) => void): void;
31
33
  /**
32
34
  * outerSync body swap — the FALLBACK path (reconcile off, or a region trapped in a shadow root the
33
35
  * morph can't pierce). The old code did `document.body.replaceWith(doc.body)`, which THROWS AWAY the
@@ -35,7 +37,8 @@ export declare function reconcile_body(live: Element, next: Element, morph: (par
35
37
  * analytics, a scroll library) attached to `document.body`. htmx 4 boosts land the same way and use
36
38
  * `outerSync` for exactly this reason: keep the element, sync its attributes, replace its children.
37
39
  * So we do too — the live body node survives, only its guts change. Incoming children are moved out
38
- * of the parsed document (insertion adopts them across documents).
40
+ * of the parsed document (insertion adopts them across documents). The page's own children (a
41
+ * portal's anchor) stay, as in the reconcile; the server's are replaced.
39
42
  */
40
43
  export declare function swap_body(live: HTMLElement, next: HTMLElement): void;
41
44
  export { endpoint_key, fingerprint_of, fnv1a, signature_of };
@@ -1,5 +1,5 @@
1
1
  import { dispose_ids } from "../ref.js";
2
- import { boot_link, morph_sync_attributes } from "./slots.js";
2
+ import { boot_link, morph_sync_attributes, slots } from "./slots.js";
3
3
  import { region_owned_ids } from "./region-ids.js";
4
4
  import { emit } from "../events/bus.js";
5
5
  import { endpoint_key, fingerprint_of, fnv1a, signature_of } from "./hash.js";
@@ -27,22 +27,48 @@ import { endpoint_key, fingerprint_of, fnv1a, signature_of } from "./hash.js";
27
27
  const DEVTOOLS = typeof __OGYGIA_DEVTOOLS__ !== "undefined" ? __OGYGIA_DEVTOOLS__ : false;
28
28
  const props_sidecar_of = (region) => boot_link().props_sidecar_of(region);
29
29
  /**
30
- * `<body>`'s attributes have two owners (runtime/ownership.ts): the server, page by page (a theme
31
- * class, a layout flag), and the scripts living on the page (a scroll lock, a consent banner's state,
32
- * an analytics marker). A navigation moves the server's attributes to the new page's — and removes
33
- * only those the PREVIOUS page's server sent that the new one doesn't. An attribute a script set is
34
- * not the server's to take away (before this, every navigation stripped them all). The server's set
35
- * starts as the body's attributes when this module loads, which the runtime does before the page's
36
- * own scripts (head-presence.ts puts it first).
30
+ * `<body>` has two owners (runtime/ownership.ts): the server, page by page (a theme class, a layout
31
+ * flag, the content), and the scripts living on the page (a scroll lock, a consent banner's state; a
32
+ * component mounted into the body, such as a popover's portal). A navigation moves the server's part
33
+ * to the new page's and leaves the page's part alone:
34
+ * - ATTRIBUTES: it removes only those the PREVIOUS page's server sent that the new one doesn't;
35
+ * - CHILDREN: the morph steps over a child the server never sent (a portal's mount anchor: taking
36
+ * it away leaves the portal inserting its content beside a detached node, so a popover in a kept
37
+ * island never opens again).
38
+ * ONE record per reconciled root (ownership.ts `record_server_body`), reached through the boot
39
+ * registry like every boot helper this lazy chunk uses: the router takes the body's when it starts,
40
+ * before any island wakes; each reconcile renews its root's (a harness driving it on its own root
41
+ * gets one from its first call). A root with no record yet is all the server's: every child is
42
+ * matched, and no attribute is taken away.
37
43
  */
38
- const attr_names = (el) => new Set(Array.from(el.attributes, (a) => a.name));
39
- let server_body_attrs = typeof document !== "undefined" && document.body ? attr_names(document.body) : null;
44
+ /** The record of what the server last sent `live`, or null while there is none. */
45
+ function body_record(live) {
46
+ const link = slots.boot;
47
+ return link && link.server_body_attributes(live) !== null ? link : null;
48
+ }
40
49
  function sync_body_attributes(live, next) {
41
50
  morph_sync_attributes()(live, next, true);
42
- if (server_body_attrs) {
43
- for (const name of server_body_attrs) if (!next.hasAttribute(name)) live.removeAttribute(name);
51
+ const sent = body_record(live)?.server_body_attributes(live);
52
+ if (sent) {
53
+ for (const name of sent) if (!next.hasAttribute(name)) live.removeAttribute(name);
44
54
  }
45
- server_body_attrs = attr_names(next);
55
+ }
56
+ /** The live body's children that are the page's own, not the server's (none without a record). */
57
+ function page_own_children(live) {
58
+ const own = /* @__PURE__ */ new Set();
59
+ const record = body_record(live);
60
+ if (record === null) return own;
61
+ for (let n = live.firstChild; n; n = n.nextSibling) if (!record.server_body_child(n)) own.add(n);
62
+ return own;
63
+ }
64
+ /** After a navigation the server's body is the new page's: its attributes, and every live child that
65
+ * is not the page's own. */
66
+ function renew_server_body(live, next, own) {
67
+ const link = slots.boot;
68
+ if (!link) return;
69
+ const children = [];
70
+ for (let n = live.firstChild; n; n = n.nextSibling) if (!own.has(n)) children.push(n);
71
+ link.record_server_body(live, next, children);
46
72
  }
47
73
  /** The `<script data-ogygia-props>` seed text of a region — keyed at the end of the body or the
48
74
  * adjacent sibling (runtime/sidecar.ts) — or '' — the same lookup the hydrator uses, read-only.
@@ -143,6 +169,7 @@ function dt_emit_reconcile(live, next) {
143
169
  function reconcile_body(live, next, morph) {
144
170
  stamp_region_keys(live);
145
171
  stamp_region_keys(next);
172
+ const own = page_own_children(live);
146
173
  sync_body_attributes(live, next);
147
174
  if (DEVTOOLS) dt_emit_reconcile(live, next);
148
175
  const next_keys = /* @__PURE__ */ new Set();
@@ -158,7 +185,8 @@ function reconcile_body(live, next, morph) {
158
185
  if (set !== void 0) for (const id of set) removed_ids.push(id);
159
186
  }
160
187
  absorb_kept_props(live, next);
161
- morph(live, next.childNodes);
188
+ morph(live, next.childNodes, own.size > 0 ? { stays: (n) => own.has(n) } : void 0);
189
+ renew_server_body(live, next, own);
162
190
  if (removed_ids.length > 0) dispose_ids(removed_ids);
163
191
  }
164
192
  /**
@@ -168,12 +196,20 @@ function reconcile_body(live, next, morph) {
168
196
  * analytics, a scroll library) attached to `document.body`. htmx 4 boosts land the same way and use
169
197
  * `outerSync` for exactly this reason: keep the element, sync its attributes, replace its children.
170
198
  * So we do too — the live body node survives, only its guts change. Incoming children are moved out
171
- * of the parsed document (insertion adopts them across documents).
199
+ * of the parsed document (insertion adopts them across documents). The page's own children (a
200
+ * portal's anchor) stay, as in the reconcile; the server's are replaced.
172
201
  */
173
202
  function swap_body(live, next) {
203
+ const own = page_own_children(live);
174
204
  sync_body_attributes(live, next);
175
- live.replaceChildren();
205
+ if (own.size === 0) live.replaceChildren();
206
+ else for (let n = live.firstChild; n;) {
207
+ const here = n;
208
+ n = n.nextSibling;
209
+ if (!own.has(here)) live.removeChild(here);
210
+ }
176
211
  while (next.firstChild) live.appendChild(next.firstChild);
212
+ renew_server_body(live, next, own);
177
213
  }
178
214
  /** For each live region whose reconcile key also appears in `next`, push the incoming region's
179
215
  * props into the live island (kept regions only — a re-mounting region gets its props on mount). */
@@ -143,6 +143,7 @@ function warm_key(url) {
143
143
  function warm_island_module(entry, base, cls = "speculative") {
144
144
  const url = island_module_url(entry, base);
145
145
  if (!url) return;
146
+ if (!entry_location(entry, base) && url.includes("/ogygia/og-region.")) return;
146
147
  const key = warm_key(url);
147
148
  if (warmed_modules.has(key)) return;
148
149
  warmed_modules.add(key);