@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.
- package/README.md +1 -3
- package/dist/webjs-core-browser.js +3 -3
- package/dist/webjs-core-browser.js.map +2 -2
- package/dist/webjs-core-testing.js +10 -10
- package/dist/webjs-core-testing.js.map +2 -2
- package/dist/webjs-core.js +4 -4
- package/dist/webjs-core.js.map +2 -2
- package/index-browser.js +10 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/component.d.ts +72 -13
- package/src/component.js +92 -3
- package/src/render-server.js +6 -2
- package/src/router-client.js +216 -49
- package/src/task.js +1 -3
- package/src/webjs-config.d.ts +45 -1
package/src/router-client.js
CHANGED
|
@@ -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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
//
|
|
775
|
-
//
|
|
776
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
964
|
-
* fast without per-link opt-in
|
|
965
|
-
*
|
|
966
|
-
*
|
|
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
|
|
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
|
|
1017
|
-
*
|
|
1018
|
-
*
|
|
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
|
|
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 :
|
|
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
|
|
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
|
-
|
|
1249
|
-
// `
|
|
1250
|
-
|
|
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
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
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
|
-
|
|
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)
|
|
3116
|
-
//
|
|
3117
|
-
//
|
|
3118
|
-
|
|
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 });
|
package/src/webjs-config.d.ts
CHANGED
|
@@ -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),
|
|
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
|
|