@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.
- package/dist/webjs-core-browser.js +3 -3
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-lazy-loader.js +1 -1
- package/dist/webjs-core-lazy-loader.js.map +2 -2
- package/dist/webjs-core-testing.js +23 -2
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +3 -3
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +4 -0
- package/index.d.ts +9 -0
- package/index.js +4 -0
- package/package.json +1 -1
- package/src/lazy-loader.js +56 -0
- package/src/metadata.d.ts +25 -0
- package/src/optimistic.js +64 -0
- package/src/render-client.js +176 -25
- package/src/router-client.js +763 -37
- package/src/testing.js +145 -3
- package/src/webjs-frame.js +154 -6
- package/src/webjs-stream.js +170 -0
package/src/router-client.js
CHANGED
|
@@ -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
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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
|
|
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):
|
|
1220
|
-
//
|
|
1221
|
-
//
|
|
1222
|
-
//
|
|
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)
|
|
1225
|
-
|
|
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
|
-
|
|
1245
|
-
//
|
|
1246
|
-
//
|
|
1247
|
-
if (
|
|
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
|
-
|
|
1250
|
-
|
|
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
|
-
|
|
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
|
|
1470
|
-
|
|
1471
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|