@webjsdev/core 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -0
- package/index.d.ts +28 -0
- package/index.js +39 -0
- package/package.json +46 -0
- package/src/component.d.ts +120 -0
- package/src/component.js +1061 -0
- package/src/context.js +327 -0
- package/src/css.js +54 -0
- package/src/directives.js +454 -0
- package/src/escape.js +15 -0
- package/src/expose.js +90 -0
- package/src/html.js +30 -0
- package/src/lazy-loader.js +107 -0
- package/src/nav.js +45 -0
- package/src/registry.js +190 -0
- package/src/render-client.js +1717 -0
- package/src/render-server.js +1128 -0
- package/src/repeat.js +37 -0
- package/src/rich-fetch.js +70 -0
- package/src/router-client.js +1434 -0
- package/src/serialize.js +452 -0
- package/src/signal.js +684 -0
- package/src/slot.js +860 -0
- package/src/suspense.js +46 -0
- package/src/task.js +386 -0
- package/src/testing.js +157 -0
- package/src/webjs-frame.js +65 -0
- package/src/websocket-client.js +111 -0
|
@@ -0,0 +1,1434 @@
|
|
|
1
|
+
// Importing this side-effect-registers <webjs-frame> so apps that
|
|
2
|
+
// `import '@webjsdev/core/client-router'` get the escape-hatch element
|
|
3
|
+
// available without a second import.
|
|
4
|
+
import './webjs-frame.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Client router for webjs: nested-layout-aware partial swap.
|
|
8
|
+
*
|
|
9
|
+
* Intercepts same-origin link clicks and form submissions, fetches the
|
|
10
|
+
* target page's HTML via `fetch()`, finds the deepest layout boundary
|
|
11
|
+
* shared by both the current and incoming pages, and replaces ONLY the
|
|
12
|
+
* children of that boundary. Outer layout DOM (header, sidenav, footer)
|
|
13
|
+
* stays mounted: no re-render, no flicker, scroll positions preserved.
|
|
14
|
+
*
|
|
15
|
+
* To enable, import this module from a layout or boot script:
|
|
16
|
+
*
|
|
17
|
+
* import '@webjsdev/core/client-router';
|
|
18
|
+
*
|
|
19
|
+
* Or call `enableClientRouter()` for programmatic control.
|
|
20
|
+
*
|
|
21
|
+
* Mechanism: auto-derived from folder structure:
|
|
22
|
+
* 1. SSR injects `<!--wj:children:<segment-path>-->...<!--/wj:children-->`
|
|
23
|
+
* comment markers around each layout's `${children}` interpolation
|
|
24
|
+
* (one pair per layout in the chain).
|
|
25
|
+
* 2. On link click, walk both the live DOM and the incoming HTML for
|
|
26
|
+
* these markers and build path → range maps.
|
|
27
|
+
* 3. Find the longest shared marker path. That's the deepest layout
|
|
28
|
+
* both pages have in common.
|
|
29
|
+
* 4. Replace nodes between that marker pair in the live DOM with the
|
|
30
|
+
* equivalent range from the incoming HTML, using a keyed reconciler
|
|
31
|
+
* that preserves input values, scroll, popover state, and the
|
|
32
|
+
* identity of any matched DOM nodes.
|
|
33
|
+
* 5. Merge head, re-run scripts, upgrade custom elements, pushState.
|
|
34
|
+
*
|
|
35
|
+
* Optimizations bundled into the same response cycle:
|
|
36
|
+
* - `X-Webjs-Have` request header lists the marker paths the client
|
|
37
|
+
* already has. Server walks the target's layout chain, skips
|
|
38
|
+
* layouts at-or-above the deepest match, returns only the
|
|
39
|
+
* divergent fragment (wrapped in the deepest shared marker). Real
|
|
40
|
+
* wire-byte savings: the layout chain is never re-serialized for
|
|
41
|
+
* same-shell navigations.
|
|
42
|
+
* - URL-keyed snapshot cache (Turbo SnapshotCache pattern). Back/
|
|
43
|
+
* forward via popstate restores from cache instantly, then
|
|
44
|
+
* revalidates in the background.
|
|
45
|
+
* - Per-segment loading templates: SSR emits each segment's
|
|
46
|
+
* loading.ts content as `<template id="wj-loading:<path>">`. On
|
|
47
|
+
* nav-start the client clones the deepest matching template into
|
|
48
|
+
* the swap slot so users see an instant skeleton instead of stale
|
|
49
|
+
* content.
|
|
50
|
+
*
|
|
51
|
+
* Escape hatch:
|
|
52
|
+
* `<webjs-frame id="...">`: declarative partial-swap region NOT
|
|
53
|
+
* tied to a folder layout. If a link's enclosing `closest('webjs-frame')`
|
|
54
|
+
* matches a frame in the incoming HTML, the frame swap takes
|
|
55
|
+
* precedence over the layout-marker mechanism. Use for ad-hoc
|
|
56
|
+
* widgets (tabs, lazy-loaded cards) where the swap region isn't a
|
|
57
|
+
* folder route segment.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse HTML into a Document. Prefers Document.parseHTMLUnsafe (processes
|
|
62
|
+
* Declarative Shadow DOM) over DOMParser (does NOT process DSD).
|
|
63
|
+
*
|
|
64
|
+
* @param {string} html
|
|
65
|
+
* @returns {Document | null}
|
|
66
|
+
*/
|
|
67
|
+
function parseHTML(html) {
|
|
68
|
+
if (typeof Document !== 'undefined' && typeof Document.parseHTMLUnsafe === 'function') {
|
|
69
|
+
return Document.parseHTMLUnsafe(html);
|
|
70
|
+
}
|
|
71
|
+
if (typeof DOMParser !== 'undefined') {
|
|
72
|
+
return new DOMParser().parseFromString(html, 'text/html');
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let enabled = false;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* AbortController for the currently in-flight fetch. A new navigation /
|
|
81
|
+
* submission `abort()`s this and replaces it: Turbo Drive's
|
|
82
|
+
* `navigator.stop()` pattern. Aborting in-flight requests on rapid
|
|
83
|
+
* link clicks avoids late responses clobbering newer settled state.
|
|
84
|
+
*
|
|
85
|
+
* @type {AbortController | null}
|
|
86
|
+
*/
|
|
87
|
+
let activeAbortController = null;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Monotonic counter incremented at the start of every navigation. Each
|
|
91
|
+
* async path captures the value at its entry point and compares before
|
|
92
|
+
* applying side effects (swap, restore-optimistic). A mismatch means a
|
|
93
|
+
* newer nav superseded this one: bail out silently. Belt-and-suspenders
|
|
94
|
+
* on top of AbortController: covers paths where a response has already
|
|
95
|
+
* resolved past the await but a newer nav started before applySwap ran.
|
|
96
|
+
*/
|
|
97
|
+
let currentNavigationToken = 0;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Global MutationObserver that upgrades any custom element inserted into
|
|
101
|
+
* the document. Safety net: if our diff / replaceChildren / View
|
|
102
|
+
* Transitions ever leave an un-upgraded element behind, this catches it.
|
|
103
|
+
*/
|
|
104
|
+
let upgradeObserver = null;
|
|
105
|
+
function ensureUpgradeObserver() {
|
|
106
|
+
if (upgradeObserver || typeof MutationObserver === 'undefined' || typeof customElements === 'undefined') return;
|
|
107
|
+
upgradeObserver = new MutationObserver((mutations) => {
|
|
108
|
+
for (const m of mutations) {
|
|
109
|
+
for (const node of m.addedNodes) {
|
|
110
|
+
if (node.nodeType !== 1) continue;
|
|
111
|
+
const el = /** @type {Element} */ (node);
|
|
112
|
+
if (el.tagName?.includes('-')) customElements.upgrade(el);
|
|
113
|
+
for (const child of el.querySelectorAll('*')) {
|
|
114
|
+
if (child.tagName?.includes('-')) customElements.upgrade(child);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
upgradeObserver.observe(document.body, { childList: true, subtree: true });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The URL the user is currently viewing: tracked separately from
|
|
124
|
+
* `location.href` because on `popstate` the browser updates
|
|
125
|
+
* `location.href` to the destination URL BEFORE firing the event,
|
|
126
|
+
* which means snapshotting "the current page" naively keys against
|
|
127
|
+
* the wrong URL (the page being arrived at, not the page being left).
|
|
128
|
+
*
|
|
129
|
+
* Updated after every successful navigation completes. Used by
|
|
130
|
+
* `snapshotCurrent` to key the snapshot under the URL the user is
|
|
131
|
+
* actually leaving.
|
|
132
|
+
*
|
|
133
|
+
* @type {string | null}
|
|
134
|
+
*/
|
|
135
|
+
let currentPageUrl = null;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Previous value of `history.scrollRestoration` (so we can restore it
|
|
139
|
+
* when the router is disabled). The browser's default behavior of
|
|
140
|
+
* auto-restoring scroll on popstate races with the SPA's own scroll
|
|
141
|
+
* restoration: disabled here so webjs is the sole authority on scroll
|
|
142
|
+
* during navigation. Same pattern as Turbo Drive's
|
|
143
|
+
* `assumeControlOfScrollRestoration()` (turbo/src/core/drive/history.js).
|
|
144
|
+
*
|
|
145
|
+
* @type {ScrollRestoration | null}
|
|
146
|
+
*/
|
|
147
|
+
let prevScrollRestoration = null;
|
|
148
|
+
|
|
149
|
+
/** Enable the client router. Idempotent. */
|
|
150
|
+
export function enableClientRouter() {
|
|
151
|
+
if (enabled || typeof document === 'undefined') return;
|
|
152
|
+
enabled = true;
|
|
153
|
+
document.addEventListener('click', onClick, true);
|
|
154
|
+
document.addEventListener('submit', onSubmit, true);
|
|
155
|
+
window.addEventListener('popstate', onPopState);
|
|
156
|
+
ensureUpgradeObserver();
|
|
157
|
+
// Take control of scroll restoration so the browser doesn't fight
|
|
158
|
+
// the SPA's own snapshot-based restore on popstate.
|
|
159
|
+
if (typeof history !== 'undefined' && 'scrollRestoration' in history) {
|
|
160
|
+
prevScrollRestoration = history.scrollRestoration;
|
|
161
|
+
history.scrollRestoration = 'manual';
|
|
162
|
+
}
|
|
163
|
+
// Seed the "current page" tracker so the first navigation can
|
|
164
|
+
// snapshot the page the user is leaving.
|
|
165
|
+
if (typeof location !== 'undefined') currentPageUrl = location.href;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Disable the client router. */
|
|
169
|
+
export function disableClientRouter() {
|
|
170
|
+
if (!enabled) return;
|
|
171
|
+
enabled = false;
|
|
172
|
+
document.removeEventListener('click', onClick, true);
|
|
173
|
+
document.removeEventListener('submit', onSubmit, true);
|
|
174
|
+
window.removeEventListener('popstate', onPopState);
|
|
175
|
+
if (typeof history !== 'undefined' && prevScrollRestoration !== null) {
|
|
176
|
+
history.scrollRestoration = prevScrollRestoration;
|
|
177
|
+
prevScrollRestoration = null;
|
|
178
|
+
}
|
|
179
|
+
currentPageUrl = null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Programmatic navigation (replaces `location.href = url`).
|
|
184
|
+
* @param {string} url
|
|
185
|
+
* @param {{ replace?: boolean }} [opts]
|
|
186
|
+
*/
|
|
187
|
+
export async function navigate(url, opts) {
|
|
188
|
+
const target = new URL(url, location.href);
|
|
189
|
+
if (target.origin !== location.origin) {
|
|
190
|
+
location.href = url;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
await performNavigation(target.href, opts?.replace ?? false, null);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Invalidate a cached snapshot. Call after a server action mutates data
|
|
198
|
+
* that affects a cached page so the next visit refetches.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} [url] Specific URL to invalidate, or omit to clear all.
|
|
201
|
+
*/
|
|
202
|
+
export function revalidate(url) {
|
|
203
|
+
// Falsy `url` (undefined, null, empty string) clears everything.
|
|
204
|
+
// Loose `== null` would have left `revalidate('')` to silently no-op,
|
|
205
|
+
// because `new URL('', location.href)` is a valid relative URL and the
|
|
206
|
+
// resulting cache key rarely matches anything.
|
|
207
|
+
if (!url) { snapshotCache.clear(); return; }
|
|
208
|
+
const u = new URL(url, location.href);
|
|
209
|
+
snapshotCache.delete(u.pathname + u.search);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Auto-enable on import (standard Turbo-Drive convention).
|
|
213
|
+
enableClientRouter();
|
|
214
|
+
|
|
215
|
+
/* ====================================================================
|
|
216
|
+
* Click + popstate handlers
|
|
217
|
+
* ==================================================================== */
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Pathnames with these extensions are never HTML pages.
|
|
221
|
+
*/
|
|
222
|
+
const NON_HTML_EXTENSIONS = /\.(?:pdf|zip|tar|gz|7z|rar|dmg|exe|msi|deb|rpm|apk|ipa|xlsx?|docx?|pptx?|csv|odt|ods|odp|rtf|epub|mobi|xml|json|rss|atom|txt|md|wasm|mp3|mp4|mov|avi|webm|ogg|flac|wav|m4a|m4v|mkv|png|jpe?g|gif|webp|avif|bmp|ico|svg|tiff?|heic)$/i;
|
|
223
|
+
|
|
224
|
+
/** @param {MouseEvent} e */
|
|
225
|
+
function onClick(e) {
|
|
226
|
+
if (!enabled) return;
|
|
227
|
+
if (e.defaultPrevented || e.button !== 0) return;
|
|
228
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
229
|
+
|
|
230
|
+
const anchor = findAnchorInPath(e);
|
|
231
|
+
if (!anchor) return;
|
|
232
|
+
if (anchor.hasAttribute('download')) return;
|
|
233
|
+
if (anchor.hasAttribute('data-no-router')) return;
|
|
234
|
+
if (anchor.target && anchor.target !== '_self') return;
|
|
235
|
+
|
|
236
|
+
const href = anchor.href;
|
|
237
|
+
if (!href) return;
|
|
238
|
+
|
|
239
|
+
const url = new URL(href);
|
|
240
|
+
if (url.origin !== location.origin) return;
|
|
241
|
+
if (url.pathname === location.pathname && url.search === location.search && url.hash) return;
|
|
242
|
+
if (NON_HTML_EXTENSIONS.test(url.pathname)) return;
|
|
243
|
+
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
// Identify the active <webjs-frame> via closest(), returning null if the
|
|
246
|
+
// click wasn't inside any frame. The frame escape-hatch takes precedence
|
|
247
|
+
// over the auto-derived layout markers when both are present.
|
|
248
|
+
const frameId = activeFrameId(anchor);
|
|
249
|
+
performNavigation(href, false, frameId);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** @param {PopStateEvent} _e */
|
|
253
|
+
function onPopState(_e) {
|
|
254
|
+
// popstate has no DOM anchor, so no frame context: restore via cache or
|
|
255
|
+
// refetch the whole document.
|
|
256
|
+
performNavigation(location.href, true, null);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Intercept form submissions. Capture phase so we run before user
|
|
261
|
+
* `@submit` handlers in component templates: they can call
|
|
262
|
+
* `e.preventDefault()` or `e.stopImmediatePropagation()` first if they
|
|
263
|
+
* want to handle the submission themselves (server-action RPC stubs do
|
|
264
|
+
* this).
|
|
265
|
+
*
|
|
266
|
+
* Filtering mirrors Turbo's `form_submit_observer.js`:
|
|
267
|
+
* - `data-no-router` on form or submitter → full browser submit.
|
|
268
|
+
* - `formmethod="dialog"` → native <dialog> dismissal, never routed.
|
|
269
|
+
* - `target` / `formtarget` that isn't `_self` → iframe / popup target.
|
|
270
|
+
* - Cross-origin or non-HTML-extension action → let the browser handle.
|
|
271
|
+
*
|
|
272
|
+
* Submitter attributes (`formmethod`, `formaction`, `formenctype`) take
|
|
273
|
+
* precedence over the form's own: HTML5 form-submission algorithm.
|
|
274
|
+
*
|
|
275
|
+
* @param {SubmitEvent} e
|
|
276
|
+
*/
|
|
277
|
+
function onSubmit(e) {
|
|
278
|
+
if (!enabled) return;
|
|
279
|
+
if (e.defaultPrevented) return;
|
|
280
|
+
|
|
281
|
+
const form = /** @type {HTMLFormElement | null} */ (e.target);
|
|
282
|
+
// Duck-type check rather than `instanceof HTMLFormElement`: linkedom
|
|
283
|
+
// and other non-browser DOMs don't always mark form elements as
|
|
284
|
+
// instances of the window's HTMLFormElement class.
|
|
285
|
+
if (!form || form.nodeType !== 1 || form.tagName !== 'FORM') return;
|
|
286
|
+
if (form.hasAttribute('data-no-router')) return;
|
|
287
|
+
|
|
288
|
+
const submitter = /** @type {HTMLElement | null} */ (e.submitter ?? null);
|
|
289
|
+
if (submitter && submitter.hasAttribute('data-no-router')) return;
|
|
290
|
+
|
|
291
|
+
const target = (submitter && submitter.getAttribute('formtarget'))
|
|
292
|
+
|| form.getAttribute('target')
|
|
293
|
+
|| '';
|
|
294
|
+
if (target && target !== '_self') return;
|
|
295
|
+
|
|
296
|
+
const method = getSubmitMethod(form, submitter);
|
|
297
|
+
if (method === 'dialog') return;
|
|
298
|
+
|
|
299
|
+
const action = getSubmitAction(form, submitter);
|
|
300
|
+
/** @type {URL} */ let url;
|
|
301
|
+
try { url = new URL(action, location.href); }
|
|
302
|
+
catch { return; }
|
|
303
|
+
if (url.origin !== location.origin) return;
|
|
304
|
+
if (NON_HTML_EXTENSIONS.test(url.pathname)) return;
|
|
305
|
+
|
|
306
|
+
const body = buildSubmitFormData(form, submitter);
|
|
307
|
+
|
|
308
|
+
e.preventDefault();
|
|
309
|
+
const frameId = activeFrameId(form);
|
|
310
|
+
performSubmission(url.href, method, body, frameId);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Method resolution: submitter's `formmethod` wins over form's `method`.
|
|
315
|
+
* Returns lowercase.
|
|
316
|
+
*
|
|
317
|
+
* @param {HTMLFormElement} form
|
|
318
|
+
* @param {HTMLElement | null} submitter
|
|
319
|
+
*/
|
|
320
|
+
function getSubmitMethod(form, submitter) {
|
|
321
|
+
const v = (submitter && submitter.getAttribute('formmethod'))
|
|
322
|
+
|| form.getAttribute('method')
|
|
323
|
+
|| 'get';
|
|
324
|
+
return v.toLowerCase();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Action resolution: submitter's `formaction` wins over form's `action`.
|
|
329
|
+
* Empty string is valid (means submit-to-current-url).
|
|
330
|
+
*
|
|
331
|
+
* @param {HTMLFormElement} form
|
|
332
|
+
* @param {HTMLElement | null} submitter
|
|
333
|
+
*/
|
|
334
|
+
function getSubmitAction(form, submitter) {
|
|
335
|
+
if (submitter && submitter.hasAttribute('formaction')) {
|
|
336
|
+
return submitter.getAttribute('formaction') || '';
|
|
337
|
+
}
|
|
338
|
+
return form.getAttribute('action') || form.action || location.href;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Build FormData honoring the submitter's name=value (per HTML5 form
|
|
343
|
+
* submission algorithm). Modern browsers + the `FormData(form, submitter)`
|
|
344
|
+
* ctor handle this automatically; older Safari needs a manual append.
|
|
345
|
+
*
|
|
346
|
+
* @param {HTMLFormElement} form
|
|
347
|
+
* @param {HTMLElement | null} submitter
|
|
348
|
+
* @returns {FormData}
|
|
349
|
+
*/
|
|
350
|
+
function buildSubmitFormData(form, submitter) {
|
|
351
|
+
try {
|
|
352
|
+
return new FormData(form, /** @type any */ (submitter || undefined));
|
|
353
|
+
} catch {
|
|
354
|
+
const fd = new FormData(form);
|
|
355
|
+
if (submitter && submitter.getAttribute('name')) {
|
|
356
|
+
fd.append(
|
|
357
|
+
/** @type {string} */ (submitter.getAttribute('name')),
|
|
358
|
+
submitter.getAttribute('value') || '',
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
return fd;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Find the nearest <a> in the event's composed path. composedPath() crosses
|
|
367
|
+
* shadow DOM boundaries: essential because nav links typically live inside
|
|
368
|
+
* the layout shell's shadow root.
|
|
369
|
+
*
|
|
370
|
+
* @param {MouseEvent} e
|
|
371
|
+
* @returns {HTMLAnchorElement | null}
|
|
372
|
+
*/
|
|
373
|
+
function findAnchorInPath(e) {
|
|
374
|
+
for (const el of e.composedPath()) {
|
|
375
|
+
if (el instanceof HTMLAnchorElement) return el;
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Find the id of the innermost <webjs-frame> enclosing `el`, walking up
|
|
382
|
+
* through normal DOM and any shadow boundaries it crosses. Returns null
|
|
383
|
+
* if the element is not inside any frame.
|
|
384
|
+
*
|
|
385
|
+
* @param {Element | null} el
|
|
386
|
+
* @returns {string | null}
|
|
387
|
+
*/
|
|
388
|
+
function activeFrameId(el) {
|
|
389
|
+
/** @type {Element | null} */
|
|
390
|
+
let cur = el;
|
|
391
|
+
while (cur) {
|
|
392
|
+
const frame = cur.closest('webjs-frame');
|
|
393
|
+
if (frame && frame.id) return frame.id;
|
|
394
|
+
// Cross shadow boundary upwards if necessary.
|
|
395
|
+
const root = cur.getRootNode();
|
|
396
|
+
if (root && /** @type any */ (root).host) {
|
|
397
|
+
cur = /** @type any */ (root).host;
|
|
398
|
+
} else {
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/* ====================================================================
|
|
406
|
+
* Marker discovery (the heart of the partial-swap mechanism)
|
|
407
|
+
* ==================================================================== */
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Walk a node tree collecting wj:children marker pairs into a Map
|
|
411
|
+
* keyed by segment path.
|
|
412
|
+
*
|
|
413
|
+
* Markers are HTML comments emitted by SSR around each layout's
|
|
414
|
+
* children interpolation:
|
|
415
|
+
* <!--wj:children:/docs-->
|
|
416
|
+
* <page content>
|
|
417
|
+
* <!--/wj:children-->
|
|
418
|
+
*
|
|
419
|
+
* The walk uses a stack to track nested marker pairs: a path can
|
|
420
|
+
* appear multiple times in a document only if a layout transitively
|
|
421
|
+
* includes itself (pathological; we take the outermost).
|
|
422
|
+
*
|
|
423
|
+
* @param {ParentNode} root
|
|
424
|
+
* @returns {Map<string, { start: Comment, end: Comment }>}
|
|
425
|
+
*/
|
|
426
|
+
export function collectChildrenSlots(root) {
|
|
427
|
+
/** @type {Map<string, { start: Comment, end: Comment }>} */
|
|
428
|
+
const slots = new Map();
|
|
429
|
+
/** @type {{ path: string, start: Comment }[]} */
|
|
430
|
+
const stack = [];
|
|
431
|
+
|
|
432
|
+
// Plain recursive comment walk: TreeWalker/NodeFilter aren't available
|
|
433
|
+
// in every DOM polyfill (notably linkedom in tests). Iterative depth-
|
|
434
|
+
// first traversal keeps us portable across linkedom + native + jsdom.
|
|
435
|
+
/** @param {Node} node */
|
|
436
|
+
function visit(node) {
|
|
437
|
+
if (node.nodeType === 8 /* COMMENT_NODE */) {
|
|
438
|
+
const c = /** @type {Comment} */ (node);
|
|
439
|
+
const data = c.data;
|
|
440
|
+
const open = /^wj:children:(.+)$/.exec(data);
|
|
441
|
+
if (open) {
|
|
442
|
+
stack.push({ path: open[1], start: c });
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (data.trim() === '/wj:children') {
|
|
446
|
+
const frame = stack.pop();
|
|
447
|
+
if (frame && !slots.has(frame.path)) {
|
|
448
|
+
slots.set(frame.path, { start: frame.start, end: c });
|
|
449
|
+
}
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (node.hasChildNodes && node.hasChildNodes()) {
|
|
455
|
+
for (let child = node.firstChild; child; child = child.nextSibling) {
|
|
456
|
+
visit(child);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
visit(/** @type {Node} */ (root));
|
|
461
|
+
return slots;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Pick the longest path that exists in both maps.
|
|
466
|
+
*
|
|
467
|
+
* Path comparison is plain string equality (matches Next.js's
|
|
468
|
+
* `matchSegment`). Longest wins so the swap is as scoped as possible.
|
|
469
|
+
*
|
|
470
|
+
* @param {Map<string, unknown>} here
|
|
471
|
+
* @param {Map<string, unknown>} there
|
|
472
|
+
* @returns {string | null}
|
|
473
|
+
*/
|
|
474
|
+
export function longestSharedPath(here, there) {
|
|
475
|
+
let best = null;
|
|
476
|
+
for (const p of here.keys()) {
|
|
477
|
+
if (!there.has(p)) continue;
|
|
478
|
+
if (best === null || p.length > best.length) best = p;
|
|
479
|
+
}
|
|
480
|
+
return best;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/* ====================================================================
|
|
484
|
+
* Snapshot cache (Turbo SnapshotCache pattern)
|
|
485
|
+
* ==================================================================== */
|
|
486
|
+
|
|
487
|
+
const SNAPSHOT_CAP = 16;
|
|
488
|
+
/** @typedef {{ html: string, scrollX: number, scrollY: number }} Snapshot */
|
|
489
|
+
/** @type {Map<string, Snapshot | string>} */
|
|
490
|
+
const snapshotCache = new Map();
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Cache the current document's HTML + window scroll position keyed by
|
|
494
|
+
* URL. Used on back/forward navigation: the cached DOM restores
|
|
495
|
+
* instantly, scroll position restores to whatever the user left it at.
|
|
496
|
+
*
|
|
497
|
+
* Turbo Drive captures `window.pageXOffset/pageYOffset` on every scroll
|
|
498
|
+
* event into history state. Webjs captures lazily at snapshot time -
|
|
499
|
+
* one read per nav rather than one per scroll event. Sufficient because
|
|
500
|
+
* we only need the position at the moment of leaving.
|
|
501
|
+
*
|
|
502
|
+
* @param {string} url
|
|
503
|
+
*/
|
|
504
|
+
function snapshotCurrent(url) {
|
|
505
|
+
const key = cacheKey(url);
|
|
506
|
+
// Move-to-front for LRU.
|
|
507
|
+
if (snapshotCache.has(key)) snapshotCache.delete(key);
|
|
508
|
+
/** @type {Snapshot} */
|
|
509
|
+
const snap = {
|
|
510
|
+
html: document.documentElement.outerHTML,
|
|
511
|
+
scrollX: typeof window !== 'undefined' ? window.scrollX || 0 : 0,
|
|
512
|
+
scrollY: typeof window !== 'undefined' ? window.scrollY || 0 : 0,
|
|
513
|
+
};
|
|
514
|
+
snapshotCache.set(key, snap);
|
|
515
|
+
while (snapshotCache.size > SNAPSHOT_CAP) {
|
|
516
|
+
const oldest = snapshotCache.keys().next().value;
|
|
517
|
+
snapshotCache.delete(oldest);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Look up a cached snapshot by URL. Returns a normalized Snapshot or
|
|
523
|
+
* null. Tolerates legacy string entries (e.g. from test fixtures that
|
|
524
|
+
* `_snapshotCache.set('/x', 'snap')`).
|
|
525
|
+
*
|
|
526
|
+
* @param {string} url
|
|
527
|
+
* @returns {Snapshot | null}
|
|
528
|
+
*/
|
|
529
|
+
function snapshotGet(url) {
|
|
530
|
+
const key = cacheKey(url);
|
|
531
|
+
const v = snapshotCache.get(key);
|
|
532
|
+
if (v == null) return null;
|
|
533
|
+
// Move-to-front.
|
|
534
|
+
snapshotCache.delete(key);
|
|
535
|
+
snapshotCache.set(key, v);
|
|
536
|
+
if (typeof v === 'string') return { html: v, scrollX: 0, scrollY: 0 };
|
|
537
|
+
return v;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** @param {string} url */
|
|
541
|
+
function cacheKey(url) {
|
|
542
|
+
const u = new URL(url, location.href);
|
|
543
|
+
return u.pathname + u.search;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/* ====================================================================
|
|
547
|
+
* Navigation
|
|
548
|
+
* ==================================================================== */
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* @param {string} href
|
|
552
|
+
* @param {boolean} isPopState
|
|
553
|
+
* @param {string | null} frameId Active <webjs-frame> id, or null.
|
|
554
|
+
*/
|
|
555
|
+
async function performNavigation(href, isPopState, frameId) {
|
|
556
|
+
// Cancel any in-flight fetch: Turbo Drive's navigator.stop().
|
|
557
|
+
if (activeAbortController) activeAbortController.abort();
|
|
558
|
+
activeAbortController = new AbortController();
|
|
559
|
+
const signal = activeAbortController.signal;
|
|
560
|
+
// Bump nav generation. Captured below + by anything we await into.
|
|
561
|
+
const myToken = ++currentNavigationToken;
|
|
562
|
+
|
|
563
|
+
// Snapshot the page the user is LEAVING (with its scroll position)
|
|
564
|
+
// so back/forward navigation can restore it. We key under
|
|
565
|
+
// `currentPageUrl` rather than `location.href` because on popstate
|
|
566
|
+
// the browser has already updated `location.href` to the destination
|
|
567
|
+
// URL: using it as the key would clobber the cached snapshot we're
|
|
568
|
+
// about to read in the popstate-restore branch below.
|
|
569
|
+
if (currentPageUrl) snapshotCurrent(currentPageUrl);
|
|
570
|
+
|
|
571
|
+
// Show a subtle loading indicator, but only if the nav takes long
|
|
572
|
+
// enough to be worth showing one. Setting an attribute on <html>
|
|
573
|
+
// invalidates global style computation: which forces CSS like
|
|
574
|
+
// `color-mix(in oklch, …)` to re-resolve. For values that use
|
|
575
|
+
// wide-gamut color spaces the re-resolution can switch between
|
|
576
|
+
// equivalent representations (oklch ↔ oklab) and fire any
|
|
577
|
+
// `transition` rules listening on that property, producing a
|
|
578
|
+
// visible flash on every nav. Defer the attribute set so quick
|
|
579
|
+
// navs (sub-150ms) never set it at all.
|
|
580
|
+
let navigatingFlagTimer = setTimeout(() => {
|
|
581
|
+
document.documentElement.setAttribute('data-navigating', '');
|
|
582
|
+
navigatingFlagTimer = null;
|
|
583
|
+
}, 150);
|
|
584
|
+
|
|
585
|
+
// Optimistic loading: clone the per-segment loading.ts template (if
|
|
586
|
+
// any) into the deepest current children-slot so the user sees an
|
|
587
|
+
// instant skeleton instead of stale content. Saved so we can restore
|
|
588
|
+
// it if the fetch fails.
|
|
589
|
+
let optimisticState = null;
|
|
590
|
+
if (!isPopState) optimisticState = applyOptimisticLoading();
|
|
591
|
+
|
|
592
|
+
try {
|
|
593
|
+
// popstate: try cache first, then refetch in background. Instant restore.
|
|
594
|
+
if (isPopState) {
|
|
595
|
+
const cached = snapshotGet(href);
|
|
596
|
+
if (cached) {
|
|
597
|
+
const cachedDoc = parseHTML(cached.html);
|
|
598
|
+
if (cachedDoc) {
|
|
599
|
+
applySwap(cachedDoc, frameId, /* revalidating */ true);
|
|
600
|
+
// Restore window scroll to where the user left it.
|
|
601
|
+
if (typeof window !== 'undefined') {
|
|
602
|
+
window.scrollTo(cached.scrollX, cached.scrollY);
|
|
603
|
+
}
|
|
604
|
+
// Fire-and-forget revalidation. Uses a fresh AbortController
|
|
605
|
+
// since this background fetch is allowed to overlap with the
|
|
606
|
+
// next foreground nav (it'll get aborted if a new nav lands).
|
|
607
|
+
fetchAndApply(href, frameId, /* recordHistory */ false, optimisticState, 'GET', null, signal, myToken)
|
|
608
|
+
.catch(() => {});
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// Cache-miss popstate. Browser-native scroll restoration is
|
|
613
|
+
// disabled (we set scrollRestoration='manual'): so without
|
|
614
|
+
// explicit handling, scroll would just stay where the user was
|
|
615
|
+
// on the page they popped FROM. Scroll to top as the reasonable
|
|
616
|
+
// default; fetchAndApply skips its own scroll handling when
|
|
617
|
+
// recordHistory=false (which is the case here).
|
|
618
|
+
if (typeof window !== 'undefined') window.scrollTo(0, 0);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
await fetchAndApply(href, frameId, !isPopState, optimisticState, 'GET', null, signal, myToken);
|
|
622
|
+
} finally {
|
|
623
|
+
if (navigatingFlagTimer) clearTimeout(navigatingFlagTimer);
|
|
624
|
+
// Only clear the navigating flag if WE are still the active nav.
|
|
625
|
+
// A newer nav has its own flag lifecycle.
|
|
626
|
+
if (myToken === currentNavigationToken) {
|
|
627
|
+
document.documentElement.removeAttribute('data-navigating');
|
|
628
|
+
// Record where the user is NOW so the next navigation can
|
|
629
|
+
// snapshot under the right URL key.
|
|
630
|
+
if (typeof location !== 'undefined') currentPageUrl = location.href;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Submit a form via the partial-swap pipeline. Mirrors performNavigation
|
|
637
|
+
* but routes the FormData body. GET submissions promote the body to a
|
|
638
|
+
* query string (HTML form-submission algorithm); non-GET submissions
|
|
639
|
+
* send the body as-is.
|
|
640
|
+
*
|
|
641
|
+
* Mutating methods (anything except GET/HEAD) clear the whole snapshot
|
|
642
|
+
* cache after a successful response: Turbo's `clearSnapshotCache()` on
|
|
643
|
+
* `!isSafe` (`navigator.js:71-88`). Other URLs in the cache may have
|
|
644
|
+
* been server-side-mutated by this submission; refusing to clear would
|
|
645
|
+
* serve stale content on subsequent back/forward.
|
|
646
|
+
*
|
|
647
|
+
* @param {string} href Absolute target URL.
|
|
648
|
+
* @param {string} method Lowercased HTTP verb.
|
|
649
|
+
* @param {FormData} body
|
|
650
|
+
* @param {string | null} frameId
|
|
651
|
+
*/
|
|
652
|
+
async function performSubmission(href, method, body, frameId) {
|
|
653
|
+
if (activeAbortController) activeAbortController.abort();
|
|
654
|
+
activeAbortController = new AbortController();
|
|
655
|
+
const signal = activeAbortController.signal;
|
|
656
|
+
const myToken = ++currentNavigationToken;
|
|
657
|
+
|
|
658
|
+
const isSafe = method === 'get' || method === 'head';
|
|
659
|
+
let url = new URL(href, location.href);
|
|
660
|
+
if (isSafe) {
|
|
661
|
+
// Promote body to query string per the HTML5 form-submission
|
|
662
|
+
// algorithm. The form's own `action` query is replaced: same as
|
|
663
|
+
// a native GET-form submission.
|
|
664
|
+
url.search = '';
|
|
665
|
+
for (const [k, v] of body) {
|
|
666
|
+
url.searchParams.append(k, typeof v === 'string' ? v : v.name);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Snapshot the page being submitted from (form submissions are
|
|
671
|
+
// always foreground / never popstate, so `currentPageUrl` already
|
|
672
|
+
// matches `location.href`: but use the tracker for consistency
|
|
673
|
+
// with performNavigation).
|
|
674
|
+
if (currentPageUrl) snapshotCurrent(currentPageUrl);
|
|
675
|
+
|
|
676
|
+
let navigatingFlagTimer = setTimeout(() => {
|
|
677
|
+
document.documentElement.setAttribute('data-navigating', '');
|
|
678
|
+
navigatingFlagTimer = null;
|
|
679
|
+
}, 150);
|
|
680
|
+
|
|
681
|
+
const optimisticState = applyOptimisticLoading();
|
|
682
|
+
|
|
683
|
+
try {
|
|
684
|
+
await fetchAndApply(
|
|
685
|
+
url.href,
|
|
686
|
+
frameId,
|
|
687
|
+
/* recordHistory */ true,
|
|
688
|
+
optimisticState,
|
|
689
|
+
isSafe ? 'GET' : method.toUpperCase(),
|
|
690
|
+
isSafe ? null : body,
|
|
691
|
+
signal,
|
|
692
|
+
myToken,
|
|
693
|
+
);
|
|
694
|
+
// Mutating submissions invalidate cached versions of other URLs -
|
|
695
|
+
// do this *after* the response applies so the new page itself is
|
|
696
|
+
// snapshotted on the next nav, not pre-emptively wiped.
|
|
697
|
+
if (!isSafe && myToken === currentNavigationToken) {
|
|
698
|
+
snapshotCache.clear();
|
|
699
|
+
}
|
|
700
|
+
} finally {
|
|
701
|
+
if (navigatingFlagTimer) clearTimeout(navigatingFlagTimer);
|
|
702
|
+
if (myToken === currentNavigationToken) {
|
|
703
|
+
document.documentElement.removeAttribute('data-navigating');
|
|
704
|
+
if (typeof location !== 'undefined') currentPageUrl = location.href;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Build the X-Webjs-Have header value from the live DOM's marker paths.
|
|
711
|
+
* Comma-separated, in document order (no canonicalization needed; the
|
|
712
|
+
* server intersects with the target's layout chain).
|
|
713
|
+
*
|
|
714
|
+
* @returns {string}
|
|
715
|
+
*/
|
|
716
|
+
function buildHaveHeader() {
|
|
717
|
+
const slots = collectChildrenSlots(document.body);
|
|
718
|
+
return [...slots.keys()].join(',');
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Fetch the target URL and apply the swap.
|
|
723
|
+
*
|
|
724
|
+
* @param {string} href
|
|
725
|
+
* @param {string | null} frameId
|
|
726
|
+
* @param {boolean} recordHistory
|
|
727
|
+
* @param {{ slot: { start: Comment, end: Comment }, oldChildren: Node[], token: number } | null} optimisticState
|
|
728
|
+
* @param {string} [method] HTTP verb (uppercase). Default 'GET'.
|
|
729
|
+
* @param {BodyInit | null} [body] Request body for non-GET methods.
|
|
730
|
+
* @param {AbortSignal | null} [signal] Abort signal - newer nav cancels this fetch.
|
|
731
|
+
* @param {number} [token] Nav-token captured at the caller's entry; stale → skip apply.
|
|
732
|
+
*/
|
|
733
|
+
async function fetchAndApply(href, frameId, recordHistory, optimisticState, method, body, signal, token) {
|
|
734
|
+
method = method || 'GET';
|
|
735
|
+
const myToken = typeof token === 'number' ? token : currentNavigationToken;
|
|
736
|
+
let html;
|
|
737
|
+
/** @type {string} */
|
|
738
|
+
let finalUrl = href;
|
|
739
|
+
try {
|
|
740
|
+
const headers = { 'x-webjs-router': '1' };
|
|
741
|
+
const have = buildHaveHeader();
|
|
742
|
+
if (have) headers['x-webjs-have'] = have;
|
|
743
|
+
if (frameId) headers['x-webjs-frame'] = frameId;
|
|
744
|
+
|
|
745
|
+
/** @type {RequestInit} */
|
|
746
|
+
const init = { method, headers, credentials: 'same-origin' };
|
|
747
|
+
if (signal) init.signal = signal;
|
|
748
|
+
if (body != null && method !== 'GET' && method !== 'HEAD') init.body = body;
|
|
749
|
+
|
|
750
|
+
const resp = await fetch(href, init);
|
|
751
|
+
const ctype = resp.headers.get('content-type') || '';
|
|
752
|
+
const isHTML = /^text\/html\b/i.test(ctype);
|
|
753
|
+
// Server-side redirect (PRG, auth-gate, etc.): fetch followed it
|
|
754
|
+
// automatically. Record the FINAL URL in history, not the
|
|
755
|
+
// originally-requested one, so back/forward + bookmarking work.
|
|
756
|
+
if (resp.redirected && resp.url) finalUrl = resp.url;
|
|
757
|
+
|
|
758
|
+
// Empty-body status codes (204 No Content, 205 Reset Content):
|
|
759
|
+
// server-rendered "stay on current page" pattern. Don't try to
|
|
760
|
+
// swap an empty document over the live one. We DO still record
|
|
761
|
+
// history for the originating URL: same as a normal navigation
|
|
762
|
+
// that decided to short-circuit.
|
|
763
|
+
if (resp.status === 204 || resp.status === 205) {
|
|
764
|
+
if (myToken === currentNavigationToken && recordHistory) {
|
|
765
|
+
history.pushState(null, '', finalUrl);
|
|
766
|
+
}
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Non-HTML response (JSON error, file download, opaque): let the
|
|
771
|
+
// browser handle it. Same for non-OK responses that aren't HTML
|
|
772
|
+
// (a 500 returning `{"error": "..."}` shouldn't be rendered as a
|
|
773
|
+
// page).
|
|
774
|
+
if (!isHTML) {
|
|
775
|
+
if (myToken === currentNavigationToken) location.href = href;
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// HTML body of ANY status: 2xx, 4xx validation errors, 5xx error
|
|
780
|
+
// pages: is parsed and applied in place. Matches Turbo Drive's
|
|
781
|
+
// `formSubmissionFailedWithResponse` behavior
|
|
782
|
+
// (turbo/src/core/drive/navigator.js:92-107). Critical for the
|
|
783
|
+
// standard server-rendered validation pattern: 422 + re-rendered
|
|
784
|
+
// form with errors keeps the user's typed input and shows context.
|
|
785
|
+
html = await resp.text();
|
|
786
|
+
} catch (err) {
|
|
787
|
+
// Aborted by a newer navigation: let it run, don't fall back.
|
|
788
|
+
if (err && /** @type any */ (err).name === 'AbortError') return;
|
|
789
|
+
// Stale (a newer nav started before we got the network error) -
|
|
790
|
+
// the newer nav owns the page now; don't clobber it.
|
|
791
|
+
if (myToken !== currentNavigationToken) return;
|
|
792
|
+
restoreOptimistic(optimisticState);
|
|
793
|
+
location.href = href;
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// A newer navigation started while we awaited the response body -
|
|
798
|
+
// bail before we overwrite its work.
|
|
799
|
+
if (myToken !== currentNavigationToken) return;
|
|
800
|
+
|
|
801
|
+
const doc = parseHTML(html);
|
|
802
|
+
if (!doc) { location.href = href; return; }
|
|
803
|
+
|
|
804
|
+
applySwap(doc, frameId, false);
|
|
805
|
+
|
|
806
|
+
if (recordHistory) history.pushState(null, '', finalUrl);
|
|
807
|
+
|
|
808
|
+
// Scroll only for foreground (history-recording) navigations. When
|
|
809
|
+
// `recordHistory` is false we're either:
|
|
810
|
+
// (a) the background revalidation after a cached popstate restore
|
|
811
|
+
// - performNavigation already set scroll from the cached
|
|
812
|
+
// position; we must NOT clobber it here.
|
|
813
|
+
// (b) a cache-miss popstate: modern browsers fire scroll-
|
|
814
|
+
// restoration themselves before dispatching popstate, so
|
|
815
|
+
// leaving scroll alone preserves the browser-native UX.
|
|
816
|
+
if (recordHistory) {
|
|
817
|
+
// Use the final URL (after any server-side redirect) so hash
|
|
818
|
+
// anchors point at the document we actually rendered.
|
|
819
|
+
const url = new URL(finalUrl);
|
|
820
|
+
if (url.hash) {
|
|
821
|
+
const t = document.getElementById(url.hash.slice(1));
|
|
822
|
+
if (t) t.scrollIntoView();
|
|
823
|
+
else window.scrollTo(0, 0);
|
|
824
|
+
} else {
|
|
825
|
+
window.scrollTo(0, 0);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url: finalUrl, frameId } }));
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Apply the swap from a parsed incoming Document onto the live document.
|
|
834
|
+
* Picks the most-scoped match: explicit webjs-frame > deepest shared
|
|
835
|
+
* layout marker > full body swap.
|
|
836
|
+
*
|
|
837
|
+
* @param {Document} doc
|
|
838
|
+
* @param {string | null} frameId
|
|
839
|
+
* @param {boolean} revalidating Restore from cache - already-matched markers may stomp inflight state; signal helps loading templates skip.
|
|
840
|
+
*/
|
|
841
|
+
function applySwap(doc, frameId, revalidating) {
|
|
842
|
+
// 1. webjs-frame escape hatch.
|
|
843
|
+
if (frameId) {
|
|
844
|
+
const target = document.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
|
|
845
|
+
const source = doc.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
|
|
846
|
+
if (target && source) {
|
|
847
|
+
// ADD-ONLY head merge: preserve runtime-generated head content
|
|
848
|
+
// (Tailwind CSS injection, etc.) that the outer layout's scripts
|
|
849
|
+
// already produced.
|
|
850
|
+
addNewHeadElements(doc.head);
|
|
851
|
+
diffChildren(target, source);
|
|
852
|
+
reactivateScripts(target);
|
|
853
|
+
upgradeCustomElements(target);
|
|
854
|
+
forwardSuspenseResolvers(doc.body);
|
|
855
|
+
blurOutgoingFocus();
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// 2. Auto-derived layout-marker swap.
|
|
861
|
+
const here = collectChildrenSlots(document.body);
|
|
862
|
+
const there = collectChildrenSlots(doc.body);
|
|
863
|
+
const sharedPath = longestSharedPath(here, there);
|
|
864
|
+
|
|
865
|
+
if (sharedPath) {
|
|
866
|
+
// ADD-ONLY head merge for the same reason: outer layout stays
|
|
867
|
+
// mounted, its head-bound runtime state must not be invalidated.
|
|
868
|
+
addNewHeadElements(doc.head);
|
|
869
|
+
swapMarkerRange(here.get(sharedPath), there.get(sharedPath), doc);
|
|
870
|
+
forwardSuspenseResolvers(doc.body);
|
|
871
|
+
blurOutgoingFocus();
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// 3. Full body swap fallback. Use full head merge: different root
|
|
876
|
+
// layout, so stale head elements should be removed.
|
|
877
|
+
mergeHead(doc.head);
|
|
878
|
+
const newChildren = [...doc.body.childNodes];
|
|
879
|
+
const doSwap = () => {
|
|
880
|
+
document.body.replaceChildren(...newChildren);
|
|
881
|
+
reactivateScripts(document.body);
|
|
882
|
+
upgradeCustomElements(document.body);
|
|
883
|
+
blurOutgoingFocus();
|
|
884
|
+
};
|
|
885
|
+
if (/** @type any */ (document).startViewTransition) {
|
|
886
|
+
const t = /** @type any */ (document).startViewTransition(doSwap);
|
|
887
|
+
t.finished.then(() => upgradeCustomElements(document.body)).catch(() => {});
|
|
888
|
+
} else {
|
|
889
|
+
doSwap();
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* After a swap, blur whatever element the user activated to trigger the
|
|
895
|
+
* navigation (the clicked sidenav link, the submitted form button, etc.).
|
|
896
|
+
*
|
|
897
|
+
* Why: browsers paint `:focus-visible` rings when the window regains
|
|
898
|
+
* focus on whatever has focus at that moment. A click leaves focus on
|
|
899
|
+
* the clicked element, so without this blur the user sees a stuck focus
|
|
900
|
+
* ring on the sidenav link every time they switch workspaces and come
|
|
901
|
+
* back: even though they navigated minutes ago.
|
|
902
|
+
*
|
|
903
|
+
* We do NOT programmatically move focus to the new page's h1/h2.
|
|
904
|
+
* That'd just relocate the same problem (focus ring on the heading
|
|
905
|
+
* after a workspace switch) and steals focus from sighted users.
|
|
906
|
+
* Screen-reader users navigate by heading via their own shortcuts
|
|
907
|
+
* (`h` in NVDA/JAWS), so they don't need us to do it for them.
|
|
908
|
+
*
|
|
909
|
+
* No-op when focus is on `<body>` (browser default after `removeChild`
|
|
910
|
+
* of a focused node) or when the active element survived the swap and
|
|
911
|
+
* was inside the new content (means the swap was internal to a region
|
|
912
|
+
* the user was already interacting with: don't fight them).
|
|
913
|
+
*/
|
|
914
|
+
function blurOutgoingFocus() {
|
|
915
|
+
const a = document.activeElement;
|
|
916
|
+
if (!a || a === document.body || a === document.documentElement) return;
|
|
917
|
+
if (typeof (/** @type any */ (a).blur) !== 'function') return;
|
|
918
|
+
/** @type any */ (a).blur();
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Replace nodes between `target.start` and `target.end` (exclusive) in the
|
|
923
|
+
* live document with the nodes between `source.start` and `source.end` in
|
|
924
|
+
* the parsed Document. Uses a keyed reconciler that preserves DOM
|
|
925
|
+
* identity for matched elements + their live attributes (scroll, value,
|
|
926
|
+
* etc.).
|
|
927
|
+
*
|
|
928
|
+
* @param {{ start: Comment, end: Comment } | undefined} target
|
|
929
|
+
* @param {{ start: Comment, end: Comment } | undefined} source
|
|
930
|
+
* @param {Document} _doc
|
|
931
|
+
*/
|
|
932
|
+
function swapMarkerRange(target, source, _doc) {
|
|
933
|
+
if (!target || !source) return;
|
|
934
|
+
|
|
935
|
+
// Build a parent-with-matching-children pair for the keyed differ.
|
|
936
|
+
// The differ wants two parents: synthesize a transient parent for
|
|
937
|
+
// the slice of `source` so we can diff in-place against `target.start`
|
|
938
|
+
// / `target.end` siblings on the live document.
|
|
939
|
+
const liveParent = target.start.parentNode;
|
|
940
|
+
if (!liveParent) return;
|
|
941
|
+
|
|
942
|
+
// Collect current children (nodes between start and end, exclusive).
|
|
943
|
+
/** @type {Node[]} */
|
|
944
|
+
const liveSlice = [];
|
|
945
|
+
for (let n = target.start.nextSibling; n && n !== target.end; n = n.nextSibling) {
|
|
946
|
+
liveSlice.push(n);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// Collect incoming children, importing into the live document.
|
|
950
|
+
/** @type {Node[]} */
|
|
951
|
+
const incomingSlice = [];
|
|
952
|
+
for (let n = source.start.nextSibling; n && n !== source.end; n = n.nextSibling) {
|
|
953
|
+
incomingSlice.push(document.importNode(n, true));
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Run the keyed diff.
|
|
957
|
+
reconcileSiblings(liveParent, target.start, target.end, liveSlice, incomingSlice);
|
|
958
|
+
|
|
959
|
+
// Upgrade + activate scripts in the just-swapped range.
|
|
960
|
+
for (let n = target.start.nextSibling; n && n !== target.end; n = n.nextSibling) {
|
|
961
|
+
if (n.nodeType === 1) {
|
|
962
|
+
reactivateScripts(/** @type {Element} */ (n));
|
|
963
|
+
upgradeCustomElements(/** @type {Element} */ (n));
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Coarse keyed reconciliation between liveSlice and incomingSlice,
|
|
970
|
+
* positioned in liveParent between `startMarker` and `endMarker`.
|
|
971
|
+
*
|
|
972
|
+
* Algorithm (Remix v3 inspired, pared down):
|
|
973
|
+
* - Match elements by (tagName + key) where key = data-key || id.
|
|
974
|
+
* - For each pair: diff attributes, recurse into children.
|
|
975
|
+
* - Unmatched live elements: remove.
|
|
976
|
+
* - Unmatched incoming elements: insert in the right slot.
|
|
977
|
+
* - Live attributes (value, checked, open, scroll-position) are
|
|
978
|
+
* preserved on matched elements regardless of server HTML.
|
|
979
|
+
*
|
|
980
|
+
* This is intentionally simple: when no keys are present, the diff
|
|
981
|
+
* matches by position only and falls back to replaceChildren-like
|
|
982
|
+
* semantics for the unkeyed range. Apps that want stronger
|
|
983
|
+
* preservation add `data-key` to elements they care about.
|
|
984
|
+
*
|
|
985
|
+
* @param {Node} parent
|
|
986
|
+
* @param {Comment} startMarker
|
|
987
|
+
* @param {Comment} endMarker
|
|
988
|
+
* @param {Node[]} live
|
|
989
|
+
* @param {Node[]} incoming
|
|
990
|
+
*/
|
|
991
|
+
function reconcileSiblings(parent, startMarker, endMarker, live, incoming) {
|
|
992
|
+
// Index live elements by (tag + key) for keyed match.
|
|
993
|
+
/** @type {Map<string, Element>} */
|
|
994
|
+
const keyedLive = new Map();
|
|
995
|
+
for (const n of live) {
|
|
996
|
+
if (n.nodeType !== 1) continue;
|
|
997
|
+
const k = keyOf(/** @type {Element} */ (n));
|
|
998
|
+
if (k) keyedLive.set(k, /** @type {Element} */ (n));
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Walk incoming, placing nodes in order between markers.
|
|
1002
|
+
/** @type {Node} */
|
|
1003
|
+
let insertBefore = endMarker;
|
|
1004
|
+
// First pass: build the final ordered list of nodes (reusing matched live).
|
|
1005
|
+
/** @type {Node[]} */
|
|
1006
|
+
const finalNodes = [];
|
|
1007
|
+
for (const inc of incoming) {
|
|
1008
|
+
if (inc.nodeType === 1) {
|
|
1009
|
+
const k = keyOf(/** @type {Element} */ (inc));
|
|
1010
|
+
if (k && keyedLive.has(k)) {
|
|
1011
|
+
const reused = keyedLive.get(k);
|
|
1012
|
+
diffElementInPlace(reused, /** @type {Element} */ (inc));
|
|
1013
|
+
finalNodes.push(reused);
|
|
1014
|
+
keyedLive.delete(k);
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
finalNodes.push(inc);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Remove live nodes that weren't reused.
|
|
1022
|
+
for (const n of live) {
|
|
1023
|
+
if (n.parentNode === parent) {
|
|
1024
|
+
if (n.nodeType === 1 && finalNodes.includes(n)) continue;
|
|
1025
|
+
parent.removeChild(n);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Insert final nodes in order before the end marker.
|
|
1030
|
+
for (const n of finalNodes) {
|
|
1031
|
+
parent.insertBefore(n, insertBefore);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Diff one matched element in place: copy attributes from `src` to `dst`,
|
|
1037
|
+
* preserve live attributes, recurse into children.
|
|
1038
|
+
*
|
|
1039
|
+
* @param {Element} dst The element to update (live DOM).
|
|
1040
|
+
* @param {Element} src The element to copy from (incoming HTML).
|
|
1041
|
+
*/
|
|
1042
|
+
function diffElementInPlace(dst, src) {
|
|
1043
|
+
if (dst.tagName !== src.tagName) {
|
|
1044
|
+
dst.replaceWith(src);
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
// Update attributes from src; remove ones not in src.
|
|
1048
|
+
const srcAttrs = new Set();
|
|
1049
|
+
for (const attr of src.attributes) {
|
|
1050
|
+
srcAttrs.add(attr.name);
|
|
1051
|
+
if (LIVE_ATTRS.has(attr.name)) continue;
|
|
1052
|
+
if (dst.getAttribute(attr.name) !== attr.value) {
|
|
1053
|
+
dst.setAttribute(attr.name, attr.value);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
for (const attr of [...dst.attributes]) {
|
|
1057
|
+
if (LIVE_ATTRS.has(attr.name)) continue;
|
|
1058
|
+
if (!srcAttrs.has(attr.name)) dst.removeAttribute(attr.name);
|
|
1059
|
+
}
|
|
1060
|
+
// For form-control-like elements, preserve live IDL state.
|
|
1061
|
+
// (`value`, `checked`, `open`, etc.: see LIVE_ATTRS below for full list.)
|
|
1062
|
+
// The attribute version is skipped above; we deliberately do nothing
|
|
1063
|
+
// here so the user's typing / checking is never blown away.
|
|
1064
|
+
|
|
1065
|
+
// Recurse into children: collect both sides, run reconcileSiblings on
|
|
1066
|
+
// them with synthetic boundary markers. Cheap implementation: use
|
|
1067
|
+
// virtual ranges instead of inserting real comment markers.
|
|
1068
|
+
reconcileChildren(dst, src);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Reconcile dst's children to match src's children, in-place.
|
|
1073
|
+
*
|
|
1074
|
+
* @param {Element} dst
|
|
1075
|
+
* @param {Element} src
|
|
1076
|
+
*/
|
|
1077
|
+
function reconcileChildren(dst, src) {
|
|
1078
|
+
const liveChildren = [...dst.childNodes];
|
|
1079
|
+
const incomingChildren = [...src.childNodes].map((n) => document.importNode(n, true));
|
|
1080
|
+
|
|
1081
|
+
// Build keyed map of live children for reuse.
|
|
1082
|
+
/** @type {Map<string, Element>} */
|
|
1083
|
+
const keyedLive = new Map();
|
|
1084
|
+
for (const n of liveChildren) {
|
|
1085
|
+
if (n.nodeType !== 1) continue;
|
|
1086
|
+
const k = keyOf(/** @type {Element} */ (n));
|
|
1087
|
+
if (k) keyedLive.set(k, /** @type {Element} */ (n));
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** @type {Node[]} */
|
|
1091
|
+
const finalNodes = [];
|
|
1092
|
+
for (let i = 0; i < incomingChildren.length; i++) {
|
|
1093
|
+
const inc = incomingChildren[i];
|
|
1094
|
+
if (inc.nodeType === 1) {
|
|
1095
|
+
const k = keyOf(/** @type {Element} */ (inc));
|
|
1096
|
+
if (k && keyedLive.has(k)) {
|
|
1097
|
+
const reused = keyedLive.get(k);
|
|
1098
|
+
diffElementInPlace(reused, /** @type {Element} */ (inc));
|
|
1099
|
+
finalNodes.push(reused);
|
|
1100
|
+
keyedLive.delete(k);
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
// Positional match: same tag, same index, neither has a key.
|
|
1104
|
+
const livePeer = liveChildren[i];
|
|
1105
|
+
if (livePeer && livePeer.nodeType === 1 &&
|
|
1106
|
+
!keyOf(/** @type {Element} */ (livePeer)) &&
|
|
1107
|
+
/** @type {Element} */ (livePeer).tagName === /** @type {Element} */ (inc).tagName) {
|
|
1108
|
+
diffElementInPlace(/** @type {Element} */ (livePeer), /** @type {Element} */ (inc));
|
|
1109
|
+
finalNodes.push(livePeer);
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
} else if (inc.nodeType === 3) {
|
|
1113
|
+
// Text node: positional reuse for stable identity.
|
|
1114
|
+
const livePeer = liveChildren[i];
|
|
1115
|
+
if (livePeer && livePeer.nodeType === 3) {
|
|
1116
|
+
if (livePeer.nodeValue !== inc.nodeValue) livePeer.nodeValue = inc.nodeValue;
|
|
1117
|
+
finalNodes.push(livePeer);
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
} else if (inc.nodeType === 8) {
|
|
1121
|
+
// Comment: positional reuse.
|
|
1122
|
+
const livePeer = liveChildren[i];
|
|
1123
|
+
if (livePeer && livePeer.nodeType === 8) {
|
|
1124
|
+
if (livePeer.nodeValue !== inc.nodeValue) livePeer.nodeValue = inc.nodeValue;
|
|
1125
|
+
finalNodes.push(livePeer);
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
finalNodes.push(inc);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Mutate dst to contain finalNodes in order, preserving reused references.
|
|
1133
|
+
// Walk forward, inserting each node before the (potentially moved) next sibling.
|
|
1134
|
+
const finalSet = new Set(finalNodes);
|
|
1135
|
+
for (const n of liveChildren) {
|
|
1136
|
+
if (!finalSet.has(n) && n.parentNode === dst) dst.removeChild(n);
|
|
1137
|
+
}
|
|
1138
|
+
for (let i = 0; i < finalNodes.length; i++) {
|
|
1139
|
+
const n = finalNodes[i];
|
|
1140
|
+
if (n.parentNode !== dst || dst.childNodes[i] !== n) {
|
|
1141
|
+
dst.insertBefore(n, dst.childNodes[i] || null);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Get the diff key for an element: `data-key` if present, else `id`.
|
|
1148
|
+
* Returns null for elements with no stable key.
|
|
1149
|
+
*
|
|
1150
|
+
* @param {Element} el
|
|
1151
|
+
* @returns {string | null}
|
|
1152
|
+
*/
|
|
1153
|
+
function keyOf(el) {
|
|
1154
|
+
const k = el.getAttribute('data-key');
|
|
1155
|
+
if (k) return `${el.tagName}:k:${k}`;
|
|
1156
|
+
if (el.id) return `${el.tagName}:i:${el.id}`;
|
|
1157
|
+
return null;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Attribute names whose live DOM state must NEVER be overwritten by
|
|
1162
|
+
* incoming server HTML during a partial swap. The server emits these
|
|
1163
|
+
* with their initial-render value; the user may have typed/clicked
|
|
1164
|
+
* between renders. Preserving them keeps focus, typing, open state,
|
|
1165
|
+
* and popover state intact across navigation.
|
|
1166
|
+
*/
|
|
1167
|
+
const LIVE_ATTRS = new Set([
|
|
1168
|
+
// Form controls
|
|
1169
|
+
'value', 'checked', 'selected', 'indeterminate', 'disabled',
|
|
1170
|
+
// Disclosure / popover
|
|
1171
|
+
'open', 'popover',
|
|
1172
|
+
]);
|
|
1173
|
+
|
|
1174
|
+
/* ====================================================================
|
|
1175
|
+
* Optimistic loading (per-segment loading.ts templates)
|
|
1176
|
+
* ==================================================================== */
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Look for `<template id="wj-loading:<deepest-current-path>">` in the
|
|
1180
|
+
* document; if present, clone its content into the deepest current
|
|
1181
|
+
* children-slot. Returns state needed to restore on fetch failure.
|
|
1182
|
+
*
|
|
1183
|
+
* The returned state carries the nav-token in effect at swap time;
|
|
1184
|
+
* `restoreOptimistic` verifies the token still matches before reverting,
|
|
1185
|
+
* so a slow nav A's late failure cannot revert a faster nav B's
|
|
1186
|
+
* already-settled state.
|
|
1187
|
+
*
|
|
1188
|
+
* @returns {{ slot: { start: Comment, end: Comment }, oldChildren: Node[], token: number } | null}
|
|
1189
|
+
*/
|
|
1190
|
+
function applyOptimisticLoading() {
|
|
1191
|
+
const slots = collectChildrenSlots(document.body);
|
|
1192
|
+
if (slots.size === 0) return null;
|
|
1193
|
+
// Pick the deepest current slot (longest path).
|
|
1194
|
+
let deepest = null;
|
|
1195
|
+
for (const p of slots.keys()) {
|
|
1196
|
+
if (deepest === null || p.length > deepest.length) deepest = p;
|
|
1197
|
+
}
|
|
1198
|
+
if (deepest === null) return null;
|
|
1199
|
+
const tpl = document.getElementById(`wj-loading:${deepest}`);
|
|
1200
|
+
if (!(tpl instanceof HTMLTemplateElement)) return null;
|
|
1201
|
+
|
|
1202
|
+
const slot = slots.get(deepest);
|
|
1203
|
+
/** @type {Node[]} */
|
|
1204
|
+
const oldChildren = [];
|
|
1205
|
+
for (let n = slot.start.nextSibling; n && n !== slot.end; n = n.nextSibling) {
|
|
1206
|
+
oldChildren.push(n);
|
|
1207
|
+
}
|
|
1208
|
+
// Replace slot contents with the loading template.
|
|
1209
|
+
const range = document.createRange();
|
|
1210
|
+
range.setStartAfter(slot.start);
|
|
1211
|
+
range.setEndBefore(slot.end);
|
|
1212
|
+
range.deleteContents();
|
|
1213
|
+
slot.start.parentNode.insertBefore(tpl.content.cloneNode(true), slot.end);
|
|
1214
|
+
return { slot, oldChildren, token: currentNavigationToken };
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/** @param {{ slot: { start: Comment, end: Comment }, oldChildren: Node[], token: number } | null} state */
|
|
1218
|
+
function restoreOptimistic(state) {
|
|
1219
|
+
if (!state) return;
|
|
1220
|
+
// A newer nav superseded the one that captured this state: don't
|
|
1221
|
+
// revert; that newer nav owns the page now.
|
|
1222
|
+
if (state.token !== currentNavigationToken) return;
|
|
1223
|
+
const { slot, oldChildren } = state;
|
|
1224
|
+
if (slot.start.parentNode !== slot.end.parentNode) return;
|
|
1225
|
+
const range = document.createRange();
|
|
1226
|
+
range.setStartAfter(slot.start);
|
|
1227
|
+
range.setEndBefore(slot.end);
|
|
1228
|
+
range.deleteContents();
|
|
1229
|
+
for (const n of oldChildren) slot.start.parentNode.insertBefore(n, slot.end);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/* ====================================================================
|
|
1233
|
+
* Diff helper for the webjs-frame escape hatch
|
|
1234
|
+
* ==================================================================== */
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* Diff children of two elements (used by the webjs-frame swap path).
|
|
1238
|
+
*
|
|
1239
|
+
* @param {Element} dst
|
|
1240
|
+
* @param {Element} src
|
|
1241
|
+
*/
|
|
1242
|
+
function diffChildren(dst, src) {
|
|
1243
|
+
reconcileChildren(dst, src);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/* ====================================================================
|
|
1247
|
+
* Head merge
|
|
1248
|
+
* ==================================================================== */
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Add-only head merge for partial (marker + frame) swaps. Updates the
|
|
1252
|
+
* title and adds new elements (modulepreloads, scripts) without
|
|
1253
|
+
* removing existing ones: runtime-generated content like Tailwind's
|
|
1254
|
+
* injected CSS must survive across navigations that keep the outer
|
|
1255
|
+
* layout mounted.
|
|
1256
|
+
*
|
|
1257
|
+
* @param {HTMLHeadElement} newHead
|
|
1258
|
+
*/
|
|
1259
|
+
function addNewHeadElements(newHead) {
|
|
1260
|
+
const newTitle = newHead.querySelector('title');
|
|
1261
|
+
if (newTitle) document.title = newTitle.textContent || '';
|
|
1262
|
+
|
|
1263
|
+
const currentSet = new Set();
|
|
1264
|
+
for (const el of document.head.children) currentSet.add(el.outerHTML);
|
|
1265
|
+
|
|
1266
|
+
for (const el of newHead.children) {
|
|
1267
|
+
if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') {
|
|
1268
|
+
// Skip: partial swaps keep the outer layout mounted, so the
|
|
1269
|
+
// existing importmap stays authoritative. Warn if the incoming
|
|
1270
|
+
// map differs: importmaps are immutable once a script has run
|
|
1271
|
+
// (modern browsers ignore subsequent <script type=importmap>),
|
|
1272
|
+
// so a mismatch means module resolution on this page won't match
|
|
1273
|
+
// what SSR rendered against.
|
|
1274
|
+
const incoming = (el.textContent || '').trim();
|
|
1275
|
+
if (incoming) {
|
|
1276
|
+
const current = document.querySelector('script[type="importmap"]');
|
|
1277
|
+
const currentText = current ? (current.textContent || '').trim() : '';
|
|
1278
|
+
if (currentText && currentText !== incoming && typeof console !== 'undefined') {
|
|
1279
|
+
console.warn('[webjs] incoming page has a different importmap; keeping the current page\'s map. Cross-layout module aliasing may be inconsistent.');
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
if (el.tagName === 'BASE') continue;
|
|
1285
|
+
if (el.tagName === 'TITLE') continue;
|
|
1286
|
+
if (!currentSet.has(el.outerHTML)) {
|
|
1287
|
+
if (el.tagName === 'SCRIPT') {
|
|
1288
|
+
const script = document.createElement('script');
|
|
1289
|
+
for (const attr of el.attributes) script.setAttribute(attr.name, attr.value);
|
|
1290
|
+
script.textContent = el.textContent;
|
|
1291
|
+
document.head.appendChild(script);
|
|
1292
|
+
} else {
|
|
1293
|
+
document.head.appendChild(el.cloneNode(true));
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/** @param {HTMLHeadElement} newHead */
|
|
1300
|
+
function mergeHead(newHead) {
|
|
1301
|
+
const currentHead = document.head;
|
|
1302
|
+
|
|
1303
|
+
const newTitle = newHead.querySelector('title');
|
|
1304
|
+
if (newTitle) document.title = newTitle.textContent || '';
|
|
1305
|
+
|
|
1306
|
+
const currentSet = new Set();
|
|
1307
|
+
for (const el of currentHead.children) {
|
|
1308
|
+
if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
|
|
1309
|
+
if (el.tagName === 'BASE') continue;
|
|
1310
|
+
currentSet.add(el.outerHTML);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const newSet = new Set();
|
|
1314
|
+
for (const el of newHead.children) {
|
|
1315
|
+
if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
|
|
1316
|
+
if (el.tagName === 'BASE') continue;
|
|
1317
|
+
newSet.add(el.outerHTML);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
for (const el of [...currentHead.children]) {
|
|
1321
|
+
if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
|
|
1322
|
+
if (el.tagName === 'BASE') continue;
|
|
1323
|
+
if (el.tagName === 'TITLE') continue;
|
|
1324
|
+
if (!newSet.has(el.outerHTML)) el.remove();
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
for (const el of newHead.children) {
|
|
1328
|
+
if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
|
|
1329
|
+
if (el.tagName === 'BASE') continue;
|
|
1330
|
+
if (el.tagName === 'TITLE') continue;
|
|
1331
|
+
if (!currentSet.has(el.outerHTML)) {
|
|
1332
|
+
if (el.tagName === 'SCRIPT') {
|
|
1333
|
+
const script = document.createElement('script');
|
|
1334
|
+
for (const attr of el.attributes) script.setAttribute(attr.name, attr.value);
|
|
1335
|
+
script.textContent = el.textContent;
|
|
1336
|
+
currentHead.appendChild(script);
|
|
1337
|
+
} else {
|
|
1338
|
+
currentHead.appendChild(el.cloneNode(true));
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/* ====================================================================
|
|
1345
|
+
* Custom-element upgrade + script reactivation
|
|
1346
|
+
* ==================================================================== */
|
|
1347
|
+
|
|
1348
|
+
/** @param {Element} container */
|
|
1349
|
+
function upgradeCustomElements(container) {
|
|
1350
|
+
if (typeof customElements === 'undefined') return;
|
|
1351
|
+
upgradeTree(container);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/** @param {Element | DocumentFragment} root */
|
|
1355
|
+
function upgradeTree(root) {
|
|
1356
|
+
const els = root instanceof Element
|
|
1357
|
+
? [root, ...root.querySelectorAll('*')]
|
|
1358
|
+
: [...root.querySelectorAll('*')];
|
|
1359
|
+
for (const el of els) {
|
|
1360
|
+
if (el.tagName && el.tagName.includes('-')) {
|
|
1361
|
+
customElements.upgrade(el);
|
|
1362
|
+
if (el.shadowRoot) upgradeTree(el.shadowRoot);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Forward streamed Suspense resolver templates from the fetched body to
|
|
1369
|
+
* the live body. Needed when the new page emits a Suspense boundary that
|
|
1370
|
+
* resolves later.
|
|
1371
|
+
*
|
|
1372
|
+
* @param {HTMLElement} fetchedBody
|
|
1373
|
+
*/
|
|
1374
|
+
function forwardSuspenseResolvers(fetchedBody) {
|
|
1375
|
+
for (const tpl of fetchedBody.querySelectorAll('template[data-webjs-resolve]')) {
|
|
1376
|
+
document.body.appendChild(tpl.cloneNode(true));
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/** @param {Element} container */
|
|
1381
|
+
function reactivateScripts(container) {
|
|
1382
|
+
for (const old of container.querySelectorAll('script')) {
|
|
1383
|
+
const script = document.createElement('script');
|
|
1384
|
+
for (const attr of old.attributes) script.setAttribute(attr.name, attr.value);
|
|
1385
|
+
script.textContent = old.textContent;
|
|
1386
|
+
old.replaceWith(script);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/* ====================================================================
|
|
1391
|
+
* Internal exports for unit testing
|
|
1392
|
+
* ==================================================================== */
|
|
1393
|
+
|
|
1394
|
+
export {
|
|
1395
|
+
addNewHeadElements as _addNewHeadElements,
|
|
1396
|
+
mergeHead as _mergeHead,
|
|
1397
|
+
reactivateScripts as _reactivateScripts,
|
|
1398
|
+
findAnchorInPath as _findAnchorInPath,
|
|
1399
|
+
activeFrameId as _activeFrameId,
|
|
1400
|
+
collectChildrenSlots as _collectChildrenSlots,
|
|
1401
|
+
longestSharedPath as _longestSharedPath,
|
|
1402
|
+
keyOf as _keyOf,
|
|
1403
|
+
diffElementInPlace as _diffElementInPlace,
|
|
1404
|
+
reconcileChildren as _reconcileChildren,
|
|
1405
|
+
onPopState as _onPopState,
|
|
1406
|
+
snapshotCache as _snapshotCache,
|
|
1407
|
+
LIVE_ATTRS as _LIVE_ATTRS,
|
|
1408
|
+
blurOutgoingFocus as _blurOutgoingFocus,
|
|
1409
|
+
onSubmit as _onSubmit,
|
|
1410
|
+
getSubmitMethod as _getSubmitMethod,
|
|
1411
|
+
getSubmitAction as _getSubmitAction,
|
|
1412
|
+
buildSubmitFormData as _buildSubmitFormData,
|
|
1413
|
+
restoreOptimistic as _restoreOptimistic,
|
|
1414
|
+
};
|
|
1415
|
+
|
|
1416
|
+
/** Test-only: read the monotonic navigation-token counter. */
|
|
1417
|
+
export function _navToken() { return currentNavigationToken; }
|
|
1418
|
+
/** Test-only: bump the navigation-token counter (simulates a fresh nav). */
|
|
1419
|
+
export function _bumpNavToken() { return ++currentNavigationToken; }
|
|
1420
|
+
/** Test-only: read the "current page URL" tracker (used for snapshot keying). */
|
|
1421
|
+
export function _currentPageUrl() { return currentPageUrl; }
|
|
1422
|
+
/** Test-only: set the tracker (simulates being on a specific page). */
|
|
1423
|
+
export function _setCurrentPageUrl(u) { currentPageUrl = u; }
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* Predicate used by the onClick handler to decide whether a same-origin
|
|
1427
|
+
* href should bypass the router. Exposed for unit testing.
|
|
1428
|
+
*
|
|
1429
|
+
* @param {string} pathname
|
|
1430
|
+
* @returns {boolean}
|
|
1431
|
+
*/
|
|
1432
|
+
export function _isNonHtmlPath(pathname) {
|
|
1433
|
+
return NON_HTML_EXTENSIONS.test(pathname);
|
|
1434
|
+
}
|