@webjsdev/core 0.7.64 → 0.8.0
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 +21 -22
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +6 -7
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +6 -2
- package/index.d.ts +7 -3
- package/index.js +6 -2
- package/package.json +1 -1
- package/src/attribute-directives.d.ts +6 -0
- package/src/attribute-directives.js +49 -0
- package/src/attribute-reader.js +37 -3
- package/src/binding-prefixes.js +10 -41
- package/src/component/lifecycle.js +261 -101
- package/src/component/reactive.js +5 -4
- package/src/component.d.ts +11 -5
- package/src/directives.d.ts +2 -0
- package/src/directives.js +2 -0
- package/src/escape.js +12 -2
- package/src/form-action.js +7 -7
- package/src/html.d.ts +4 -0
- package/src/html.js +22 -1
- package/src/optimistic.js +3 -3
- package/src/registry.d.ts +5 -0
- package/src/registry.js +35 -4
- package/src/render-client/adopt-slots.js +245 -0
- package/src/render-client/parts.js +53 -19
- package/src/render-client/reconciler.js +28 -12
- package/src/render-client/template-compiler.js +19 -5
- package/src/render-client.d.ts +2 -1
- package/src/render-server/attrs.js +5 -4
- package/src/render-server/compile.js +433 -0
- package/src/render-server/dsd.js +11 -435
- package/src/render-server/errors.js +84 -0
- package/src/render-server/expand.js +346 -0
- package/src/render-server/html-scan.js +19 -358
- package/src/render-server/served-attrs.js +79 -0
- package/src/render-server/stream.js +11 -13
- package/src/render-server/template-renderer.js +13 -936
- package/src/render-server/walk.js +798 -0
- package/src/router-client/anchors.js +3 -1
- package/src/router-client/boundaries.js +15 -1
- package/src/router-client/constants.js +30 -0
- package/src/router-client/fetch-apply.js +21 -0
- package/src/router-client/frames.js +6 -3
- package/src/router-client/head-merge.js +58 -10
- package/src/router-client/navigator.js +4 -0
- package/src/router-client/owned-styles.js +372 -0
- package/src/router-client/prefetch.js +3 -0
- package/src/router-client/snapshot-cache.js +2 -12
- package/src/router-client/stylesheets.js +191 -0
- package/src/router-client/swap.js +49 -9
- package/src/sentinels.d.ts +2 -0
- package/src/sentinels.js +4 -0
- package/src/slot.js +1 -1
- package/src/webjs-config.d.ts +1 -1
- package/src/render-server/slots.js +0 -213
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { SNAPSHOT_CAP } from './constants.js';
|
|
10
10
|
|
|
11
11
|
/** @typedef {{ html: string, scrollX: number, scrollY: number, scrollHeight: number }} Snapshot */
|
|
12
|
-
/** @type {Map<string, Snapshot
|
|
12
|
+
/** @type {Map<string, Snapshot>} */
|
|
13
13
|
export const snapshotCache = new Map();
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -59,9 +59,7 @@ export function snapshotCurrent(url) {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
* Look up a cached snapshot by URL
|
|
63
|
-
* null. Tolerates legacy string entries (e.g. from test fixtures that
|
|
64
|
-
* `_snapshotCache.set('/x', 'snap')`).
|
|
62
|
+
* Look up a cached snapshot by URL, or null on a miss.
|
|
65
63
|
*
|
|
66
64
|
* @param {string} url
|
|
67
65
|
* @returns {Snapshot | null}
|
|
@@ -73,14 +71,6 @@ export function snapshotGet(url) {
|
|
|
73
71
|
// Move-to-front.
|
|
74
72
|
snapshotCache.delete(key);
|
|
75
73
|
snapshotCache.set(key, v);
|
|
76
|
-
// A legacy string entry carries no offsets and no height. `scrollHeight: 0`
|
|
77
|
-
// is stated rather than left undefined so the returned object satisfies the
|
|
78
|
-
// `Snapshot` typedef, and because `reserveRestoredHeight` treats a
|
|
79
|
-
// non-positive height as "nothing to reserve" and no-ops: there is no
|
|
80
|
-
// recorded height to hold, so reserving is not merely skippable, it is
|
|
81
|
-
// meaningless. Such an entry restores with no reservation, which is the
|
|
82
|
-
// pre-#1428 behaviour and correct for a snapshot that never recorded one.
|
|
83
|
-
if (typeof v === 'string') return { html: v, scrollX: 0, scrollY: 0, scrollHeight: 0 };
|
|
84
74
|
return v;
|
|
85
75
|
}
|
|
86
76
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Stylesheets a navigation brings with it are loaded BEFORE the swap (#1666).
|
|
4
|
+
*
|
|
5
|
+
* A soft navigation parses the response and applies it in the same task, so a
|
|
6
|
+
* `<link rel="stylesheet">` the incoming page carries starts loading only once
|
|
7
|
+
* it is in the live document, and the new content paints for a frame or more
|
|
8
|
+
* without it: a flash of the page in the wrong styles. Turbo Drive closes the
|
|
9
|
+
* same gap by appending each new head stylesheet and awaiting its `load`
|
|
10
|
+
* before it renders, Remix v2 preloads a route's links and awaits them before
|
|
11
|
+
* it commits the transition, and the Next.js app router holds a commit until
|
|
12
|
+
* new route CSS has loaded.
|
|
13
|
+
*
|
|
14
|
+
* WebJs reads the BODY as well as the head, because a section's stylesheet can
|
|
15
|
+
* only ride the body on a soft navigation: only the root layout may write
|
|
16
|
+
* `<head>`, and the `X-Webjs-Have` short-circuit never re-renders it, so a
|
|
17
|
+
* nested layout or a page that needs a stylesheet emits its link inline. On a
|
|
18
|
+
* frame navigation only the frame's own subtree is read, since nothing else in
|
|
19
|
+
* the response is swapped in.
|
|
20
|
+
*
|
|
21
|
+
* The router adds no stylesheet state of its own; it only bridges the load
|
|
22
|
+
* gap, Remix's preload-then-render shape. Each stylesheet the live document
|
|
23
|
+
* does not already link gets a TEMPORARY copy in `<head>`, marked
|
|
24
|
+
* `data-webjs-preload-style`, and the swap waits for it. Once the swap has
|
|
25
|
+
* committed, `releaseIncomingStylesheets` removes each copy as soon as the
|
|
26
|
+
* page's own link to the same file has loaded (from the HTTP cache by then),
|
|
27
|
+
* so some copy of the sheet applies on every frame and none outlives it. A
|
|
28
|
+
* copy whose file the swapped-in page does not link is removed at once, and
|
|
29
|
+
* so is every copy of a navigation that was superseded or discarded. A sheet
|
|
30
|
+
* that fails or stalls never holds a navigation hostage: an `error` resolves
|
|
31
|
+
* the wait, and so does a timeout, Turbo's 2 seconds.
|
|
32
|
+
*/
|
|
33
|
+
import { PRELOAD_ATTR, relIsStylesheet } from './constants.js';
|
|
34
|
+
import { HELD_MEDIA_ATTR, OWNER_ATTR, holdMedia, placeOwnedLink } from './owned-styles.js';
|
|
35
|
+
import { getCspNonce } from './head-merge.js';
|
|
36
|
+
|
|
37
|
+
/** How long a navigation waits for one stylesheet before it swaps anyway. */
|
|
38
|
+
export const STYLESHEET_TIMEOUT_MS = 2000;
|
|
39
|
+
|
|
40
|
+
/** The attribute that marks the router's temporary copies (defined in the leaf). */
|
|
41
|
+
export { PRELOAD_ATTR };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Is `el` a stylesheet that applies now: a `<link>` whose `rel` names
|
|
45
|
+
* `stylesheet` but not `alternate` (an alternate sheet is off until chosen),
|
|
46
|
+
* that is not `disabled`, has an `href`, and has no `media` query that does
|
|
47
|
+
* not match.
|
|
48
|
+
* @param {Element} el
|
|
49
|
+
*/
|
|
50
|
+
function isActiveStylesheet(el) {
|
|
51
|
+
if (!relIsStylesheet(el)) return false;
|
|
52
|
+
if (el.hasAttribute('disabled') || !el.getAttribute('href')) return false;
|
|
53
|
+
// A held link (owned-styles.js) applies under its author's media once released.
|
|
54
|
+
const media = el.hasAttribute(HELD_MEDIA_ATTR) ? el.getAttribute(HELD_MEDIA_ATTR) : el.getAttribute('media');
|
|
55
|
+
if (media && typeof matchMedia === 'function') {
|
|
56
|
+
try { if (!matchMedia(media).matches) return false; } catch { /* an unparseable query applies to nothing */ return false; }
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve when `link` has loaded or failed, or after `timeout` ms.
|
|
63
|
+
* @param {HTMLLinkElement} link
|
|
64
|
+
* @param {number} timeout
|
|
65
|
+
* @returns {Promise<void>}
|
|
66
|
+
*/
|
|
67
|
+
function waitForLoad(link, timeout) {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const done = () => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
link.removeEventListener('load', done);
|
|
72
|
+
link.removeEventListener('error', done);
|
|
73
|
+
resolve();
|
|
74
|
+
};
|
|
75
|
+
const timer = setTimeout(done, timeout);
|
|
76
|
+
link.addEventListener('load', done);
|
|
77
|
+
link.addEventListener('error', done);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @typedef {{ ready: Promise<void>, copies: HTMLLinkElement[] }} IncomingStylesheets
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Start loading every stylesheet the incoming page links that the live
|
|
87
|
+
* document does not, through a temporary copy in `<head>`. Returns `null` when
|
|
88
|
+
* there is nothing new, so the caller swaps in the same task as before and a
|
|
89
|
+
* navigation that brings no stylesheet costs no extra time. Otherwise returns
|
|
90
|
+
* the copies and a promise that settles when all of them have loaded, failed
|
|
91
|
+
* or timed out; pass the result to `releaseIncomingStylesheets` once the swap
|
|
92
|
+
* has committed, or once the navigation is abandoned.
|
|
93
|
+
*
|
|
94
|
+
* @param {Document} doc The parsed incoming page.
|
|
95
|
+
* @param {string | null | undefined} baseUrl The URL `doc` was fetched from,
|
|
96
|
+
* which a relative `href` in it resolves against.
|
|
97
|
+
* @param {string | null} [frameId] On a frame navigation, the frame being
|
|
98
|
+
* swapped: only its subtree in `doc` is read.
|
|
99
|
+
* @param {number} [timeout]
|
|
100
|
+
* @returns {IncomingStylesheets | null}
|
|
101
|
+
*/
|
|
102
|
+
export function loadIncomingStylesheets(doc, baseUrl, frameId, timeout = STYLESHEET_TIMEOUT_MS) {
|
|
103
|
+
if (typeof document === 'undefined' || !doc || !document.head) return null;
|
|
104
|
+
/** @type {ParentNode | null} */
|
|
105
|
+
let scope = doc;
|
|
106
|
+
if (frameId) {
|
|
107
|
+
try { scope = doc.querySelector(`webjs-frame#${CSS.escape(frameId)}`); } catch { scope = null; }
|
|
108
|
+
}
|
|
109
|
+
if (!scope) return null;
|
|
110
|
+
const base = baseUrl || document.baseURI;
|
|
111
|
+
/** @param {string} href */
|
|
112
|
+
const resolve = (href) => {
|
|
113
|
+
try { return new URL(href, base).href; } catch { return null; }
|
|
114
|
+
};
|
|
115
|
+
// What the page already links. Another navigation's temporary copies do
|
|
116
|
+
// not count: they are released on that navigation's own schedule.
|
|
117
|
+
const live = new Set();
|
|
118
|
+
for (const el of document.querySelectorAll('link[href]')) {
|
|
119
|
+
if (el.hasAttribute(PRELOAD_ATTR) || !isActiveStylesheet(el)) continue;
|
|
120
|
+
const href = /** @type {HTMLLinkElement} */ (el).href;
|
|
121
|
+
if (href) live.add(href);
|
|
122
|
+
}
|
|
123
|
+
const nonce = getCspNonce();
|
|
124
|
+
/** @type {HTMLLinkElement[]} */
|
|
125
|
+
const copies = [];
|
|
126
|
+
/** @type {Promise<void>[]} */
|
|
127
|
+
const waits = [];
|
|
128
|
+
for (const el of scope.querySelectorAll('link[href]')) {
|
|
129
|
+
if (!isActiveStylesheet(el)) continue;
|
|
130
|
+
const href = resolve(/** @type {string} */ (el.getAttribute('href')));
|
|
131
|
+
if (!href || live.has(href)) continue;
|
|
132
|
+
live.add(href);
|
|
133
|
+
const link = document.createElement('link');
|
|
134
|
+
link.rel = 'stylesheet';
|
|
135
|
+
link.setAttribute(PRELOAD_ATTR, '');
|
|
136
|
+
// The attributes that change how the file is fetched and applied travel
|
|
137
|
+
// with it, so the copy loads exactly what the page asked for. The nonce is
|
|
138
|
+
// the LIVE page's: the browser checks CSP against the nonce of the original
|
|
139
|
+
// page load, never a later response's (#1050).
|
|
140
|
+
for (const name of ['media', 'crossorigin', 'integrity', 'referrerpolicy']) {
|
|
141
|
+
const value = el.getAttribute(name);
|
|
142
|
+
if (value) link.setAttribute(name, value);
|
|
143
|
+
}
|
|
144
|
+
if (nonce) link.setAttribute('nonce', nonce);
|
|
145
|
+
link.href = href;
|
|
146
|
+
// Loaded without applying: the outgoing page, and a view transition's
|
|
147
|
+
// capture of it, must not paint with the incoming CSS. The swap commit
|
|
148
|
+
// releases it (owned-styles.js `releaseIncomingSheets`).
|
|
149
|
+
holdMedia(link);
|
|
150
|
+
copies.push(link);
|
|
151
|
+
waits.push(waitForLoad(link, timeout));
|
|
152
|
+
// A sheet a layout owns (#1669) goes where the page's own link belongs,
|
|
153
|
+
// so the head merge can adopt this copy as that link instead of adding a
|
|
154
|
+
// second one; any other copy goes at the end of the head.
|
|
155
|
+
const owner = el.getAttribute(OWNER_ATTR);
|
|
156
|
+
if (owner) {
|
|
157
|
+
link.setAttribute(OWNER_ATTR, owner);
|
|
158
|
+
placeOwnedLink(link, owner);
|
|
159
|
+
} else {
|
|
160
|
+
document.head.appendChild(link);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!copies.length) return null;
|
|
164
|
+
return { ready: Promise.all(waits).then(() => undefined), copies };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Remove the temporary copies `loadIncomingStylesheets` made. Each one stays
|
|
169
|
+
* until the live document's own link to the same file has loaded (or failed,
|
|
170
|
+
* or the timeout passes), so the sheet applies on every frame of the handoff;
|
|
171
|
+
* a copy with no such link (a discarded response, a frame swap that did not
|
|
172
|
+
* bring the file, a superseded navigation) goes at once.
|
|
173
|
+
*
|
|
174
|
+
* @param {IncomingStylesheets | null} incoming
|
|
175
|
+
* @param {number} [timeout]
|
|
176
|
+
*/
|
|
177
|
+
export function releaseIncomingStylesheets(incoming, timeout = STYLESHEET_TIMEOUT_MS) {
|
|
178
|
+
if (!incoming || typeof document === 'undefined') return;
|
|
179
|
+
for (const copy of incoming.copies) {
|
|
180
|
+
// Gone already, or adopted by the head merge as the page's own link.
|
|
181
|
+
if (!copy.isConnected || !copy.hasAttribute(PRELOAD_ATTR)) continue;
|
|
182
|
+
/** @type {HTMLLinkElement | null} */
|
|
183
|
+
let twin = null;
|
|
184
|
+
for (const el of document.querySelectorAll('link[href]')) {
|
|
185
|
+
if (el === copy || el.hasAttribute(PRELOAD_ATTR) || !isActiveStylesheet(el)) continue;
|
|
186
|
+
if (/** @type {HTMLLinkElement} */ (el).href === copy.href) { twin = /** @type {HTMLLinkElement} */ (el); break; }
|
|
187
|
+
}
|
|
188
|
+
if (!twin || twin.sheet) { copy.remove(); continue; }
|
|
189
|
+
waitForLoad(twin, timeout).then(() => copy.remove());
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -11,8 +11,9 @@ import { collectBoundaries, planBoundarySwap } from './boundaries.js';
|
|
|
11
11
|
import { reportFallback } from './diagnostics.js';
|
|
12
12
|
import { blurOutgoingFocus, diffChildren, replaceBoundaryRange, resyncEnclosingHostSlots, swapMarkerRange } from './dom-differ.js';
|
|
13
13
|
import { trackedReloadSignature } from './frames.js';
|
|
14
|
-
import { addNewHeadElements, mergeHead } from './head-merge.js';
|
|
14
|
+
import { addNewHeadElements, mergeHead, mergeHeadStylesheets } from './head-merge.js';
|
|
15
15
|
import { morphLayoutChrome, planLayoutChrome } from './layout-chrome.js';
|
|
16
|
+
import { primeLiveStylesheetKeys, releaseIncomingSheets, switchOffDepartedStylesheets } from './owned-styles.js';
|
|
16
17
|
import { clearPrefetchRefused, prefetchClearAll } from './prefetch.js';
|
|
17
18
|
import { snapshotCache } from './snapshot-cache.js';
|
|
18
19
|
import { hardNavigate } from './state.js';
|
|
@@ -84,6 +85,13 @@ export let _swapCommit = Promise.resolve();
|
|
|
84
85
|
* history (a revalidation, a refresh, the popstate restore).
|
|
85
86
|
*/
|
|
86
87
|
export function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc, refresh, recordHistoryNow) {
|
|
88
|
+
// The URL the incoming page came from, which its relative stylesheet hrefs
|
|
89
|
+
// resolve against, and the live sheets' keys taken NOW, while the document
|
|
90
|
+
// still has the outgoing page's URL: every commit point below records the
|
|
91
|
+
// new history entry before it merges the head (#1669).
|
|
92
|
+
let incomingBase = typeof location !== 'undefined' ? location.href : '';
|
|
93
|
+
if (href) { try { incomingBase = new URL(href, incomingBase).href; } catch { /* keep the live url */ } }
|
|
94
|
+
primeLiveStylesheetKeys();
|
|
87
95
|
// SSR action seeding (#472): ingest the incoming page's seed payload BEFORE
|
|
88
96
|
// its components are grafted into the live DOM and upgrade, so a
|
|
89
97
|
// soft-navigated async component resolves from the seed instead of
|
|
@@ -279,7 +287,7 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
|
|
|
279
287
|
// ADD-ONLY head merge: preserve runtime-generated head content
|
|
280
288
|
// (Tailwind CSS injection, etc.) that the outer layout's scripts
|
|
281
289
|
// already produced.
|
|
282
|
-
addNewHeadElements(doc.head);
|
|
290
|
+
addNewHeadElements(doc.head, { base: incomingBase, frame: true });
|
|
283
291
|
// `diffChildren` -> `reconcileChildren` regrafts permanent elements
|
|
284
292
|
// by node identity (it imports the incoming children first, then
|
|
285
293
|
// swaps the live permanent node into the imported tree), so the live
|
|
@@ -288,6 +296,8 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
|
|
|
288
296
|
// ALSO progressively streams a Suspense boundary gates its resolves on the
|
|
289
297
|
// committed frame swap, not a stale prior _swapCommit (#1048).
|
|
290
298
|
_swapCommit = runWithTransition(() => {
|
|
299
|
+
// The frame's incoming sheets loaded held; they apply with the swap.
|
|
300
|
+
releaseIncomingSheets();
|
|
291
301
|
diffChildren(target, source);
|
|
292
302
|
reactivateScripts(target);
|
|
293
303
|
upgradeCustomElements(target);
|
|
@@ -334,7 +344,7 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
|
|
|
334
344
|
// commit point without a reader having to prove reachability first, which
|
|
335
345
|
// is what stops the next person adding a commit point from omitting it.
|
|
336
346
|
if (recordHistoryNow) recordHistoryNow();
|
|
337
|
-
swapFullBody(doc);
|
|
347
|
+
swapFullBody(doc, incomingBase, true);
|
|
338
348
|
return;
|
|
339
349
|
}
|
|
340
350
|
|
|
@@ -361,7 +371,7 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
|
|
|
361
371
|
if (plan && refresh === 'layouts' && !chrome) {
|
|
362
372
|
ingestSeeds();
|
|
363
373
|
if (recordHistoryNow) recordHistoryNow();
|
|
364
|
-
swapFullBody(doc);
|
|
374
|
+
swapFullBody(doc, incomingBase, true);
|
|
365
375
|
return;
|
|
366
376
|
}
|
|
367
377
|
|
|
@@ -375,11 +385,32 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
|
|
|
375
385
|
if (recordHistoryNow) recordHistoryNow();
|
|
376
386
|
const { mode, live, incoming } = plan;
|
|
377
387
|
// ADD-ONLY head merge: the outer layout stays mounted, so its head-bound
|
|
378
|
-
// runtime state (Tailwind injection, etc.) must not be invalidated.
|
|
379
|
-
|
|
388
|
+
// runtime state (Tailwind injection, etc.) must not be invalidated. It runs
|
|
389
|
+
// ahead of the commit, as the incoming sheets' load-before-swap copies
|
|
390
|
+
// already do (#1666, Turbo's order), so the new page's sheets apply from
|
|
391
|
+
// the swap's first frame.
|
|
392
|
+
// Its stylesheets wait for the commit (#1669, below).
|
|
393
|
+
addNewHeadElements(doc.head, { base: incomingBase, styles: false });
|
|
394
|
+
// A live element inside the range this swap replaces is on its way out,
|
|
395
|
+
// so a stylesheet link there does not count as one the page keeps.
|
|
396
|
+
/** @param {Element} el */
|
|
397
|
+
const leaving = (el) => !!(live.start.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)
|
|
398
|
+
&& !!(live.end.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING);
|
|
380
399
|
_swapCommit = runWithTransition(() => {
|
|
400
|
+
// Everything stylesheet-related happens inside the commit, so the
|
|
401
|
+
// outgoing page, and a view transition's capture of it, keep exactly the
|
|
402
|
+
// sheets that page used, and the crossing restyles once (#1669): the
|
|
403
|
+
// incoming sheets (loaded held, stylesheets.js) are adopted, switched
|
|
404
|
+
// back on or inserted, then released, before the new markup lands.
|
|
405
|
+
mergeHeadStylesheets(doc.head, { base: incomingBase, segment: plan.segment, leaving });
|
|
406
|
+
releaseIncomingSheets();
|
|
381
407
|
if (mode === 'replace') replaceBoundaryRange(live, incoming);
|
|
382
408
|
else swapMarkerRange(live, incoming, doc);
|
|
409
|
+
// The swapped range's own stylesheets go with it unless the incoming
|
|
410
|
+
// page links them, in its head or its body, on a replace and on a morph
|
|
411
|
+
// alike: the server re-rendered the whole range either way, so a sheet a
|
|
412
|
+
// searchParams change dropped is retired too.
|
|
413
|
+
switchOffDepartedStylesheets(plan.segment, doc, incomingBase);
|
|
383
414
|
// No key sync is needed on the anchor's own comments: the plan's anchor
|
|
384
415
|
// carries EQUAL live/incoming route-keys in every tier (a changed-key
|
|
385
416
|
// REPLACE anchors at a parent already compared equal; the other tiers
|
|
@@ -438,7 +469,7 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
|
|
|
438
469
|
// because the user is already viewing the page).
|
|
439
470
|
ingestSeeds(); // committed: past both discard branches above
|
|
440
471
|
if (recordHistoryNow) recordHistoryNow();
|
|
441
|
-
swapFullBody(doc);
|
|
472
|
+
swapFullBody(doc, incomingBase, false);
|
|
442
473
|
}
|
|
443
474
|
|
|
444
475
|
/**
|
|
@@ -460,9 +491,12 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
|
|
|
460
491
|
* it only past their own discard branches.
|
|
461
492
|
*
|
|
462
493
|
* @param {Document} doc
|
|
494
|
+
* @param {string} incomingBase the URL the incoming page came from
|
|
495
|
+
* @param {boolean} complete the response rendered every layout (no X-Webjs-Have), so it can retire a departed section's sheets
|
|
463
496
|
*/
|
|
464
|
-
function swapFullBody(doc) {
|
|
465
|
-
|
|
497
|
+
function swapFullBody(doc, incomingBase, complete) {
|
|
498
|
+
// Its stylesheets wait for the commit, as on a boundary swap (#1669).
|
|
499
|
+
mergeHead(doc.head, { base: incomingBase, styles: false });
|
|
466
500
|
// Persist permanent elements by node identity across the full-body
|
|
467
501
|
// swap: move each live [data-webjs-permanent][id] node into the matching
|
|
468
502
|
// position in the incoming body BEFORE replaceChildren reads it, so the
|
|
@@ -470,7 +504,13 @@ function swapFullBody(doc) {
|
|
|
470
504
|
regraftPermanentElements(document.body, doc.body);
|
|
471
505
|
const newChildren = [...doc.body.childNodes];
|
|
472
506
|
const doSwap = () => {
|
|
507
|
+
// The whole body is replaced, so every live body link is leaving.
|
|
508
|
+
mergeHeadStylesheets(doc.head, { base: incomingBase, segment: '/', leaving: () => true });
|
|
509
|
+
releaseIncomingSheets();
|
|
473
510
|
document.body.replaceChildren(...newChildren);
|
|
511
|
+
// Only a response known to be complete can say a section's sheet is gone:
|
|
512
|
+
// the background restore path's head can be a reduced one (#936).
|
|
513
|
+
if (complete) switchOffDepartedStylesheets('/', doc, incomingBase);
|
|
474
514
|
reactivateScripts(document.body);
|
|
475
515
|
upgradeCustomElements(document.body);
|
|
476
516
|
blurOutgoingFocus();
|
package/src/sentinels.js
ADDED
package/src/slot.js
CHANGED
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
*
|
|
75
75
|
* SSR. This module is import-safe in Node (DOM access is guarded on
|
|
76
76
|
* `typeof HTMLSlotElement !== 'undefined'`). Server-side slot substitution
|
|
77
|
-
* lives in render-server.js (
|
|
77
|
+
* lives in render-server/expand.js (`finishSlot`); slot.js drives the client runtime.
|
|
78
78
|
*/
|
|
79
79
|
|
|
80
80
|
// ---------------------------------------------------------------------------
|
package/src/webjs-config.d.ts
CHANGED
|
@@ -94,7 +94,7 @@ export interface WebjsRegenerateRule {
|
|
|
94
94
|
output: string;
|
|
95
95
|
/** The shell command that rebuilds `output` (the same command prod uses in `start.before`, so dev and prod cannot diverge). */
|
|
96
96
|
command: string;
|
|
97
|
-
/** Source files/directories
|
|
97
|
+
/** Source files/directories the output is built from. Every build records a fingerprint of the files under them (path, size, mtime) in `.webjs/regenerate.json`, and the output is rebuilt when that fingerprint, the command, or the output itself no longer matches the record, or the output is missing (#1672). `webjs regenerate` builds and records outside the dev server. */
|
|
98
98
|
inputs?: string[];
|
|
99
99
|
}
|
|
100
100
|
|
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
import { escapeAttr } from '../escape.js';
|
|
2
|
-
import {
|
|
3
|
-
endOfComment, escapeRegex, findClosingTagInString, inertAt, inertRanges, isVoidElement,
|
|
4
|
-
} from './html-scan.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* SSR light-DOM slot projection: partition a component's authored children by
|
|
8
|
-
* their `slot=""` attribute, then substitute each `<slot>` in the rendered
|
|
9
|
-
* template with the projected children or the authored fallback.
|
|
10
|
-
*
|
|
11
|
-
* String-level throughout, so it depends on `html-scan.js` and nothing else in
|
|
12
|
-
* this directory.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Extract the `slot` attribute value from an attribute string. Returns
|
|
17
|
-
* null when the attribute is absent.
|
|
18
|
-
*
|
|
19
|
-
* @param {string} attrsRaw
|
|
20
|
-
* @returns {string | null}
|
|
21
|
-
*/
|
|
22
|
-
function extractSlotAttr(attrsRaw) {
|
|
23
|
-
const m = /\bslot\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
|
|
24
|
-
if (!m) return null;
|
|
25
|
-
const value = m[1] ?? m[2] ?? m[3] ?? '';
|
|
26
|
-
// Per shadow DOM spec, slot="" (empty) and missing slot attribute both
|
|
27
|
-
// route to the default slot. `default` is the framework's reserved alias
|
|
28
|
-
// for it (#1015: the client record normalizes it identically, so both
|
|
29
|
-
// sides agree end to end).
|
|
30
|
-
return value === '' || value === 'default' ? null : value;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Partition authored inner HTML by each top-level child's `slot=""`
|
|
35
|
-
* attribute. Text nodes, comment nodes, and elements without `slot=""`
|
|
36
|
-
* all route to the default-slot key (null).
|
|
37
|
-
*
|
|
38
|
-
* Returns a Map keyed by slot name (null for default) whose values are
|
|
39
|
-
* the concatenated HTML strings for that slot in source order.
|
|
40
|
-
*
|
|
41
|
-
* @param {string} html
|
|
42
|
-
* @returns {Map<string|null, string>}
|
|
43
|
-
*/
|
|
44
|
-
export function partitionAuthoredBySlot(html) {
|
|
45
|
-
/** @type {Map<string|null, string>} */
|
|
46
|
-
const groups = new Map();
|
|
47
|
-
let defaultBuf = '';
|
|
48
|
-
let cursor = 0;
|
|
49
|
-
while (cursor < html.length) {
|
|
50
|
-
const lt = html.indexOf('<', cursor);
|
|
51
|
-
if (lt === -1) {
|
|
52
|
-
defaultBuf += html.slice(cursor);
|
|
53
|
-
break;
|
|
54
|
-
}
|
|
55
|
-
if (lt > cursor) defaultBuf += html.slice(cursor, lt);
|
|
56
|
-
const rest = html.slice(lt);
|
|
57
|
-
if (rest.startsWith('<!--')) {
|
|
58
|
-
// Find the comment's end the same way inertRanges does, rather than with
|
|
59
|
-
// a bare `indexOf('-->')`. The two helpers both decide where a comment
|
|
60
|
-
// stops, so a bare search makes them DISAGREE on the spec short forms
|
|
61
|
-
// (`--!>`, `<!-->`, `<!--->`): this one would run past the real end and
|
|
62
|
-
// swallow the slotted children that follow, silently routing a
|
|
63
|
-
// `slot="head"` child into the default slot.
|
|
64
|
-
const commentEnd = endOfComment(html, lt);
|
|
65
|
-
if (commentEnd === -1) {
|
|
66
|
-
defaultBuf += rest;
|
|
67
|
-
cursor = html.length;
|
|
68
|
-
break;
|
|
69
|
-
}
|
|
70
|
-
defaultBuf += html.slice(lt, commentEnd);
|
|
71
|
-
cursor = commentEnd;
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
if (rest.startsWith('<!') || rest.startsWith('</')) {
|
|
75
|
-
const end = html.indexOf('>', lt);
|
|
76
|
-
if (end === -1) {
|
|
77
|
-
defaultBuf += rest;
|
|
78
|
-
cursor = html.length;
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
81
|
-
defaultBuf += html.slice(lt, end + 1);
|
|
82
|
-
cursor = end + 1;
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
const tagMatch = /^<([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>])*?)(\/?)>/.exec(rest);
|
|
86
|
-
if (!tagMatch) {
|
|
87
|
-
defaultBuf += '<';
|
|
88
|
-
cursor = lt + 1;
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
const [tagFull, tagName, attrsRaw, selfCloseSlash] = tagMatch;
|
|
92
|
-
const lower = tagName.toLowerCase();
|
|
93
|
-
const isSelfClose = !!selfCloseSlash || isVoidElement(lower);
|
|
94
|
-
const slotAttr = extractSlotAttr(attrsRaw);
|
|
95
|
-
let elemEnd;
|
|
96
|
-
if (isSelfClose) {
|
|
97
|
-
elemEnd = lt + tagFull.length;
|
|
98
|
-
} else {
|
|
99
|
-
const innerStart = lt + tagFull.length;
|
|
100
|
-
const closeIdx = findClosingTagInString(html, innerStart, lower);
|
|
101
|
-
if (closeIdx === -1) {
|
|
102
|
-
// Unclosed element. Take to end of html.
|
|
103
|
-
const elementHTML = html.slice(lt);
|
|
104
|
-
if (slotAttr !== null) appendStringToMap(groups, slotAttr, elementHTML);
|
|
105
|
-
else defaultBuf += elementHTML;
|
|
106
|
-
cursor = html.length;
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
const closeRe = new RegExp(`</${escapeRegex(lower)}\\s*>`, 'i');
|
|
110
|
-
const tail = html.slice(closeIdx);
|
|
111
|
-
const closeMatch = closeRe.exec(tail);
|
|
112
|
-
const closeLen = closeMatch ? closeMatch[0].length : `</${lower}>`.length;
|
|
113
|
-
elemEnd = closeIdx + closeLen;
|
|
114
|
-
}
|
|
115
|
-
const elementHTML = html.slice(lt, elemEnd);
|
|
116
|
-
if (slotAttr !== null) appendStringToMap(groups, slotAttr, elementHTML);
|
|
117
|
-
else defaultBuf += elementHTML;
|
|
118
|
-
cursor = elemEnd;
|
|
119
|
-
}
|
|
120
|
-
if (defaultBuf.length > 0) groups.set(null, defaultBuf);
|
|
121
|
-
return groups;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Append a string to a Map<K, string>, concatenating if the key exists. */
|
|
125
|
-
function appendStringToMap(map, key, value) {
|
|
126
|
-
const existing = map.get(key);
|
|
127
|
-
if (existing !== undefined) map.set(key, existing + value);
|
|
128
|
-
else map.set(key, value);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Substitute every `<slot>` tag in `rendered` with a framework-marked
|
|
133
|
-
* `<slot data-webjs-light data-projection="actual|fallback">` element
|
|
134
|
-
* carrying either the projected children for that slot (from
|
|
135
|
-
* `partitioned`) or the slot's authored fallback content. Multiple
|
|
136
|
-
* slots with the same name follow the first-wins rule per spec; later
|
|
137
|
-
* same-named slots fall back regardless of available projection.
|
|
138
|
-
*
|
|
139
|
-
* The `ownerTag` (the tag of the component whose template rendered these
|
|
140
|
-
* slots) is emitted as `data-wj-slot-owner` so the client resolves template
|
|
141
|
-
* ownership on hydration the same way the client renderer stamps SLOT_OWNER,
|
|
142
|
-
* which is what makes a FORWARDED slot (rendered by this component but nested
|
|
143
|
-
* inside a child) route to this component and not the child (#1023).
|
|
144
|
-
*
|
|
145
|
-
* @param {string} rendered
|
|
146
|
-
* @param {Map<string|null, string>} partitioned
|
|
147
|
-
* @param {string} ownerTag
|
|
148
|
-
* @returns {string}
|
|
149
|
-
*/
|
|
150
|
-
export function substituteSlotsInRender(rendered, partitioned, ownerTag) {
|
|
151
|
-
const ownerAttr = ownerTag ? ` data-wj-slot-owner="${escapeAttr(ownerTag)}"` : '';
|
|
152
|
-
/** @type {Set<string|null>} */
|
|
153
|
-
const consumedNames = new Set();
|
|
154
|
-
let result = '';
|
|
155
|
-
let cursor = 0;
|
|
156
|
-
const slotRe = /<slot((?:"[^"]*"|'[^']*'|[^>])*?)(\/?)>/gi;
|
|
157
|
-
// A `<slot>` written inside a comment is documentation, not a slot (#1128).
|
|
158
|
-
// Substituting one is worse here than in the element walk: a commented slot
|
|
159
|
-
// has no `</slot>`, so the fallback scan below swallows the rest of the
|
|
160
|
-
// template, the component's REAL slot is never substituted, and the authored
|
|
161
|
-
// children are dropped from the page entirely.
|
|
162
|
-
const inert = inertRanges(rendered);
|
|
163
|
-
const isInert = inertAt(inert);
|
|
164
|
-
let m;
|
|
165
|
-
while ((m = slotRe.exec(rendered)) !== null) {
|
|
166
|
-
if (isInert(m.index)) continue;
|
|
167
|
-
result += rendered.slice(cursor, m.index);
|
|
168
|
-
const [fullOpen, attrsRaw, selfCloseSlash] = m;
|
|
169
|
-
const isSelfClose = !!selfCloseSlash;
|
|
170
|
-
const nameMatch = /\bname\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
|
|
171
|
-
const name = nameMatch ? (nameMatch[1] ?? nameMatch[2] ?? nameMatch[3]) : null;
|
|
172
|
-
// Strip the `name` attribute from the carried-through attribute
|
|
173
|
-
// string so we can re-add it (with escaping) on the framework slot.
|
|
174
|
-
const otherAttrs = attrsRaw.replace(/\bname\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/i, '').trim();
|
|
175
|
-
let fallback = '';
|
|
176
|
-
let totalEnd;
|
|
177
|
-
if (isSelfClose) {
|
|
178
|
-
totalEnd = m.index + fullOpen.length;
|
|
179
|
-
} else {
|
|
180
|
-
const innerStart = m.index + fullOpen.length;
|
|
181
|
-
const closeIdx = findClosingTagInString(rendered, innerStart, 'slot', inert);
|
|
182
|
-
if (closeIdx === -1) {
|
|
183
|
-
fallback = rendered.slice(innerStart);
|
|
184
|
-
totalEnd = rendered.length;
|
|
185
|
-
} else {
|
|
186
|
-
fallback = rendered.slice(innerStart, closeIdx);
|
|
187
|
-
const closeRe = /<\/slot\s*>/i;
|
|
188
|
-
const tail = rendered.slice(closeIdx);
|
|
189
|
-
const closeMatch = closeRe.exec(tail);
|
|
190
|
-
const closeLen = closeMatch ? closeMatch[0].length : '</slot>'.length;
|
|
191
|
-
totalEnd = closeIdx + closeLen;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
// `default` and `''` are the reserved aliases for the default slot
|
|
195
|
-
// (#1015), matching the client's keyOfName exactly: the LOOKUP key
|
|
196
|
-
// normalizes, while the emitted name attribute stays as authored so the
|
|
197
|
-
// output bytes are unchanged for every other app.
|
|
198
|
-
const slotKey = name === 'default' || name === '' ? null : name;
|
|
199
|
-
const projected = partitioned.get(slotKey);
|
|
200
|
-
const nameAttr = name !== null ? ` name="${escapeAttr(name)}"` : '';
|
|
201
|
-
const extraAttrs = otherAttrs ? ` ${otherAttrs}` : '';
|
|
202
|
-
if (projected !== undefined && !consumedNames.has(slotKey)) {
|
|
203
|
-
consumedNames.add(slotKey);
|
|
204
|
-
result += `<slot data-webjs-light data-projection="actual"${ownerAttr}${nameAttr}${extraAttrs}>${projected}</slot>`;
|
|
205
|
-
} else {
|
|
206
|
-
result += `<slot data-webjs-light data-projection="fallback"${ownerAttr}${nameAttr}${extraAttrs}>${fallback}</slot>`;
|
|
207
|
-
}
|
|
208
|
-
cursor = totalEnd;
|
|
209
|
-
slotRe.lastIndex = totalEnd;
|
|
210
|
-
}
|
|
211
|
-
result += rendered.slice(cursor);
|
|
212
|
-
return result;
|
|
213
|
-
}
|