@webjsdev/core 0.7.45 → 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.
- package/dist/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +22 -21
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -6
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +5 -0
- package/index.d.ts +4 -0
- package/index.js +5 -0
- package/package.json +2 -2
- package/src/asset-url.d.ts +3 -0
- package/src/asset-url.js +99 -0
- package/src/component.js +137 -2
- package/src/form-action.d.ts +16 -0
- package/src/form-action.js +1257 -0
- package/src/optimistic.js +7 -2
- package/src/render-client.js +1004 -107
- package/src/render-server.js +847 -70
- package/src/router-client.js +338 -23
- package/src/webjs-config.d.ts +49 -17
package/src/router-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1327
|
+
hardNavigate(href);
|
|
1181
1328
|
return;
|
|
1182
1329
|
}
|
|
1183
1330
|
|
|
@@ -1674,7 +1821,16 @@ function prefetch(href) {
|
|
|
1674
1821
|
const headers = { 'x-webjs-router': '1', 'x-webjs-prefetch': '1' };
|
|
1675
1822
|
if (have) headers['x-webjs-have'] = have;
|
|
1676
1823
|
|
|
1677
|
-
|
|
1824
|
+
// `no-cache` (revalidate, NOT bypass) is load-bearing (#1131): the deploy
|
|
1825
|
+
// check below reads x-webjs-build / x-webjs-src off this response, and a
|
|
1826
|
+
// page served with a browser max-age would otherwise satisfy the fetch
|
|
1827
|
+
// wholly from the HTTP cache, replaying pre-deploy ids. The check would then
|
|
1828
|
+
// compare two equally stale values and skip the eviction, so a deploy stayed
|
|
1829
|
+
// invisible for the whole freshness window plus one stale-while-revalidate
|
|
1830
|
+
// serving per URL. With stable page ETags a forced revalidation is a
|
|
1831
|
+
// conditional request answered 304, so the cost is a header round-trip, not
|
|
1832
|
+
// a re-download.
|
|
1833
|
+
fetch(href, { method: 'GET', headers, credentials: 'same-origin', cache: 'no-cache' })
|
|
1678
1834
|
.then(async (resp) => {
|
|
1679
1835
|
const ctype = resp.headers.get('content-type') || '';
|
|
1680
1836
|
if (!/^text\/html\b/i.test(ctype)) return;
|
|
@@ -2069,7 +2225,7 @@ function handleNavigationError(href, status, error) {
|
|
|
2069
2225
|
// carries no `cause` / `willReload`, so it is not a substitute.
|
|
2070
2226
|
if (typeof location !== 'undefined') {
|
|
2071
2227
|
reportFallback('navigation-error-unrecoverable', href);
|
|
2072
|
-
|
|
2228
|
+
hardNavigate(href);
|
|
2073
2229
|
}
|
|
2074
2230
|
}
|
|
2075
2231
|
|
|
@@ -2155,13 +2311,21 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
|
|
|
2155
2311
|
}
|
|
2156
2312
|
|
|
2157
2313
|
/** @type {RequestInit} */
|
|
2158
|
-
|
|
2314
|
+
// `no-cache` for the same reason as the prefetch fetch (#1131): applySwap
|
|
2315
|
+
// hard-reloads on a build change it can only see if these headers are
|
|
2316
|
+
// live, not replayed from the HTTP cache.
|
|
2317
|
+
const init = { method, headers, credentials: 'same-origin', cache: 'no-cache' };
|
|
2159
2318
|
if (signal) init.signal = signal;
|
|
2160
2319
|
if (body != null && method !== 'GET' && method !== 'HEAD') init.body = body;
|
|
2161
2320
|
|
|
2162
2321
|
const resp = await fetch(href, init);
|
|
2163
2322
|
respStatus = resp.status;
|
|
2164
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);
|
|
2165
2329
|
const ctype = resp.headers.get('content-type') || '';
|
|
2166
2330
|
const isHTML = /^text\/html\b/i.test(ctype);
|
|
2167
2331
|
const isStream = ctype.toLowerCase().indexOf(STREAM_MIME) === 0;
|
|
@@ -2619,6 +2783,24 @@ function runWithTransition(thunk, afterFinished) {
|
|
|
2619
2783
|
return Promise.resolve();
|
|
2620
2784
|
}
|
|
2621
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
|
+
|
|
2622
2804
|
/**
|
|
2623
2805
|
* Persist `data-webjs-permanent` elements across a swap by NODE IDENTITY.
|
|
2624
2806
|
*
|
|
@@ -2672,6 +2854,7 @@ function regraftPermanentElements(currentRoot, incomingRoot) {
|
|
|
2672
2854
|
// incoming placeholder. The swap then adopts the live node.
|
|
2673
2855
|
if (placeholder === live) continue;
|
|
2674
2856
|
parent.replaceChild(live, placeholder);
|
|
2857
|
+
regraftedPermanents.add(live);
|
|
2675
2858
|
}
|
|
2676
2859
|
}
|
|
2677
2860
|
|
|
@@ -2714,12 +2897,16 @@ function regraftPermanentInSlice(liveSlice, incomingSlice) {
|
|
|
2714
2897
|
const parent = placeholder.parentNode;
|
|
2715
2898
|
if (parent) {
|
|
2716
2899
|
parent.replaceChild(live, placeholder);
|
|
2900
|
+
regraftedPermanents.add(live);
|
|
2717
2901
|
} else {
|
|
2718
2902
|
// Placeholder is a top-level slice member with no parent (detached):
|
|
2719
2903
|
// replace it in the incomingSlice array so the reconciler inserts the
|
|
2720
2904
|
// live node in that position.
|
|
2721
2905
|
const idx = incomingSlice.indexOf(placeholder);
|
|
2722
|
-
if (idx !== -1)
|
|
2906
|
+
if (idx !== -1) {
|
|
2907
|
+
incomingSlice[idx] = live;
|
|
2908
|
+
regraftedPermanents.add(live);
|
|
2909
|
+
}
|
|
2723
2910
|
}
|
|
2724
2911
|
}
|
|
2725
2912
|
}
|
|
@@ -2889,14 +3076,14 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
2889
3076
|
} else {
|
|
2890
3077
|
if (sessionStorage) sessionStorage.setItem(flag, '1');
|
|
2891
3078
|
reportFallback('deploy-mismatch', href);
|
|
2892
|
-
|
|
3079
|
+
hardNavigate(href);
|
|
2893
3080
|
return;
|
|
2894
3081
|
}
|
|
2895
3082
|
} catch {
|
|
2896
3083
|
// sessionStorage unavailable (private mode w/ quota etc.):
|
|
2897
3084
|
// fall through to a single reload like before.
|
|
2898
3085
|
reportFallback('deploy-mismatch', href);
|
|
2899
|
-
|
|
3086
|
+
hardNavigate(href);
|
|
2900
3087
|
return;
|
|
2901
3088
|
}
|
|
2902
3089
|
} else if (!mismatch) {
|
|
@@ -3034,7 +3221,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
3034
3221
|
reportFallback(!here ? 'live-boundaries-malformed'
|
|
3035
3222
|
: !there ? 'incoming-boundaries-malformed'
|
|
3036
3223
|
: 'no-shared-boundary', href);
|
|
3037
|
-
|
|
3224
|
+
hardNavigate(href);
|
|
3038
3225
|
return;
|
|
3039
3226
|
}
|
|
3040
3227
|
|
|
@@ -3130,12 +3317,7 @@ function replaceBoundaryRange(target, source) {
|
|
|
3130
3317
|
for (const n of incomingSlice) {
|
|
3131
3318
|
liveParent.insertBefore(n, target.end);
|
|
3132
3319
|
}
|
|
3133
|
-
|
|
3134
|
-
if (n.nodeType === 1) {
|
|
3135
|
-
reactivateScripts(/** @type {Element} */ (n));
|
|
3136
|
-
upgradeCustomElements(/** @type {Element} */ (n));
|
|
3137
|
-
}
|
|
3138
|
-
}
|
|
3320
|
+
activateSwappedRange(target);
|
|
3139
3321
|
}
|
|
3140
3322
|
|
|
3141
3323
|
/**
|
|
@@ -3188,13 +3370,13 @@ function swapMarkerRange(target, source, _doc) {
|
|
|
3188
3370
|
// Run the keyed diff.
|
|
3189
3371
|
reconcileSiblings(liveParent, target.start, target.end, liveSlice, incomingSlice);
|
|
3190
3372
|
|
|
3191
|
-
// Upgrade + activate scripts in the just-swapped range.
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
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);
|
|
3198
3380
|
}
|
|
3199
3381
|
|
|
3200
3382
|
/**
|
|
@@ -4348,11 +4530,127 @@ async function streamBoundariesProgressively(reader, dec, initialBuf, isCurrent)
|
|
|
4348
4530
|
}
|
|
4349
4531
|
}
|
|
4350
4532
|
|
|
4351
|
-
/**
|
|
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
|
+
*/
|
|
4352
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
|
+
|
|
4353
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;
|
|
4354
4609
|
old.replaceWith(cloneScriptWithCorrectNonce(/** @type {HTMLScriptElement} */ (old)));
|
|
4355
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
|
+
}
|
|
4356
4654
|
}
|
|
4357
4655
|
|
|
4358
4656
|
/* ====================================================================
|
|
@@ -4363,6 +4661,9 @@ export {
|
|
|
4363
4661
|
addNewHeadElements as _addNewHeadElements,
|
|
4364
4662
|
mergeHead as _mergeHead,
|
|
4365
4663
|
reactivateScripts as _reactivateScripts,
|
|
4664
|
+
isPreBootNavigation as _isPreBootNavigation,
|
|
4665
|
+
FALLBACK_MARKER_KEY as _FALLBACK_MARKER_KEY,
|
|
4666
|
+
activateSwappedRange as _activateSwappedRange,
|
|
4366
4667
|
findAnchorInPath as _findAnchorInPath,
|
|
4367
4668
|
activeFrameId as _activeFrameId,
|
|
4368
4669
|
resolveTargetFrameId as _resolveTargetFrameId,
|
|
@@ -4423,6 +4724,20 @@ export function _resetPrefetch() {
|
|
|
4423
4724
|
clearPrefetchViewTimers();
|
|
4424
4725
|
}
|
|
4425
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
|
+
|
|
4426
4741
|
/** Test-only: read the monotonic navigation-token counter. */
|
|
4427
4742
|
export function _navToken() { return currentNavigationToken; }
|
|
4428
4743
|
/** Test-only: bump the navigation-token counter (simulates a fresh nav). */
|
package/src/webjs-config.d.ts
CHANGED
|
@@ -7,22 +7,18 @@
|
|
|
7
7
|
* import type { WebjsConfig } from '@webjsdev/core';
|
|
8
8
|
* const config: WebjsConfig = { trailingSlash: 'never', csp: true };
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* typo'd key (e.g. `redirect` for
|
|
12
|
-
* the feature stayed at its default
|
|
13
|
-
* the published JSON Schema close that
|
|
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
|
|
17
|
-
*
|
|
18
|
-
* (
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|