@webjsdev/core 0.7.65 → 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.
Files changed (44) hide show
  1. package/dist/webjs-core-browser.js +5 -5
  2. package/dist/webjs-core-browser.js.map +3 -3
  3. package/dist/webjs-core-testing.js +19 -19
  4. package/dist/webjs-core-testing.js.map +3 -3
  5. package/dist/webjs-core.js +6 -6
  6. package/dist/webjs-core.js.map +3 -3
  7. package/index-browser.js +5 -1
  8. package/index.d.ts +5 -1
  9. package/index.js +5 -1
  10. package/package.json +1 -1
  11. package/src/attribute-directives.d.ts +6 -0
  12. package/src/attribute-directives.js +49 -0
  13. package/src/attribute-reader.js +17 -3
  14. package/src/binding-prefixes.js +10 -41
  15. package/src/component/lifecycle.js +177 -59
  16. package/src/component/reactive.js +5 -4
  17. package/src/component.d.ts +11 -5
  18. package/src/directives.d.ts +2 -0
  19. package/src/directives.js +2 -0
  20. package/src/html.d.ts +4 -0
  21. package/src/html.js +22 -1
  22. package/src/render-client/adopt-slots.js +245 -0
  23. package/src/render-client/parts.js +51 -17
  24. package/src/render-client/reconciler.js +28 -12
  25. package/src/render-client/template-compiler.js +19 -5
  26. package/src/render-client.d.ts +2 -1
  27. package/src/render-server/compile.js +16 -2
  28. package/src/render-server/expand.js +39 -12
  29. package/src/render-server/served-attrs.js +79 -0
  30. package/src/render-server/walk.js +42 -2
  31. package/src/router-client/anchors.js +3 -1
  32. package/src/router-client/boundaries.js +15 -1
  33. package/src/router-client/constants.js +30 -0
  34. package/src/router-client/fetch-apply.js +21 -0
  35. package/src/router-client/frames.js +6 -3
  36. package/src/router-client/head-merge.js +58 -10
  37. package/src/router-client/navigator.js +4 -0
  38. package/src/router-client/owned-styles.js +372 -0
  39. package/src/router-client/prefetch.js +3 -0
  40. package/src/router-client/stylesheets.js +191 -0
  41. package/src/router-client/swap.js +49 -9
  42. package/src/sentinels.d.ts +2 -0
  43. package/src/sentinels.js +4 -0
  44. package/src/webjs-config.d.ts +1 -1
@@ -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
- export function addNewHeadElements(newHead) {
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), and WebJs is Tailwind-first (one
267
- * global sheet, no page-specific sheets to drop), so the knob would be unsafe
268
- * and unused. Keeping every stylesheet is correct here; a genuinely changed one
269
- * is dropped by the deploy-level hard reload (build-id mismatch), not a soft swap.
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
- return el.tagName === 'LINK' &&
277
- (el.getAttribute('rel') || '').toLowerCase().split(/\s+/).includes('stylesheet');
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
- /** @param {HTMLHeadElement} newHead */
281
- export function mergeHead(newHead) {
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));