@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
|
@@ -37,8 +37,10 @@ export function closestAnchor(target) {
|
|
|
37
37
|
const a = el.closest && el.closest('a');
|
|
38
38
|
if (a instanceof HTMLAnchorElement) return a;
|
|
39
39
|
}
|
|
40
|
+
// Only a real ShadowRoot has a host to cross to (a detached subtree's
|
|
41
|
+
// root can be an <a>, whose `host` is a URL string).
|
|
40
42
|
const root = node.getRootNode ? node.getRootNode() : null;
|
|
41
|
-
node =
|
|
43
|
+
node = typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot ? root.host : null;
|
|
42
44
|
}
|
|
43
45
|
return null;
|
|
44
46
|
}
|
|
@@ -159,6 +159,20 @@ export function collectBoundaries(root) {
|
|
|
159
159
|
* live: { routeKey: string, start: Comment, end: Comment },
|
|
160
160
|
* incoming: { routeKey: string, start: Comment, end: Comment } } | null}
|
|
161
161
|
*/
|
|
162
|
+
/**
|
|
163
|
+
* Does segment `inner` sit at or below segment `outer`. Boundary segments are
|
|
164
|
+
* nested path prefixes, so `/ui/docs` is within `/ui` and within `/`, and
|
|
165
|
+
* `/uix` is within neither `/ui` nor `/ui/docs`. The one statement of the
|
|
166
|
+
* nesting rule: the swap plan below and the stylesheet ownership in
|
|
167
|
+
* owned-styles.js both use it, so they cannot disagree about which range a
|
|
168
|
+
* segment belongs to.
|
|
169
|
+
* @param {string} inner
|
|
170
|
+
* @param {string} outer
|
|
171
|
+
*/
|
|
172
|
+
export function segmentWithin(inner, outer) {
|
|
173
|
+
return inner === outer || inner.startsWith(outer === '/' ? '/' : outer + '/');
|
|
174
|
+
}
|
|
175
|
+
|
|
162
176
|
export function planBoundarySwap(here, there) {
|
|
163
177
|
// Shared segments, shallowest first (a nested path prefix is shorter).
|
|
164
178
|
const shared = [...here.keys()].filter((s) => there.has(s)).sort((a, b) => a.length - b.length);
|
|
@@ -169,7 +183,7 @@ export function planBoundarySwap(here, there) {
|
|
|
169
183
|
let parent = null;
|
|
170
184
|
for (const p of shared) {
|
|
171
185
|
if (p === seg) break;
|
|
172
|
-
if (seg
|
|
186
|
+
if (seg !== p && segmentWithin(seg, p)) parent = p;
|
|
173
187
|
}
|
|
174
188
|
if (!parent) return null; // no anchored parent: degrade
|
|
175
189
|
return { mode: 'replace', segment: parent, live: here.get(parent), incoming: there.get(parent) };
|
|
@@ -204,3 +204,33 @@ export function _isNonHtmlPath(pathname) {
|
|
|
204
204
|
* alone rather than reverted.
|
|
205
205
|
*/
|
|
206
206
|
export const WRITE_BACK_ARMED_MS = 250;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Marks the router's temporary stylesheet copies (`stylesheets.js`). Here,
|
|
210
|
+
* in the leaf module, so `owned-styles.js` can skip those copies without
|
|
211
|
+
* importing `stylesheets.js`, which imports `head-merge.js`, which imports
|
|
212
|
+
* `owned-styles.js`.
|
|
213
|
+
*/
|
|
214
|
+
export const PRELOAD_ATTR = 'data-webjs-preload-style';
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Does `el`'s `rel` name a stylesheet that applies (`stylesheet`, not
|
|
218
|
+
* `alternate`). The one test both the stylesheet loader (`stylesheets.js`) and
|
|
219
|
+
* the owned-sheet merge (`owned-styles.js`) use, so the two cannot disagree.
|
|
220
|
+
* @param {Element} el
|
|
221
|
+
*/
|
|
222
|
+
export function relIsStylesheet(el) {
|
|
223
|
+
const rel = relTokens(el);
|
|
224
|
+
return rel.includes('stylesheet') && !rel.includes('alternate');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The lowercased tokens of `el`'s `rel`, the one parse every stylesheet test
|
|
229
|
+
* in the router builds on (`relIsStylesheet` here, and the head merge's
|
|
230
|
+
* never-remove rule, which counts an alternate stylesheet too).
|
|
231
|
+
* @param {Element} el
|
|
232
|
+
* @returns {string[]}
|
|
233
|
+
*/
|
|
234
|
+
export function relTokens(el) {
|
|
235
|
+
return (el.getAttribute('rel') || '').toLowerCase().split(/\s+/);
|
|
236
|
+
}
|
|
@@ -19,6 +19,7 @@ import { navigate } from './navigator.js';
|
|
|
19
19
|
import { prefetchTake } from './prefetch.js';
|
|
20
20
|
import { currentNavigationToken } from './state.js';
|
|
21
21
|
import { readStreamedShell, streamBoundariesProgressively } from './stream.js';
|
|
22
|
+
import { loadIncomingStylesheets, releaseIncomingStylesheets } from './stylesheets.js';
|
|
22
23
|
import { _swapCommit, applySwap } from './swap.js';
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -295,6 +296,21 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
|
|
|
295
296
|
// recover in place rather than a destructive full reload.
|
|
296
297
|
if (!doc) { restoreOptimistic(optimisticState); handleNavigationError(href, null, new Error('navigation response did not parse as HTML')); return { ok: false, status: respStatus, aborted: false, applied: false }; }
|
|
297
298
|
|
|
299
|
+
// Load the stylesheets the incoming page brings before it is swapped in, so
|
|
300
|
+
// its first paint is styled (see stylesheets.js). Null when it brings none,
|
|
301
|
+
// and the swap then stays in this task. A newer navigation that started
|
|
302
|
+
// during the wait owns the page, exactly as after the body read above, and
|
|
303
|
+
// this one's temporary copies go with it.
|
|
304
|
+
const sheets = loadIncomingStylesheets(doc, finalUrl || href, frameId);
|
|
305
|
+
if (sheets) {
|
|
306
|
+
await sheets.ready;
|
|
307
|
+
if (myToken !== currentNavigationToken) {
|
|
308
|
+
releaseIncomingStylesheets(sheets);
|
|
309
|
+
if (streamCtx && streamCtx.reader) { try { streamCtx.reader.cancel(); } catch { /* ignore */ } }
|
|
310
|
+
return { ok: false, status: respStatus, aborted: true, applied: false };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
298
314
|
// #1406: recording the push after the swap finalizes the outgoing page's
|
|
299
315
|
// entry against the DESTINATION document, at a scroll offset the browser has
|
|
300
316
|
// already clamped to that document's height (measured on the gallery: 1600 to
|
|
@@ -336,6 +352,11 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
|
|
|
336
352
|
: null;
|
|
337
353
|
|
|
338
354
|
const disposition = applySwap(doc, frameId, !!revalidating, finalUrl, incomingBuild, incomingSrc, refresh || (mutation ? 'layouts' : undefined), recordHistoryNow);
|
|
355
|
+
// The temporary stylesheet copies are released once the swap has COMMITTED:
|
|
356
|
+
// under a view transition the new markup, and the page's own links with it,
|
|
357
|
+
// lands a frame later. A copy with no twin in the result (a discarded or
|
|
358
|
+
// uncommitted swap) goes as soon as the commit settles.
|
|
359
|
+
if (sheets) _swapCommit.then(() => releaseIncomingStylesheets(sheets), () => releaseIncomingStylesheets(sheets));
|
|
339
360
|
// `'none'` means applySwap returned WITHOUT committing anything: the frame the
|
|
340
361
|
// response was for is missing, or it degraded to a hard navigation (an
|
|
341
362
|
// importmap/build mismatch, a poisoned boundary scan). The page is not left
|
|
@@ -24,10 +24,13 @@ export function activeFrameId(el) {
|
|
|
24
24
|
while (cur) {
|
|
25
25
|
const frame = cur.closest('webjs-frame');
|
|
26
26
|
if (frame && frame.id) return frame.id;
|
|
27
|
-
// Cross shadow boundary upwards if necessary.
|
|
27
|
+
// Cross a shadow boundary upwards if necessary. Only a real ShadowRoot
|
|
28
|
+
// has a host to cross to: a DETACHED element's root is the top of its
|
|
29
|
+
// own subtree, and when that is an <a>, its `host` is the URL's host
|
|
30
|
+
// STRING, which the next closest() call threw on.
|
|
28
31
|
const root = cur.getRootNode();
|
|
29
|
-
if (
|
|
30
|
-
cur =
|
|
32
|
+
if (typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot) {
|
|
33
|
+
cur = root.host;
|
|
31
34
|
} else {
|
|
32
35
|
break;
|
|
33
36
|
}
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
|
-
import { META_KEY_CSP_NONCE } from './constants.js';
|
|
9
|
+
import { META_KEY_CSP_NONCE, relTokens } from './constants.js';
|
|
10
|
+
import { isStylesheetLink, stylesheetMerge } from './owned-styles.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Read the CSP nonce that the original page load published via
|
|
@@ -214,7 +215,16 @@ function reconcileHeadMetas(newHead) {
|
|
|
214
215
|
}
|
|
215
216
|
}
|
|
216
217
|
|
|
217
|
-
|
|
218
|
+
/**
|
|
219
|
+
* @param {HTMLHeadElement} newHead
|
|
220
|
+
* @param {{ base?: string | null, frame?: boolean, segment?: string | null }} [opts] `base` is the URL
|
|
221
|
+
* the incoming page came from; `frame` marks a frame swap, which switches no
|
|
222
|
+
* owned stylesheet on (owned-styles.js `stylesheetMerge`).
|
|
223
|
+
*/
|
|
224
|
+
export function addNewHeadElements(newHead, opts = {}) {
|
|
225
|
+
// `styles: false` leaves the stylesheet links to `mergeHeadStylesheets`,
|
|
226
|
+
// which the boundary swap calls inside its commit (#1669).
|
|
227
|
+
const mergeSheet = opts.styles === false ? null : stylesheetMerge(opts);
|
|
218
228
|
const newTitle = newHead.querySelector('title');
|
|
219
229
|
if (newTitle) document.title = newTitle.textContent || '';
|
|
220
230
|
|
|
@@ -236,6 +246,10 @@ export function addNewHeadElements(newHead) {
|
|
|
236
246
|
// A keyed <meta> is add/update/remove reconciled below (#1046), so skip it
|
|
237
247
|
// here to avoid appending a duplicate when its content changed.
|
|
238
248
|
if (el.tagName === 'META' && metaIdentity(el)) continue;
|
|
249
|
+
// One link per stylesheet, and an owned one placed root to leaf (#1669).
|
|
250
|
+
if (isStylesheetLink(el)) {
|
|
251
|
+
if (!mergeSheet || mergeSheet(el, cloneElementWithCorrectNonce)) continue;
|
|
252
|
+
}
|
|
239
253
|
if (!currentSet.has(outerHTMLForDiff(el))) {
|
|
240
254
|
if (el.tagName === 'SCRIPT') {
|
|
241
255
|
document.head.appendChild(
|
|
@@ -252,6 +266,26 @@ export function addNewHeadElements(newHead) {
|
|
|
252
266
|
reconcileHeadMetas(newHead);
|
|
253
267
|
}
|
|
254
268
|
|
|
269
|
+
/**
|
|
270
|
+
* The stylesheet half of `addNewHeadElements`, for the boundary swap to run
|
|
271
|
+
* INSIDE its commit (#1669): adopting the router's loading copies, switching
|
|
272
|
+
* a sheet back on, and inserting an owned one all take effect with the swap,
|
|
273
|
+
* so the outgoing page, and a view transition's capture of it, never paint
|
|
274
|
+
* with the incoming page's CSS. A stylesheet the merge does not handle (an
|
|
275
|
+
* unowned one the head lacks) is appended, as the full merge does.
|
|
276
|
+
* @param {HTMLHeadElement} newHead
|
|
277
|
+
* @param {{ base?: string | null, segment?: string | null, leaving?: (el: Element) => boolean }} [opts]
|
|
278
|
+
*/
|
|
279
|
+
export function mergeHeadStylesheets(newHead, opts = {}) {
|
|
280
|
+
const mergeSheet = stylesheetMerge(opts);
|
|
281
|
+
const currentSet = new Set();
|
|
282
|
+
for (const el of document.head.children) currentSet.add(outerHTMLForDiff(el));
|
|
283
|
+
for (const el of newHead.children) {
|
|
284
|
+
if (!isStylesheetLink(el) || mergeSheet(el, cloneElementWithCorrectNonce)) continue;
|
|
285
|
+
if (!currentSet.has(outerHTMLForDiff(el))) document.head.appendChild(cloneElementWithCorrectNonce(el));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
255
289
|
/**
|
|
256
290
|
* Is `el` a stylesheet the head merge must never remove: a `<style>` or a
|
|
257
291
|
* `<link rel~="stylesheet">`. WebJs ALWAYS keeps these on a soft nav, with no
|
|
@@ -263,22 +297,32 @@ export function addNewHeadElements(newHead) {
|
|
|
263
297
|
* head (the shared app stylesheet is omitted because the client already has it),
|
|
264
298
|
* so "absent from the incoming head" means "optimized away", NOT "removed". A
|
|
265
299
|
* dynamic-removal opt-out would therefore re-introduce #936 (it would strip a
|
|
266
|
-
* still-needed sheet on any partial response)
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
300
|
+
* still-needed sheet on any partial response). Keeping every stylesheet is
|
|
301
|
+
* correct HERE; a genuinely changed one is dropped by the deploy-level hard
|
|
302
|
+
* reload (build-id mismatch), not a soft swap. A section's own stylesheet is
|
|
303
|
+
* the one case that does leave, and it is decided elsewhere, by ownership
|
|
304
|
+
* rather than by head absence: `owned-styles.js` switches a sheet off when the
|
|
305
|
+
* boundary that owns it is swapped, a replace or a morph, or on a full-body
|
|
306
|
+
* swap whose response is complete (#1669). This merge itself never removes
|
|
307
|
+
* one.
|
|
270
308
|
*
|
|
271
309
|
* @param {Element} el
|
|
272
310
|
* @returns {boolean}
|
|
273
311
|
*/
|
|
274
312
|
function isPersistentHeadStyle(el) {
|
|
275
313
|
if (el.tagName === 'STYLE') return true;
|
|
276
|
-
|
|
277
|
-
|
|
314
|
+
// Any stylesheet link, an alternate one included, so not `relIsStylesheet`.
|
|
315
|
+
return el.tagName === 'LINK' && relTokens(el).includes('stylesheet');
|
|
278
316
|
}
|
|
279
317
|
|
|
280
|
-
/**
|
|
281
|
-
|
|
318
|
+
/**
|
|
319
|
+
* @param {HTMLHeadElement} newHead
|
|
320
|
+
* @param {{ base?: string | null, segment?: string | null }} [opts] the URL the incoming page came from, and the range being replaced
|
|
321
|
+
*/
|
|
322
|
+
export function mergeHead(newHead, opts = {}) {
|
|
323
|
+
// `styles: false` leaves the stylesheet links to `mergeHeadStylesheets`,
|
|
324
|
+
// which the full-body swap calls inside its commit (#1669).
|
|
325
|
+
const mergeSheet = opts.styles === false ? null : stylesheetMerge(opts);
|
|
282
326
|
const currentHead = document.head;
|
|
283
327
|
|
|
284
328
|
const newTitle = newHead.querySelector('title');
|
|
@@ -327,6 +371,10 @@ export function mergeHead(newHead) {
|
|
|
327
371
|
// kept above), or the head would carry two and `getCspNonce` could read the
|
|
328
372
|
// wrong one (#1050).
|
|
329
373
|
if (el.tagName === 'META' && metaIdentity(el) === META_KEY_CSP_NONCE) continue;
|
|
374
|
+
// A stylesheet the head already links is not appended twice, and an owned
|
|
375
|
+
// one is placed root to leaf (#1669). Nothing here REMOVES a sheet: this
|
|
376
|
+
// head can be a reduced one (#936).
|
|
377
|
+
if (isStylesheetLink(el) && (!mergeSheet || mergeSheet(el, cloneElementWithCorrectNonce))) continue;
|
|
330
378
|
if (!currentSet.has(outerHTMLForDiff(el))) {
|
|
331
379
|
if (el.tagName === 'SCRIPT') {
|
|
332
380
|
currentHead.appendChild(
|
|
@@ -21,6 +21,7 @@ import { clearPrefetchHover, clearPrefetchRefused, clearPrefetchViewTimers, onPr
|
|
|
21
21
|
import { afterTwoFrames, bumpRestoreGeneration, releaseHeightReservation, releaseScrollAnchor, reserveRestoredHeight, restoreGeneration, suppressScrollAnchoring } from './scroll.js';
|
|
22
22
|
import { snapshotCache, snapshotCurrent, snapshotGet } from './snapshot-cache.js';
|
|
23
23
|
import { _setEnabled, bumpNavToken, clearFragmentNav, consumeFragmentNav, currentNavigationToken, enabled, hardNavigate } from './state.js';
|
|
24
|
+
import { primeLiveStylesheetKeys } from './owned-styles.js';
|
|
24
25
|
import { _swapCommit, applySwap } from './swap.js';
|
|
25
26
|
import { ensureUpgradeObserver } from './upgrade.js';
|
|
26
27
|
import { viewTransitionsEnabled } from './view-transition.js';
|
|
@@ -143,6 +144,9 @@ let prevScrollRestoration = null;
|
|
|
143
144
|
export function enableClientRouter() {
|
|
144
145
|
if (enabled || typeof document === 'undefined') return;
|
|
145
146
|
_setEnabled(true);
|
|
147
|
+
// Key the page's stylesheets against the URL they were served at, before
|
|
148
|
+
// any navigation moves it (#1669, owned-styles.js).
|
|
149
|
+
primeLiveStylesheetKeys();
|
|
146
150
|
// Publish the in-place refresh entry on the global (#1398). The dev
|
|
147
151
|
// live-reload client is a separate served script with no import of this
|
|
148
152
|
// module, so a global is the only seam it has, and its PRESENCE is the
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stylesheets owned by a layout boundary (#1669).
|
|
3
|
+
*
|
|
4
|
+
* The server hoists the `<link rel="stylesheet">` at the start of a nested
|
|
5
|
+
* layout's or page's output into `<head>`, tagged `data-wj-style-owner` with
|
|
6
|
+
* the segment of the `wj:children` range it was rendered in. This module is
|
|
7
|
+
* the client half: it switches such a sheet OFF when a soft navigation
|
|
8
|
+
* replaces that range and the incoming page no longer carries the file, and
|
|
9
|
+
* back ON when a later page (or a back / forward restore) carries it again.
|
|
10
|
+
*
|
|
11
|
+
* Why OFF and not removed. Every framework the router takes cues from puts a
|
|
12
|
+
* route's sheet in the head and dedupes by href, but they split on what a
|
|
13
|
+
* departed route's sheet should do: Next, React Float, TanStack and SvelteKit
|
|
14
|
+
* keep it, Remix and Astro remove it, and Turbo removes it only when the link
|
|
15
|
+
* opts in with `data-turbo-track="dynamic"`. Keeping it costs real time here,
|
|
16
|
+
* since a disjoint section's sheet can carry hundreds of `:has()` rules every
|
|
17
|
+
* element insertion is checked against. Removing it would leave a back /
|
|
18
|
+
* forward restore painting before the sheet reloads, because a restore applies
|
|
19
|
+
* synchronously so the browser can replay the scroll offset (#1428). A
|
|
20
|
+
* disabled sheet is in neither bind: the browser skips it in matching and
|
|
21
|
+
* invalidation, and turning it back on is synchronous because it is already
|
|
22
|
+
* parsed. The `data-wj-style-off` marker records the state in the DOM, so a
|
|
23
|
+
* snapshot serializes it and a restore knows which sheets were off.
|
|
24
|
+
*
|
|
25
|
+
* Why it cannot reintroduce #936. A sheet is switched off ONLY when the range
|
|
26
|
+
* that owns it is SWAPPED (replaced, or morphed in place), never because the
|
|
27
|
+
* incoming head lacks it in general: a reduced `X-Webjs-Have` response omits
|
|
28
|
+
* what the client already has, and reading that absence as "no longer needed"
|
|
29
|
+
* is what left the page unstyled there. Everything inside a swapped range was
|
|
30
|
+
* re-rendered by the server, so the incoming head carries every owned sheet
|
|
31
|
+
* that range still needs. A sheet with no owner (the root layout's, or one an
|
|
32
|
+
* app writes into its head) is never touched.
|
|
33
|
+
*/
|
|
34
|
+
import { segmentWithin } from './boundaries.js';
|
|
35
|
+
import { PRELOAD_ATTR, relIsStylesheet } from './constants.js';
|
|
36
|
+
|
|
37
|
+
/** The server's tag naming a hoisted stylesheet's owner segment. */
|
|
38
|
+
export const OWNER_ATTR = 'data-wj-style-owner';
|
|
39
|
+
/** Marks an owned stylesheet the router has switched off. */
|
|
40
|
+
export const OFF_ATTR = 'data-wj-style-off';
|
|
41
|
+
/**
|
|
42
|
+
* Marks a link loading under `media="not all"` so it is fetched without
|
|
43
|
+
* applying, and holds the author's own media (empty for none) until it is
|
|
44
|
+
* released. The dev refresh (`dev-styles.js`) reads the same attribute.
|
|
45
|
+
*/
|
|
46
|
+
export const HELD_MEDIA_ATTR = 'data-wj-style-media';
|
|
47
|
+
|
|
48
|
+
/** The query the dev refresh re-requests a sheet under (`dev-styles.js`). */
|
|
49
|
+
const DEV_REFRESH_PARAM = '__webjs_dev';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Is `el` a `<link>` naming a stylesheet (`rel` has `stylesheet`, not
|
|
53
|
+
* `alternate`) with an href.
|
|
54
|
+
* @param {Element} el
|
|
55
|
+
*/
|
|
56
|
+
export function isStylesheetLink(el) {
|
|
57
|
+
return el.tagName === 'LINK' && !!el.getAttribute('href') && relIsStylesheet(el);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The media a link applies under: the author's, also while the link is held
|
|
62
|
+
* under `not all`.
|
|
63
|
+
* @param {Element} el
|
|
64
|
+
*/
|
|
65
|
+
function authoredMedia(el) {
|
|
66
|
+
return ((el.hasAttribute(HELD_MEDIA_ATTR) ? el.getAttribute(HELD_MEDIA_ATTR) : el.getAttribute('media')) || '').trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The identity of a stylesheet link: its URL resolved against `base`, with the
|
|
71
|
+
* dev refresh's cache-buster stripped (so a sheet the dev refresh re-requested
|
|
72
|
+
* still matches the bare href the next response carries), plus the `media` it
|
|
73
|
+
* applies under (a print copy and a screen copy of one file are two sheets).
|
|
74
|
+
* The server's hoist keys sheets the same way.
|
|
75
|
+
* @param {Element} el
|
|
76
|
+
* @param {string} base
|
|
77
|
+
* @returns {string | null}
|
|
78
|
+
*/
|
|
79
|
+
function keyFor(el, base) {
|
|
80
|
+
const href = el.getAttribute('href');
|
|
81
|
+
if (!href) return null;
|
|
82
|
+
try {
|
|
83
|
+
const url = new URL(href, base);
|
|
84
|
+
url.searchParams.delete(DEV_REFRESH_PARAM);
|
|
85
|
+
const media = authoredMedia(el);
|
|
86
|
+
return media && media !== 'all' ? `${url.href} ${media}` : url.href;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The key of each live link, taken against the URL the document had when the
|
|
94
|
+
* router first saw it. A swap records the new history entry before it merges
|
|
95
|
+
* the head, so by then `document.baseURI` is the NEW page's, and resolving a
|
|
96
|
+
* live relative href against it would name a different file.
|
|
97
|
+
* @type {WeakMap<Element, string>}
|
|
98
|
+
*/
|
|
99
|
+
const liveKeys = new WeakMap();
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Record the key of every live stylesheet the router has not seen yet,
|
|
103
|
+
* against the current `document.baseURI`. Called when the router enables and
|
|
104
|
+
* at the start of every swap, before the history entry moves.
|
|
105
|
+
*/
|
|
106
|
+
export function primeLiveStylesheetKeys() {
|
|
107
|
+
if (typeof document === 'undefined' || !document.head) return;
|
|
108
|
+
for (const el of document.querySelectorAll('link[href]')) {
|
|
109
|
+
if (liveKeys.has(el) || !isStylesheetLink(el)) continue;
|
|
110
|
+
const key = keyFor(el, document.baseURI);
|
|
111
|
+
if (key) liveKeys.set(el, key);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @param {Element} el */
|
|
116
|
+
function liveKey(el) {
|
|
117
|
+
let key = liveKeys.get(el);
|
|
118
|
+
if (!key) {
|
|
119
|
+
key = keyFor(el, document.baseURI) || '';
|
|
120
|
+
if (key) liveKeys.set(el, key);
|
|
121
|
+
}
|
|
122
|
+
return key || null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Hold a link under `media="not all"`: it is fetched but does not apply, the
|
|
127
|
+
* standard way to load a stylesheet without applying it (a `disabled` link is
|
|
128
|
+
* never fetched at all). The author's media is kept in `HELD_MEDIA_ATTR`.
|
|
129
|
+
* @param {Element} link
|
|
130
|
+
*/
|
|
131
|
+
export function holdMedia(link) {
|
|
132
|
+
if (link.hasAttribute(HELD_MEDIA_ATTR)) return;
|
|
133
|
+
link.setAttribute(HELD_MEDIA_ATTR, link.getAttribute('media') || '');
|
|
134
|
+
link.setAttribute('media', 'not all');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Give a held link its own media back. Firefox and WebKit give the link a new
|
|
139
|
+
* sheet object when its media changes, so a caller that disables the sheet
|
|
140
|
+
* does it after this.
|
|
141
|
+
* @param {Element} link
|
|
142
|
+
*/
|
|
143
|
+
export function releaseMedia(link) {
|
|
144
|
+
if (!link.hasAttribute(HELD_MEDIA_ATTR)) return;
|
|
145
|
+
const media = link.getAttribute(HELD_MEDIA_ATTR);
|
|
146
|
+
link.removeAttribute(HELD_MEDIA_ATTR);
|
|
147
|
+
if (media) link.setAttribute('media', media);
|
|
148
|
+
else link.removeAttribute('media');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Links with a settle listener pending, so one is never added twice. */
|
|
152
|
+
const settling = new WeakSet();
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Switch a link off or on.
|
|
156
|
+
*
|
|
157
|
+
* A sheet still loading has no `sheet` yet. It is neither removed (a back /
|
|
158
|
+
* forward restore would then paint before a reload) nor left to apply: a
|
|
159
|
+
* stylesheet takes effect when it loads, a rendering frame can fall before
|
|
160
|
+
* its `load` task, and the departed section's rules would paint for that
|
|
161
|
+
* frame. So it is held (`holdMedia`) and, when it loads, released and then
|
|
162
|
+
* disabled if it is still marked off. Switching it back on releases it at
|
|
163
|
+
* once, so a link that already failed to load never keeps `not all`.
|
|
164
|
+
* @param {HTMLLinkElement} link
|
|
165
|
+
* @param {boolean} off
|
|
166
|
+
*/
|
|
167
|
+
function setOff(link, off) {
|
|
168
|
+
if (off) link.setAttribute(OFF_ATTR, '');
|
|
169
|
+
else link.removeAttribute(OFF_ATTR);
|
|
170
|
+
if (!off) {
|
|
171
|
+
releaseMedia(link);
|
|
172
|
+
if (link.sheet) link.sheet.disabled = false;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (link.sheet) { link.sheet.disabled = true; return; }
|
|
176
|
+
holdMedia(link);
|
|
177
|
+
if (settling.has(link)) return;
|
|
178
|
+
settling.add(link);
|
|
179
|
+
const settle = () => {
|
|
180
|
+
link.removeEventListener('load', settle);
|
|
181
|
+
link.removeEventListener('error', settle);
|
|
182
|
+
settling.delete(link);
|
|
183
|
+
if (!link.hasAttribute(OFF_ATTR)) return;
|
|
184
|
+
releaseMedia(link);
|
|
185
|
+
if (link.sheet) link.sheet.disabled = true;
|
|
186
|
+
};
|
|
187
|
+
link.addEventListener('load', settle);
|
|
188
|
+
link.addEventListener('error', settle);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Put an owned stylesheet link where a full load would have it: after the
|
|
193
|
+
* last owned sheet of its own boundary or an enclosing one, else before the
|
|
194
|
+
* first owned sheet, else at the end of the head. Root to leaf, so the
|
|
195
|
+
* cascade does not depend on how the page was reached. The router's
|
|
196
|
+
* temporary copies are placed the same way, which is what lets the head
|
|
197
|
+
* merge adopt a copy as the real link without moving it.
|
|
198
|
+
* @param {Element} node
|
|
199
|
+
* @param {string} owner
|
|
200
|
+
*/
|
|
201
|
+
export function placeOwnedLink(node, owner) {
|
|
202
|
+
/** @type {Element | null} */
|
|
203
|
+
let after = null;
|
|
204
|
+
/** @type {Element | null} */
|
|
205
|
+
let firstOwned = null;
|
|
206
|
+
for (const el of document.head.querySelectorAll(`link[${OWNER_ATTR}]`)) {
|
|
207
|
+
// The router's temporary copies count too (they carry their owner), so
|
|
208
|
+
// two sheets one navigation brings keep their relative order.
|
|
209
|
+
if (el === node || !isStylesheetLink(el)) continue;
|
|
210
|
+
if (!firstOwned) firstOwned = el;
|
|
211
|
+
if (segmentWithin(owner, /** @type {string} */ (el.getAttribute(OWNER_ATTR)))) after = el;
|
|
212
|
+
}
|
|
213
|
+
if (after) after.after(node);
|
|
214
|
+
else if (firstOwned) firstOwned.before(node);
|
|
215
|
+
else document.head.appendChild(node);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Start merging one incoming head's stylesheets into the live document.
|
|
220
|
+
* Returns a `merge(el, clone)` that answers true when it handled `el`, so the
|
|
221
|
+
* caller does not append it. The live links are read once, here, and kept
|
|
222
|
+
* current as the merge inserts. Called inside the swap commit, so whatever it
|
|
223
|
+
* switches on or adds takes effect with the swap and not before it.
|
|
224
|
+
*
|
|
225
|
+
* For each incoming stylesheet:
|
|
226
|
+
* - The live document already links the file, in its head or in a part of
|
|
227
|
+
* its body this swap keeps (`leaving` says which elements are on their way
|
|
228
|
+
* out): no second copy, since two links to one URL are two active sheets.
|
|
229
|
+
* An owned one that was off is switched back on. Its owner is the outermost
|
|
230
|
+
* boundary still on screen that links it: a sheet a layout and a page under
|
|
231
|
+
* it both link stays the layout's while the layout is held (a reduced
|
|
232
|
+
* response re-rendered only the page), and is handed to the incoming owner
|
|
233
|
+
* once the old one is gone (the swap replaces it, or the sheet was off), so
|
|
234
|
+
* a later swap of the new owner's range can retire it.
|
|
235
|
+
* - The incoming link is one a snapshot recorded as off, or one of the
|
|
236
|
+
* router's temporary copies a snapshot captured: nothing to do.
|
|
237
|
+
* - The router's temporary copy of the file is loading (stylesheets.js): it
|
|
238
|
+
* is adopted as the page's own link, where it already sits.
|
|
239
|
+
* - An incoming OWNED sheet with neither is inserted root to leaf
|
|
240
|
+
* (`placeOwnedLink`), the order a full load puts them in.
|
|
241
|
+
* - Anything else is left to the caller's append.
|
|
242
|
+
*
|
|
243
|
+
* In `frame` mode (a frame swap whose response turned out to be a whole
|
|
244
|
+
* page) nothing owned is switched on, re-owned, adopted or inserted: the page
|
|
245
|
+
* around the frame decides which sheets apply, and a frame swap never
|
|
246
|
+
* switches one off either.
|
|
247
|
+
*
|
|
248
|
+
* @param {{ base?: string | null, frame?: boolean, segment?: string | null, leaving?: (el: Element) => boolean }} [opts]
|
|
249
|
+
* `base` is the URL the incoming document came from, which its relative
|
|
250
|
+
* hrefs resolve against; `segment` is the range this swap replaces (`/` for
|
|
251
|
+
* a full-body swap), whose owners are on their way out.
|
|
252
|
+
* @returns {(el: Element, clone: (el: Element) => Element) => boolean}
|
|
253
|
+
*/
|
|
254
|
+
export function stylesheetMerge(opts = {}) {
|
|
255
|
+
const base = opts.base || (typeof document !== 'undefined' ? document.baseURI : '');
|
|
256
|
+
/** @type {Map<string, HTMLLinkElement>} */
|
|
257
|
+
const live = new Map();
|
|
258
|
+
/** The router's load-before-swap copies (stylesheets.js), by key. */
|
|
259
|
+
/** @type {Map<string, HTMLLinkElement>} */
|
|
260
|
+
const copies = new Map();
|
|
261
|
+
for (const el of document.querySelectorAll('link[href]')) {
|
|
262
|
+
if (!isStylesheetLink(el)) continue;
|
|
263
|
+
const inHead = el.parentNode === document.head;
|
|
264
|
+
if (!inHead && opts.leaving && opts.leaving(el)) continue;
|
|
265
|
+
const key = liveKey(el);
|
|
266
|
+
if (!key) continue;
|
|
267
|
+
const into = el.hasAttribute(PRELOAD_ATTR) ? (inHead ? copies : null) : live;
|
|
268
|
+
if (into && !into.has(key)) into.set(key, /** @type {HTMLLinkElement} */ (el));
|
|
269
|
+
}
|
|
270
|
+
return (incoming, clone) => {
|
|
271
|
+
// A link a snapshot recorded as off, or one of the router's temporary
|
|
272
|
+
// load-before-swap copies a snapshot captured mid-navigation: neither is
|
|
273
|
+
// a sheet that page used, so neither is appended or switched on.
|
|
274
|
+
if (incoming.hasAttribute(OFF_ATTR) || incoming.hasAttribute(PRELOAD_ATTR)) return true;
|
|
275
|
+
const key = keyFor(incoming, base);
|
|
276
|
+
if (!key) return false;
|
|
277
|
+
const owner = incoming.getAttribute(OWNER_ATTR);
|
|
278
|
+
const existing = live.get(key);
|
|
279
|
+
if (existing) {
|
|
280
|
+
if (opts.frame) return true;
|
|
281
|
+
const wasOff = existing.hasAttribute(OFF_ATTR);
|
|
282
|
+
if (wasOff) setOff(existing, false);
|
|
283
|
+
const held = existing.getAttribute(OWNER_ATTR);
|
|
284
|
+
if (owner && held !== null && held !== owner) {
|
|
285
|
+
// The held owner stays only while it is on screen and encloses the
|
|
286
|
+
// incoming one: a layout this response did not re-render. A held
|
|
287
|
+
// owner the swap replaces, one whose sheet was off, or one inside the
|
|
288
|
+
// incoming owner gives way.
|
|
289
|
+
const heldStays = !wasOff
|
|
290
|
+
&& !(opts.segment != null && segmentWithin(held, opts.segment))
|
|
291
|
+
&& segmentWithin(owner, held);
|
|
292
|
+
if (!heldStays) existing.setAttribute(OWNER_ATTR, owner);
|
|
293
|
+
}
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
const copy = opts.frame ? null : copies.get(key);
|
|
297
|
+
if (copy) {
|
|
298
|
+
for (const attr of incoming.attributes) {
|
|
299
|
+
if (attr.name === 'href' || attr.name === 'nonce') continue;
|
|
300
|
+
// The copy is still held: its media goes into the held slot, and is
|
|
301
|
+
// put back when the commit releases the incoming sheets.
|
|
302
|
+
if (attr.name === 'media' && copy.hasAttribute(HELD_MEDIA_ATTR)) copy.setAttribute(HELD_MEDIA_ATTR, attr.value);
|
|
303
|
+
else copy.setAttribute(attr.name, attr.value);
|
|
304
|
+
}
|
|
305
|
+
copy.removeAttribute(PRELOAD_ATTR);
|
|
306
|
+
copies.delete(key);
|
|
307
|
+
live.set(key, copy);
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
if (!owner) return false;
|
|
311
|
+
if (opts.frame) return true;
|
|
312
|
+
const node = /** @type {HTMLLinkElement} */ (clone(incoming));
|
|
313
|
+
// Absolute, so the inserted link names the file the response meant
|
|
314
|
+
// whatever the document's URL is when it is next read.
|
|
315
|
+
try { node.setAttribute('href', new URL(/** @type {string} */ (incoming.getAttribute('href')), base).href); } catch { /* keep as authored */ }
|
|
316
|
+
placeOwnedLink(node, owner);
|
|
317
|
+
liveKeys.set(node, key);
|
|
318
|
+
live.set(key, node);
|
|
319
|
+
return true;
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Let the incoming page's sheets apply: release every held link that is not
|
|
325
|
+
* switched off, meaning the router's load-before-swap copies (stylesheets.js)
|
|
326
|
+
* and the ones the merge adopted. Called inside the swap commit, so the
|
|
327
|
+
* outgoing page, and a view transition's capture of it, never paint with the
|
|
328
|
+
* incoming page's CSS.
|
|
329
|
+
*/
|
|
330
|
+
export function releaseIncomingSheets() {
|
|
331
|
+
if (typeof document === 'undefined' || !document.head) return;
|
|
332
|
+
for (const el of document.head.querySelectorAll(`link[${HELD_MEDIA_ATTR}]`)) {
|
|
333
|
+
if (!el.hasAttribute(OFF_ATTR)) releaseMedia(el);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Switch off every owned stylesheet whose boundary sits at or below the
|
|
339
|
+
* swapped `segment` and that the incoming page does not link, in its head or
|
|
340
|
+
* its body. Called inside the swap commit of the boundary tiers, a replace or
|
|
341
|
+
* a morph (the server re-rendered everything inside that range, so the
|
|
342
|
+
* incoming page carries every owned sheet the range still uses), and of a
|
|
343
|
+
* full-body swap whose response is known to be complete. Never from a frame
|
|
344
|
+
* swap, nor from a full-body swap whose head can be a reduced one that omits
|
|
345
|
+
* what the client already has (#936).
|
|
346
|
+
*
|
|
347
|
+
* @param {string} segment The segment of the range being swapped.
|
|
348
|
+
* @param {ParentNode | null} incoming The incoming document (head and body).
|
|
349
|
+
* @param {string | null} [base] The URL the incoming document came from.
|
|
350
|
+
*/
|
|
351
|
+
export function switchOffDepartedStylesheets(segment, incoming, base) {
|
|
352
|
+
if (typeof document === 'undefined' || !document.head) return;
|
|
353
|
+
const from = base || document.baseURI;
|
|
354
|
+
const carried = new Set();
|
|
355
|
+
if (incoming) {
|
|
356
|
+
for (const el of incoming.querySelectorAll('link[href]')) {
|
|
357
|
+
// A snapshot taken mid-navigation can hold the router's temporary copy
|
|
358
|
+
// of the NEXT page's sheet; it does not mean the page used it.
|
|
359
|
+
if (!isStylesheetLink(el) || el.hasAttribute(OFF_ATTR) || el.hasAttribute(PRELOAD_ATTR)) continue;
|
|
360
|
+
const key = keyFor(el, from);
|
|
361
|
+
if (key) carried.add(key);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
for (const el of document.head.querySelectorAll(`link[${OWNER_ATTR}]`)) {
|
|
365
|
+
if (el.hasAttribute(PRELOAD_ATTR) || el.hasAttribute(OFF_ATTR) || el.hasAttribute('disabled') || !isStylesheetLink(el)) continue;
|
|
366
|
+
const owner = /** @type {string} */ (el.getAttribute(OWNER_ATTR));
|
|
367
|
+
if (!segmentWithin(owner, segment)) continue;
|
|
368
|
+
const key = liveKey(el);
|
|
369
|
+
if (key && carried.has(key)) continue;
|
|
370
|
+
setOff(/** @type {HTMLLinkElement} */ (el), true);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
@@ -662,6 +662,9 @@ export function onPrefetchIntent(e) {
|
|
|
662
662
|
prefetchHoverTimer = setTimeout(() => {
|
|
663
663
|
prefetchHoverTimer = null;
|
|
664
664
|
prefetchHoverAnchor = null;
|
|
665
|
+
// A navigation during the dwell can swap the hovered link out of the
|
|
666
|
+
// page; a link nobody can click any more warms nothing.
|
|
667
|
+
if (!anchor.isConnected) return;
|
|
665
668
|
// Resolved at FIRE time, so it sees the document a click at this moment
|
|
666
669
|
// would (a soft nav during the dwell can change which frame encloses it).
|
|
667
670
|
prefetch(href, resolveTargetFrameId(anchor));
|