@webjsdev/core 0.7.50 → 0.7.51

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 (57) hide show
  1. package/dist/webjs-core-browser.js +5 -5
  2. package/dist/webjs-core-browser.js.map +3 -3
  3. package/dist/webjs-core-testing.js +20 -20
  4. package/dist/webjs-core-testing.js.map +3 -3
  5. package/dist/webjs-core.js +7 -7
  6. package/dist/webjs-core.js.map +3 -3
  7. package/index-browser.js +1 -1
  8. package/index.d.ts +1 -1
  9. package/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/component/lifecycle.js +1481 -0
  12. package/src/component/reactive.js +171 -0
  13. package/src/component/server-element.js +248 -0
  14. package/src/component.js +2 -1896
  15. package/src/render-client/parts.js +2007 -0
  16. package/src/render-client/reconciler.js +388 -0
  17. package/src/render-client/template-compiler.js +525 -0
  18. package/src/render-client.js +1 -2938
  19. package/src/render-server/attrs.js +160 -0
  20. package/src/render-server/dsd.js +445 -0
  21. package/src/render-server/html-scan.js +402 -0
  22. package/src/render-server/slots.js +213 -0
  23. package/src/render-server/stream.js +163 -0
  24. package/src/render-server/template-renderer.js +923 -0
  25. package/src/render-server/text.js +163 -0
  26. package/src/render-server.js +6 -2405
  27. package/src/router-client/anchors.js +44 -0
  28. package/src/router-client/boundaries.js +221 -0
  29. package/src/router-client/constants.js +187 -0
  30. package/src/router-client/diagnostics.js +377 -0
  31. package/src/router-client/dom-differ.js +709 -0
  32. package/src/router-client/dom-parse.js +150 -0
  33. package/src/router-client/events.js +139 -0
  34. package/src/router-client/fetch-apply.js +387 -0
  35. package/src/router-client/form-encoder.js +135 -0
  36. package/src/router-client/frames.js +329 -0
  37. package/src/router-client/head-merge.js +344 -0
  38. package/src/router-client/nav-error.js +105 -0
  39. package/src/router-client/navigator.js +781 -0
  40. package/src/router-client/prefetch.js +824 -0
  41. package/src/router-client/scroll.js +259 -0
  42. package/src/router-client/snapshot-cache.js +97 -0
  43. package/src/router-client/state.js +92 -0
  44. package/src/router-client/stream.js +206 -0
  45. package/src/router-client/swap.js +451 -0
  46. package/src/router-client/upgrade.js +261 -0
  47. package/src/router-client/view-transition.js +223 -0
  48. package/src/router-client.d.ts +10 -1
  49. package/src/router-client.js +75 -5345
  50. package/src/slot/assignment.js +304 -0
  51. package/src/slot/interception.js +558 -0
  52. package/src/slot/polyfills.js +365 -0
  53. package/src/slot/project.js +432 -0
  54. package/src/slot/sensors.js +303 -0
  55. package/src/slot/state.js +245 -0
  56. package/src/slot/symbols.js +85 -0
  57. package/src/slot.js +7 -2199
@@ -0,0 +1,451 @@
1
+ /**
2
+ * Client router: swap.
3
+ *
4
+ * Moved verbatim out of the pre-split `router-client.js`; that barrel holds
5
+ * the router's full contract and the public entry points.
6
+ *
7
+ * @module
8
+ */
9
+ import { scanSeeds } from '../action-seed-client.js';
10
+ import { collectBoundaries, planBoundarySwap } from './boundaries.js';
11
+ import { reportFallback } from './diagnostics.js';
12
+ import { blurOutgoingFocus, diffChildren, replaceBoundaryRange, resyncEnclosingHostSlots, swapMarkerRange } from './dom-differ.js';
13
+ import { trackedReloadSignature } from './frames.js';
14
+ import { addNewHeadElements, mergeHead } from './head-merge.js';
15
+ import { clearPrefetchRefused, prefetchClearAll } from './prefetch.js';
16
+ import { snapshotCache } from './snapshot-cache.js';
17
+ import { hardNavigate } from './state.js';
18
+ import { forwardSuspenseResolvers } from './stream.js';
19
+ import { reactivateScripts, upgradeCustomElements, upgradeCustomElementsInRange } from './upgrade.js';
20
+ import { regraftPermanentElements, runWithTransition } from './view-transition.js';
21
+
22
+ /**
23
+ * Resolves when the most recent `applySwap` DOM mutation has committed. Under an
24
+ * async view transition the swap is deferred a frame, so the progressive
25
+ * Suspense streamer must await this before applying resolves, or it targets the
26
+ * pre-swap DOM (no placeholder yet) and drops the boundary (#1048). A resolved
27
+ * promise on the synchronous (no-transition) path.
28
+ * @type {Promise<void>}
29
+ */
30
+ export let _swapCommit = Promise.resolve();
31
+
32
+ /**
33
+ * @param {Document} doc
34
+ * @param {string | null} frameId
35
+ * @param {boolean} revalidating
36
+ * @param {string | null} href
37
+ * @param {string | null} [incomingBuild]
38
+ * @param {string | null} [incomingSrc]
39
+ * @returns {'discard' | 'none' | undefined} `'discard'` when a background
40
+ * revalidation was thrown away, `'none'` when it returned without committing
41
+ * anything (a missing frame, or a degradation to a hard navigation), and
42
+ * `undefined` when a swap committed. `fetchAndApply` maps the first two to
43
+ * `applied: false`.
44
+ * @param {'page' | 'shell' | undefined} [refresh] same-URL in-place refresh
45
+ * mode (#1398). `'shell'` takes the full-body tier directly, because the
46
+ * layout's OWN markup changed and that lives outside every children range.
47
+ * `'page'` falls through to the normal two-tier logic, which morphs the
48
+ * deepest shared boundary and so preserves outer-layout component state.
49
+ * @param {(() => void) | null} [recordHistoryNow] Record this navigation's
50
+ * history entry (#1406). Called at each COMMIT point, immediately BEFORE the
51
+ * DOM mutation, for the same reason `ingestSeeds` is: this function can still
52
+ * decide to throw the response away after parsing it, and a `pushState`
53
+ * issued for a swap that never happened is worse than a late one. It runs
54
+ * BEFORE the incoming document replaces what is on screen, because WebKit
55
+ * binds a same-document entry's back-forward gesture snapshot to the page
56
+ * state at the moment the entry is recorded, and a snapshot taken against
57
+ * the incoming (often shorter) document previews blank.
58
+ *
59
+ * "Before the content swap" is the precise claim, NOT "before anything on
60
+ * the page has changed". The page may already have been mutated by the time
61
+ * this runs, and on one route class it reliably has: where the live chain
62
+ * contains a boundary carrying a `loading.{js,ts}` template,
63
+ * `applyOptimisticLoading` swapped that range for the skeleton and let the
64
+ * engine clamp the offset before the fetch was even issued. (It is the
65
+ * innermost boundary WITH a template, found by walking deepest-first and
66
+ * skipping any without one, so usually not the deepest boundary: that is
67
+ * normally the page's own, and a `loading` file sits next to a layout.) The
68
+ * snapshot there is of this route's shell plus a skeleton, which still beats
69
+ * the destination document, but it is not the page the reader left. Fixing
70
+ * that class means recording the entry ahead of the optimistic swap too,
71
+ * which is a separate change on a path nothing here measured.
72
+ *
73
+ * The caller's thunk is one-shot, so it is safe to call here AND on the
74
+ * caller's fall-through. Omitted or null on every path that records no
75
+ * history (a revalidation, a refresh, the popstate restore).
76
+ */
77
+ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc, refresh, recordHistoryNow) {
78
+ // SSR action seeding (#472): ingest the incoming page's seed payload BEFORE
79
+ // its components are grafted into the live DOM and upgrade, so a
80
+ // soft-navigated async component resolves from the seed instead of
81
+ // re-fetching. Scanning `doc` (the detached parse) also strips the carriers,
82
+ // so the inert payload never lands in the live document.
83
+ //
84
+ // Called at each COMMIT point rather than once up front, because this function
85
+ // can still decide to throw the response away after parsing it (a hard
86
+ // navigate, or a background revalidation with no trustworthy boundary plan).
87
+ // Scanning eagerly would clear the visible page's own unconsumed seeds and
88
+ // ingest a render that is never painted, so the next `async render()` would
89
+ // hit on data that disagrees with the HTML on screen. That is the same hole
90
+ // the frame ID-MISSING case closes, reached through a different discard.
91
+ //
92
+ // A frame swap is NOT a page navigation, so say so: the consumer must leave
93
+ // the surrounding page's state alone (see `scanSeeds`).
94
+ let seedsScanned = false;
95
+ const ingestSeeds = () => {
96
+ if (seedsScanned) return;
97
+ seedsScanned = true;
98
+ try { scanSeeds(doc, { frame: !!frameId }); } catch { /* seeding is best-effort */ }
99
+ };
100
+
101
+ // Every host in this parsed doc is FRAMEWORK-SERIALIZED markup (an SSR
102
+ // fragment or a back/forward snapshot of post-hydration HTML), never
103
+ // author-written children. Stamp them so connectedCallback's slot chooser
104
+ // ADOPTS instead of capture-hoovering the rendered tree, which matters for
105
+ // a restored host whose serialized shape carries no projected slot (a
106
+ // conditionally closed slot at snapshot time) where the structural
107
+ // slot-marker detector has nothing to see. The chooser consumes and removes
108
+ // the attribute on upgrade.
109
+ // (An ELIDED display-only host never upgrades, so its stamp is retained as
110
+ // an inert attribute; diffElementInPlace never copies it onto a live host,
111
+ // and the upgrade path consumes it, so it cannot mis-route anything.)
112
+ try {
113
+ for (const el of doc.querySelectorAll('[data-wj-host]')) {
114
+ el.setAttribute('data-wj-serialized', '');
115
+ }
116
+ } catch { /* stamping is best-effort */ }
117
+
118
+ // Any clean swap (no importmap mismatch, including cache restores
119
+ // and frame swaps where we don't even run the mismatch check) is a
120
+ // signal that the user successfully navigated, so clear the reload
121
+ // flag. Otherwise a sequence "reload because of mismatch → Back to
122
+ // a cache restore → Forward to a deploy-bumped URL" would find the
123
+ // stale flag still set and suppress the second legitimate reload.
124
+ try {
125
+ if (typeof sessionStorage !== 'undefined' && (!href || frameId || revalidating)) {
126
+ sessionStorage.removeItem('webjs:importmap-reload');
127
+ }
128
+ } catch { /* ignore */ }
129
+
130
+ // Importmap-mismatch guard. Only fires for foreground navs (href
131
+ // present); revalidation passes href=null to keep cache restores
132
+ // soft. Skipped if a <webjs-frame> escape hatch is in play (frame
133
+ // swaps are intra-page and don't change the importmap).
134
+ if (href && !frameId && !revalidating) {
135
+ const currentTag = document.querySelector('script[type="importmap"]');
136
+ const currentBuild = currentTag ? currentTag.getAttribute('data-webjs-build') : null;
137
+ let mismatch = false;
138
+ if (incomingBuild && currentBuild) {
139
+ // Preferred path: compare per-response build id. Works even
140
+ // when the response body has no importmap (partial swap).
141
+ mismatch = incomingBuild !== currentBuild;
142
+ }
143
+ // An empty / absent build id on EITHER side means "version unknown":
144
+ // the server has not published an authoritative importmap yet (the
145
+ // warmup window, where a runtime-first-boot app resolves its vendor
146
+ // map over the first request), or the response predates the build
147
+ // header. In that state a hard reload is unsafe and destructive: it
148
+ // would fire repeatedly as the warming server's id flips from empty
149
+ // to its final value, wiping any half-filled form on the page. So we
150
+ // never hard-reload against an unknown id and leave `mismatch` false;
151
+ // the soft swap proceeds and the page settles once the server is
152
+ // warm. A real cross-deploy reload still fires, because both sides
153
+ // then carry non-empty, differing ids. (No importmap-textContent
154
+ // fallback: the published-id contract above supersedes it, and the
155
+ // textContent of a warming map drifts for the same reason the id does.)
156
+ // Generic `data-webjs-track="reload"` opt-in. ANY element in the
157
+ // head that the user marks gets included in the tracked-element
158
+ // signature. If the signature differs between current and incoming
159
+ // documents, hard-reload. Mirrors hotwired/turbo's
160
+ // data-turbo-track="reload" semantics (head_snapshot.js
161
+ // trackedElementSignature). Lets app authors tag arbitrary
162
+ // version-sensitive elements (CSS bundle <link>, deploy meta tag)
163
+ // for cross-deploy reload, not just the importmap.
164
+ //
165
+ // Importmap-specific data-webjs-build / X-Webjs-Build remain the
166
+ // primary mechanism because they ALSO work on partial responses
167
+ // (no head in the body). data-webjs-track is for elements that
168
+ // can't ride the build hash.
169
+ //
170
+ // Skip the check when the incoming response has no head content
171
+ // (X-Webjs-Have partial-fragment response). Without this guard
172
+ // a partial response would always mismatch any current tracked
173
+ // signature and falsely reload. With the guard, a partial
174
+ // response means "trust the build hash; don't decide based on
175
+ // missing head info." Comparing on full responses also catches
176
+ // added/removed track markers because empty `incomingSig`
177
+ // would correctly differ from a non-empty `currentSig`.
178
+ if (!mismatch && doc.head && doc.head.children.length > 0) {
179
+ const currentSig = trackedReloadSignature(document);
180
+ const incomingSig = trackedReloadSignature(doc);
181
+ if (currentSig !== incomingSig) mismatch = true;
182
+ }
183
+ if (mismatch && typeof location !== 'undefined') {
184
+ // A detected cross-deploy mismatch means every URL-keyed snapshot and
185
+ // speculative prefetch was captured on the OLD deploy, so it is stale
186
+ // pre-deploy HTML (#899). Evict both caches so no stale entry is applied
187
+ // on a later soft nav, even when the infinite-reload guard below bails to
188
+ // a partial swap instead of a full reload (that partial swap must not then
189
+ // pull a pre-deploy fragment out of the cache).
190
+ snapshotCache.clear();
191
+ prefetchClearAll();
192
+ clearPrefetchRefused();
193
+ // Infinite-reload guard: if the importmap appears to genuinely
194
+ // change EVERY navigation (e.g. a developer is live-editing the
195
+ // pin file in dev, or a misbehaving CDN returns different
196
+ // jspm.io URLs each request), the user would experience a hard
197
+ // reload on every click. Use a one-shot sessionStorage flag:
198
+ // set before the first reload, cleared by the next successful
199
+ // swap. Two reloads BACK-TO-BACK (without an intervening clean
200
+ // nav) trip the guard.
201
+ try {
202
+ const flag = 'webjs:importmap-reload';
203
+ if (sessionStorage && sessionStorage.getItem(flag)) {
204
+ // Already reloaded once for an importmap mismatch and the
205
+ // next nav STILL mismatches: bail to the partial swap. The
206
+ // user is on a stale importmap but at least the page
207
+ // renders.
208
+ sessionStorage.removeItem(flag);
209
+ reportFallback('deploy-mismatch-reload-suppressed', href, false);
210
+ } else {
211
+ if (sessionStorage) sessionStorage.setItem(flag, '1');
212
+ reportFallback('deploy-mismatch', href);
213
+ hardNavigate(href);
214
+ return 'none';
215
+ }
216
+ } catch {
217
+ // sessionStorage unavailable (private mode w/ quota etc.):
218
+ // fall through to a single reload like before.
219
+ reportFallback('deploy-mismatch', href);
220
+ hardNavigate(href);
221
+ return 'none';
222
+ }
223
+ } else if (!mismatch) {
224
+ // No importmap/build mismatch, so no hard reload. But the app-source
225
+ // signal (#899) is the SECOND tier: if `data-webjs-src` differs, an
226
+ // app-source or server-framework deploy changed the SSR output while the
227
+ // running page's browser code is unchanged. A hard reload would be an
228
+ // over-correction; instead EVICT the URL-keyed snapshot + prefetch caches
229
+ // (all captured on the OLD deploy) so a later soft nav re-fetches fresh.
230
+ // The current nav's already-fetched `doc` still applies normally. Both ids
231
+ // must be present (an empty id is the warmup "unknown", never a signal),
232
+ // exactly like the build guard.
233
+ const currentSrc = currentTag ? currentTag.getAttribute('data-webjs-src') : null;
234
+ if (incomingSrc && currentSrc && incomingSrc !== currentSrc) {
235
+ snapshotCache.clear();
236
+ prefetchClearAll();
237
+ clearPrefetchRefused();
238
+ // Advance the page's reference id. The importmap <script> is preserved
239
+ // across soft navs (an importmap cannot be re-registered), so without
240
+ // this the tag would keep its OLD id and EVERY later nav in the new
241
+ // deploy would re-detect the same mismatch and evict again, defeating
242
+ // the caches. Updating the attribute (not the importmap body) settles
243
+ // the page onto the new deploy: evict once, then cache normally.
244
+ if (currentTag) currentTag.setAttribute('data-webjs-src', incomingSrc);
245
+ }
246
+ // A clean swap (no importmap mismatch) means we're back to
247
+ // matching client/server importmaps. Clear the reload flag so
248
+ // a future LEGITIMATE mismatch (e.g. a later deploy) gets a
249
+ // fresh single-shot reload instead of being suppressed by a
250
+ // stale flag from an unrelated earlier reload.
251
+ try {
252
+ if (typeof sessionStorage !== 'undefined') {
253
+ sessionStorage.removeItem('webjs:importmap-reload');
254
+ }
255
+ } catch { /* ignore */ }
256
+ }
257
+ }
258
+
259
+ // 1. webjs-frame escape hatch.
260
+ if (frameId) {
261
+ // Both outcomes here (a successful subtree swap, or frame-missing) discard
262
+ // whatever the parse carried, so this is safe wherever it lands.
263
+ ingestSeeds();
264
+ const target = document.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
265
+ const source = doc.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
266
+ if (target && source) {
267
+ // #1406: record at the commit, ahead of the head merge below (a
268
+ // stylesheet it adds can change layout height) and of the frame swap.
269
+ if (recordHistoryNow) recordHistoryNow();
270
+ // ADD-ONLY head merge: preserve runtime-generated head content
271
+ // (Tailwind CSS injection, etc.) that the outer layout's scripts
272
+ // already produced.
273
+ addNewHeadElements(doc.head);
274
+ // `diffChildren` -> `reconcileChildren` regrafts permanent elements
275
+ // by node identity (it imports the incoming children first, then
276
+ // swaps the live permanent node into the imported tree), so the live
277
+ // `<audio>`/widget keeps running across the frame swap.
278
+ // Capture the swap commit like the other swap paths so a frame nav that
279
+ // ALSO progressively streams a Suspense boundary gates its resolves on the
280
+ // committed frame swap, not a stale prior _swapCommit (#1048).
281
+ _swapCommit = runWithTransition(() => {
282
+ diffChildren(target, source);
283
+ reactivateScripts(target);
284
+ upgradeCustomElements(target);
285
+ // Inside the swap so the placeholder exists before we resolve (#1048).
286
+ forwardSuspenseResolvers(doc.body);
287
+ blurOutgoingFocus();
288
+ }, () => upgradeCustomElements(target));
289
+ return;
290
+ }
291
+ // The response did not carry the requested frame (source null), or the
292
+ // target frame is gone from the live DOM (target null). Falling through
293
+ // would wholesale-replace the document, a silent full-page swap that
294
+ // destroys the page (e.g. an auth redirect returning a login page without
295
+ // the frame). Surface the contract violation with a cancelable event
296
+ // instead. Default: warn and leave the frame unchanged. A listener that
297
+ // calls preventDefault owns the outcome.
298
+ const evt = new CustomEvent('webjs:frame-missing', {
299
+ bubbles: true,
300
+ cancelable: true,
301
+ detail: { frameId, url: href || (typeof location !== 'undefined' ? location.href : null), document: doc },
302
+ });
303
+ (target || document).dispatchEvent(evt);
304
+ if (!evt.defaultPrevented) {
305
+ console.warn(`[webjs] frame "${frameId}" was not in the navigation response, leaving it unchanged. Handle "webjs:frame-missing" (preventDefault) to override.`);
306
+ }
307
+ return 'none';
308
+ }
309
+
310
+ // 1b. Same-URL refresh in `shell` mode (#1398). A boundary morph can only
311
+ // rewrite what sits INSIDE a `<!--wj:children:...-->` range, and a layout's
312
+ // own header, nav, and footer sit outside every one of them, so a layout edit
313
+ // applied through the boundary tiers would silently do nothing, which is
314
+ // strictly worse than the reload it replaced. Take the full-body tier
315
+ // directly instead. Component instances do not survive it, which is the
316
+ // honest cost of a layout edit and still beats a page reload: scroll is kept
317
+ // and no history entry is written.
318
+ if (refresh === 'shell') {
319
+ ingestSeeds();
320
+ // Reads as a contradiction with "no history entry is written" above, so:
321
+ // the prose is authoritative and this call cannot fire. A refresh always
322
+ // reaches `fetchAndApply` with `recordHistory` false (`navigator.js` passes
323
+ // `!isPopState && !refresh`), so the thunk is null here, every time. It is
324
+ // written anyway so "the entry is recorded at the commit" holds at EVERY
325
+ // commit point without a reader having to prove reachability first, which
326
+ // is what stops the next person adding a commit point from omitting it.
327
+ if (recordHistoryNow) recordHistoryNow();
328
+ swapFullBody(doc);
329
+ return;
330
+ }
331
+
332
+ // 2. Two-tier keyed boundary swap (#1015). Scan both trees STRICTLY: a
333
+ // poisoned side (malformed, truncated, or mispaired boundaries) yields null
334
+ // and falls through to the integrity degradation below. A valid pair of
335
+ // scans picks the swap tier by route-key comparison: a CHANGED key REPLACES
336
+ // (remount, Next param-change parity) at the PARENT of the shallowest
337
+ // change, else MORPH (state-preserving keyed reconcile) at the deepest
338
+ // shared boundary.
339
+ const here = collectBoundaries(document.body);
340
+ const there = collectBoundaries(doc.body);
341
+ const plan = here && there ? planBoundarySwap(here, there) : null;
342
+
343
+ if (plan) {
344
+ // Committed: this response is being applied, so its seeds are the ones the
345
+ // user is about to look at.
346
+ ingestSeeds();
347
+ // #1406: and the history entry is recorded here, while the outgoing page is
348
+ // still in the DOM at its own scroll offset. This is the path the iOS
349
+ // back-swipe defect was measured on.
350
+ if (recordHistoryNow) recordHistoryNow();
351
+ const { mode, live, incoming } = plan;
352
+ // ADD-ONLY head merge: the outer layout stays mounted, so its head-bound
353
+ // runtime state (Tailwind injection, etc.) must not be invalidated.
354
+ addNewHeadElements(doc.head);
355
+ _swapCommit = runWithTransition(() => {
356
+ if (mode === 'replace') replaceBoundaryRange(live, incoming);
357
+ else swapMarkerRange(live, incoming, doc);
358
+ // No key sync is needed on the anchor's own comments: the plan's anchor
359
+ // carries EQUAL live/incoming route-keys in every tier (a changed-key
360
+ // REPLACE anchors at a parent already compared equal; the other tiers
361
+ // require no change at all), and the fresh deeper keys arrive via the
362
+ // physically replaced boundary comments inside the range.
363
+ //
364
+ // When the swapped range lives INSIDE a light-DOM slot (a layout whose
365
+ // ${children} render inside a slotted shell component), the raw swap
366
+ // just rewrote nodes the slot runtime believes it owns, so its record is
367
+ // now stale. Resync the owning host's record from the slot's real
368
+ // children through the one public seam, or the host's next
369
+ // applySlotAssignments would wipe the freshly swapped content and
370
+ // restore the stale list.
371
+ resyncEnclosingHostSlots(live.start, incoming.start);
372
+ // Resolve buffered Suspense boundaries INSIDE the swap so the placeholder
373
+ // exists first. Doing this after `runWithTransition` returned raced the
374
+ // async view-transition swap and stuck the skeleton (#1048).
375
+ forwardSuspenseResolvers(doc.body);
376
+ blurOutgoingFocus();
377
+ }, () => upgradeCustomElementsInRange(live));
378
+ return;
379
+ }
380
+
381
+ // 3. Integrity degradation (#1015). No trustworthy shared boundary exists:
382
+ // one side is poisoned, or the trees share no segment (a divergent shell).
383
+ // For a FOREGROUND nav, degrade to a FULL PAGE LOAD: bounded, correct, and
384
+ // exactly what an MPA would do, where the deleted heuristic recovery could
385
+ // guess wrong and corrupt silently. Dev logs the cause so a systematic
386
+ // producer of malformed boundaries is visible immediately. A REVALIDATION
387
+ // (the background refresh after a snapshot restore) is excluded: the user
388
+ // is already viewing a page, so a background op must never yank them
389
+ // through a hard load; it takes the in-place path below.
390
+ if (href && !revalidating && typeof location !== 'undefined') {
391
+ reportFallback(!here ? 'live-boundaries-malformed'
392
+ : !there ? 'incoming-boundaries-malformed'
393
+ : 'no-shared-boundary', href);
394
+ hardNavigate(href);
395
+ return 'none';
396
+ }
397
+
398
+ // A BACKGROUND revalidation (revalidating + href) with no trustworthy plan
399
+ // DISCARDS the response outright: the user is viewing a valid restored
400
+ // snapshot, and the response may be a reduced (chrome-less) X-Webjs-Have
401
+ // fragment, so the full-body swap below would wipe the shell from a
402
+ // background op. Doing nothing is the only safe degradation here.
403
+ if (href && revalidating) {
404
+ reportFallback('revalidation-discarded', href, false);
405
+ return 'discard';
406
+ }
407
+
408
+ // 4. In-place full-body swap: the background paths only (a snapshot
409
+ // restore or its revalidation, where a full load is not an option
410
+ // because the user is already viewing the page).
411
+ ingestSeeds(); // committed: past both discard branches above
412
+ if (recordHistoryNow) recordHistoryNow();
413
+ swapFullBody(doc);
414
+ }
415
+
416
+ /**
417
+ * In-place FULL-BODY swap: merge the head, then replace every body child.
418
+ *
419
+ * `mergeHead` PRESERVES stylesheets and `<style>` unconditionally (#936), so
420
+ * this can never leave the page unstyled, and `regraftPermanentElements` adopts
421
+ * each live `[data-webjs-permanent][id]` node by identity so a running widget
422
+ * survives. Component INSTANCES do not: every element is re-created and
423
+ * re-upgraded, which is the difference between this and a boundary morph.
424
+ *
425
+ * Two callers. The background snapshot-restore path in `applySwap`, where a full
426
+ * load is not an option because the user is already viewing the page. And the
427
+ * `shell` mode of `refreshPage` (#1398), which needs the LAYOUT's own markup
428
+ * replaced and cannot get that from a boundary-range swap, since a layout's own
429
+ * header, nav, and footer sit outside every `<!--wj:children:...-->` range.
430
+ *
431
+ * The caller ingests seeds first: this is a commit point, and both callers reach
432
+ * it only past their own discard branches.
433
+ *
434
+ * @param {Document} doc
435
+ */
436
+ function swapFullBody(doc) {
437
+ mergeHead(doc.head);
438
+ // Persist permanent elements by node identity across the full-body
439
+ // swap: move each live [data-webjs-permanent][id] node into the matching
440
+ // position in the incoming body BEFORE replaceChildren reads it, so the
441
+ // live node is adopted rather than destroyed.
442
+ regraftPermanentElements(document.body, doc.body);
443
+ const newChildren = [...doc.body.childNodes];
444
+ const doSwap = () => {
445
+ document.body.replaceChildren(...newChildren);
446
+ reactivateScripts(document.body);
447
+ upgradeCustomElements(document.body);
448
+ blurOutgoingFocus();
449
+ };
450
+ _swapCommit = runWithTransition(doSwap, () => upgradeCustomElements(document.body));
451
+ }