@webjsdev/core 0.7.50 → 0.7.53

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