@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.
- package/README.md +5 -0
- package/dist/webjs-core-browser.js +6 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +22 -21
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -6
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +18 -9
- package/index.d.ts +26 -10
- package/index.js +21 -9
- package/package.json +15 -2
- package/src/action-effects-client.d.ts +5 -0
- package/src/action-effects-client.js +84 -0
- package/src/action-seed-client.js +5 -5
- package/src/asset-url.js +12 -13
- package/src/async-directive.d.ts +18 -0
- package/src/async-directive.js +88 -0
- package/src/attribute-directives.d.ts +6 -0
- package/src/attribute-directives.js +49 -0
- package/src/attribute-reader.js +213 -25
- package/src/binding-prefixes.js +10 -53
- package/src/component/lifecycle.js +921 -252
- package/src/component/reactive.js +7 -14
- package/src/component/server-element.js +23 -1
- package/src/component.d.ts +160 -44
- package/src/context.d.ts +94 -18
- package/src/context.js +341 -217
- package/src/converters.d.ts +7 -0
- package/src/converters.js +38 -0
- package/src/css.d.ts +32 -6
- package/src/css.js +178 -14
- package/src/directive.d.ts +58 -0
- package/src/directive.js +119 -0
- package/src/directives.d.ts +40 -1
- package/src/directives.js +160 -1
- package/src/form-action.d.ts +13 -0
- package/src/form-action.js +139 -4
- package/src/hot.js +23 -0
- package/src/html.d.ts +8 -0
- package/src/html.js +55 -2
- package/src/is-server.d.ts +2 -0
- package/src/is-server.js +18 -0
- package/src/jsx-guard.js +130 -0
- package/src/metadata.d.ts +22 -3
- package/src/nav.d.ts +4 -1
- package/src/nav.js +44 -11
- package/src/optimistic.js +2 -2
- package/src/refresh-header.js +52 -0
- package/src/registry.js +10 -1
- package/src/render-client/adopt-slots.js +250 -0
- package/src/render-client/parts.js +790 -42
- package/src/render-client/reconciler.js +84 -23
- package/src/render-client/template-compiler.js +152 -43
- package/src/render-client.d.ts +6 -1
- package/src/render-server/attrs.js +2 -2
- package/src/render-server/compile.js +50 -2
- package/src/render-server/errors.js +37 -0
- package/src/render-server/expand.js +100 -20
- package/src/render-server/served-attrs.js +78 -0
- package/src/render-server/walk.js +177 -5
- package/src/render-server.d.ts +3 -0
- package/src/render-server.js +2 -0
- package/src/repeat.d.ts +7 -2
- package/src/repeat.js +20 -6
- 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/dom-differ.js +17 -0
- package/src/router-client/fetch-apply.js +107 -5
- package/src/router-client/frames.js +6 -3
- package/src/router-client/head-merge.js +58 -10
- package/src/router-client/navigator.js +49 -9
- package/src/router-client/owned-styles.js +372 -0
- package/src/router-client/prefetch.js +6 -4
- package/src/router-client/root-attrs.js +43 -0
- package/src/router-client/stream.js +7 -0
- package/src/router-client/stylesheets.js +191 -0
- package/src/router-client/swap.js +53 -10
- package/src/routes.d.ts +75 -18
- package/src/sentinels.d.ts +2 -0
- package/src/sentinels.js +4 -0
- package/src/serialize.d.ts +2 -0
- package/src/serialize.js +35 -4
- package/src/signal.d.ts +7 -0
- package/src/signal.js +26 -6
- package/src/slot/polyfills.js +3 -3
- package/src/slot.js +4 -8
- package/src/static-html.d.ts +22 -0
- package/src/static-html.js +144 -0
- package/src/task.d.ts +63 -20
- package/src/task.js +326 -293
- 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}`
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
+
}
|