@webjsdev/core 0.7.9 → 0.7.11

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.
@@ -2,6 +2,15 @@
2
2
  // `import '@webjsdev/core/client-router'` get the escape-hatch element
3
3
  // available without a second import.
4
4
  import './webjs-frame.js';
5
+ // Same for the <webjs-stream> element. Registering it here means the surgical
6
+ // stream-action applier (and `renderStream`) is available app-wide wherever
7
+ // the client router is active, for both the HTTP form path (below) and a
8
+ // live-channel `connectWS` handler.
9
+ import './webjs-stream.js';
10
+ import { renderStream } from './webjs-stream.js';
11
+
12
+ /** The content type a content-negotiated stream-action response carries (#248). */
13
+ const STREAM_MIME = 'text/vnd.webjs-stream.html';
5
14
 
6
15
  /**
7
16
  * Client router for webjs: nested-layout-aware partial swap.
@@ -227,6 +236,58 @@ export async function navigate(url, opts) {
227
236
  await performNavigation(target.href, opts?.replace ?? false, null);
228
237
  }
229
238
 
239
+ /**
240
+ * Self-load a `<webjs-frame src>`: fetch `url` as a frame nav and apply the
241
+ * matching `<webjs-frame id>` subtree into `frameEl` through the EXACT same
242
+ * frame-swap path a click-driven frame nav uses (`fetchAndApply` with the
243
+ * frame's id). So the #252 `aria-busy` lifecycle + `webjs:frame-busy` events,
244
+ * the #249 `webjs:navigation-error` recovery, the keyed reconciler, and the
245
+ * `webjs:frame-missing` fallback all apply for free; a `src` self-load and a
246
+ * click that targets the same frame produce identical DOM.
247
+ *
248
+ * This is NOT a page navigation: it records no history entry, takes no page
249
+ * snapshot, and shows no optimistic loading skeleton (it swaps one region, not
250
+ * the page). It runs under a fresh nav token + AbortController so it interleaves
251
+ * safely with real navigations and with a superseding `src` change on the same
252
+ * frame (the later load's token wins; the earlier one's teardown never clears
253
+ * the newer load's busy state, see `frameBusyTokens`).
254
+ *
255
+ * Called only by `<webjs-frame>` itself (`webjs-frame.js`), which owns the
256
+ * no-double-load guard (eager connect vs lazy-viewport vs a `src` mutation).
257
+ *
258
+ * @param {Element} frameEl The live `<webjs-frame>` element to fill.
259
+ * @param {string} url The `src` value, resolved against `location.href`.
260
+ * @returns {Promise<{ ok: boolean, status: number | null, aborted: boolean }>}
261
+ */
262
+ export async function loadFrame(frameEl, url) {
263
+ if (typeof location === 'undefined') return { ok: false, status: null, aborted: false };
264
+ const id = frameEl && /** @type any */ (frameEl).id;
265
+ if (!id) return { ok: false, status: null, aborted: false };
266
+ const target = new URL(url, location.href);
267
+ // Cross-origin can't be a same-document frame swap (and a frame fetch must
268
+ // send a same-origin credentialed request). Leave the frame unchanged.
269
+ if (target.origin !== location.origin) return { ok: false, status: null, aborted: false };
270
+
271
+ // A frame self-load shares the global abort + token machinery so a real
272
+ // navigation that starts mid-load supersedes it (and vice versa), exactly
273
+ // like a click-driven frame nav routed through performNavigation.
274
+ if (activeAbortController) activeAbortController.abort();
275
+ activeAbortController = new AbortController();
276
+ const signal = activeAbortController.signal;
277
+ const myToken = ++currentNavigationToken;
278
+
279
+ return fetchAndApply(
280
+ target.href,
281
+ id,
282
+ /* recordHistory */ false,
283
+ /* optimisticState */ null,
284
+ 'GET',
285
+ /* body */ null,
286
+ signal,
287
+ myToken,
288
+ );
289
+ }
290
+
230
291
  /**
231
292
  * Invalidate a cached snapshot. Call after a server action mutates data
232
293
  * that affects a cached page so the next visit refetches.
@@ -288,10 +349,11 @@ function onClick(e) {
288
349
  if (NON_HTML_EXTENSIONS.test(url.pathname)) return;
289
350
 
290
351
  e.preventDefault();
291
- // Identify the active <webjs-frame> via closest(), returning null if the
292
- // click wasn't inside any frame. The frame escape-hatch takes precedence
293
- // over the auto-derived layout markers when both are present.
294
- const frameId = activeFrameId(anchor);
352
+ // Resolve the target frame. An explicit `data-webjs-frame` on (or above)
353
+ // the anchor drives a frame by id from anywhere in the document (an
354
+ // external sidebar/nav link), `_top` breaks out to a full-page nav, and
355
+ // absence falls back to the closest enclosing frame (today's default).
356
+ const frameId = resolveTargetFrameId(anchor);
295
357
  performNavigation(href, false, frameId);
296
358
  }
297
359
 
@@ -355,8 +417,11 @@ function onSubmit(e) {
355
417
  const body = buildSubmitFormData(form, submitter);
356
418
 
357
419
  e.preventDefault();
358
- const frameId = activeFrameId(form);
359
- performSubmission(url.href, method, body, frameId);
420
+ // Resolve the target frame for the submit, same precedence as a link:
421
+ // an explicit `data-webjs-frame` on (or above) the form or its submitter
422
+ // wins, `_top` breaks out, absence falls back to the enclosing frame.
423
+ const frameId = resolveTargetFrameId(submitter || form);
424
+ performSubmission(url.href, method, body, frameId, form);
360
425
  }
361
426
 
362
427
  /**
@@ -451,6 +516,89 @@ function activeFrameId(el) {
451
516
  return null;
452
517
  }
453
518
 
519
+ /**
520
+ * The reserved `data-webjs-frame` token that forces a full-page navigation,
521
+ * breaking OUT of any enclosing frame (Turbo's `data-turbo-frame="_top"`).
522
+ * `resolveTargetFrameId` returns this sentinel; callers treat it exactly
523
+ * like "no frame" (a normal layout-marker / full-body swap), so a trigger
524
+ * physically nested in a frame escapes the frame swap. Distinct from `null`
525
+ * only inside `resolveTargetFrameId` (where `null` would otherwise fall back
526
+ * to the enclosing frame); both reach `performNavigation` as a frameless
527
+ * nav, so they behave identically downstream.
528
+ */
529
+ const FRAME_TOP = '_top';
530
+
531
+ /**
532
+ * Resolve which `<webjs-frame>` (if any) a trigger drives, honoring an
533
+ * explicit `data-webjs-frame` attribute before the closest-enclosing-frame
534
+ * default. Models Turbo's `data-turbo-frame` external targeting:
535
+ *
536
+ * - `data-webjs-frame="<id>"` on (or above) the trigger drives the frame
537
+ * with that id, resolved via `getElementById` in the CURRENT document.
538
+ * This lets an EXTERNAL link / form (a sidebar, a filter form) drive a
539
+ * content frame it is NOT DOM-nested in. If the id does not resolve to a
540
+ * live `<webjs-frame>`, we warn ONCE and fall back to a normal full nav
541
+ * (the fail-safe posture: never throw, never silently swap the wrong
542
+ * region).
543
+ * - `data-webjs-frame="_top"` forces a full-page navigation even when the
544
+ * trigger is inside a frame, returning `null` so the swap escapes to the
545
+ * layout-marker / full-body path.
546
+ * - No `data-webjs-frame` keeps today's behavior: the innermost enclosing
547
+ * frame via `activeFrameId`.
548
+ *
549
+ * Resolution precedence: explicit `data-webjs-frame` > closest enclosing
550
+ * frame. The attribute is read with `closest('[data-webjs-frame]')` so it
551
+ * may live on the trigger itself or any ancestor (e.g. a `<nav>` wrapping a
552
+ * set of links that all target one frame).
553
+ *
554
+ * @param {Element | null} trigger
555
+ * @returns {string | null} A frame id to swap, or null for a full nav.
556
+ */
557
+ function resolveTargetFrameId(trigger) {
558
+ if (!trigger) return null;
559
+ const carrier = trigger.closest && trigger.closest('[data-webjs-frame]');
560
+ const explicit = carrier
561
+ ? (/** @type {HTMLElement} */ (carrier).dataset
562
+ ? /** @type {HTMLElement} */ (carrier).dataset.webjsFrame
563
+ : carrier.getAttribute('data-webjs-frame'))
564
+ : null;
565
+ if (explicit != null && explicit !== '') {
566
+ if (explicit === FRAME_TOP) {
567
+ // Break out: a full-page nav, never a frame swap.
568
+ return null;
569
+ }
570
+ // External targeting by id. Resolve in the current document.
571
+ const el = typeof document !== 'undefined' ? document.getElementById(explicit) : null;
572
+ if (el && el.tagName && el.tagName.toLowerCase() === 'webjs-frame') {
573
+ return explicit;
574
+ }
575
+ // Unresolvable id: warn once, fall back to a normal full nav so the
576
+ // click still works rather than swapping nothing or the wrong region.
577
+ warnOnce(
578
+ `webjs:frame-unresolved:${explicit}`,
579
+ `[webjs] data-webjs-frame="${explicit}" did not match a live <webjs-frame id="${explicit}">; performing a normal navigation instead.`,
580
+ );
581
+ return null;
582
+ }
583
+ // No explicit target: today's closest-enclosing-frame default.
584
+ return activeFrameId(trigger);
585
+ }
586
+
587
+ /**
588
+ * Emit a `console.warn` at most once per `key` for the lifetime of the
589
+ * page, so a repeated misconfiguration (a stale `data-webjs-frame` clicked
590
+ * many times) does not spam the console.
591
+ *
592
+ * @type {Set<string>}
593
+ */
594
+ const warnedKeys = new Set();
595
+ /** @param {string} key @param {string} message */
596
+ function warnOnce(key, message) {
597
+ if (warnedKeys.has(key)) return;
598
+ warnedKeys.add(key);
599
+ if (typeof console !== 'undefined' && console.warn) console.warn(message);
600
+ }
601
+
454
602
  /* ====================================================================
455
603
  * Marker discovery (the heart of the partial-swap mechanism)
456
604
  * ==================================================================== */
@@ -693,12 +841,29 @@ async function performNavigation(href, isPopState, frameId) {
693
841
  * been server-side-mutated by this submission; refusing to clear would
694
842
  * serve stale content on subsequent back/forward.
695
843
  *
844
+ * Submission-state events + aria-busy: while the enhanced submission fetch
845
+ * is in flight the router sets `aria-busy="true"` on the FORM element and
846
+ * dispatches `webjs:submit-start` (detail `{ form, url }`); on EVERY settle
847
+ * path (success swap, validation re-render, navigation error, abort by a
848
+ * superseding submit/nav) it clears `aria-busy` and dispatches
849
+ * `webjs:submit-end` (detail `{ form, url, ok }`, `ok` = the submission was
850
+ * not an error outcome). The toggle uses the same nav-token guard the
851
+ * `<webjs-frame>` busy state uses (`formBusyTokens` / `markFormBusy` /
852
+ * `clearFormBusy`): a superseded submit's teardown never clears the busy
853
+ * state a NEWER submit already set, so a rapid re-submit stays busy until the
854
+ * live submission settles. The native `aria-busy` attribute on the form is
855
+ * the readable "is this form submitting" primitive (any component can read
856
+ * it); the events are the push-notification counterpart. Progressive
857
+ * enhancement: with JS off this whole code path is skipped and the form is a
858
+ * plain POST.
859
+ *
696
860
  * @param {string} href Absolute target URL.
697
861
  * @param {string} method Lowercased HTTP verb.
698
862
  * @param {FormData} body
699
863
  * @param {string | null} frameId
864
+ * @param {HTMLFormElement | null} [form] The submitted form, for busy + events.
700
865
  */
701
- async function performSubmission(href, method, body, frameId) {
866
+ async function performSubmission(href, method, body, frameId, form) {
702
867
  if (activeAbortController) activeAbortController.abort();
703
868
  activeAbortController = new AbortController();
704
869
  const signal = activeAbortController.signal;
@@ -729,8 +894,17 @@ async function performSubmission(href, method, body, frameId) {
729
894
 
730
895
  const optimisticState = applyOptimisticLoading();
731
896
 
897
+ // Submission-state lifecycle: mark the form busy + announce the start, then
898
+ // clear + announce the settle in the finally so EVERY exit (success,
899
+ // validation re-render, navigation error, abort by a superseding submit)
900
+ // balances the pair. `ok` is filled from the fetch outcome; an abort or a
901
+ // teardown that never reached the fetch settles ok:false. The token guard
902
+ // (markFormBusy/clearFormBusy) keeps a superseded submit's teardown from
903
+ // clearing the busy state a newer submit set.
904
+ const busyForm = form ? markFormBusy(form, myToken, url.href) : null;
905
+ let outcomeOk = false;
732
906
  try {
733
- await fetchAndApply(
907
+ const outcome = await fetchAndApply(
734
908
  url.href,
735
909
  frameId,
736
910
  /* recordHistory */ true,
@@ -740,6 +914,7 @@ async function performSubmission(href, method, body, frameId) {
740
914
  signal,
741
915
  myToken,
742
916
  );
917
+ outcomeOk = !!(outcome && outcome.ok);
743
918
  // Mutating submissions invalidate cached versions of other URLs -
744
919
  // do this *after* the response applies so the new page itself is
745
920
  // snapshotted on the next nav, not pre-emptively wiped. Clear the
@@ -750,6 +925,7 @@ async function performSubmission(href, method, body, frameId) {
750
925
  prefetchCache.clear();
751
926
  }
752
927
  } finally {
928
+ if (busyForm) clearFormBusy(busyForm, myToken, url.href, outcomeOk);
753
929
  if (navigatingFlagTimer) clearTimeout(navigatingFlagTimer);
754
930
  if (myToken === currentNavigationToken) {
755
931
  document.documentElement.removeAttribute('data-navigating');
@@ -1154,6 +1330,91 @@ function refreshPrefetchObservers() {
1154
1330
  }
1155
1331
  }
1156
1332
 
1333
+ /**
1334
+ * Render the minimal default in-place error surface into the deepest
1335
+ * shared layout children slot, so the SPA shell (outer chrome, nav,
1336
+ * scroll, focus, client state) survives a failed navigation instead of
1337
+ * being destroyed by a full reload. Returns true when it rendered into a
1338
+ * slot, false when no shared layout marker exists (a cross-document nav).
1339
+ * On a false return the caller may fall back to a hard load as a last
1340
+ * resort.
1341
+ *
1342
+ * @param {number | null} status HTTP status of the failed response, or null for a transport/parse failure.
1343
+ * @returns {boolean}
1344
+ */
1345
+ function renderInPlaceNavError(status) {
1346
+ if (typeof document === 'undefined' || !document.body) return false;
1347
+ const here = collectChildrenSlots(document.body);
1348
+ // The deepest slot is the same swap target a normal partial swap writes
1349
+ // to (longest path wins), so the outer chrome / nav are preserved.
1350
+ /** @type {{ start: Comment, end: Comment } | undefined} */
1351
+ let deepest;
1352
+ let deepestPathLen = -1;
1353
+ for (const [path, slot] of here) {
1354
+ if (path.length > deepestPathLen) { deepestPathLen = path.length; deepest = slot; }
1355
+ }
1356
+ if (!deepest) return false;
1357
+ const liveParent = deepest.start.parentNode;
1358
+ if (!liveParent || deepest.start.parentNode !== deepest.end.parentNode) return false;
1359
+
1360
+ const alert = document.createElement('div');
1361
+ alert.setAttribute('role', 'alert');
1362
+ alert.setAttribute('data-webjs-nav-error', '');
1363
+ const msg = status
1364
+ ? `This page could not be loaded. (status ${status})`
1365
+ : 'This page could not be loaded.';
1366
+ alert.textContent = msg;
1367
+
1368
+ // Replace the slot contents with the alert.
1369
+ const range = document.createRange();
1370
+ range.setStartAfter(deepest.start);
1371
+ range.setEndBefore(deepest.end);
1372
+ range.deleteContents();
1373
+ liveParent.insertBefore(alert, deepest.end);
1374
+ return true;
1375
+ }
1376
+
1377
+ /**
1378
+ * Shared fallback for a non-HTML error response or a transport/parse
1379
+ * failure during a client navigation. Dispatches a cancelable
1380
+ * `webjs:navigation-error` event on `document` (matching the
1381
+ * `webjs:frame-missing` / `webjs:prefetch` dispatch convention) so the
1382
+ * app can recover in place. If the app calls `preventDefault()`, the
1383
+ * router does NOTHING further and leaves the current page exactly as it
1384
+ * is. Otherwise it renders a minimal in-place `role="alert"` surface into
1385
+ * the deepest layout children slot (the SPA shell survives), and only
1386
+ * hard-navigates as a last resort when no in-place target exists.
1387
+ *
1388
+ * Never call this for an AbortError: a superseding nav is a normal
1389
+ * supersede, not an error, and must not surface a navigation-error.
1390
+ *
1391
+ * @param {string} href The URL that failed to navigate to.
1392
+ * @param {number | null} status HTTP status when a response arrived, else null.
1393
+ * @param {Error | null} error The Error for a transport/parse failure, else null.
1394
+ */
1395
+ function handleNavigationError(href, status, error) {
1396
+ const evt = new CustomEvent('webjs:navigation-error', {
1397
+ bubbles: true,
1398
+ cancelable: true,
1399
+ detail: { url: href, status: status == null ? null : status, error: error || null },
1400
+ });
1401
+ // Guard the dispatch: a throwing app listener must not wedge the nav engine.
1402
+ if (typeof document !== 'undefined') {
1403
+ try { document.dispatchEvent(evt); } catch { /* a buggy listener cannot break recovery */ }
1404
+ }
1405
+ // The app owns recovery: leave the page untouched (shell, scroll, focus,
1406
+ // client state all preserved). No reload, no render.
1407
+ if (evt.defaultPrevented) return;
1408
+ // Default: render a minimal in-place error surface so the SPA is not
1409
+ // destroyed and the user is not sent to a second failing round-trip.
1410
+ if (renderInPlaceNavError(status)) return;
1411
+ // Last resort only: no shared layout marker to render into (a genuine
1412
+ // cross-document nav). Fall back to a hard load so an unrecoverable case
1413
+ // is not a silent dead-end. This is the exception, reached only after
1414
+ // the event was not cancelled AND no in-place target exists.
1415
+ if (typeof location !== 'undefined') location.href = href;
1416
+ }
1417
+
1157
1418
  /**
1158
1419
  * Fetch the target URL and apply the swap.
1159
1420
  *
@@ -1163,16 +1424,35 @@ function refreshPrefetchObservers() {
1163
1424
  * @param {{ slot: { start: Comment, end: Comment }, oldChildren: Node[], token: number } | null} optimisticState
1164
1425
  * @param {string} [method] HTTP verb (uppercase). Default 'GET'.
1165
1426
  * @param {BodyInit | null} [body] Request body for non-GET methods.
1166
- * @param {AbortSignal | null} [signal] Abort signal - newer nav cancels this fetch.
1427
+ * @param {AbortSignal | null} [signal] Abort signal. A newer nav cancels this fetch.
1167
1428
  * @param {number} [token] Nav-token captured at the caller's entry; stale → skip apply.
1429
+ * @returns {Promise<{ ok: boolean, status: number | null, aborted: boolean }>}
1430
+ * The fetch outcome, so a caller (the form-submission busy/event lifecycle)
1431
+ * can report whether the submission settled as a success, an error, or an
1432
+ * abort. `ok` mirrors `response.ok` for an HTTP response (a 422 validation
1433
+ * re-render is `ok:false`), `false` for a transport/parse error, and `false`
1434
+ * for an abort (which also sets `aborted:true`). `status` is the HTTP status
1435
+ * or `null` when the request never produced one.
1168
1436
  */
1169
1437
  async function fetchAndApply(href, frameId, recordHistory, optimisticState, method, body, signal, token) {
1170
1438
  method = method || 'GET';
1171
1439
  const myToken = typeof token === 'number' ? token : currentNavigationToken;
1172
1440
  let html;
1173
1441
  let incomingBuild = null;
1442
+ /** @type {number | null} */
1443
+ let respStatus = null;
1444
+ /** @type {boolean} */
1445
+ let respOk = false;
1174
1446
  /** @type {string} */
1175
1447
  let finalUrl = href;
1448
+ // aria-busy lifecycle: when this nav targets a <webjs-frame>, mark the
1449
+ // live frame busy for the duration of its fetch+apply so assistive tech
1450
+ // can announce it and CSS can style `webjs-frame[aria-busy="true"]`. The
1451
+ // outer try/finally guarantees the busy state is cleared on EVERY exit
1452
+ // (success swap, frame-missing, an HTTP/transport error, an abort by a
1453
+ // newer nav), never leaving a frame stuck busy.
1454
+ const busyFrame = frameId ? markFrameBusy(frameId, myToken) : null;
1455
+ try {
1176
1456
  try {
1177
1457
  // Warm-cache fast path: a hover/focus/viewport prefetch may have
1178
1458
  // already fetched this exact page (same X-Webjs-Have shell). Consume
@@ -1185,11 +1465,21 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
1185
1465
  html = prefetched.html;
1186
1466
  incomingBuild = prefetched.build;
1187
1467
  finalUrl = prefetched.finalUrl;
1468
+ // A consumed prefetch is a successful 200 GET fragment.
1469
+ respStatus = 200;
1470
+ respOk = true;
1188
1471
  } else {
1189
1472
  const headers = { 'x-webjs-router': '1' };
1190
1473
  const have = buildHaveHeader();
1191
1474
  if (have) headers['x-webjs-have'] = have;
1192
1475
  if (frameId) headers['x-webjs-frame'] = frameId;
1476
+ // Content-negotiate a stream-action response on a write submission (a
1477
+ // non-GET body). The server returns the stream MIME only when this Accept
1478
+ // is present, so with JS off (no router, no Accept) the same form gets a
1479
+ // normal render/redirect: the grammar is additive and PE-safe (#248).
1480
+ if (body != null && method !== 'GET' && method !== 'HEAD') {
1481
+ headers['accept'] = STREAM_MIME + ', text/html';
1482
+ }
1193
1483
 
1194
1484
  /** @type {RequestInit} */
1195
1485
  const init = { method, headers, credentials: 'same-origin' };
@@ -1197,8 +1487,26 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
1197
1487
  if (body != null && method !== 'GET' && method !== 'HEAD') init.body = body;
1198
1488
 
1199
1489
  const resp = await fetch(href, init);
1490
+ respStatus = resp.status;
1491
+ respOk = resp.ok;
1200
1492
  const ctype = resp.headers.get('content-type') || '';
1201
1493
  const isHTML = /^text\/html\b/i.test(ctype);
1494
+ const isStream = ctype.toLowerCase().indexOf(STREAM_MIME) === 0;
1495
+ // Stream-action response (#248): the body is `<webjs-stream>` elements
1496
+ // applied surgically to the live DOM, NOT a region swap. Apply them and
1497
+ // return; do not parse the body as a page document (it has no shell). A
1498
+ // stream body of any status is fine. This runs BEFORE the !isHTML branch
1499
+ // so the non-text/html stream MIME is not treated as a navigation error.
1500
+ if (isStream) {
1501
+ const text = await resp.text();
1502
+ if (myToken === currentNavigationToken) {
1503
+ // Roll back any optimistic loading skeleton: a stream response patches
1504
+ // the page in place, it does not swap the region the skeleton covered.
1505
+ restoreOptimistic(optimisticState);
1506
+ renderStream(text);
1507
+ }
1508
+ return { ok: respOk, status: respStatus, aborted: false };
1509
+ }
1202
1510
  // Server-side redirect (PRG, auth-gate, etc.): fetch followed it
1203
1511
  // automatically. Record the FINAL URL in history, not the
1204
1512
  // originally-requested one, so back/forward + bookmarking work.
@@ -1213,16 +1521,27 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
1213
1521
  if (myToken === currentNavigationToken && recordHistory) {
1214
1522
  history.pushState(null, '', finalUrl);
1215
1523
  }
1216
- return;
1524
+ return { ok: respOk, status: respStatus, aborted: false };
1217
1525
  }
1218
1526
 
1219
- // Non-HTML response (JSON error, file download, opaque): let the
1220
- // browser handle it. Same for non-OK responses that aren't HTML
1221
- // (a 500 returning `{"error": "..."}` shouldn't be rendered as a
1222
- // page).
1527
+ // Non-HTML response (JSON error, file download, opaque): can't be
1528
+ // rendered as a page (a 500 returning `{"error": "..."}` is not an
1529
+ // HTML page). Instead of abandoning the SPA with a full reload (which
1530
+ // discards the partial-swap shell, scroll, and in-flight state, and
1531
+ // eats a second round-trip that may itself fail), dispatch a
1532
+ // cancelable `webjs:navigation-error` so the app can recover in place;
1533
+ // by default render a minimal in-place error surface. The adjacent
1534
+ // HTML-status branch below already renders 4xx/5xx HTML bodies in
1535
+ // place; this closes the same gap for a non-HTML error body.
1223
1536
  if (!isHTML) {
1224
- if (myToken === currentNavigationToken) location.href = href;
1225
- return;
1537
+ if (myToken === currentNavigationToken) {
1538
+ // Roll back any optimistic loading skeleton FIRST, so a
1539
+ // preventDefault()-ing app sees the page exactly as it was (the catch
1540
+ // block below does the same for a transport failure).
1541
+ restoreOptimistic(optimisticState);
1542
+ handleNavigationError(href, resp.status, null);
1543
+ }
1544
+ return { ok: false, status: respStatus, aborted: false };
1226
1545
  }
1227
1546
 
1228
1547
  // HTML body of ANY status: 2xx, 4xx validation errors, 5xx error
@@ -1240,22 +1559,31 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
1240
1559
  html = await resp.text();
1241
1560
  }
1242
1561
  } catch (err) {
1243
- // Aborted by a newer navigation: let it run, don't fall back.
1244
- if (err && /** @type any */ (err).name === 'AbortError') return;
1245
- // Stale (a newer nav started before we got the network error) -
1246
- // the newer nav owns the page now; don't clobber it.
1247
- if (myToken !== currentNavigationToken) return;
1562
+ // Aborted by a newer navigation: let it run, don't fall back. An
1563
+ // AbortError is a normal supersede, NOT a navigation error, so it must
1564
+ // NEVER dispatch webjs:navigation-error (the key no-false-positive
1565
+ // line).
1566
+ if (err && /** @type any */ (err).name === 'AbortError') return { ok: false, status: null, aborted: true };
1567
+ // Stale (a newer nav started before we got the network error): the
1568
+ // newer nav owns the page now, so don't clobber it.
1569
+ if (myToken !== currentNavigationToken) return { ok: false, status: null, aborted: true };
1248
1570
  restoreOptimistic(optimisticState);
1249
- location.href = href;
1250
- return;
1571
+ // Transport/parse failure (fetch rejected, e.g. offline / DNS / TLS).
1572
+ // Surface a navigation-error so the app can recover in place instead
1573
+ // of a destructive full reload.
1574
+ handleNavigationError(href, null, err instanceof Error ? err : new Error(String(err)));
1575
+ return { ok: false, status: null, aborted: false };
1251
1576
  }
1252
1577
 
1253
1578
  // A newer navigation started while we awaited the response body -
1254
1579
  // bail before we overwrite its work.
1255
- if (myToken !== currentNavigationToken) return;
1580
+ if (myToken !== currentNavigationToken) return { ok: false, status: respStatus, aborted: true };
1256
1581
 
1257
1582
  const doc = parseHTML(html);
1258
- if (!doc) { location.href = href; return; }
1583
+ // The body claimed text/html but didn't parse into a document (a
1584
+ // malformed/empty HTML body). Surface a navigation-error so the app can
1585
+ // recover in place rather than a destructive full reload.
1586
+ if (!doc) { restoreOptimistic(optimisticState); handleNavigationError(href, null, new Error('navigation response did not parse as HTML')); return { ok: false, status: respStatus, aborted: false }; }
1259
1587
 
1260
1588
  applySwap(doc, frameId, false, finalUrl, incomingBuild);
1261
1589
 
@@ -1283,6 +1611,164 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
1283
1611
  }
1284
1612
 
1285
1613
  document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url: finalUrl, frameId, from: 'navigate' } }));
1614
+ return { ok: respOk, status: respStatus, aborted: false };
1615
+ } finally {
1616
+ // Clear the frame's busy state on every exit path (the early returns
1617
+ // above all unwind through here). No-op when this was not a frame nav.
1618
+ if (busyFrame) clearFrameBusy(busyFrame, myToken);
1619
+ }
1620
+ }
1621
+
1622
+ /**
1623
+ * The nav token that currently OWNS each frame's busy state. Under two rapid
1624
+ * frame navs the router aborts the first; its `finally` would otherwise clear
1625
+ * `aria-busy` that the SECOND nav already re-set, leaving the frame falsely
1626
+ * idle while still loading (and an unbalanced busy-event stream). A clear only
1627
+ * fires when its token still owns the frame, so the superseding nav's busy
1628
+ * state survives the aborted nav's teardown.
1629
+ *
1630
+ * @type {WeakMap<Element, number>}
1631
+ */
1632
+ const frameBusyTokens = new WeakMap();
1633
+
1634
+ /**
1635
+ * Set `aria-busy="true"` on the live `<webjs-frame id>` element and announce
1636
+ * the start of its load with a bubbling `webjs:frame-busy` event (detail
1637
+ * `{ frameId, busy: true }`), mirroring Turbo's `frame.markAsBusy`. Stamps the
1638
+ * nav `token` as the frame's busy owner (see `frameBusyTokens`). Returns the
1639
+ * resolved frame element so `clearFrameBusy` can target the SAME node even if
1640
+ * the swap later replaces the frame's id lookup (the element identity is stable
1641
+ * across a child-only frame swap). Returns null when the frame is not in the
1642
+ * live DOM (e.g. a stale external `data-webjs-frame` that slipped the
1643
+ * resolve-time check), so nothing to mark.
1644
+ *
1645
+ * @param {string} frameId
1646
+ * @param {number} token
1647
+ * @returns {Element | null}
1648
+ */
1649
+ function markFrameBusy(frameId, token) {
1650
+ if (typeof document === 'undefined') return null;
1651
+ let frame = null;
1652
+ try {
1653
+ frame = document.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
1654
+ } catch { frame = document.getElementById(frameId); }
1655
+ if (!frame) return null;
1656
+ // Dispatch the `true` edge only on a real idle -> busy transition, so a nav
1657
+ // that supersedes an in-flight one (frame already busy) does not emit a
1658
+ // redundant `true`. The token always advances to the newest owner.
1659
+ const wasBusy = frameBusyTokens.has(frame);
1660
+ frameBusyTokens.set(frame, token);
1661
+ frame.setAttribute('aria-busy', 'true');
1662
+ if (!wasBusy) {
1663
+ frame.dispatchEvent(new CustomEvent('webjs:frame-busy', {
1664
+ bubbles: true,
1665
+ detail: { frameId, busy: true },
1666
+ }));
1667
+ }
1668
+ return frame;
1669
+ }
1670
+
1671
+ /**
1672
+ * Clear the busy state set by `markFrameBusy`: set `aria-busy="false"` and
1673
+ * dispatch the matching `webjs:frame-busy` (detail `{ frameId, busy: false }`)
1674
+ * so app code sees a symmetric start/finish pair. Mirrors Turbo's
1675
+ * `frame.clearBusyState`. Operates on the element captured at start, so an
1676
+ * abort / error clears the same node the start marked. A clear whose token no
1677
+ * longer owns the frame (a newer nav re-set busy) is a stale teardown from a
1678
+ * superseded nav and is skipped, so the live nav stays busy.
1679
+ *
1680
+ * @param {Element} frame
1681
+ * @param {number} token
1682
+ */
1683
+ function clearFrameBusy(frame, token) {
1684
+ if (frameBusyTokens.get(frame) !== token) return;
1685
+ frameBusyTokens.delete(frame);
1686
+ frame.setAttribute('aria-busy', 'false');
1687
+ const frameId = frame.id || null;
1688
+ frame.dispatchEvent(new CustomEvent('webjs:frame-busy', {
1689
+ bubbles: true,
1690
+ detail: { frameId, busy: false },
1691
+ }));
1692
+ }
1693
+
1694
+ /**
1695
+ * The nav token that currently OWNS each form's submission-busy state. Same
1696
+ * role as `frameBusyTokens` for frames: under two rapid submits the router
1697
+ * aborts the first, and its `finally` would otherwise clear `aria-busy` /
1698
+ * dispatch `webjs:submit-end` for a submission the SECOND submit already
1699
+ * re-set, leaving the form falsely idle while still submitting (and an
1700
+ * unbalanced start/end event stream). A clear only fires when its token still
1701
+ * owns the form, so the superseding submit's busy state survives the aborted
1702
+ * submit's teardown.
1703
+ *
1704
+ * @type {WeakMap<Element, number>}
1705
+ */
1706
+ const formBusyTokens = new WeakMap();
1707
+
1708
+ /**
1709
+ * Mark a submitting `<form>` busy: set the native `aria-busy="true"` (the
1710
+ * readable "is this form submitting" primitive any component can poll) and
1711
+ * dispatch a bubbling `webjs:submit-start` event (detail `{ form, url }`).
1712
+ * Stamps `token` as the form's busy owner (see `formBusyTokens`). The `true`
1713
+ * edge fires only on a real idle -> busy transition, so a submit that
1714
+ * supersedes an in-flight one (form already busy) does not emit a redundant
1715
+ * start; the token always advances to the newest owner. Returns the form so
1716
+ * `clearFormBusy` targets the same node.
1717
+ *
1718
+ * @param {HTMLFormElement} form
1719
+ * @param {number} token
1720
+ * @param {string} url Resolved action URL the submission targets.
1721
+ * @returns {HTMLFormElement}
1722
+ */
1723
+ function markFormBusy(form, token, url) {
1724
+ const wasBusy = formBusyTokens.has(form);
1725
+ formBusyTokens.set(form, token);
1726
+ form.setAttribute('aria-busy', 'true');
1727
+ if (!wasBusy) {
1728
+ form.dispatchEvent(new CustomEvent('webjs:submit-start', {
1729
+ bubbles: true,
1730
+ detail: { form, url },
1731
+ }));
1732
+ }
1733
+ return form;
1734
+ }
1735
+
1736
+ /**
1737
+ * Clear the busy state set by `markFormBusy`: set `aria-busy="false"` and
1738
+ * dispatch the matching `webjs:submit-end` (detail `{ form, url, ok }`, `ok` =
1739
+ * the submission settled as a success / not an error outcome) so app code sees
1740
+ * a symmetric start/finish pair. Operates on the element captured at start, so
1741
+ * an abort / error clears the same node the start marked. A clear whose token
1742
+ * no longer owns the form (a newer submit re-set busy) is a stale teardown
1743
+ * from a superseded submit and is skipped, so the live submit stays busy.
1744
+ *
1745
+ * @param {HTMLFormElement} form
1746
+ * @param {number} token
1747
+ * @param {string} url
1748
+ * @param {boolean} ok
1749
+ */
1750
+ function clearFormBusy(form, token, url, ok) {
1751
+ if (formBusyTokens.get(form) !== token) return;
1752
+ formBusyTokens.delete(form);
1753
+ form.setAttribute('aria-busy', 'false');
1754
+ const evt = new CustomEvent('webjs:submit-end', {
1755
+ bubbles: true,
1756
+ detail: { form, url, ok: !!ok },
1757
+ });
1758
+ // A successful submission swaps the page in place, and a full-body swap
1759
+ // (or a swap whose region contained the form) detaches the form before
1760
+ // this teardown runs. A bubbling event dispatched on a DISCONNECTED node
1761
+ // never reaches a `document`-level listener, so a synchronous swap (the
1762
+ // no-view-transition default) would silently drop `submit-end`. Dispatch
1763
+ // on `document` when the form is no longer connected so the symmetric
1764
+ // start/end pair always lands, regardless of swap timing.
1765
+ if (form.isConnected) {
1766
+ form.dispatchEvent(evt);
1767
+ } else if (typeof document !== 'undefined') {
1768
+ document.dispatchEvent(evt);
1769
+ } else {
1770
+ form.dispatchEvent(evt);
1771
+ }
1286
1772
  }
1287
1773
 
1288
1774
  /**
@@ -1349,6 +1835,207 @@ function trackedReloadSignature(root) {
1349
1835
  return sig;
1350
1836
  }
1351
1837
 
1838
+ /* ====================================================================
1839
+ * View Transitions (opt-in) + permanent-element persistence
1840
+ * ==================================================================== */
1841
+
1842
+ /**
1843
+ * Whether the current page opts into the native View Transitions API for
1844
+ * client-router swaps. OFF by default (no animation surprise, no
1845
+ * regression for browsers without the API): a transition is purely
1846
+ * opt-in via a `<meta name="view-transition" content="same-origin">` in
1847
+ * the document head, mirroring Turbo's `<meta name="view-transition">`
1848
+ * convention. The accepted opt-in value is `same-origin` (every
1849
+ * client-router swap is same-origin by construction, so it reads as "yes,
1850
+ * animate these in-app navigations"). Any other value, or the meta being
1851
+ * absent, keeps transitions off.
1852
+ *
1853
+ * Re-read per navigation rather than cached: the meta can be added or
1854
+ * removed by a swap (the head merge brings in the new page's head), so a
1855
+ * page can turn transitions on or off as the user navigates.
1856
+ *
1857
+ * @returns {boolean}
1858
+ */
1859
+ function viewTransitionsEnabled() {
1860
+ if (typeof document === 'undefined') return false;
1861
+ const meta = document.querySelector('meta[name="view-transition"]');
1862
+ if (!meta) return false;
1863
+ const content = (meta.getAttribute('content') || '').trim().toLowerCase();
1864
+ return content === 'same-origin';
1865
+ }
1866
+
1867
+ /**
1868
+ * Run a synchronous DOM-mutation thunk, wrapping it in
1869
+ * `document.startViewTransition()` when the page has opted in AND the
1870
+ * browser supports the API. Otherwise the thunk runs synchronously,
1871
+ * byte-identical to the pre-View-Transitions behaviour (no flash, no
1872
+ * regression). The thunk is the SAME swap code in both branches; the
1873
+ * transition only captures the before/after around the mutation (the
1874
+ * fetch already happened, so it is never inside the callback).
1875
+ *
1876
+ * @param {() => void} thunk The synchronous DOM swap to perform.
1877
+ * @param {() => void} [afterFinished] Optional post-transition work
1878
+ * (e.g. re-upgrade custom elements) run when the transition settles; for
1879
+ * the synchronous fallback it runs immediately after the thunk.
1880
+ */
1881
+ function runWithTransition(thunk, afterFinished) {
1882
+ const start = typeof document !== 'undefined'
1883
+ ? /** @type any */ (document).startViewTransition
1884
+ : undefined;
1885
+ if (viewTransitionsEnabled() && typeof start === 'function') {
1886
+ const t = start.call(document, thunk);
1887
+ if (t && t.finished && typeof t.finished.then === 'function') {
1888
+ t.finished.then(() => { if (afterFinished) afterFinished(); }).catch(() => {});
1889
+ } else if (afterFinished) {
1890
+ afterFinished();
1891
+ }
1892
+ return;
1893
+ }
1894
+ thunk();
1895
+ if (afterFinished) afterFinished();
1896
+ }
1897
+
1898
+ /**
1899
+ * Persist `data-webjs-permanent` elements across a swap by NODE IDENTITY.
1900
+ *
1901
+ * Mirrors Turbo's permanent-element behaviour: an element the author
1902
+ * marks `data-webjs-permanent` (and which carries an `id`) survives a
1903
+ * destructive swap as the SAME live DOM node, so a playing
1904
+ * `<audio>` / `<video>`, a live widget, an open menu, or any element with
1905
+ * accumulated JS state keeps running across the navigation instead of
1906
+ * being destroyed and re-created from the incoming HTML.
1907
+ *
1908
+ * The mechanism runs BEFORE the destructive `replaceChildren` / range
1909
+ * delete: for each `[data-webjs-permanent][id]` in the CURRENT subtree, if
1910
+ * the INCOMING tree has a matching `#id`, the live current node is MOVED
1911
+ * into the incoming tree's position (replacing the incoming placeholder).
1912
+ * The subsequent swap then ADOPTS the live node (it is already part of the
1913
+ * incoming tree) rather than destroying the current one. The keyed
1914
+ * reconciler matches it by id afterwards and leaves it in place.
1915
+ *
1916
+ * Guards (correctness):
1917
+ * - both-exist: only regraft an id present in BOTH the current and
1918
+ * incoming subtree. An id in the current but NOT the incoming is being
1919
+ * removed; leave it (do not force it to persist).
1920
+ * - current-is-permanent: only move when the CURRENT node actually
1921
+ * carries `data-webjs-permanent` (an incoming `#id` that resolves to a
1922
+ * non-permanent current element is left untouched).
1923
+ * - boundary-respecting: the live node is placed exactly where the
1924
+ * incoming document puts it, so it never escapes a frame/region.
1925
+ *
1926
+ * @param {ParentNode} currentRoot The live subtree being swapped out.
1927
+ * @param {ParentNode} incomingRoot The incoming subtree being swapped in.
1928
+ */
1929
+ function regraftPermanentElements(currentRoot, incomingRoot) {
1930
+ if (!currentRoot || !incomingRoot) return;
1931
+ if (typeof currentRoot.querySelectorAll !== 'function') return;
1932
+ const permanents = currentRoot.querySelectorAll('[data-webjs-permanent][id]');
1933
+ for (const live of permanents) {
1934
+ const id = live.id;
1935
+ if (!id) continue;
1936
+ // both-exist guard: the incoming subtree must carry a matching #id.
1937
+ let placeholder = null;
1938
+ try {
1939
+ placeholder = incomingRoot.querySelector(`#${CSS.escape(id)}`);
1940
+ } catch { placeholder = null; }
1941
+ if (!placeholder) continue;
1942
+ // current-is-permanent guard is implicit in the selector above, but
1943
+ // re-assert defensively (the live node is the one we move).
1944
+ if (!live.hasAttribute || !live.hasAttribute('data-webjs-permanent')) continue;
1945
+ const parent = placeholder.parentNode;
1946
+ if (!parent) continue;
1947
+ // Move the LIVE node into the incoming tree's position, replacing the
1948
+ // incoming placeholder. The swap then adopts the live node.
1949
+ if (placeholder === live) continue;
1950
+ parent.replaceChild(live, placeholder);
1951
+ }
1952
+ }
1953
+
1954
+ /**
1955
+ * Permanent-element regraft for the marker-range path, where the two
1956
+ * sides are ARRAYS of sibling nodes (the live slice between markers, and
1957
+ * the imported-but-detached incoming slice) rather than single roots.
1958
+ *
1959
+ * For each `[data-webjs-permanent][id]` reachable from the LIVE slice, if
1960
+ * a matching `#id` exists anywhere in the INCOMING slice, replace the
1961
+ * incoming (freshly-imported) copy with the LIVE node so the reconciler
1962
+ * adopts the live node by identity. Searches both top-level slice members
1963
+ * and their descendants. The same both-exist + current-is-permanent
1964
+ * guards as `regraftPermanentElements` apply.
1965
+ *
1966
+ * @param {Node[]} liveSlice
1967
+ * @param {Node[]} incomingSlice
1968
+ */
1969
+ function regraftPermanentInSlice(liveSlice, incomingSlice) {
1970
+ /** @type {Element[]} */
1971
+ const livePermanents = [];
1972
+ for (const n of liveSlice) {
1973
+ if (n.nodeType !== 1) continue;
1974
+ const el = /** @type {Element} */ (n);
1975
+ if (el.hasAttribute && el.hasAttribute('data-webjs-permanent') && el.id) {
1976
+ livePermanents.push(el);
1977
+ }
1978
+ if (typeof el.querySelectorAll === 'function') {
1979
+ for (const d of el.querySelectorAll('[data-webjs-permanent][id]')) livePermanents.push(d);
1980
+ }
1981
+ }
1982
+ if (!livePermanents.length) return;
1983
+
1984
+ for (const live of livePermanents) {
1985
+ const id = live.id;
1986
+ if (!id) continue;
1987
+ const placeholder = findInSlice(incomingSlice, id);
1988
+ if (!placeholder) continue; // both-exist guard
1989
+ if (placeholder === live) continue;
1990
+ const parent = placeholder.parentNode;
1991
+ if (parent) {
1992
+ parent.replaceChild(live, placeholder);
1993
+ } else {
1994
+ // Placeholder is a top-level slice member with no parent (detached):
1995
+ // replace it in the incomingSlice array so the reconciler inserts the
1996
+ // live node in that position.
1997
+ const idx = incomingSlice.indexOf(placeholder);
1998
+ if (idx !== -1) incomingSlice[idx] = live;
1999
+ }
2000
+ }
2001
+ }
2002
+
2003
+ /**
2004
+ * Find an element with `#id` within an array of (possibly detached)
2005
+ * sibling nodes, searching each member and its descendants.
2006
+ *
2007
+ * @param {Node[]} slice
2008
+ * @param {string} id
2009
+ * @returns {Element | null}
2010
+ */
2011
+ function findInSlice(slice, id) {
2012
+ for (const n of slice) {
2013
+ if (n.nodeType !== 1) continue;
2014
+ const el = /** @type {Element} */ (n);
2015
+ if (el.id === id) return el;
2016
+ if (typeof el.querySelector === 'function') {
2017
+ let match = null;
2018
+ try { match = el.querySelector(`#${CSS.escape(id)}`); } catch { match = null; }
2019
+ if (match) return match;
2020
+ }
2021
+ }
2022
+ return null;
2023
+ }
2024
+
2025
+ /**
2026
+ * Re-upgrade custom elements between a marker pair after a transitioned
2027
+ * swap settles. The View Transitions API snapshots and replaces DOM, so
2028
+ * elements can need a re-upgrade once the animation finishes.
2029
+ *
2030
+ * @param {{ start: Comment, end: Comment } | undefined} range
2031
+ */
2032
+ function upgradeCustomElementsInRange(range) {
2033
+ if (!range || !range.start) return;
2034
+ for (let n = range.start.nextSibling; n && n !== range.end; n = n.nextSibling) {
2035
+ if (n.nodeType === 1) upgradeCustomElements(/** @type {Element} */ (n));
2036
+ }
2037
+ }
2038
+
1352
2039
  function applySwap(doc, frameId, revalidating, href, incomingBuild) {
1353
2040
  // Any clean swap (no importmap mismatch, including cache restores
1354
2041
  // and frame swaps where we don't even run the mismatch check) is a
@@ -1466,11 +2153,17 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild) {
1466
2153
  // (Tailwind CSS injection, etc.) that the outer layout's scripts
1467
2154
  // already produced.
1468
2155
  addNewHeadElements(doc.head);
1469
- diffChildren(target, source);
1470
- reactivateScripts(target);
1471
- upgradeCustomElements(target);
2156
+ // `diffChildren` -> `reconcileChildren` regrafts permanent elements
2157
+ // by node identity (it imports the incoming children first, then
2158
+ // swaps the live permanent node into the imported tree), so the live
2159
+ // `<audio>`/widget keeps running across the frame swap.
2160
+ runWithTransition(() => {
2161
+ diffChildren(target, source);
2162
+ reactivateScripts(target);
2163
+ upgradeCustomElements(target);
2164
+ blurOutgoingFocus();
2165
+ }, () => upgradeCustomElements(target));
1472
2166
  forwardSuspenseResolvers(doc.body);
1473
- blurOutgoingFocus();
1474
2167
  return;
1475
2168
  }
1476
2169
  // The response did not carry the requested frame (source null), or the
@@ -1501,15 +2194,22 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild) {
1501
2194
  // ADD-ONLY head merge for the same reason: outer layout stays
1502
2195
  // mounted, its head-bound runtime state must not be invalidated.
1503
2196
  addNewHeadElements(doc.head);
1504
- swapMarkerRange(here.get(sharedPath), there.get(sharedPath), doc);
2197
+ runWithTransition(() => {
2198
+ swapMarkerRange(here.get(sharedPath), there.get(sharedPath), doc);
2199
+ blurOutgoingFocus();
2200
+ }, () => upgradeCustomElementsInRange(here.get(sharedPath)));
1505
2201
  forwardSuspenseResolvers(doc.body);
1506
- blurOutgoingFocus();
1507
2202
  return;
1508
2203
  }
1509
2204
 
1510
2205
  // 3. Full body swap fallback. Use full head merge: different root
1511
2206
  // layout, so stale head elements should be removed.
1512
2207
  mergeHead(doc.head);
2208
+ // Persist permanent elements by node identity across the full-body
2209
+ // swap: move each live [data-webjs-permanent][id] node into the matching
2210
+ // position in the incoming body BEFORE replaceChildren reads it, so the
2211
+ // live node is adopted rather than destroyed.
2212
+ regraftPermanentElements(document.body, doc.body);
1513
2213
  const newChildren = [...doc.body.childNodes];
1514
2214
  const doSwap = () => {
1515
2215
  document.body.replaceChildren(...newChildren);
@@ -1517,12 +2217,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild) {
1517
2217
  upgradeCustomElements(document.body);
1518
2218
  blurOutgoingFocus();
1519
2219
  };
1520
- if (/** @type any */ (document).startViewTransition) {
1521
- const t = /** @type any */ (document).startViewTransition(doSwap);
1522
- t.finished.then(() => upgradeCustomElements(document.body)).catch(() => {});
1523
- } else {
1524
- doSwap();
1525
- }
2220
+ runWithTransition(doSwap, () => upgradeCustomElements(document.body));
1526
2221
  }
1527
2222
 
1528
2223
  /**
@@ -1588,6 +2283,12 @@ function swapMarkerRange(target, source, _doc) {
1588
2283
  incomingSlice.push(document.importNode(n, true));
1589
2284
  }
1590
2285
 
2286
+ // Persist permanent elements by node identity: regraft each live
2287
+ // [data-webjs-permanent][id] node into the matching position in the
2288
+ // imported incoming slice, replacing the freshly-imported copy, so the
2289
+ // keyed reconciler adopts the live node instead of destroying it.
2290
+ regraftPermanentInSlice(liveSlice, incomingSlice);
2291
+
1591
2292
  // Run the keyed diff.
1592
2293
  reconcileSiblings(liveParent, target.start, target.end, liveSlice, incomingSlice);
1593
2294
 
@@ -1675,6 +2376,12 @@ function reconcileSiblings(parent, startMarker, endMarker, live, incoming) {
1675
2376
  * @param {Element} src The element to copy from (incoming HTML).
1676
2377
  */
1677
2378
  function diffElementInPlace(dst, src) {
2379
+ // A regrafted `data-webjs-permanent` node is the SAME node on both
2380
+ // sides (the live node was moved into the incoming tree). Diffing it
2381
+ // against itself would recurse into its own children and re-import
2382
+ // them; instead leave it exactly as the user left it (that is the whole
2383
+ // point of permanence).
2384
+ if (dst === src) return;
1678
2385
  if (dst.tagName !== src.tagName) {
1679
2386
  dst.replaceWith(src);
1680
2387
  return;
@@ -1713,6 +2420,15 @@ function reconcileChildren(dst, src) {
1713
2420
  const liveChildren = [...dst.childNodes];
1714
2421
  const incomingChildren = [...src.childNodes].map((n) => document.importNode(n, true));
1715
2422
 
2423
+ // Persist `data-webjs-permanent` elements by node identity: regraft each
2424
+ // live permanent node into the matching position in the freshly-imported
2425
+ // incoming children (replacing the imported copy), so the keyed match
2426
+ // below adopts the LIVE node and the reconciler never recreates it. This
2427
+ // is the in-region (frame + nested) counterpart of the full-body and
2428
+ // marker-range regrafts; running it here covers permanents nested below
2429
+ // the top keyed level too.
2430
+ regraftPermanentInSlice(liveChildren, incomingChildren);
2431
+
1716
2432
  // Build keyed map of live children for reuse.
1717
2433
  /** @type {Map<string, Element>} */
1718
2434
  const keyedLive = new Map();
@@ -2133,6 +2849,12 @@ export {
2133
2849
  reactivateScripts as _reactivateScripts,
2134
2850
  findAnchorInPath as _findAnchorInPath,
2135
2851
  activeFrameId as _activeFrameId,
2852
+ resolveTargetFrameId as _resolveTargetFrameId,
2853
+ FRAME_TOP as _FRAME_TOP,
2854
+ markFrameBusy as _markFrameBusy,
2855
+ clearFrameBusy as _clearFrameBusy,
2856
+ markFormBusy as _markFormBusy,
2857
+ clearFormBusy as _clearFormBusy,
2136
2858
  collectChildrenSlots as _collectChildrenSlots,
2137
2859
  longestSharedPath as _longestSharedPath,
2138
2860
  keyOf as _keyOf,
@@ -2148,6 +2870,10 @@ export {
2148
2870
  buildSubmitFormData as _buildSubmitFormData,
2149
2871
  restoreOptimistic as _restoreOptimistic,
2150
2872
  eligibleAnchorHref as _eligibleAnchorHref,
2873
+ viewTransitionsEnabled as _viewTransitionsEnabled,
2874
+ runWithTransition as _runWithTransition,
2875
+ regraftPermanentElements as _regraftPermanentElements,
2876
+ regraftPermanentInSlice as _regraftPermanentInSlice,
2151
2877
  prefetchSuppressed as _prefetchSuppressed,
2152
2878
  prefetchMode as _prefetchMode,
2153
2879
  prefetch as _prefetch,