@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.
- package/dist/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +20 -20
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -7
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +1 -1
- package/index.d.ts +3 -3
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/component/lifecycle.js +1481 -0
- package/src/component/reactive.js +171 -0
- package/src/component/server-element.js +248 -0
- package/src/component.js +2 -1896
- package/src/css.d.ts +9 -0
- package/src/escape.d.ts +2 -0
- package/src/html.d.ts +9 -0
- package/src/render-client/parts.js +2039 -0
- package/src/render-client/reconciler.js +388 -0
- package/src/render-client/template-compiler.js +525 -0
- package/src/render-client.js +1 -2938
- package/src/render-server/attrs.js +160 -0
- package/src/render-server/dsd.js +445 -0
- package/src/render-server/html-scan.js +402 -0
- package/src/render-server/slots.js +213 -0
- package/src/render-server/stream.js +163 -0
- package/src/render-server/template-renderer.js +938 -0
- package/src/render-server/text.js +163 -0
- package/src/render-server.js +6 -2405
- package/src/repeat.d.ts +15 -0
- package/src/rich-fetch.d.ts +4 -0
- package/src/router-client/anchors.js +44 -0
- package/src/router-client/boundaries.js +221 -0
- package/src/router-client/constants.js +206 -0
- package/src/router-client/diagnostics.js +377 -0
- package/src/router-client/dom-differ.js +709 -0
- package/src/router-client/dom-parse.js +150 -0
- package/src/router-client/events.js +182 -0
- package/src/router-client/fetch-apply.js +432 -0
- package/src/router-client/form-encoder.js +135 -0
- package/src/router-client/frames.js +329 -0
- package/src/router-client/head-merge.js +344 -0
- package/src/router-client/nav-error.js +105 -0
- package/src/router-client/navigator.js +952 -0
- package/src/router-client/prefetch.js +831 -0
- package/src/router-client/scroll.js +308 -0
- package/src/router-client/snapshot-cache.js +113 -0
- package/src/router-client/state.js +155 -0
- package/src/router-client/stream.js +206 -0
- package/src/router-client/swap.js +454 -0
- package/src/router-client/upgrade.js +261 -0
- package/src/router-client/view-transition.js +223 -0
- package/src/router-client.d.ts +11 -2
- package/src/router-client.js +78 -5345
- package/src/slot/assignment.js +304 -0
- package/src/slot/interception.js +558 -0
- package/src/slot/polyfills.js +365 -0
- package/src/slot/project.js +432 -0
- package/src/slot/sensors.js +303 -0
- package/src/slot/state.js +245 -0
- package/src/slot/symbols.js +85 -0
- package/src/slot.js +7 -2199
- package/src/suspense.d.ts +9 -0
- 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
|
+
}
|