@webjsdev/core 0.7.46 → 0.7.47

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.
@@ -14,6 +14,10 @@ import './webjs-suspense.js';
14
14
  // Ingest SSR action seeds (#472) from an incoming soft-nav document before its
15
15
  // components hydrate, so a navigated async component resolves from the seed.
16
16
  import { scanSeeds } from './action-seed-client.js';
17
+ // A form-bound action's `invalidates` tags (#1155) ride the submission response
18
+ // the same way an RPC mutation's do, so a cached GET action is not served stale
19
+ // after a no-JS-shaped write went through the router.
20
+ import { markStale, parseTagHeader } from './action-cache-client.js';
17
21
  // Slot-runtime constants for re-projecting page-authored slotted content of a
18
22
  // reused hydrated light-DOM component across a soft nav (#908).
19
23
  import {
@@ -341,6 +345,11 @@ export function enableClientRouter() {
341
345
  // Seed the "current page" tracker so the first navigation can
342
346
  // snapshot the page the user is leaving.
343
347
  if (typeof location !== 'undefined') currentPageUrl = location.href;
348
+ // Last, once the listeners are on: report whether the load that got us here
349
+ // was a same-origin navigation the router never saw (#1118). Running it after
350
+ // the listeners means a throw inside a diagnostic can never leave the router
351
+ // half-installed.
352
+ reportPreBootNavigation();
344
353
  }
345
354
 
346
355
  /** Disable the client router. */
@@ -373,7 +382,9 @@ export function disableClientRouter() {
373
382
  export async function navigate(url, opts) {
374
383
  const target = new URL(url, location.href);
375
384
  if (target.origin !== location.origin) {
376
- location.href = url;
385
+ // Cross-origin: an intentional full-page nav, not a degradation, but it
386
+ // ends the session in a test just the same, so it rides the same seam.
387
+ hardNavigate(url);
377
388
  return;
378
389
  }
379
390
  await performNavigation(target.href, opts?.replace ?? false, null);
@@ -471,6 +482,28 @@ export function revalidate(url) {
471
482
  */
472
483
  const NON_HTML_EXTENSIONS = /\.(?:pdf|zip|tar|gz|7z|rar|dmg|exe|msi|deb|rpm|apk|ipa|xlsx?|docx?|pptx?|csv|odt|ods|odp|rtf|epub|mobi|xml|json|rss|atom|txt|md|wasm|mp3|mp4|mov|avi|webm|ogg|flac|wav|m4a|m4v|mkv|png|jpe?g|gif|webp|avif|bmp|ico|svg|tiff?|heic)$/i;
473
484
 
485
+ /**
486
+ * The one place the router hands a navigation back to the browser.
487
+ *
488
+ * Every hard navigation the router performs goes through here rather than
489
+ * assigning `location.href` inline, so a browser test can observe it. The
490
+ * default is exactly the assignment it replaces, so behaviour is unchanged
491
+ * unless something calls `setHardNavigate`.
492
+ *
493
+ * This exists because a hard navigation is UNOBSERVABLE and UNPREVENTABLE from
494
+ * outside. `preventDefault` cancels a default action, not a script assignment,
495
+ * and `location.href` is non-configurable on Chromium, Firefox, and WebKit
496
+ * alike, so a test cannot redefine its setter either (measured; the older
497
+ * `spyOnReload` helper that tried was silently a no-op on every engine). In a
498
+ * web-test-runner session a real navigation aborts the WHOLE session, so one
499
+ * degradation destroys every remaining browser test file and reports `0 failed`
500
+ * on the way out. A seam is the only thing that makes it catchable.
501
+ *
502
+ * @param {string} href
503
+ */
504
+ let hardNavigate = (href) => { location.href = href; };
505
+
506
+
474
507
  /** @param {MouseEvent} e */
475
508
  function onClick(e) {
476
509
  if (!enabled) return;
@@ -763,6 +796,105 @@ function shouldFullLoadDuringParse(isPopState, frameId) {
763
796
  );
764
797
  }
765
798
 
799
+ /**
800
+ * `sessionStorage` key holding the destination of a full load the ROUTER
801
+ * itself chose (#1118). Written by `reportFallback` when `willReload` is true,
802
+ * consumed once by the next document's boot. Per-tab and cleared with the tab,
803
+ * which is the right lifetime for a marker about one navigation.
804
+ */
805
+ const FALLBACK_MARKER_KEY = 'webjs:nav-fallback';
806
+
807
+ /**
808
+ * Has the pre-boot check already run for THIS document (#1118)? Module scope,
809
+ * so it resets with the document, which is the lifetime the report is about.
810
+ */
811
+ let reportedPreBoot = false;
812
+
813
+ /**
814
+ * Was THIS document load a same-origin navigation the client router never saw?
815
+ *
816
+ * Pure so the branch logic is testable without driving a real navigation
817
+ * (#1118). Every argument is read from the environment by the one caller.
818
+ *
819
+ * @param {string} navType `performance.getEntriesByType('navigation')[0].type`.
820
+ * Only `'navigate'` qualifies: a `'reload'` and a `'back_forward'` restore are
821
+ * things the browser does, not clicks the router could have intercepted.
822
+ * @param {string} referrer `document.referrer`. Must parse to the same origin as
823
+ * `href`: an empty referrer means a typed URL or an external entry (no router
824
+ * was running to miss the click), and a cross-origin one means the previous
825
+ * page was not ours.
826
+ * @param {string} href `location.href` of the document that just loaded.
827
+ * @param {string | null} marker the consumed `FALLBACK_MARKER_KEY` value. When
828
+ * it equals `href` the router already reported this load under its own cause,
829
+ * so counting it again would double-count a known degradation as an unknown.
830
+ * @returns {boolean}
831
+ */
832
+ function isPreBootNavigation(navType, referrer, href, marker) {
833
+ if (navType !== 'navigate') return false;
834
+ if (!referrer) return false;
835
+ if (marker && marker === href) return false;
836
+ try {
837
+ return new URL(referrer).origin === new URL(href).origin;
838
+ } catch {
839
+ return false;
840
+ }
841
+ }
842
+
843
+ /**
844
+ * Report a document load that reached us by a same-origin navigation the router
845
+ * did not soft-navigate (#1118).
846
+ *
847
+ * A module script is deferred by spec, so it runs only after HTML parsing
848
+ * completes, while the links it will intercept are clickable from first paint.
849
+ * That window cannot be closed from inside the router (see #1118 for why an
850
+ * inline capture shim was rejected), so it is MEASURED instead: this turns the
851
+ * frequency into a production number a deployed app can read off the existing
852
+ * `webjs:navigation-fallback` channel, rather than folklore.
853
+ *
854
+ * Deliberately imprecise, and the docs say so: a `data-no-router` link, a
855
+ * cross-document form post, and an app that opted out of the client router all
856
+ * land here too. The signal is the RATE, not any single event.
857
+ *
858
+ * `willReload` is false because the document load has already happened. That is
859
+ * exactly the distinction the flag was added for.
860
+ */
861
+ function reportPreBootNavigation() {
862
+ // Same guard the scroll/current-page seeding above uses: a DOM shim without a
863
+ // `location` (linkedom under the unit runner) is not a document load to
864
+ // report on, and reading through would throw inside the boot.
865
+ if (typeof location === 'undefined') return;
866
+ // Once per DOCUMENT, not once per enable. `enableClientRouter` is re-callable
867
+ // after `disableClientRouter()` (the documented per-moment opt-out), and this
868
+ // reports on the load that produced the document, which does not happen again
869
+ // when the router is toggled back on. Without this, an app that toggles would
870
+ // emit a duplicate for a single load and inflate the very rate the report
871
+ // exists to measure. The marker is already consumed by then, so it cannot
872
+ // suppress the duplicate on its own.
873
+ if (reportedPreBoot) return;
874
+ reportedPreBoot = true;
875
+ /** @type {string | null} */
876
+ let marker = null;
877
+ try {
878
+ marker = sessionStorage.getItem(FALLBACK_MARKER_KEY);
879
+ // Consume unconditionally, even when it does not match: a stale marker left
880
+ // by an earlier navigation must never suppress a later real one.
881
+ sessionStorage.removeItem(FALLBACK_MARKER_KEY);
882
+ } catch {
883
+ // No marker available. Treated as absent, which can only over-report.
884
+ }
885
+ let navType = '';
886
+ try {
887
+ const nav = performance.getEntriesByType('navigation')[0];
888
+ navType = nav ? /** @type {PerformanceNavigationTiming} */ (nav).type : '';
889
+ } catch {
890
+ // No Navigation Timing Level 2 entry. Without a nav type the check cannot
891
+ // exclude a reload, so it reports nothing rather than guessing.
892
+ }
893
+ if (isPreBootNavigation(navType, document.referrer, location.href, marker)) {
894
+ reportFallback('pre-boot-navigation', location.href, false);
895
+ }
896
+ }
897
+
766
898
  /**
767
899
  * The client router degraded a soft navigation. Records WHY (the `cause`), so
768
900
  * "why did my SPA nav do a full reload?" is answerable instead of guessed at.
@@ -796,6 +928,21 @@ function shouldFullLoadDuringParse(isPopState, frameId) {
796
928
  * dropped", which are very different user-visible events.
797
929
  */
798
930
  function reportFallback(cause, href, willReload = true) {
931
+ if (willReload) {
932
+ // Leave a marker naming the destination this full load is going to
933
+ // (#1118). The next document's boot reads it to tell "the router itself
934
+ // chose this full load, and already reported it under its own cause" from
935
+ // "a same-origin navigation the router never saw", which is the pre-boot
936
+ // click window. Best-effort: `sessionStorage` throws in some privacy modes
937
+ // and partitioned contexts, and a diagnostic must never break a navigation.
938
+ try {
939
+ sessionStorage.setItem(FALLBACK_MARKER_KEY, href);
940
+ } catch {
941
+ // Without the marker the next boot may attribute this load to the
942
+ // pre-boot window. That is a false positive in a diagnostic, which is
943
+ // strictly better than a thrown navigation.
944
+ }
945
+ }
799
946
  if (typeof document !== 'undefined' && typeof CustomEvent !== 'undefined') {
800
947
  try {
801
948
  document.dispatchEvent(new CustomEvent('webjs:navigation-fallback', {
@@ -1177,7 +1324,7 @@ async function performNavigation(href, isPopState, frameId) {
1177
1324
  // nav carries its own boundary element.
1178
1325
  if (shouldFullLoadDuringParse(isPopState, frameId) && typeof location !== 'undefined') {
1179
1326
  reportFallback('readyState-loading', href);
1180
- location.href = href;
1327
+ hardNavigate(href);
1181
1328
  return;
1182
1329
  }
1183
1330
 
@@ -2078,7 +2225,7 @@ function handleNavigationError(href, status, error) {
2078
2225
  // carries no `cause` / `willReload`, so it is not a substitute.
2079
2226
  if (typeof location !== 'undefined') {
2080
2227
  reportFallback('navigation-error-unrecoverable', href);
2081
- location.href = href;
2228
+ hardNavigate(href);
2082
2229
  }
2083
2230
  }
2084
2231
 
@@ -2174,6 +2321,11 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
2174
2321
  const resp = await fetch(href, init);
2175
2322
  respStatus = resp.status;
2176
2323
  respOk = resp.ok;
2324
+ // `fetch` follows a 303 transparently and its headers are then unreadable,
2325
+ // so this lands on a non-redirect response: the 422 failure re-render, or
2326
+ // any form response that answers in place.
2327
+ const invalidated = parseTagHeader(resp.headers.get('x-webjs-invalidate'));
2328
+ if (invalidated.length) markStale(invalidated);
2177
2329
  const ctype = resp.headers.get('content-type') || '';
2178
2330
  const isHTML = /^text\/html\b/i.test(ctype);
2179
2331
  const isStream = ctype.toLowerCase().indexOf(STREAM_MIME) === 0;
@@ -2631,6 +2783,24 @@ function runWithTransition(thunk, afterFinished) {
2631
2783
  return Promise.resolve();
2632
2784
  }
2633
2785
 
2786
+ /**
2787
+ * Live nodes a regraft actually moved into the incoming tree, so they
2788
+ * survived the swap BY IDENTITY. Membership is strictly narrower than
2789
+ * "carries `data-webjs-permanent`": the regrafts have a both-exist guard, so
2790
+ * a permanent element arriving for the first time is a freshly imported node
2791
+ * that was never preserved and is not in here. `reactivateScripts` reads this
2792
+ * to decide whether a script inside a permanent element is a script the
2793
+ * author kept alive (skip it) or one that has never run (run it).
2794
+ *
2795
+ * Weak and keyed by node identity, so a destroyed node drops out on its own
2796
+ * and a later element reusing the same `#id` is a different object that
2797
+ * correctly re-runs. Never cleared per navigation: a node preserved across
2798
+ * several navigations must keep its exemption on every one of them.
2799
+ *
2800
+ * @type {WeakSet<Element>}
2801
+ */
2802
+ const regraftedPermanents = new WeakSet();
2803
+
2634
2804
  /**
2635
2805
  * Persist `data-webjs-permanent` elements across a swap by NODE IDENTITY.
2636
2806
  *
@@ -2684,6 +2854,7 @@ function regraftPermanentElements(currentRoot, incomingRoot) {
2684
2854
  // incoming placeholder. The swap then adopts the live node.
2685
2855
  if (placeholder === live) continue;
2686
2856
  parent.replaceChild(live, placeholder);
2857
+ regraftedPermanents.add(live);
2687
2858
  }
2688
2859
  }
2689
2860
 
@@ -2726,12 +2897,16 @@ function regraftPermanentInSlice(liveSlice, incomingSlice) {
2726
2897
  const parent = placeholder.parentNode;
2727
2898
  if (parent) {
2728
2899
  parent.replaceChild(live, placeholder);
2900
+ regraftedPermanents.add(live);
2729
2901
  } else {
2730
2902
  // Placeholder is a top-level slice member with no parent (detached):
2731
2903
  // replace it in the incomingSlice array so the reconciler inserts the
2732
2904
  // live node in that position.
2733
2905
  const idx = incomingSlice.indexOf(placeholder);
2734
- if (idx !== -1) incomingSlice[idx] = live;
2906
+ if (idx !== -1) {
2907
+ incomingSlice[idx] = live;
2908
+ regraftedPermanents.add(live);
2909
+ }
2735
2910
  }
2736
2911
  }
2737
2912
  }
@@ -2901,14 +3076,14 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
2901
3076
  } else {
2902
3077
  if (sessionStorage) sessionStorage.setItem(flag, '1');
2903
3078
  reportFallback('deploy-mismatch', href);
2904
- location.href = href;
3079
+ hardNavigate(href);
2905
3080
  return;
2906
3081
  }
2907
3082
  } catch {
2908
3083
  // sessionStorage unavailable (private mode w/ quota etc.):
2909
3084
  // fall through to a single reload like before.
2910
3085
  reportFallback('deploy-mismatch', href);
2911
- location.href = href;
3086
+ hardNavigate(href);
2912
3087
  return;
2913
3088
  }
2914
3089
  } else if (!mismatch) {
@@ -3046,7 +3221,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
3046
3221
  reportFallback(!here ? 'live-boundaries-malformed'
3047
3222
  : !there ? 'incoming-boundaries-malformed'
3048
3223
  : 'no-shared-boundary', href);
3049
- location.href = href;
3224
+ hardNavigate(href);
3050
3225
  return;
3051
3226
  }
3052
3227
 
@@ -3142,12 +3317,7 @@ function replaceBoundaryRange(target, source) {
3142
3317
  for (const n of incomingSlice) {
3143
3318
  liveParent.insertBefore(n, target.end);
3144
3319
  }
3145
- for (let n = target.start.nextSibling; n && n !== target.end; n = n.nextSibling) {
3146
- if (n.nodeType === 1) {
3147
- reactivateScripts(/** @type {Element} */ (n));
3148
- upgradeCustomElements(/** @type {Element} */ (n));
3149
- }
3150
- }
3320
+ activateSwappedRange(target);
3151
3321
  }
3152
3322
 
3153
3323
  /**
@@ -3200,13 +3370,13 @@ function swapMarkerRange(target, source, _doc) {
3200
3370
  // Run the keyed diff.
3201
3371
  reconcileSiblings(liveParent, target.start, target.end, liveSlice, incomingSlice);
3202
3372
 
3203
- // Upgrade + activate scripts in the just-swapped range.
3204
- for (let n = target.start.nextSibling; n && n !== target.end; n = n.nextSibling) {
3205
- if (n.nodeType === 1) {
3206
- reactivateScripts(/** @type {Element} */ (n));
3207
- upgradeCustomElements(/** @type {Element} */ (n));
3208
- }
3209
- }
3373
+ // Upgrade + activate scripts in the just-swapped range. A top-level script
3374
+ // here is usually one the keyed reconciler REUSED (`keyOf` reads
3375
+ // `data-key || id`), so it re-executes on every soft nav that morphs this
3376
+ // boundary. That is deliberate: a descendant script inside a reused
3377
+ // container has always re-run through this same pass, and a script's
3378
+ // position in the range is not a reason to treat it differently (#1102).
3379
+ activateSwappedRange(target);
3210
3380
  }
3211
3381
 
3212
3382
  /**
@@ -4360,11 +4530,127 @@ async function streamBoundariesProgressively(reader, dec, initialBuf, isCurrent)
4360
4530
  }
4361
4531
  }
4362
4532
 
4363
- /** @param {Element} container */
4533
+ /**
4534
+ * Re-execute every `<script>` in `container`, INCLUDING `container` itself when
4535
+ * it is one (#1102). A script parsed by `DOMParser` carries the spec's
4536
+ * "already started" flag, so the node grafted into the live document is inert
4537
+ * and only a fresh clone runs. `querySelectorAll` never matches the element it
4538
+ * is called on, so a container-is-a-script was silently skipped: the two swap
4539
+ * tiers hand this function each TOP-LEVEL node of the swapped range in turn, so
4540
+ * a script emitted as a sibling of the content (a layout's progressive-
4541
+ * enhancement script, the shape that surfaced this) never ran after a soft nav.
4542
+ *
4543
+ * Replacing the container DETACHES it, which is why both callers snapshot the
4544
+ * range before iterating rather than walking live `nextSibling` links.
4545
+ *
4546
+ * `data-webjs-permanent` splits into two cases here, and the split is the whole
4547
+ * rule (#1252):
4548
+ *
4549
+ * - The marked element IS a script: NEVER exempt, however the walk reaches
4550
+ * it. This holds whether it arrives as the `container` or as a descendant
4551
+ * of one, because the regraft selector has no tag filter and will happily
4552
+ * preserve a `<script id data-webjs-permanent>` by identity. The exemption
4553
+ * below is therefore STRICT containment, never reflexive. This must not be
4554
+ * changed. The regrafts have a both-exist guard, so on the swap that first
4555
+ * mounts a route there is no live node to preserve, the inert parsed copy
4556
+ * is what lands, and exempting it would leave a script that runs on a cold
4557
+ * load and never on a soft navigation. That is precisely the #1102 failure,
4558
+ * reintroduced under the banner of fixing it. A script's only state is that
4559
+ * it ran, and re-running is the contract for everything in a swapped range.
4560
+ * - A script INSIDE a marked element that was ACTUALLY preserved: exempt.
4561
+ * The attribute means the subtree survives as the same live node, which is
4562
+ * what `diffElementInPlace` already implements by returning early rather
4563
+ * than recursing into it. Re-emitting an init script against a widget
4564
+ * instance the author deliberately kept alive is a double-initialization,
4565
+ * not a refresh.
4566
+ *
4567
+ * The filter keys on `regraftedPermanents` (actual preservation by identity),
4568
+ * never on the attribute alone. A permanent element arriving for the FIRST time
4569
+ * was never preserved, so its scripts have never run and must run now; an
4570
+ * attribute-only filter would leave them never running on any path.
4571
+ *
4572
+ * @param {Element} container
4573
+ * @returns {Element} `container`, or its replacement when it was a script that
4574
+ * was re-emitted. The replacement sits wherever `container` was; when
4575
+ * `container` was already detached, `replaceWith` is a spec no-op and the
4576
+ * returned clone is detached too, so callers must not assume it is connected.
4577
+ */
4364
4578
  function reactivateScripts(container) {
4579
+ if (container.tagName === 'SCRIPT') {
4580
+ const fresh = cloneScriptWithCorrectNonce(/** @type {HTMLScriptElement} */ (container));
4581
+ // A no-op when the node has no parent. That happens when an EARLIER
4582
+ // script's reactivation ran code that removed this node from the range
4583
+ // (reactivation executes synchronously), so a stale snapshot entry cannot
4584
+ // resurrect itself into the document.
4585
+ container.replaceWith(fresh);
4586
+ return fresh;
4587
+ }
4588
+ // Roots whose subtrees survived this swap by identity. Collected from the
4589
+ // container DOWNWARD so the exemption is bounded to the swapped range by
4590
+ // construction; `closest()` from a script upward could escape into an outer
4591
+ // ancestor that was never part of this swap.
4592
+ /** @type {Element[]} */
4593
+ const preserved = [];
4594
+ if (regraftedPermanents.has(container)) preserved.push(container);
4595
+ for (const el of container.querySelectorAll('[data-webjs-permanent]')) {
4596
+ if (regraftedPermanents.has(el)) preserved.push(el);
4597
+ }
4598
+
4365
4599
  for (const old of container.querySelectorAll('script')) {
4600
+ // STRICT containment: a preserved root exempts its DESCENDANTS, never
4601
+ // itself. The regrafts select `[data-webjs-permanent][id]` with no tag
4602
+ // filter, so a `<script id data-webjs-permanent>` present on both sides is
4603
+ // regrafted like any other element and lands in the WeakSet. Skipping it
4604
+ // here would exempt the marked script itself whenever the walk reaches it
4605
+ // as a descendant (the full-body path), while the container branch above
4606
+ // still re-emits it, so one script would get opposite answers depending on
4607
+ // which entry point reached it. `contains()` is reflexive, hence `p !== old`.
4608
+ if (preserved.length && preserved.some((p) => p !== old && p.contains(old))) continue;
4366
4609
  old.replaceWith(cloneScriptWithCorrectNonce(/** @type {HTMLScriptElement} */ (old)));
4367
4610
  }
4611
+ return container;
4612
+ }
4613
+
4614
+ /**
4615
+ * Reactivate scripts + upgrade custom elements across a just-swapped boundary
4616
+ * range. The range is SNAPSHOT first: `reactivateScripts` replaces a top-level
4617
+ * script node, which detaches it and cuts a live `nextSibling` walk, silently
4618
+ * skipping every node after it (#1102).
4619
+ *
4620
+ * The snapshot is taken AFTER the tier has finished writing the range (the
4621
+ * replace tier's insert loop, the morph tier's `reconcileSiblings`), so every
4622
+ * entry is attached when it is recorded. It can still go stale DURING the walk,
4623
+ * because reactivating a script executes it synchronously and that code may
4624
+ * mutate the range. Two consequences, both deliberate. A node an earlier script
4625
+ * REMOVED is skipped, since `replaceWith` on a parentless node is a spec no-op.
4626
+ * A node an earlier script INSERTED is not visited, unlike the live walk this
4627
+ * replaced. That costs nothing reachable: such a node is connected by
4628
+ * definition, so the browser upgrades it on insertion, and `ensureUpgradeObserver`
4629
+ * catches it on the microtask regardless. Do NOT "restore" the live walk to
4630
+ * recover it. A correct live walk has to advance off the REPLACEMENT (advancing
4631
+ * off the detached original is bug #1102 itself), and in that form a script
4632
+ * appending a sibling script loops forever, each clone appending the next; it
4633
+ * would also re-run a script that already executed when its own creator
4634
+ * inserted it. The snapshot is additionally safer under a MOVE, since a live
4635
+ * walk would follow a node out of the range and start re-executing unrelated
4636
+ * scripts in the rest of the body.
4637
+ *
4638
+ * @param {{ start: Comment, end: Comment }} range
4639
+ */
4640
+ function activateSwappedRange(range) {
4641
+ /** @type {Element[]} */
4642
+ const swapped = [];
4643
+ for (let n = range.start.nextSibling; n && n !== range.end; n = n.nextSibling) {
4644
+ if (n.nodeType === 1) swapped.push(/** @type {Element} */ (n));
4645
+ }
4646
+ for (const el of swapped) {
4647
+ const live = reactivateScripts(el);
4648
+ // Nothing to upgrade in a detached tree. `customElements.upgrade` off
4649
+ // document runs the CONSTRUCTOR (not `connectedCallback`, which waits for
4650
+ // insertion), so this skips constructing elements for a tree that was just
4651
+ // removed and will never be seen.
4652
+ if (live.isConnected !== false) upgradeCustomElements(live);
4653
+ }
4368
4654
  }
4369
4655
 
4370
4656
  /* ====================================================================
@@ -4375,6 +4661,9 @@ export {
4375
4661
  addNewHeadElements as _addNewHeadElements,
4376
4662
  mergeHead as _mergeHead,
4377
4663
  reactivateScripts as _reactivateScripts,
4664
+ isPreBootNavigation as _isPreBootNavigation,
4665
+ FALLBACK_MARKER_KEY as _FALLBACK_MARKER_KEY,
4666
+ activateSwappedRange as _activateSwappedRange,
4378
4667
  findAnchorInPath as _findAnchorInPath,
4379
4668
  activeFrameId as _activeFrameId,
4380
4669
  resolveTargetFrameId as _resolveTargetFrameId,
@@ -4435,6 +4724,20 @@ export function _resetPrefetch() {
4435
4724
  clearPrefetchViewTimers();
4436
4725
  }
4437
4726
 
4727
+ /**
4728
+ * Test-only: replace the hard-navigate action so a browser test can observe a
4729
+ * navigation instead of being destroyed by it. Call with no argument to
4730
+ * restore. Underscore-prefixed and kept in this block like every other
4731
+ * test-only export here, so it stays out of `router-client.d.ts` and out of
4732
+ * the app-facing API (the `./client-router` subpath resolves this file under
4733
+ * the `source` condition, so an unprefixed name here would read as public).
4734
+ *
4735
+ * @param {((href: string) => void) | null} [fn]
4736
+ */
4737
+ export function _setHardNavigate(fn) {
4738
+ hardNavigate = fn || ((href) => { location.href = href; });
4739
+ }
4740
+
4438
4741
  /** Test-only: read the monotonic navigation-token counter. */
4439
4742
  export function _navToken() { return currentNavigationToken; }
4440
4743
  /** Test-only: bump the navigation-token counter (simulates a fresh nav). */
@@ -7,22 +7,18 @@
7
7
  * import type { WebjsConfig } from '@webjsdev/core';
8
8
  * const config: WebjsConfig = { trailingSlash: 'never', csp: true };
9
9
  *
10
- * The server reads this object key by key. Without a type or schema a
11
- * typo'd key (e.g. `redirect` for `redirects`) was silently dropped and
12
- * the feature stayed at its default with no diagnostic. This type plus
13
- * the published JSON Schema close that gap.
10
+ * This object is read key by key, mostly by the server and for a few keys
11
+ * by the CLI. Without a type or schema a typo'd key (e.g. `redirect` for
12
+ * `redirects`) was silently dropped and the feature stayed at its default
13
+ * with no diagnostic. This type plus the published JSON Schema close that
14
+ * gap.
14
15
  *
15
16
  * LOCKSTEP: this file, the JSON Schema at
16
- * packages/server/webjs-config.schema.json, and the server reader
17
- * functions MUST stay in sync. The readers are: readElideEnabled
18
- * (dev.js, elide), readSeedEnabled (dev.js, seed), readClientRouterEnabled
19
- * (dev.js, clientRouter), compileHeaderRules (headers.js, headers),
20
- * compileRedirectRules / readTrailingSlashPolicy (redirects.js,
21
- * redirects / trailingSlash), readBasePath (base-path.js, basePath),
22
- * readCspConfig (csp.js, csp), and
23
- * readBodyLimits / computeServerTimeouts (body-limit.js, the byte caps
24
- * and timeouts). Adding a `webjs.*` key means updating all three places.
25
- * See packages/server/AGENTS.md for the one documented procedure.
17
+ * packages/server/webjs-config.schema.json, and the reader functions MUST
18
+ * stay in sync, so adding a `webjs.*` key means updating all three places
19
+ * (plus the KNOWN_KEYS drift test). The reader inventory is NOT repeated
20
+ * here on purpose: packages/server/AGENTS.md holds the one canonical list
21
+ * along with the procedure, and every copy of it that existed drifted.
26
22
  *
27
23
  * Every key is optional (the whole block is optional and every key has a
28
24
  * default). Zero runtime cost: nothing in this file ships to the browser.
@@ -91,18 +87,19 @@ export interface WebjsRegenerateRule {
91
87
  inputs?: string[];
92
88
  }
93
89
 
94
- /** Dev task orchestration in `webjs.dev` (#550). `before`/`parallel` read by the CLI, `regenerate` by the server. */
90
+ /** Dev task orchestration in `webjs.dev` (#550). `before`/`parallel` read by the CLI, `regenerate` and `watch` by the server (each sub-key names its own reader below). */
95
91
  export interface WebjsDevTasks {
96
92
  /**
97
93
  * One-shot commands run sequentially to completion BEFORE the dev server
98
94
  * boots (the old `predev` hook: `webjs db migrate`, a registry copy). A
99
- * non-zero exit aborts the boot.
95
+ * non-zero exit aborts the boot. Read by the CLI (`readAppTasks`).
100
96
  */
101
97
  before?: string[];
102
98
  /**
103
99
  * Long-lived commands run as child processes ALONGSIDE the dev server (the
104
100
  * old `concurrently` watchers). Spawned once in the parent and torn down on
105
- * exit, so a watcher cannot leak past the server.
101
+ * exit, so a watcher cannot leak past the server. Read by the CLI
102
+ * (`readAppTasks`).
106
103
  */
107
104
  parallel?: string[];
108
105
  /**
@@ -136,6 +133,34 @@ export interface WebjsStartTasks {
136
133
  before?: string[];
137
134
  }
138
135
 
136
+ /**
137
+ * A severity a `webjs.doctor.gate` entry may declare, mirroring ESLint's
138
+ * three-level scale. `error` fails the `webjs doctor` exit, `warn` reports
139
+ * without failing, and `off` silences the check: its finding is not printed and
140
+ * it cannot fail the exit, including under `--strict`. A silenced check still
141
+ * appears on the checklist as `[off]` and in the summary's silenced count, and
142
+ * `--json` still carries its whole result.
143
+ */
144
+ export type WebjsDoctorSeverity = 'off' | 'warn' | 'error';
145
+
146
+ /** The object form of `webjs.doctor` (#1257). */
147
+ export interface WebjsDoctorConfig {
148
+ /**
149
+ * Per-check severity, keyed by the stable doctor code (`NODE_VERSION`,
150
+ * `UNMARKED_ASSET_LINKS`, `ELISION_CARRIERS`, and so on; `webjs doctor --json`
151
+ * carries the code on every result). A code with no entry keeps its default:
152
+ * `error` for a hard toolchain failure, `warn` otherwise. This is how CI gates
153
+ * on a chosen subset without `--strict` making every warning fatal.
154
+ *
155
+ * A malformed gate is a hard error, so a typo cannot silently un-gate CI:
156
+ * that covers an unknown code, a bad severity, a wrong SHAPE (a non-object
157
+ * `doctor` or `gate`), and a misspelled sibling of `gate` such as `gates`.
158
+ * A result that could not check (a network or toolchain outage) is capped at
159
+ * `warn` and can never be escalated to `error`.
160
+ */
161
+ gate?: Record<string, WebjsDoctorSeverity>;
162
+ }
163
+
139
164
  /** The object form of `webjs.csp` (the non-boolean shape). */
140
165
  export interface WebjsCspConfig {
141
166
  /**
@@ -191,6 +216,13 @@ export interface WebjsConfig {
191
216
  dev?: WebjsDevTasks;
192
217
  start?: WebjsStartTasks;
193
218
 
219
+ /**
220
+ * `webjs doctor` policy (#1257): which project-health checks the project
221
+ * treats as fatal. Read by the CLI (`packages/cli/lib/doctor.js`), NOT the
222
+ * server readers.
223
+ */
224
+ doctor?: WebjsDoctorConfig;
225
+
194
226
  /** Per-path response-header rules, shaped like Next's. */
195
227
  headers?: WebjsHeaderRule[];
196
228