@webjsdev/core 0.7.21 → 0.7.23

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.
@@ -27,11 +27,11 @@ const STREAM_MIME = 'text/vnd.webjs-stream.html';
27
27
  * children of that boundary. Outer layout DOM (header, sidenav, footer)
28
28
  * stays mounted: no re-render, no flicker, scroll positions preserved.
29
29
  *
30
- * To enable, import this module from a layout or boot script:
31
- *
32
- * import '@webjsdev/core/client-router';
33
- *
34
- * Or call `enableClientRouter()` for programmatic control.
30
+ * Enablement is automatic: this module calls `enableClientRouter()` at its
31
+ * end (idempotent), and the `@webjsdev/core` browser entry loads it, so any
32
+ * page that ships a component gets the router with no import to add. Call
33
+ * `disableClientRouter()` to opt out, or `enableClientRouter()` for
34
+ * programmatic control.
35
35
  *
36
36
  * Mechanism: auto-derived from folder structure:
37
37
  * 1. SSR injects `<!--wj:children:<segment-path>-->...<!--/wj:children-->`
@@ -220,6 +220,7 @@ export function disableClientRouter() {
220
220
  document.removeEventListener('pointerout', onPrefetchOut, true);
221
221
  document.removeEventListener('webjs:navigate', refreshPrefetchObservers);
222
222
  clearPrefetchHover();
223
+ clearPrefetchViewTimers();
223
224
  if (prefetchViewObserver) { prefetchViewObserver.disconnect(); prefetchViewObserver = null; }
224
225
  if (typeof history !== 'undefined' && prevScrollRestoration !== null) {
225
226
  history.scrollRestoration = prevScrollRestoration;
@@ -605,6 +606,64 @@ function warnOnce(key, message) {
605
606
  if (typeof console !== 'undefined' && console.warn) console.warn(message);
606
607
  }
607
608
 
609
+ /**
610
+ * Dev-only, fire-once hint: the router forces an INSTANT scroll-to-top on a
611
+ * forward navigation (matching a native page load), so an app-level
612
+ * `scroll-behavior: smooth` on <html> does not affect route transitions (it
613
+ * still applies to in-page #anchor links via `scrollIntoView`). A developer
614
+ * who set smooth expecting smooth nav scrolling would otherwise be puzzled.
615
+ * Also flags the iOS sticky-`backdrop-filter` flash this combination can
616
+ * cause (#610). Never warns in production, never throws.
617
+ *
618
+ * The `smoothScrollChecked` flag gates the `getComputedStyle` read (a forced
619
+ * style flush) to AT MOST ONCE per page, so a dev session does not pay a
620
+ * per-navigation reflow after the first forward nav.
621
+ */
622
+ let smoothScrollChecked = false;
623
+ function warnIfSmoothScrollOnHtml() {
624
+ if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') return;
625
+ if (smoothScrollChecked) return;
626
+ if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') return;
627
+ const root = document.documentElement;
628
+ if (!root) return;
629
+ smoothScrollChecked = true;
630
+ let behavior;
631
+ try { behavior = getComputedStyle(root).scrollBehavior; } catch { return; }
632
+ if (behavior !== 'smooth') return;
633
+ warnOnce(
634
+ 'scroll-behavior-smooth-html',
635
+ '[webjs] Detected `scroll-behavior: smooth` on <html>. The client router scrolls ' +
636
+ 'to the top instantly on navigation (like a native page load), so route transitions ' +
637
+ 'are not affected by it. It still applies to in-page #anchor links. Pairing it with a ' +
638
+ 'sticky `backdrop-filter` header can also flash on iOS during navigation.'
639
+ );
640
+ }
641
+
642
+ /**
643
+ * Nav-in-flight signalling. The router can expose `data-navigating` on <html>
644
+ * so an app may style a loading indicator with `html[data-navigating] { … }`.
645
+ *
646
+ * This is OPT-IN, set only when the app marks `<html data-webjs-nav-progress>`.
647
+ * The reason it is not unconditional: toggling ANY attribute on the root
648
+ * re-runs global style resolution, and on WebKit (so every iOS browser, since
649
+ * they all use it) that re-resolves `oklch()` / `color-mix(in oklch, …)` token
650
+ * values to an equivalent oklab representation and repaints them for one frame.
651
+ * On a token-driven theme that is a visible background flash on navigation
652
+ * (#610). The flash only shows on a nav slow enough to reach the deferred set
653
+ * below, which a desktop nav rarely is but a mobile forward fetch routinely is,
654
+ * so the symptom is iOS-and-forward-only. With no opt-in the attribute is never
655
+ * written, so the re-resolution never happens and the flash cannot occur.
656
+ */
657
+ function setNavigating(on) {
658
+ if (typeof document === 'undefined') return;
659
+ const root = document.documentElement;
660
+ if (!root || !root.hasAttribute('data-webjs-nav-progress')) return;
661
+ try {
662
+ if (on) root.setAttribute('data-navigating', '');
663
+ else root.removeAttribute('data-navigating');
664
+ } catch { /* non-DOM environment */ }
665
+ }
666
+
608
667
  /* ====================================================================
609
668
  * Marker discovery (the heart of the partial-swap mechanism)
610
669
  * ==================================================================== */
@@ -771,17 +830,11 @@ async function performNavigation(href, isPopState, frameId) {
771
830
  // about to read in the popstate-restore branch below.
772
831
  if (currentPageUrl) snapshotCurrent(currentPageUrl);
773
832
 
774
- // Show a subtle loading indicator, but only if the nav takes long
775
- // enough to be worth showing one. Setting an attribute on <html>
776
- // invalidates global style computation: which forces CSS like
777
- // `color-mix(in oklch, …)` to re-resolve. For values that use
778
- // wide-gamut color spaces the re-resolution can switch between
779
- // equivalent representations (oklch ↔ oklab) and fire any
780
- // `transition` rules listening on that property, producing a
781
- // visible flash on every nav. Defer the attribute set so quick
782
- // navs (sub-150ms) never set it at all.
833
+ // Expose the opt-in `data-navigating` loading-indicator hook (see
834
+ // setNavigating), but only if the nav takes long enough to be worth showing
835
+ // one. Deferred so quick navs (sub-150ms) never set it at all.
783
836
  let navigatingFlagTimer = setTimeout(() => {
784
- document.documentElement.setAttribute('data-navigating', '');
837
+ setNavigating(true);
785
838
  navigatingFlagTimer = null;
786
839
  }, 150);
787
840
 
@@ -800,9 +853,11 @@ async function performNavigation(href, isPopState, frameId) {
800
853
  const cachedDoc = parseHTML(cached.html);
801
854
  if (cachedDoc) {
802
855
  applySwap(cachedDoc, frameId, /* revalidating */ true, /* href */ null);
803
- // Restore window scroll to where the user left it.
856
+ // Restore window scroll to where the user left it. Use
857
+ // behavior:'instant' so an app-level `scroll-behavior: smooth`
858
+ // stylesheet does not animate the restore (native nav jumps).
804
859
  if (typeof window !== 'undefined') {
805
- window.scrollTo(cached.scrollX, cached.scrollY);
860
+ window.scrollTo({ left: cached.scrollX, top: cached.scrollY, behavior: 'instant' });
806
861
  }
807
862
  // Fire-and-forget revalidation. Uses a fresh AbortController
808
863
  // since this background fetch is allowed to overlap with the
@@ -818,7 +873,7 @@ async function performNavigation(href, isPopState, frameId) {
818
873
  // on the page they popped FROM. Scroll to top as the reasonable
819
874
  // default; fetchAndApply skips its own scroll handling when
820
875
  // recordHistory=false (which is the case here).
821
- if (typeof window !== 'undefined') window.scrollTo(0, 0);
876
+ if (typeof window !== 'undefined') window.scrollTo({ left: 0, top: 0, behavior: 'instant' });
822
877
  }
823
878
 
824
879
  await fetchAndApply(href, frameId, !isPopState, optimisticState, 'GET', null, signal, myToken);
@@ -827,7 +882,7 @@ async function performNavigation(href, isPopState, frameId) {
827
882
  // Only clear the navigating flag if WE are still the active nav.
828
883
  // A newer nav has its own flag lifecycle.
829
884
  if (myToken === currentNavigationToken) {
830
- document.documentElement.removeAttribute('data-navigating');
885
+ setNavigating(false);
831
886
  // Record where the user is NOW so the next navigation can
832
887
  // snapshot under the right URL key.
833
888
  if (typeof location !== 'undefined') currentPageUrl = location.href;
@@ -894,7 +949,7 @@ async function performSubmission(href, method, body, frameId, form) {
894
949
  if (currentPageUrl) snapshotCurrent(currentPageUrl);
895
950
 
896
951
  let navigatingFlagTimer = setTimeout(() => {
897
- document.documentElement.setAttribute('data-navigating', '');
952
+ setNavigating(true);
898
953
  navigatingFlagTimer = null;
899
954
  }, 150);
900
955
 
@@ -934,7 +989,7 @@ async function performSubmission(href, method, body, frameId, form) {
934
989
  if (busyForm) clearFormBusy(busyForm, myToken, url.href, outcomeOk);
935
990
  if (navigatingFlagTimer) clearTimeout(navigatingFlagTimer);
936
991
  if (myToken === currentNavigationToken) {
937
- document.documentElement.removeAttribute('data-navigating');
992
+ setNavigating(false);
938
993
  if (typeof location !== 'undefined') currentPageUrl = location.href;
939
994
  }
940
995
  }
@@ -960,12 +1015,16 @@ function buildHaveHeader() {
960
1015
  * warms a dedicated cache speculatively so the click reads it instantly.
961
1016
  *
962
1017
  * Strategy per anchor via a `data-prefetch` attribute (valid-HTML data-*,
963
- * like SvelteKit / Astro), defaulting to `intent` so the common case is
964
- * fast without per-link opt-in, the way Next / Nuxt / SvelteKit ship
965
- * auto-prefetch. Value vocabulary borrows Next's true/false/auto aliases:
966
- * - intent (default) : hover / focus / touch, after a short dwell
1018
+ * like SvelteKit / Astro). The default is DEVICE-ADAPTIVE so the common case
1019
+ * is fast on every device without per-link opt-in: `intent` on a hover-capable
1020
+ * pointer (a real head-start before the click), `viewport` on touch (no hover
1021
+ * exists, and `touchstart` fires too close to the tap to front-run it). Value
1022
+ * vocabulary borrows Next's true/false/auto aliases:
1023
+ * - absent (default) : intent on pointer, viewport on touch (adaptive)
1024
+ * - intent : hover / focus / touch, after a short dwell
967
1025
  * - true / render : eager, as soon as a document scan sees it
968
- * - auto / viewport : on viewport entry (IntersectionObserver, 0.5)
1026
+ * - auto / viewport : on viewport entry (IntersectionObserver, 0.5),
1027
+ * after a dwell so a fast scroll-through skips it
969
1028
  * - false / none : never (also data-no-prefetch / rel="external")
970
1029
  *
971
1030
  * Why a separate cache, not snapshotCache: snapshotCache is keyed to the
@@ -976,7 +1035,10 @@ function buildHaveHeader() {
976
1035
  * prefetchTake() before falling back to the network.
977
1036
  *
978
1037
  * Only same-origin in-app links are prefetched (the same eligibility as
979
- * a click), and never under Save-Data / prefers-reduced-data. There is no
1038
+ * a click), and never under Save-Data / prefers-reduced-data / a 2g link,
1039
+ * never past a small concurrency cap, and never twice (deduped + cached). The
1040
+ * viewport path additionally waits a dwell and cancels on scroll-out, so a
1041
+ * fast scroll through a long list does not flood the network tab. There is no
980
1042
  * logout-style heuristic: prefetch issues a real GET, so as everywhere in
981
1043
  * the ecosystem (Next / Nuxt / Remix), a non-idempotent action must be a
982
1044
  * POST or a `<form>`, and `data-no-prefetch` / `rel="external"` opt out.
@@ -996,6 +1058,13 @@ const PREFETCH_CONCURRENCY = 3;
996
1058
  const PREFETCH_QUEUE_CAP = 24;
997
1059
  /** Hover dwell before a prefetch fires (ms): filter drive-by pointer moves. Matches Remix's intent timeout. */
998
1060
  const PREFETCH_HOVER_DELAY = 100;
1061
+ /**
1062
+ * Viewport dwell before a prefetch fires (ms): a link must SETTLE on-screen,
1063
+ * not merely flash past during a scroll. A fast scroll-through clears the
1064
+ * timer on exit, so flicked-past links never fetch. Astro uses 300ms for the
1065
+ * same purpose; we sit a touch lower so a deliberate stop still feels instant.
1066
+ */
1067
+ const PREFETCH_VIEWPORT_DELAY = 250;
999
1068
 
1000
1069
  /** @typedef {{ html: string, build: string | null, finalUrl: string, at: number }} PrefetchEntry */
1001
1070
  /** @type {Map<string, PrefetchEntry>} */
@@ -1011,18 +1080,28 @@ let prefetchHoverTimer = null;
1011
1080
  let prefetchHoverAnchor = null;
1012
1081
  /** IntersectionObserver for data-prefetch="viewport" anchors, or null. */
1013
1082
  let prefetchViewObserver = null;
1083
+ /** Per-anchor viewport-dwell timers, so a scroll-out can cancel before firing. */
1084
+ let prefetchViewTimers = new WeakMap();
1085
+ /** Live viewport-dwell timer ids, for bulk teardown on disable. */
1086
+ const prefetchViewPending = new Set();
1014
1087
 
1015
1088
  /**
1016
- * True when the user or platform has asked us to conserve data. Both the
1017
- * Save-Data client hint and the prefers-reduced-data media query disable
1018
- * speculative fetching. Guarded for non-browser / partial DOM.
1089
+ * True when the user or platform has asked us to conserve data, OR the
1090
+ * connection is too slow to spend bytes speculatively. The Save-Data client
1091
+ * hint, the prefers-reduced-data media query, and a 2g `effectiveType` all
1092
+ * disable speculative fetching, the same gate Astro / Nuxt apply. Guarded for
1093
+ * non-browser / partial DOM.
1019
1094
  *
1020
1095
  * @returns {boolean}
1021
1096
  */
1022
1097
  function prefetchSaysSaveData() {
1023
1098
  try {
1024
1099
  const c = typeof navigator !== 'undefined' ? /** @type any */ (navigator).connection : null;
1025
- if (c && c.saveData === true) return true;
1100
+ if (c) {
1101
+ if (c.saveData === true) return true;
1102
+ // effectiveType is 'slow-2g' | '2g' | '3g' | '4g'; skip the 2g tiers.
1103
+ if (typeof c.effectiveType === 'string' && /2g$/.test(c.effectiveType)) return true;
1104
+ }
1026
1105
  if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-data: reduce)').matches) {
1027
1106
  return true;
1028
1107
  }
@@ -1030,6 +1109,27 @@ function prefetchSaysSaveData() {
1030
1109
  return false;
1031
1110
  }
1032
1111
 
1112
+ /**
1113
+ * Whether the device drives a hover-capable fine pointer (a mouse or
1114
+ * trackpad), as opposed to touch. This picks the ADAPTIVE prefetch default:
1115
+ * `intent` (hover / focus) on a pointer device, `viewport` on touch, since a
1116
+ * touch device has no hover and `touchstart` fires too close to the tap to
1117
+ * front-run it. Detected with `matchMedia('(hover: hover) and (pointer: fine)')`
1118
+ * rather than a user-agent sniff. When `matchMedia` is unavailable we assume a
1119
+ * pointer (the historical default), so a non-browser / partial-DOM environment
1120
+ * keeps the `intent` behaviour and never silently switches to viewport.
1121
+ *
1122
+ * @returns {boolean}
1123
+ */
1124
+ function prefetchHasHoverPointer() {
1125
+ try {
1126
+ if (typeof matchMedia === 'function') {
1127
+ return matchMedia('(hover: hover) and (pointer: fine)').matches;
1128
+ }
1129
+ } catch { /* ignore */ }
1130
+ return true;
1131
+ }
1132
+
1033
1133
  /**
1034
1134
  * Lowercased whitespace-separated rel tokens of an anchor.
1035
1135
  * @param {Element} anchor
@@ -1091,12 +1191,21 @@ function prefetchSuppressed(anchor) {
1091
1191
  * absent or unrecognised.
1092
1192
  *
1093
1193
  * Value mapping (case-insensitive):
1094
- * - absent / unknown : `intent` (the default)
1194
+ * - absent / unknown : the DEVICE-ADAPTIVE default (intent on a pointer,
1195
+ * viewport on touch); an explicit value always wins
1095
1196
  * - `intent` : hover / focus / touch, after a short dwell
1096
1197
  * - `true` / `render` : eager, as soon as a document scan sees the link
1097
- * - `auto` / `viewport`: on viewport entry (IntersectionObserver)
1198
+ * - `auto` / `viewport`: on viewport entry (IntersectionObserver), after a dwell
1098
1199
  * - `false` / `none` : never (also via data-no-prefetch / rel="external")
1099
1200
  *
1201
+ * The default is adaptive (not a single `intent`) because `intent` does not
1202
+ * help on mobile: a touch device has no hover, and `touchstart` fires at tap
1203
+ * time, so the prefetch races the navigation. On touch we default to
1204
+ * `viewport` (warm links as they settle on-screen) and keep `touchstart` as an
1205
+ * extra warm for the tapped link; on a pointer device `intent` stays the
1206
+ * default (precise, cheap, a real head-start before the click). A per-link
1207
+ * `data-prefetch` always overrides the adaptive default.
1208
+ *
1100
1209
  * Returns `none` for suppressed anchors so callers have a single check.
1101
1210
  *
1102
1211
  * @param {Element} anchor
@@ -1118,8 +1227,8 @@ function prefetchMode(anchor) {
1118
1227
  case 'intent':
1119
1228
  return 'intent';
1120
1229
  default:
1121
- // Unset or unrecognised value: the fast default.
1122
- return 'intent';
1230
+ // Unset or unrecognised value: the device-adaptive default.
1231
+ return prefetchHasHoverPointer() ? 'intent' : 'viewport';
1123
1232
  }
1124
1233
  }
1125
1234
 
@@ -1134,6 +1243,9 @@ function prefetchMode(anchor) {
1134
1243
  * @param {string} href
1135
1244
  */
1136
1245
  function prefetch(href) {
1246
+ // Never speculate once the router is torn down: a leftover hover / queue /
1247
+ // dwell timer that fires after disableClientRouter must not issue a fetch.
1248
+ if (!enabled) return;
1137
1249
  if (typeof fetch !== 'function') return;
1138
1250
  if (prefetchSaysSaveData()) return;
1139
1251
  const key = cacheKey(href);
@@ -1245,12 +1357,19 @@ function onPrefetchIntent(e) {
1245
1357
  if (!enabled) return;
1246
1358
  const anchor = closestAnchor(/** @type any */ (e.target));
1247
1359
  if (!anchor) return;
1248
- // Only `intent` links prefetch on hover/focus/touch. `render` and
1249
- // `viewport` links are handled by the document scan / observer, and
1250
- // `none` is suppressed.
1251
- if (prefetchMode(anchor) !== 'intent') return;
1360
+ const mode = prefetchMode(anchor);
1361
+ // `none` is suppressed; `render` already prefetched on the document scan.
1362
+ if (mode === 'none' || mode === 'render') return;
1252
1363
  const href = eligibleAnchorHref(anchor);
1253
1364
  if (!href) return;
1365
+ // touchstart IS the tap: warm the tapped link immediately, for both intent
1366
+ // and viewport modes (a single request for a link about to be navigated, the
1367
+ // small mobile win the viewport default cannot give for the link just tapped).
1368
+ // No dwell, since the tap is the intent.
1369
+ if (e.type === 'touchstart') { prefetch(href); return; }
1370
+ // hover / focus only warm `intent` links; `viewport` links are the
1371
+ // observer's job (warmed on a dwell, not on a stray hover).
1372
+ if (mode !== 'intent') return;
1254
1373
  // pointerover/focusin bubble, so re-entering a child of the same anchor
1255
1374
  // would re-arm; collapse to one timer per anchor.
1256
1375
  if (prefetchHoverAnchor === anchor && prefetchHoverTimer) return;
@@ -1274,6 +1393,13 @@ function clearPrefetchHover() {
1274
1393
  prefetchHoverAnchor = null;
1275
1394
  }
1276
1395
 
1396
+ /** Cancel every pending viewport-dwell timer and reset the per-anchor map. */
1397
+ function clearPrefetchViewTimers() {
1398
+ for (const timer of prefetchViewPending) clearTimeout(timer);
1399
+ prefetchViewPending.clear();
1400
+ prefetchViewTimers = new WeakMap();
1401
+ }
1402
+
1277
1403
  /**
1278
1404
  * Nearest enclosing <a>, crossing shadow boundaries, from an event
1279
1405
  * target. composedPath is click-only, so walk getRootNode().host here.
@@ -1314,15 +1440,40 @@ function refreshPrefetchObservers() {
1314
1440
  if (!prefetchViewObserver) {
1315
1441
  prefetchViewObserver = new IntersectionObserver((entries) => {
1316
1442
  for (const entry of entries) {
1317
- if (!entry.isIntersecting) continue;
1318
1443
  const anchor = /** @type {Element} */ (entry.target);
1319
- prefetchViewObserver.unobserve(anchor);
1320
- const href = eligibleAnchorHref(anchor);
1321
- if (href && prefetchMode(anchor) === 'viewport') prefetch(href);
1444
+ if (entry.isIntersecting) {
1445
+ // Arm a dwell timer; the link must STAY on-screen to warm. One
1446
+ // timer per anchor, so re-entry while pending does not stack.
1447
+ if (prefetchViewTimers.has(anchor)) continue;
1448
+ const timer = setTimeout(() => {
1449
+ prefetchViewPending.delete(timer);
1450
+ prefetchViewTimers.delete(anchor);
1451
+ prefetchViewObserver.unobserve(anchor);
1452
+ const href = eligibleAnchorHref(anchor);
1453
+ if (href && prefetchMode(anchor) === 'viewport') prefetch(href);
1454
+ }, PREFETCH_VIEWPORT_DELAY);
1455
+ prefetchViewTimers.set(anchor, timer);
1456
+ prefetchViewPending.add(timer);
1457
+ } else {
1458
+ // Scrolled out before the dwell elapsed: cancel, so a fast
1459
+ // scroll-through never spends a request.
1460
+ const timer = prefetchViewTimers.get(anchor);
1461
+ if (timer) {
1462
+ clearTimeout(timer);
1463
+ prefetchViewPending.delete(timer);
1464
+ prefetchViewTimers.delete(anchor);
1465
+ }
1466
+ }
1322
1467
  }
1323
1468
  }, { threshold: 0.5 });
1324
1469
  } else {
1470
+ // Re-scan: drop the old observation set AND cancel any pending dwell
1471
+ // timers, so a timer armed for an anchor the soft-nav swap removed cannot
1472
+ // fire a prefetch for a stale URL (its exit callback never comes once it
1473
+ // is gone). Anchors still on-screen re-arm when observe() below redelivers
1474
+ // their current intersection state.
1325
1475
  prefetchViewObserver.disconnect();
1476
+ clearPrefetchViewTimers();
1326
1477
  }
1327
1478
  }
1328
1479
  for (const anchor of document.querySelectorAll('a[href]')) {
@@ -1622,10 +1773,16 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
1622
1773
  const url = new URL(finalUrl);
1623
1774
  if (url.hash) {
1624
1775
  const t = document.getElementById(url.hash.slice(1));
1776
+ // A hash anchor is the one nav scroll we DON'T force instant: a
1777
+ // `#section` link is exactly where an app's `scroll-behavior: smooth`
1778
+ // is wanted, and native browsers animate it too.
1625
1779
  if (t) t.scrollIntoView();
1626
- else window.scrollTo(0, 0);
1780
+ else { warnIfSmoothScrollOnHtml(); window.scrollTo({ left: 0, top: 0, behavior: 'instant' }); }
1627
1781
  } else {
1628
- window.scrollTo(0, 0);
1782
+ // Scroll-to-top on a forward nav. behavior:'instant' so an app-level
1783
+ // `scroll-behavior: smooth` does not animate it (match native nav).
1784
+ warnIfSmoothScrollOnHtml();
1785
+ window.scrollTo({ left: 0, top: 0, behavior: 'instant' });
1629
1786
  }
1630
1787
  }
1631
1788
 
@@ -3070,6 +3227,7 @@ export {
3070
3227
  regraftPermanentInSlice as _regraftPermanentInSlice,
3071
3228
  prefetchSuppressed as _prefetchSuppressed,
3072
3229
  prefetchMode as _prefetchMode,
3230
+ prefetchHasHoverPointer as _prefetchHasHoverPointer,
3073
3231
  prefetch as _prefetch,
3074
3232
  prefetchTake as _prefetchTake,
3075
3233
  prefetchSaysSaveData as _prefetchSaysSaveData,
@@ -3090,6 +3248,7 @@ export function _resetPrefetch() {
3090
3248
  prefetchQueue.length = 0;
3091
3249
  prefetchQueued.clear();
3092
3250
  clearPrefetchHover();
3251
+ clearPrefetchViewTimers();
3093
3252
  }
3094
3253
 
3095
3254
  /** Test-only: read the monotonic navigation-token counter. */
@@ -3100,6 +3259,8 @@ export function _bumpNavToken() { return ++currentNavigationToken; }
3100
3259
  export function _currentPageUrl() { return currentPageUrl; }
3101
3260
  /** Test-only: set the tracker (simulates being on a specific page). */
3102
3261
  export function _setCurrentPageUrl(u) { currentPageUrl = u; }
3262
+ /** Test-only: clear the fire-once warning guards so a case can be re-exercised. */
3263
+ export function _resetWarnOnce() { warnedKeys.clear(); smoothScrollChecked = false; }
3103
3264
 
3104
3265
  /**
3105
3266
  * Predicate used by the onClick handler to decide whether a same-origin
@@ -3112,7 +3273,13 @@ export function _isNonHtmlPath(pathname) {
3112
3273
  return NON_HTML_EXTENSIONS.test(pathname);
3113
3274
  }
3114
3275
 
3115
- // Auto-enable on import (standard Turbo-Drive convention). Placed last so
3116
- // every top-level binding the router touches (notably the prefetch state)
3117
- // is initialised before enableClientRouter() runs.
3118
- enableClientRouter();
3276
+ // Auto-enable on import (standard Turbo-Drive convention) UNLESS the app opted
3277
+ // out with `webjs.clientRouter: false` (#629), which the server signals by
3278
+ // setting `window.__WEBJS_CLIENT_ROUTER__ = false` in an inline script emitted
3279
+ // BEFORE this (deferred) bundle runs. On the server `window` is undefined, so
3280
+ // the call still runs and no-ops behind its own `typeof document` guard, as
3281
+ // before. Placed last so every top-level binding the router touches (notably
3282
+ // the prefetch state) is initialised before enableClientRouter() runs.
3283
+ if (typeof window === 'undefined' || window.__WEBJS_CLIENT_ROUTER__ !== false) {
3284
+ enableClientRouter();
3285
+ }
package/src/task.js CHANGED
@@ -29,9 +29,7 @@
29
29
  * import { WebComponent, html } from '@webjsdev/core';
30
30
  * import { Task } from '@webjsdev/core/task';
31
31
  *
32
- * class UserSearch extends WebComponent {
33
- * static properties = { query: { type: String } };
34
- *
32
+ * class UserSearch extends WebComponent({ query: String }) {
35
33
  * _search = new Task(this, {
36
34
  * task: async (query, { signal }) => {
37
35
  * const res = await fetch(`/api/users?q=${query}`, { signal });
@@ -15,7 +15,8 @@
15
15
  * LOCKSTEP: this file, the JSON Schema at
16
16
  * packages/server/webjs-config.schema.json, and the server reader
17
17
  * functions MUST stay in sync. The readers are: readElideEnabled
18
- * (dev.js, elide), compileHeaderRules (headers.js, headers),
18
+ * (dev.js, elide), readSeedEnabled (dev.js, seed), readClientRouterEnabled
19
+ * (dev.js, clientRouter), compileHeaderRules (headers.js, headers),
19
20
  * compileRedirectRules / readTrailingSlashPolicy (redirects.js,
20
21
  * redirects / trailingSlash), readBasePath (base-path.js, basePath),
21
22
  * readCspConfig (csp.js, csp), and
@@ -80,6 +81,32 @@ export interface WebjsRedirectRule {
80
81
  /** The trailing-slash canonicalization policy in `webjs.trailingSlash`. */
81
82
  export type WebjsTrailingSlash = 'never' | 'always' | 'ignore';
82
83
 
84
+ /** Dev task orchestration in `webjs.dev` (#550). Read by the CLI, not the server. */
85
+ export interface WebjsDevTasks {
86
+ /**
87
+ * One-shot commands run sequentially to completion BEFORE the dev server
88
+ * boots (the old `predev` hook: `webjs db migrate`, a registry copy). A
89
+ * non-zero exit aborts the boot.
90
+ */
91
+ before?: string[];
92
+ /**
93
+ * Long-lived commands run as child processes ALONGSIDE the dev server (the
94
+ * old `concurrently` watchers: the Tailwind CLI `--watch`). Spawned once in
95
+ * the parent and torn down on exit, so a watcher cannot leak past the server.
96
+ */
97
+ parallel?: string[];
98
+ }
99
+
100
+ /** Start task orchestration in `webjs.start` (#550). Read by the CLI, not the server. */
101
+ export interface WebjsStartTasks {
102
+ /**
103
+ * One-shot commands run sequentially to completion BEFORE the prod server
104
+ * boots (the old `prestart` hook: `webjs db migrate`). A non-zero exit
105
+ * aborts the boot.
106
+ */
107
+ before?: string[];
108
+ }
109
+
83
110
  /** The object form of `webjs.csp` (the non-boolean shape). */
84
111
  export interface WebjsCspConfig {
85
112
  /**
@@ -118,6 +145,23 @@ export interface WebjsConfig {
118
145
  */
119
146
  seed?: boolean;
120
147
 
148
+ /**
149
+ * Client-router switch (#629). Default `true`: the client router auto-enables
150
+ * in the browser whenever `@webjsdev/core` loads, so any page that ships a
151
+ * component gets SPA-style navigation with no import. Set to `false` to opt
152
+ * the whole app out (pure MPA, full-page navigation; `disableClientRouter()`
153
+ * stays the programmatic escape hatch).
154
+ */
155
+ clientRouter?: boolean;
156
+
157
+ /**
158
+ * Dev/start task orchestration (#550). `webjs dev` / `webjs start` run these
159
+ * so a bare CLI invocation matches `npm run dev` / `start`. Read by the CLI
160
+ * (`packages/cli/lib/app-tasks.js`), NOT the server readers.
161
+ */
162
+ dev?: WebjsDevTasks;
163
+ start?: WebjsStartTasks;
164
+
121
165
  /** Per-path response-header rules, shaped like Next's. */
122
166
  headers?: WebjsHeaderRule[];
123
167