@webjsdev/core 0.7.65 → 0.9.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 (92) hide show
  1. package/README.md +5 -0
  2. package/dist/webjs-core-browser.js +6 -5
  3. package/dist/webjs-core-browser.js.map +3 -3
  4. package/dist/webjs-core-testing.js +22 -21
  5. package/dist/webjs-core-testing.js.map +3 -3
  6. package/dist/webjs-core.js +7 -6
  7. package/dist/webjs-core.js.map +3 -3
  8. package/index-browser.js +18 -9
  9. package/index.d.ts +26 -10
  10. package/index.js +21 -9
  11. package/package.json +15 -2
  12. package/src/action-effects-client.d.ts +5 -0
  13. package/src/action-effects-client.js +84 -0
  14. package/src/action-seed-client.js +5 -5
  15. package/src/asset-url.js +12 -13
  16. package/src/async-directive.d.ts +18 -0
  17. package/src/async-directive.js +88 -0
  18. package/src/attribute-directives.d.ts +6 -0
  19. package/src/attribute-directives.js +49 -0
  20. package/src/attribute-reader.js +213 -25
  21. package/src/binding-prefixes.js +10 -53
  22. package/src/component/lifecycle.js +921 -252
  23. package/src/component/reactive.js +7 -14
  24. package/src/component/server-element.js +23 -1
  25. package/src/component.d.ts +160 -44
  26. package/src/context.d.ts +94 -18
  27. package/src/context.js +341 -217
  28. package/src/converters.d.ts +7 -0
  29. package/src/converters.js +38 -0
  30. package/src/css.d.ts +32 -6
  31. package/src/css.js +178 -14
  32. package/src/directive.d.ts +58 -0
  33. package/src/directive.js +119 -0
  34. package/src/directives.d.ts +40 -1
  35. package/src/directives.js +160 -1
  36. package/src/form-action.d.ts +13 -0
  37. package/src/form-action.js +139 -4
  38. package/src/hot.js +23 -0
  39. package/src/html.d.ts +8 -0
  40. package/src/html.js +55 -2
  41. package/src/is-server.d.ts +2 -0
  42. package/src/is-server.js +18 -0
  43. package/src/jsx-guard.js +130 -0
  44. package/src/metadata.d.ts +22 -3
  45. package/src/nav.d.ts +4 -1
  46. package/src/nav.js +44 -11
  47. package/src/optimistic.js +2 -2
  48. package/src/refresh-header.js +52 -0
  49. package/src/registry.js +10 -1
  50. package/src/render-client/adopt-slots.js +250 -0
  51. package/src/render-client/parts.js +790 -42
  52. package/src/render-client/reconciler.js +84 -23
  53. package/src/render-client/template-compiler.js +152 -43
  54. package/src/render-client.d.ts +6 -1
  55. package/src/render-server/attrs.js +2 -2
  56. package/src/render-server/compile.js +50 -2
  57. package/src/render-server/errors.js +37 -0
  58. package/src/render-server/expand.js +100 -20
  59. package/src/render-server/served-attrs.js +78 -0
  60. package/src/render-server/walk.js +177 -5
  61. package/src/render-server.d.ts +3 -0
  62. package/src/render-server.js +2 -0
  63. package/src/repeat.d.ts +7 -2
  64. package/src/repeat.js +20 -6
  65. package/src/router-client/anchors.js +3 -1
  66. package/src/router-client/boundaries.js +15 -1
  67. package/src/router-client/constants.js +30 -0
  68. package/src/router-client/dom-differ.js +17 -0
  69. package/src/router-client/fetch-apply.js +107 -5
  70. package/src/router-client/frames.js +6 -3
  71. package/src/router-client/head-merge.js +58 -10
  72. package/src/router-client/navigator.js +49 -9
  73. package/src/router-client/owned-styles.js +372 -0
  74. package/src/router-client/prefetch.js +6 -4
  75. package/src/router-client/root-attrs.js +43 -0
  76. package/src/router-client/stream.js +7 -0
  77. package/src/router-client/stylesheets.js +191 -0
  78. package/src/router-client/swap.js +53 -10
  79. package/src/routes.d.ts +75 -18
  80. package/src/sentinels.d.ts +2 -0
  81. package/src/sentinels.js +4 -0
  82. package/src/serialize.d.ts +2 -0
  83. package/src/serialize.js +35 -4
  84. package/src/signal.d.ts +7 -0
  85. package/src/signal.js +26 -6
  86. package/src/slot/polyfills.js +3 -3
  87. package/src/slot.js +4 -8
  88. package/src/static-html.d.ts +22 -0
  89. package/src/static-html.js +144 -0
  90. package/src/task.d.ts +63 -20
  91. package/src/task.js +326 -293
  92. package/src/webjs-config.d.ts +2 -9
@@ -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
+ }
@@ -92,10 +92,9 @@ function prefetchRefusedRecently(key) {
92
92
  * dev live-reload path, where an edit can add or remove a `Suspense` boundary
93
93
  * and so start or stop the route streaming. Only an edit to a file the browser
94
94
  * does NOT download arrives there; anything that SHIPS takes a full reload
95
- * instead. A `loading.{js,ts}` reloads either way, though for two different
96
- * reasons: editing or deleting one reloads because it can never be elided and
97
- * so always ships, while ADDING one reloads because a brand-new file is in no
98
- * set the previous build produced.
95
+ * instead. A `loading.{js,ts}` is a route module, so editing one arrives here
96
+ * like a page edit (it never ships, #1686), while ADDING one reloads because a
97
+ * brand-new file is in no set the previous build produced.
99
98
  *
100
99
  * NEITHER form of `revalidate` clears them, which is a cost/benefit call rather
101
100
  * than an impossibility. It is the post-MUTATION api an app calls after an RPC
@@ -662,6 +661,9 @@ export function onPrefetchIntent(e) {
662
661
  prefetchHoverTimer = setTimeout(() => {
663
662
  prefetchHoverTimer = null;
664
663
  prefetchHoverAnchor = null;
664
+ // A navigation during the dwell can swap the hovered link out of the
665
+ // page; a link nobody can click any more warms nothing.
666
+ if (!anchor.isConnected) return;
665
667
  // Resolved at FIRE time, so it sees the document a click at this moment
666
668
  // would (a soft nav during the dwell can change which frame encloses it).
667
669
  prefetch(href, resolveTargetFrameId(anchor));
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `<html>` and `<body>` attributes follow the server across a soft navigation
3
+ * (#1686). The root layout owns them (`<html class=${theme}>`, `<body
4
+ * class=...>`), and a Next.js developer expects a changed theme cookie to
5
+ * show once the action or navigation lands, as it does when the root layout
6
+ * re-renders there. The swap replaces content inside `<body>`, so without
7
+ * this the attributes stayed as the first load left them until a reload.
8
+ *
9
+ * Only attributes the SERVER rendered are touched: the server lists their
10
+ * names in `data-wj-html` / `data-wj-body`, so an attribute a script sets in
11
+ * the browser (a theme bootstrap writing `data-theme` from localStorage) is
12
+ * never removed. A response that did not render the root layout (a reduced
13
+ * `X-Webjs-Have` fragment) carries no list and changes nothing.
14
+ */
15
+
16
+ const MARKS = /** @type {const} */ ([['documentElement', 'data-wj-html'], ['body', 'data-wj-body']]);
17
+
18
+ /** @param {string | null} v */
19
+ const names = (v) => (v || '').split(/\s+/).filter(Boolean);
20
+
21
+ /**
22
+ * Bring the live `<html>` and `<body>` attributes in line with an incoming
23
+ * page's, for the attributes its server rendered.
24
+ * @param {Document} doc the parsed incoming page
25
+ */
26
+ export function syncRootAttributes(doc) {
27
+ if (typeof document === 'undefined' || !doc) return;
28
+ for (const [key, mark] of MARKS) {
29
+ const incoming = /** @type {Element | null} */ (/** @type any */ (doc)[key]);
30
+ const live = /** @type {Element | null} */ (/** @type any */ (document)[key]);
31
+ if (!incoming || !live || !incoming.hasAttribute(mark)) continue;
32
+ const next = names(incoming.getAttribute(mark));
33
+ for (const n of names(live.getAttribute(mark))) {
34
+ if (!next.includes(n)) live.removeAttribute(n);
35
+ }
36
+ for (const n of next) {
37
+ const v = incoming.getAttribute(n);
38
+ if (v === null) live.removeAttribute(n);
39
+ else if (live.getAttribute(n) !== v) live.setAttribute(n, v);
40
+ }
41
+ live.setAttribute(mark, next.join(' '));
42
+ }
43
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { warnDropped } from './diagnostics.js';
10
10
  import { upgradeTree } from './upgrade.js';
11
+ import { navigate } from './navigator.js';
11
12
 
12
13
  /**
13
14
  * Forward streamed Suspense resolver templates from the fetched body to
@@ -155,6 +156,12 @@ export function applyStreamedResolve(id, content) {
155
156
  const tpl = document.createElement('template');
156
157
  tpl.innerHTML = content;
157
158
  const inserted = [...tpl.content.childNodes];
159
+ // A boundary whose content threw redirect() on the server streams a marker
160
+ // (#1686): follow it the way a first load follows its refresh.
161
+ for (const n of inserted) {
162
+ const to = n.nodeType === 1 ? /** @type {Element} */ (n).getAttribute('data-webjs-redirect') : null;
163
+ if (to) { navigate(to, { replace: true }); return; }
164
+ }
158
165
  boundary.replaceWith(tpl.content);
159
166
  // Upgrade any custom elements now that they are connected (belt-and-braces:
160
167
  // a connected, defined element upgrades on insertion, but a fragment that was
@@ -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
+ }