@webjsdev/core 0.7.43 → 0.7.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +2 -2
- package/dist/webjs-core.js +6 -6
- package/dist/webjs-core.js.map +2 -2
- package/package.json +1 -1
- package/src/component.js +1 -1
- package/src/router-client.js +169 -22
package/package.json
CHANGED
package/src/component.js
CHANGED
|
@@ -576,7 +576,7 @@ class WebComponentBase extends Base {
|
|
|
576
576
|
`properties via the factory instead: \`class ${name} extends WebComponent({ ` +
|
|
577
577
|
`count: Number })\`. Use the \`prop()\` helper for options ` +
|
|
578
578
|
`(\`prop(Number, { reflect: true })\`) and set defaults in the ` +
|
|
579
|
-
`constructor after \`super()\`. See https://
|
|
579
|
+
`constructor after \`super()\`. See https://webjs.dev/docs/components.`,
|
|
580
580
|
);
|
|
581
581
|
}
|
|
582
582
|
C = Object.getPrototypeOf(C);
|
package/src/router-client.js
CHANGED
|
@@ -764,19 +764,58 @@ function shouldFullLoadDuringParse(isPopState, frameId) {
|
|
|
764
764
|
}
|
|
765
765
|
|
|
766
766
|
/**
|
|
767
|
-
*
|
|
768
|
-
*
|
|
769
|
-
*
|
|
770
|
-
*
|
|
767
|
+
* The client router degraded a soft navigation. Records WHY (the `cause`), so
|
|
768
|
+
* "why did my SPA nav do a full reload?" is answerable instead of guessed at.
|
|
769
|
+
*
|
|
770
|
+
* Two channels, deliberately different in reach:
|
|
771
|
+
*
|
|
772
|
+
* - A **`webjs:navigation-fallback` event on `document`, in EVERY environment**,
|
|
773
|
+
* detail `{ cause, href, willReload }`. Dispatch convention matches
|
|
774
|
+
* `webjs:navigate` / `webjs:prefetch` / `webjs:navigation-error`.
|
|
775
|
+
* - A dev-only console warning, deduped per cause so a repeat does not spam.
|
|
776
|
+
*
|
|
777
|
+
* The event exists because the console warning alone made this class of bug
|
|
778
|
+
* UNDIAGNOSABLE in production (#1114). A degradation is correct behaviour, not
|
|
779
|
+
* an error, so nothing was logged and nothing was thrown, and a deployed app had
|
|
780
|
+
* no way to observe that a click had turned into a full document load. The
|
|
781
|
+
* user-visible symptoms (a loading spinner in the browser tab, a whole-document
|
|
782
|
+
* flash including preserved chrome) were then attributed to a styling problem
|
|
783
|
+
* for a full investigation cycle, because the actual cause emitted no signal.
|
|
784
|
+
*
|
|
785
|
+
* An event costs nothing when nobody listens, is greppable from a page console,
|
|
786
|
+
* and lets a deployed app wire this to analytics. It is NOT cancelable: by the
|
|
787
|
+
* time this fires the decision to degrade is already made and is the only safe
|
|
788
|
+
* option (#1015 chose a bounded full load over a heuristic recovery that could
|
|
789
|
+
* corrupt the DOM silently), so there is nothing for a listener to veto.
|
|
771
790
|
*
|
|
772
791
|
* @param {string} cause a short stable slug for the degradation reason
|
|
773
792
|
* @param {string} href the destination the router fell back to loading
|
|
793
|
+
* @param {boolean} [willReload] true when a full document load follows (the
|
|
794
|
+
* default). False for a degradation that does NOT reload, so a listener can
|
|
795
|
+
* tell "this click became a document load" from "this background op was
|
|
796
|
+
* dropped", which are very different user-visible events.
|
|
774
797
|
*/
|
|
775
|
-
function
|
|
798
|
+
function reportFallback(cause, href, willReload = true) {
|
|
799
|
+
if (typeof document !== 'undefined' && typeof CustomEvent !== 'undefined') {
|
|
800
|
+
try {
|
|
801
|
+
document.dispatchEvent(new CustomEvent('webjs:navigation-fallback', {
|
|
802
|
+
detail: { cause, href, willReload },
|
|
803
|
+
}));
|
|
804
|
+
} catch {
|
|
805
|
+
// A listener that throws must never turn a correct degradation into a
|
|
806
|
+
// broken navigation. Diagnostics are strictly best-effort.
|
|
807
|
+
}
|
|
808
|
+
}
|
|
776
809
|
if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') return;
|
|
810
|
+
// Word the warning from `willReload`: several causes deliberately do NOT
|
|
811
|
+
// reload (a suppressed deploy reload, a discarded background revalidation),
|
|
812
|
+
// and claiming a full page load for those sends a reader hunting the wrong
|
|
813
|
+
// symptom.
|
|
777
814
|
warnOnce(
|
|
778
815
|
`fallback:${cause}`,
|
|
779
|
-
|
|
816
|
+
willReload
|
|
817
|
+
? `[webjs] client router fell back to a full page load (${cause}) navigating to ${href}. This is correct (no DOM corruption), just not a soft nav.`
|
|
818
|
+
: `[webjs] client router degraded a soft navigation (${cause}) for ${href}, without a full page load.`
|
|
780
819
|
);
|
|
781
820
|
}
|
|
782
821
|
|
|
@@ -1137,7 +1176,7 @@ async function performNavigation(href, isPopState, frameId) {
|
|
|
1137
1176
|
// Scoped to frameless forward navs: popstate is browser-driven, and a frame
|
|
1138
1177
|
// nav carries its own boundary element.
|
|
1139
1178
|
if (shouldFullLoadDuringParse(isPopState, frameId) && typeof location !== 'undefined') {
|
|
1140
|
-
|
|
1179
|
+
reportFallback('readyState-loading', href);
|
|
1141
1180
|
location.href = href;
|
|
1142
1181
|
return;
|
|
1143
1182
|
}
|
|
@@ -1588,6 +1627,22 @@ function prefetch(href) {
|
|
|
1588
1627
|
if (typeof fetch !== 'function') return;
|
|
1589
1628
|
if (prefetchSaysSaveData()) return;
|
|
1590
1629
|
const key = cacheKey(href);
|
|
1630
|
+
// Never prefetch the page we are already ON (#1106). The request cannot help
|
|
1631
|
+
// any future navigation, because a same-URL click short-circuits, and it
|
|
1632
|
+
// occupies one of the capped cache slots until its TTL expires. Fires
|
|
1633
|
+
// routinely: a hover's intent timer outlives the click it belongs to, so it
|
|
1634
|
+
// resolves after the swap has landed and now points at the current page.
|
|
1635
|
+
//
|
|
1636
|
+
// It ALSO removes one producer of the #1114 stale entry, though it is not the
|
|
1637
|
+
// cure for it (the anchor check in prefetchTake is). Worth being precise,
|
|
1638
|
+
// because the first version of this fix had the causality backwards: the
|
|
1639
|
+
// server short-circuits on LAYOUT segments only and ignores the page's own
|
|
1640
|
+
// boundary entry, so a self-prefetch is not a near-empty response, it is a
|
|
1641
|
+
// normal fragment anchored at the innermost LAYOUT. That fragment is
|
|
1642
|
+
// perfectly applicable from a sibling page and fails only from outside that
|
|
1643
|
+
// layout, which is exactly what the anchor check catches, and which a
|
|
1644
|
+
// never-clicked hover on a sibling link produces without this guard.
|
|
1645
|
+
if (typeof location !== 'undefined' && key === cacheKey(location.href)) return;
|
|
1591
1646
|
if (prefetchInflight.has(key)) return;
|
|
1592
1647
|
if (prefetchQueued.has(key)) return;
|
|
1593
1648
|
const existing = prefetchCache.get(key);
|
|
@@ -1699,20 +1754,83 @@ function prefetchStore(key, entry) {
|
|
|
1699
1754
|
}
|
|
1700
1755
|
}
|
|
1701
1756
|
|
|
1757
|
+
/**
|
|
1758
|
+
* The `segment:routeKey` a cached fragment is ANCHORED at, or null when it
|
|
1759
|
+
* carries no boundary (a full document, or an unparseable body).
|
|
1760
|
+
*
|
|
1761
|
+
* A reduced response begins at the deepest boundary the server short-circuited
|
|
1762
|
+
* on, so its FIRST open-boundary comment is the join point the swap will look
|
|
1763
|
+
* for in the live DOM. Verified against the server: `have=/:/` yields a
|
|
1764
|
+
* fragment starting at `<!--wj:children:/:/-->` (anchored at the root, so it
|
|
1765
|
+
* applies on any page), while `have=/docs:/docs,/:/` yields one starting at
|
|
1766
|
+
* `<!--wj:children:/docs:/docs-->` (applies only under /docs).
|
|
1767
|
+
*
|
|
1768
|
+
* Read with a regex rather than a parse: this runs on the click path, and the
|
|
1769
|
+
* full parse happens moments later in `fetchAndApply` anyway. The pattern is
|
|
1770
|
+
* anchored on the literal marker the SSR emits, and a miss is treated as
|
|
1771
|
+
* "no constraint", so a shape change degrades to the old permissive behaviour
|
|
1772
|
+
* rather than silently rejecting every entry.
|
|
1773
|
+
*
|
|
1774
|
+
* @param {string} html
|
|
1775
|
+
* @returns {string | null}
|
|
1776
|
+
*/
|
|
1777
|
+
function prefetchAnchor(html) {
|
|
1778
|
+
const m = /<!--wj:children:([^>]*?)-->/.exec(html || '');
|
|
1779
|
+
return m ? m[1] : null;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1702
1782
|
/**
|
|
1703
1783
|
* Consume a fresh speculative entry for `href`, removing it (a fragment
|
|
1704
1784
|
* is single-use: once applied it becomes a real snapshot). Returns null
|
|
1705
|
-
* on miss
|
|
1785
|
+
* on miss, when the entry has aged past the TTL, or when the live DOM no
|
|
1786
|
+
* longer offers the boundary the fragment is anchored at.
|
|
1706
1787
|
*
|
|
1707
1788
|
* @param {string} href
|
|
1789
|
+
* @param {string} [liveKeysOverride] the `X-Webjs-Have` view to validate the
|
|
1790
|
+
* anchor against, when the caller holds a truer one than the live DOM does.
|
|
1791
|
+
* Used for the optimistic loading skeleton, which deletes nested boundaries
|
|
1792
|
+
* before the fetch, so reading the DOM here would under-report them.
|
|
1708
1793
|
* @returns {PrefetchEntry | null}
|
|
1709
1794
|
*/
|
|
1710
|
-
function prefetchTake(href) {
|
|
1795
|
+
function prefetchTake(href, liveKeysOverride) {
|
|
1711
1796
|
const key = cacheKey(href);
|
|
1712
1797
|
const entry = prefetchCache.get(key);
|
|
1713
1798
|
if (!entry) return null;
|
|
1799
|
+
if ((nowMs() - entry.at) >= PREFETCH_TTL) { prefetchCache.delete(key); return null; }
|
|
1800
|
+
// The reduced response VARIES on X-Webjs-Have, and this cache is a
|
|
1801
|
+
// client-side cache of that response, so it has to respect its own vary
|
|
1802
|
+
// dimension (#1114). The dimension is NOT the whole have string though: a
|
|
1803
|
+
// fragment is anchored at ONE boundary and applies to any live DOM that still
|
|
1804
|
+
// offers that boundary with the same route-key. Checking the whole string
|
|
1805
|
+
// instead discards entries that would have applied (prefetch /docs/x from /,
|
|
1806
|
+
// soft-nav to /blog, click: the fragment is anchored at the root, which /blog
|
|
1807
|
+
// also has), and worse, `applyOptimisticLoading` removes the page's own
|
|
1808
|
+
// boundary before the fetch to insert a loading skeleton, so on any route
|
|
1809
|
+
// with a `loading.{js,ts}` the live have is legitimately SHORTER at consume
|
|
1810
|
+
// time than at prefetch time with no navigation at all.
|
|
1811
|
+
//
|
|
1812
|
+
// So: consume when the anchor is still live, discard when it is not.
|
|
1813
|
+
// Discarding costs one round-trip; consuming a fragment whose anchor is gone
|
|
1814
|
+
// hands `applySwap` a tree sharing no boundary with the live DOM, and the
|
|
1815
|
+
// #1015 integrity degradation correctly turns that into a full page load,
|
|
1816
|
+
// which is the whole-document flash this guard exists to prevent.
|
|
1817
|
+
const anchor = prefetchAnchor(entry.html);
|
|
1818
|
+
if (anchor) {
|
|
1819
|
+
// `buildHaveHeader()` emits exactly comma-joined `segment:routeKey` entries,
|
|
1820
|
+
// so it is the single source of truth for the comparison format: membership
|
|
1821
|
+
// in it IS "the live DOM offers this boundary with this route-key". It
|
|
1822
|
+
// returns '' mid-parse, which rejects an anchored entry, and that is the
|
|
1823
|
+
// safe direction (a click during parse takes the full-load path regardless).
|
|
1824
|
+
const liveKeys = new Set(
|
|
1825
|
+
String(liveKeysOverride != null ? liveKeysOverride : (buildHaveHeader() || ''))
|
|
1826
|
+
.split(',').filter(Boolean)
|
|
1827
|
+
);
|
|
1828
|
+
if (!liveKeys.has(anchor)) {
|
|
1829
|
+
prefetchCache.delete(key);
|
|
1830
|
+
return null;
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1714
1833
|
prefetchCache.delete(key);
|
|
1715
|
-
if ((nowMs() - entry.at) >= PREFETCH_TTL) return null;
|
|
1716
1834
|
return entry;
|
|
1717
1835
|
}
|
|
1718
1836
|
|
|
@@ -1944,7 +2062,15 @@ function handleNavigationError(href, status, error) {
|
|
|
1944
2062
|
// cross-document nav). Fall back to a hard load so an unrecoverable case
|
|
1945
2063
|
// is not a silent dead-end. This is the exception, reached only after
|
|
1946
2064
|
// the event was not cancelled AND no in-place target exists.
|
|
1947
|
-
|
|
2065
|
+
//
|
|
2066
|
+
// Report it like every other degradation (#1114): this IS a click turning
|
|
2067
|
+
// into a document load, so an app watching `webjs:navigation-fallback` to
|
|
2068
|
+
// count full loads must see it. The preceding `webjs:navigation-error`
|
|
2069
|
+
// carries no `cause` / `willReload`, so it is not a substitute.
|
|
2070
|
+
if (typeof location !== 'undefined') {
|
|
2071
|
+
reportFallback('navigation-error-unrecoverable', href);
|
|
2072
|
+
location.href = href;
|
|
2073
|
+
}
|
|
1948
2074
|
}
|
|
1949
2075
|
|
|
1950
2076
|
/**
|
|
@@ -1994,13 +2120,19 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
|
|
|
1994
2120
|
const busyFrame = frameId ? markFrameBusy(frameId, myToken) : null;
|
|
1995
2121
|
try {
|
|
1996
2122
|
try {
|
|
1997
|
-
// Warm-cache fast path: a hover/focus/viewport prefetch may
|
|
1998
|
-
//
|
|
1999
|
-
//
|
|
2000
|
-
//
|
|
2001
|
-
//
|
|
2002
|
-
//
|
|
2003
|
-
|
|
2123
|
+
// Warm-cache fast path: a hover/focus/viewport prefetch may already hold
|
|
2124
|
+
// this page. Consume it instead of going to the network, so the click
|
|
2125
|
+
// resolves with no round-trip. Only for plain GET navs without a frame
|
|
2126
|
+
// target; form submissions and frame swaps always hit the server. The entry
|
|
2127
|
+
// is single-use (prefetchTake removes it), TTL-guarded, and validated by its
|
|
2128
|
+
// ANCHOR rather than by an identical X-Webjs-Have (#1114): a fragment
|
|
2129
|
+
// applies wherever the boundary it starts at is still live, so an unrelated
|
|
2130
|
+
// navigation between the prefetch and this click does not disqualify it.
|
|
2131
|
+
// The optimistic skeleton has already deleted nested boundaries by now, so
|
|
2132
|
+
// pass the view captured before it ran.
|
|
2133
|
+
const prefetched = (method === 'GET' && !body && !frameId)
|
|
2134
|
+
? prefetchTake(href, optimisticState ? optimisticState.haveKeys : undefined)
|
|
2135
|
+
: null;
|
|
2004
2136
|
if (prefetched) {
|
|
2005
2137
|
html = prefetched.html;
|
|
2006
2138
|
incomingBuild = prefetched.build;
|
|
@@ -2753,14 +2885,17 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
2753
2885
|
// user is on a stale importmap but at least the page
|
|
2754
2886
|
// renders.
|
|
2755
2887
|
sessionStorage.removeItem(flag);
|
|
2888
|
+
reportFallback('deploy-mismatch-reload-suppressed', href, false);
|
|
2756
2889
|
} else {
|
|
2757
2890
|
if (sessionStorage) sessionStorage.setItem(flag, '1');
|
|
2891
|
+
reportFallback('deploy-mismatch', href);
|
|
2758
2892
|
location.href = href;
|
|
2759
2893
|
return;
|
|
2760
2894
|
}
|
|
2761
2895
|
} catch {
|
|
2762
2896
|
// sessionStorage unavailable (private mode w/ quota etc.):
|
|
2763
2897
|
// fall through to a single reload like before.
|
|
2898
|
+
reportFallback('deploy-mismatch', href);
|
|
2764
2899
|
location.href = href;
|
|
2765
2900
|
return;
|
|
2766
2901
|
}
|
|
@@ -2896,7 +3031,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
2896
3031
|
// is already viewing a page, so a background op must never yank them
|
|
2897
3032
|
// through a hard load; it takes the in-place path below.
|
|
2898
3033
|
if (href && !revalidating && typeof location !== 'undefined') {
|
|
2899
|
-
|
|
3034
|
+
reportFallback(!here ? 'live-boundaries-malformed'
|
|
2900
3035
|
: !there ? 'incoming-boundaries-malformed'
|
|
2901
3036
|
: 'no-shared-boundary', href);
|
|
2902
3037
|
location.href = href;
|
|
@@ -2909,7 +3044,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
2909
3044
|
// fragment, so the full-body swap below would wipe the shell from a
|
|
2910
3045
|
// background op. Doing nothing is the only safe degradation here.
|
|
2911
3046
|
if (href && revalidating) {
|
|
2912
|
-
|
|
3047
|
+
reportFallback('revalidation-discarded', href, false);
|
|
2913
3048
|
return 'discard';
|
|
2914
3049
|
}
|
|
2915
3050
|
|
|
@@ -3586,6 +3721,16 @@ function applyOptimisticLoading() {
|
|
|
3586
3721
|
if (deepest === null || tpl === null) return null;
|
|
3587
3722
|
|
|
3588
3723
|
const slot = slots.get(deepest);
|
|
3724
|
+
// Snapshot the boundary keys BEFORE the skeleton wipes them (#1114). The
|
|
3725
|
+
// range below deletes everything between this slot's markers, which includes
|
|
3726
|
+
// every NESTED boundary comment, so `buildHaveHeader()` afterwards is
|
|
3727
|
+
// legitimately shorter than the page really is. `prefetchTake` validates a
|
|
3728
|
+
// cached fragment's anchor against the live boundaries, and without this it
|
|
3729
|
+
// would judge against the skeleton's truncated view: on an app whose only
|
|
3730
|
+
// loading.{js,ts} sits at the root, every deeper anchor vanishes and NO
|
|
3731
|
+
// prefetch is ever consumable. Carried on the state that already threads to
|
|
3732
|
+
// fetchAndApply, so nothing new has to be plumbed.
|
|
3733
|
+
const haveKeys = buildHaveHeader();
|
|
3589
3734
|
/** @type {Node[]} */
|
|
3590
3735
|
const oldChildren = [];
|
|
3591
3736
|
for (let n = slot.start.nextSibling; n && n !== slot.end; n = n.nextSibling) {
|
|
@@ -3597,10 +3742,10 @@ function applyOptimisticLoading() {
|
|
|
3597
3742
|
range.setEndBefore(slot.end);
|
|
3598
3743
|
range.deleteContents();
|
|
3599
3744
|
slot.start.parentNode.insertBefore(tpl.content.cloneNode(true), slot.end);
|
|
3600
|
-
return { slot, oldChildren, token: currentNavigationToken };
|
|
3745
|
+
return { slot, oldChildren, token: currentNavigationToken, haveKeys };
|
|
3601
3746
|
}
|
|
3602
3747
|
|
|
3603
|
-
/** @param {{ slot: { start: Comment, end: Comment }, oldChildren: Node[], token: number } | null} state */
|
|
3748
|
+
/** @param {{ slot: { start: Comment, end: Comment }, oldChildren: Node[], token: number, haveKeys?: string } | null} state */
|
|
3604
3749
|
function restoreOptimistic(state) {
|
|
3605
3750
|
if (!state) return;
|
|
3606
3751
|
// A newer nav superseded the one that captured this state: don't
|
|
@@ -4255,6 +4400,8 @@ export {
|
|
|
4255
4400
|
prefetchHasHoverPointer as _prefetchHasHoverPointer,
|
|
4256
4401
|
prefetch as _prefetch,
|
|
4257
4402
|
prefetchTake as _prefetchTake,
|
|
4403
|
+
prefetchAnchor as _prefetchAnchor,
|
|
4404
|
+
applyOptimisticLoading as _applyOptimisticLoading,
|
|
4258
4405
|
prefetchSaysSaveData as _prefetchSaysSaveData,
|
|
4259
4406
|
readStreamedShell as _readStreamedShell,
|
|
4260
4407
|
takeResolveUnit as _takeResolveUnit,
|