@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
@@ -0,0 +1,191 @@
1
+ // @ts-check
2
+ /**
3
+ * Stylesheets a navigation brings with it are loaded BEFORE the swap (#1666).
4
+ *
5
+ * A soft navigation parses the response and applies it in the same task, so a
6
+ * `<link rel="stylesheet">` the incoming page carries starts loading only once
7
+ * it is in the live document, and the new content paints for a frame or more
8
+ * without it: a flash of the page in the wrong styles. Turbo Drive closes the
9
+ * same gap by appending each new head stylesheet and awaiting its `load`
10
+ * before it renders, Remix v2 preloads a route's links and awaits them before
11
+ * it commits the transition, and the Next.js app router holds a commit until
12
+ * new route CSS has loaded.
13
+ *
14
+ * WebJs reads the BODY as well as the head, because a section's stylesheet can
15
+ * only ride the body on a soft navigation: only the root layout may write
16
+ * `<head>`, and the `X-Webjs-Have` short-circuit never re-renders it, so a
17
+ * nested layout or a page that needs a stylesheet emits its link inline. On a
18
+ * frame navigation only the frame's own subtree is read, since nothing else in
19
+ * the response is swapped in.
20
+ *
21
+ * The router adds no stylesheet state of its own; it only bridges the load
22
+ * gap, Remix's preload-then-render shape. Each stylesheet the live document
23
+ * does not already link gets a TEMPORARY copy in `<head>`, marked
24
+ * `data-webjs-preload-style`, and the swap waits for it. Once the swap has
25
+ * committed, `releaseIncomingStylesheets` removes each copy as soon as the
26
+ * page's own link to the same file has loaded (from the HTTP cache by then),
27
+ * so some copy of the sheet applies on every frame and none outlives it. A
28
+ * copy whose file the swapped-in page does not link is removed at once, and
29
+ * so is every copy of a navigation that was superseded or discarded. A sheet
30
+ * that fails or stalls never holds a navigation hostage: an `error` resolves
31
+ * the wait, and so does a timeout, Turbo's 2 seconds.
32
+ */
33
+ import { PRELOAD_ATTR, relIsStylesheet } from './constants.js';
34
+ import { HELD_MEDIA_ATTR, OWNER_ATTR, holdMedia, placeOwnedLink } from './owned-styles.js';
35
+ import { getCspNonce } from './head-merge.js';
36
+
37
+ /** How long a navigation waits for one stylesheet before it swaps anyway. */
38
+ export const STYLESHEET_TIMEOUT_MS = 2000;
39
+
40
+ /** The attribute that marks the router's temporary copies (defined in the leaf). */
41
+ export { PRELOAD_ATTR };
42
+
43
+ /**
44
+ * Is `el` a stylesheet that applies now: a `<link>` whose `rel` names
45
+ * `stylesheet` but not `alternate` (an alternate sheet is off until chosen),
46
+ * that is not `disabled`, has an `href`, and has no `media` query that does
47
+ * not match.
48
+ * @param {Element} el
49
+ */
50
+ function isActiveStylesheet(el) {
51
+ if (!relIsStylesheet(el)) return false;
52
+ if (el.hasAttribute('disabled') || !el.getAttribute('href')) return false;
53
+ // A held link (owned-styles.js) applies under its author's media once released.
54
+ const media = el.hasAttribute(HELD_MEDIA_ATTR) ? el.getAttribute(HELD_MEDIA_ATTR) : el.getAttribute('media');
55
+ if (media && typeof matchMedia === 'function') {
56
+ try { if (!matchMedia(media).matches) return false; } catch { /* an unparseable query applies to nothing */ return false; }
57
+ }
58
+ return true;
59
+ }
60
+
61
+ /**
62
+ * Resolve when `link` has loaded or failed, or after `timeout` ms.
63
+ * @param {HTMLLinkElement} link
64
+ * @param {number} timeout
65
+ * @returns {Promise<void>}
66
+ */
67
+ function waitForLoad(link, timeout) {
68
+ return new Promise((resolve) => {
69
+ const done = () => {
70
+ clearTimeout(timer);
71
+ link.removeEventListener('load', done);
72
+ link.removeEventListener('error', done);
73
+ resolve();
74
+ };
75
+ const timer = setTimeout(done, timeout);
76
+ link.addEventListener('load', done);
77
+ link.addEventListener('error', done);
78
+ });
79
+ }
80
+
81
+ /**
82
+ * @typedef {{ ready: Promise<void>, copies: HTMLLinkElement[] }} IncomingStylesheets
83
+ */
84
+
85
+ /**
86
+ * Start loading every stylesheet the incoming page links that the live
87
+ * document does not, through a temporary copy in `<head>`. Returns `null` when
88
+ * there is nothing new, so the caller swaps in the same task as before and a
89
+ * navigation that brings no stylesheet costs no extra time. Otherwise returns
90
+ * the copies and a promise that settles when all of them have loaded, failed
91
+ * or timed out; pass the result to `releaseIncomingStylesheets` once the swap
92
+ * has committed, or once the navigation is abandoned.
93
+ *
94
+ * @param {Document} doc The parsed incoming page.
95
+ * @param {string | null | undefined} baseUrl The URL `doc` was fetched from,
96
+ * which a relative `href` in it resolves against.
97
+ * @param {string | null} [frameId] On a frame navigation, the frame being
98
+ * swapped: only its subtree in `doc` is read.
99
+ * @param {number} [timeout]
100
+ * @returns {IncomingStylesheets | null}
101
+ */
102
+ export function loadIncomingStylesheets(doc, baseUrl, frameId, timeout = STYLESHEET_TIMEOUT_MS) {
103
+ if (typeof document === 'undefined' || !doc || !document.head) return null;
104
+ /** @type {ParentNode | null} */
105
+ let scope = doc;
106
+ if (frameId) {
107
+ try { scope = doc.querySelector(`webjs-frame#${CSS.escape(frameId)}`); } catch { scope = null; }
108
+ }
109
+ if (!scope) return null;
110
+ const base = baseUrl || document.baseURI;
111
+ /** @param {string} href */
112
+ const resolve = (href) => {
113
+ try { return new URL(href, base).href; } catch { return null; }
114
+ };
115
+ // What the page already links. Another navigation's temporary copies do
116
+ // not count: they are released on that navigation's own schedule.
117
+ const live = new Set();
118
+ for (const el of document.querySelectorAll('link[href]')) {
119
+ if (el.hasAttribute(PRELOAD_ATTR) || !isActiveStylesheet(el)) continue;
120
+ const href = /** @type {HTMLLinkElement} */ (el).href;
121
+ if (href) live.add(href);
122
+ }
123
+ const nonce = getCspNonce();
124
+ /** @type {HTMLLinkElement[]} */
125
+ const copies = [];
126
+ /** @type {Promise<void>[]} */
127
+ const waits = [];
128
+ for (const el of scope.querySelectorAll('link[href]')) {
129
+ if (!isActiveStylesheet(el)) continue;
130
+ const href = resolve(/** @type {string} */ (el.getAttribute('href')));
131
+ if (!href || live.has(href)) continue;
132
+ live.add(href);
133
+ const link = document.createElement('link');
134
+ link.rel = 'stylesheet';
135
+ link.setAttribute(PRELOAD_ATTR, '');
136
+ // The attributes that change how the file is fetched and applied travel
137
+ // with it, so the copy loads exactly what the page asked for. The nonce is
138
+ // the LIVE page's: the browser checks CSP against the nonce of the original
139
+ // page load, never a later response's (#1050).
140
+ for (const name of ['media', 'crossorigin', 'integrity', 'referrerpolicy']) {
141
+ const value = el.getAttribute(name);
142
+ if (value) link.setAttribute(name, value);
143
+ }
144
+ if (nonce) link.setAttribute('nonce', nonce);
145
+ link.href = href;
146
+ // Loaded without applying: the outgoing page, and a view transition's
147
+ // capture of it, must not paint with the incoming CSS. The swap commit
148
+ // releases it (owned-styles.js `releaseIncomingSheets`).
149
+ holdMedia(link);
150
+ copies.push(link);
151
+ waits.push(waitForLoad(link, timeout));
152
+ // A sheet a layout owns (#1669) goes where the page's own link belongs,
153
+ // so the head merge can adopt this copy as that link instead of adding a
154
+ // second one; any other copy goes at the end of the head.
155
+ const owner = el.getAttribute(OWNER_ATTR);
156
+ if (owner) {
157
+ link.setAttribute(OWNER_ATTR, owner);
158
+ placeOwnedLink(link, owner);
159
+ } else {
160
+ document.head.appendChild(link);
161
+ }
162
+ }
163
+ if (!copies.length) return null;
164
+ return { ready: Promise.all(waits).then(() => undefined), copies };
165
+ }
166
+
167
+ /**
168
+ * Remove the temporary copies `loadIncomingStylesheets` made. Each one stays
169
+ * until the live document's own link to the same file has loaded (or failed,
170
+ * or the timeout passes), so the sheet applies on every frame of the handoff;
171
+ * a copy with no such link (a discarded response, a frame swap that did not
172
+ * bring the file, a superseded navigation) goes at once.
173
+ *
174
+ * @param {IncomingStylesheets | null} incoming
175
+ * @param {number} [timeout]
176
+ */
177
+ export function releaseIncomingStylesheets(incoming, timeout = STYLESHEET_TIMEOUT_MS) {
178
+ if (!incoming || typeof document === 'undefined') return;
179
+ for (const copy of incoming.copies) {
180
+ // Gone already, or adopted by the head merge as the page's own link.
181
+ if (!copy.isConnected || !copy.hasAttribute(PRELOAD_ATTR)) continue;
182
+ /** @type {HTMLLinkElement | null} */
183
+ let twin = null;
184
+ for (const el of document.querySelectorAll('link[href]')) {
185
+ if (el === copy || el.hasAttribute(PRELOAD_ATTR) || !isActiveStylesheet(el)) continue;
186
+ if (/** @type {HTMLLinkElement} */ (el).href === copy.href) { twin = /** @type {HTMLLinkElement} */ (el); break; }
187
+ }
188
+ if (!twin || twin.sheet) { copy.remove(); continue; }
189
+ waitForLoad(twin, timeout).then(() => copy.remove());
190
+ }
191
+ }
@@ -11,8 +11,9 @@ import { collectBoundaries, planBoundarySwap } from './boundaries.js';
11
11
  import { reportFallback } from './diagnostics.js';
12
12
  import { blurOutgoingFocus, diffChildren, replaceBoundaryRange, resyncEnclosingHostSlots, swapMarkerRange } from './dom-differ.js';
13
13
  import { trackedReloadSignature } from './frames.js';
14
- import { addNewHeadElements, mergeHead } from './head-merge.js';
14
+ import { addNewHeadElements, mergeHead, mergeHeadStylesheets } from './head-merge.js';
15
15
  import { morphLayoutChrome, planLayoutChrome } from './layout-chrome.js';
16
+ import { primeLiveStylesheetKeys, releaseIncomingSheets, switchOffDepartedStylesheets } from './owned-styles.js';
16
17
  import { clearPrefetchRefused, prefetchClearAll } from './prefetch.js';
17
18
  import { snapshotCache } from './snapshot-cache.js';
18
19
  import { hardNavigate } from './state.js';
@@ -84,6 +85,13 @@ export let _swapCommit = Promise.resolve();
84
85
  * history (a revalidation, a refresh, the popstate restore).
85
86
  */
86
87
  export function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc, refresh, recordHistoryNow) {
88
+ // The URL the incoming page came from, which its relative stylesheet hrefs
89
+ // resolve against, and the live sheets' keys taken NOW, while the document
90
+ // still has the outgoing page's URL: every commit point below records the
91
+ // new history entry before it merges the head (#1669).
92
+ let incomingBase = typeof location !== 'undefined' ? location.href : '';
93
+ if (href) { try { incomingBase = new URL(href, incomingBase).href; } catch { /* keep the live url */ } }
94
+ primeLiveStylesheetKeys();
87
95
  // SSR action seeding (#472): ingest the incoming page's seed payload BEFORE
88
96
  // its components are grafted into the live DOM and upgrade, so a
89
97
  // soft-navigated async component resolves from the seed instead of
@@ -279,7 +287,7 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
279
287
  // ADD-ONLY head merge: preserve runtime-generated head content
280
288
  // (Tailwind CSS injection, etc.) that the outer layout's scripts
281
289
  // already produced.
282
- addNewHeadElements(doc.head);
290
+ addNewHeadElements(doc.head, { base: incomingBase, frame: true });
283
291
  // `diffChildren` -> `reconcileChildren` regrafts permanent elements
284
292
  // by node identity (it imports the incoming children first, then
285
293
  // swaps the live permanent node into the imported tree), so the live
@@ -288,6 +296,8 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
288
296
  // ALSO progressively streams a Suspense boundary gates its resolves on the
289
297
  // committed frame swap, not a stale prior _swapCommit (#1048).
290
298
  _swapCommit = runWithTransition(() => {
299
+ // The frame's incoming sheets loaded held; they apply with the swap.
300
+ releaseIncomingSheets();
291
301
  diffChildren(target, source);
292
302
  reactivateScripts(target);
293
303
  upgradeCustomElements(target);
@@ -334,7 +344,7 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
334
344
  // commit point without a reader having to prove reachability first, which
335
345
  // is what stops the next person adding a commit point from omitting it.
336
346
  if (recordHistoryNow) recordHistoryNow();
337
- swapFullBody(doc);
347
+ swapFullBody(doc, incomingBase, true);
338
348
  return;
339
349
  }
340
350
 
@@ -361,7 +371,7 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
361
371
  if (plan && refresh === 'layouts' && !chrome) {
362
372
  ingestSeeds();
363
373
  if (recordHistoryNow) recordHistoryNow();
364
- swapFullBody(doc);
374
+ swapFullBody(doc, incomingBase, true);
365
375
  return;
366
376
  }
367
377
 
@@ -375,11 +385,32 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
375
385
  if (recordHistoryNow) recordHistoryNow();
376
386
  const { mode, live, incoming } = plan;
377
387
  // ADD-ONLY head merge: the outer layout stays mounted, so its head-bound
378
- // runtime state (Tailwind injection, etc.) must not be invalidated.
379
- addNewHeadElements(doc.head);
388
+ // runtime state (Tailwind injection, etc.) must not be invalidated. It runs
389
+ // ahead of the commit, as the incoming sheets' load-before-swap copies
390
+ // already do (#1666, Turbo's order), so the new page's sheets apply from
391
+ // the swap's first frame.
392
+ // Its stylesheets wait for the commit (#1669, below).
393
+ addNewHeadElements(doc.head, { base: incomingBase, styles: false });
394
+ // A live element inside the range this swap replaces is on its way out,
395
+ // so a stylesheet link there does not count as one the page keeps.
396
+ /** @param {Element} el */
397
+ const leaving = (el) => !!(live.start.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)
398
+ && !!(live.end.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING);
380
399
  _swapCommit = runWithTransition(() => {
400
+ // Everything stylesheet-related happens inside the commit, so the
401
+ // outgoing page, and a view transition's capture of it, keep exactly the
402
+ // sheets that page used, and the crossing restyles once (#1669): the
403
+ // incoming sheets (loaded held, stylesheets.js) are adopted, switched
404
+ // back on or inserted, then released, before the new markup lands.
405
+ mergeHeadStylesheets(doc.head, { base: incomingBase, segment: plan.segment, leaving });
406
+ releaseIncomingSheets();
381
407
  if (mode === 'replace') replaceBoundaryRange(live, incoming);
382
408
  else swapMarkerRange(live, incoming, doc);
409
+ // The swapped range's own stylesheets go with it unless the incoming
410
+ // page links them, in its head or its body, on a replace and on a morph
411
+ // alike: the server re-rendered the whole range either way, so a sheet a
412
+ // searchParams change dropped is retired too.
413
+ switchOffDepartedStylesheets(plan.segment, doc, incomingBase);
383
414
  // No key sync is needed on the anchor's own comments: the plan's anchor
384
415
  // carries EQUAL live/incoming route-keys in every tier (a changed-key
385
416
  // REPLACE anchors at a parent already compared equal; the other tiers
@@ -438,7 +469,7 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
438
469
  // because the user is already viewing the page).
439
470
  ingestSeeds(); // committed: past both discard branches above
440
471
  if (recordHistoryNow) recordHistoryNow();
441
- swapFullBody(doc);
472
+ swapFullBody(doc, incomingBase, false);
442
473
  }
443
474
 
444
475
  /**
@@ -460,9 +491,12 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
460
491
  * it only past their own discard branches.
461
492
  *
462
493
  * @param {Document} doc
494
+ * @param {string} incomingBase the URL the incoming page came from
495
+ * @param {boolean} complete the response rendered every layout (no X-Webjs-Have), so it can retire a departed section's sheets
463
496
  */
464
- function swapFullBody(doc) {
465
- mergeHead(doc.head);
497
+ function swapFullBody(doc, incomingBase, complete) {
498
+ // Its stylesheets wait for the commit, as on a boundary swap (#1669).
499
+ mergeHead(doc.head, { base: incomingBase, styles: false });
466
500
  // Persist permanent elements by node identity across the full-body
467
501
  // swap: move each live [data-webjs-permanent][id] node into the matching
468
502
  // position in the incoming body BEFORE replaceChildren reads it, so the
@@ -470,7 +504,13 @@ function swapFullBody(doc) {
470
504
  regraftPermanentElements(document.body, doc.body);
471
505
  const newChildren = [...doc.body.childNodes];
472
506
  const doSwap = () => {
507
+ // The whole body is replaced, so every live body link is leaving.
508
+ mergeHeadStylesheets(doc.head, { base: incomingBase, segment: '/', leaving: () => true });
509
+ releaseIncomingSheets();
473
510
  document.body.replaceChildren(...newChildren);
511
+ // Only a response known to be complete can say a section's sheet is gone:
512
+ // the background restore path's head can be a reduced one (#936).
513
+ if (complete) switchOffDepartedStylesheets('/', doc, incomingBase);
474
514
  reactivateScripts(document.body);
475
515
  upgradeCustomElements(document.body);
476
516
  blurOutgoingFocus();
@@ -0,0 +1,2 @@
1
+ export const nothing: unique symbol;
2
+ export const noChange: unique symbol;
@@ -0,0 +1,4 @@
1
+ /** Clear a rendered value using Lit's position-specific rules. */
2
+ export const nothing = Symbol.for('webjs.nothing');
3
+ /** Keep the previously committed value. */
4
+ export const noChange = Symbol.for('webjs.noChange');
@@ -94,7 +94,7 @@ export interface WebjsRegenerateRule {
94
94
  output: string;
95
95
  /** The shell command that rebuilds `output` (the same command prod uses in `start.before`, so dev and prod cannot diverge). */
96
96
  command: string;
97
- /** Source files/directories whose newest mtime is compared to `output`'s; a newer one (or a missing output) triggers the rebuild. */
97
+ /** Source files/directories the output is built from. Every build records a fingerprint of the files under them (path, size, mtime) in `.webjs/regenerate.json`, and the output is rebuilt when that fingerprint, the command, or the output itself no longer matches the record, or the output is missing (#1672). `webjs regenerate` builds and records outside the dev server. */
98
98
  inputs?: string[];
99
99
  }
100
100