@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.
@@ -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
+ }