@webjsdev/core 0.7.35 → 0.7.36

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.35",
3
+ "version": "0.7.36",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
package/src/component.js CHANGED
@@ -779,6 +779,16 @@ class WebComponentBase extends Base {
779
779
  // hydrated element (whose attribute already arrived from SSR) is unchanged.
780
780
  this._reflectDeclaredAttributes();
781
781
  const Ctor = /** @type any */ (this.constructor);
782
+ // Mark LIGHT-DOM component hosts so the framework default host rule
783
+ // (`@layer webjs-host { :where([data-wj-host]) { display: block } }`)
784
+ // applies. SSR already stamps this on server-rendered light hosts
785
+ // (idempotent here); this also covers a client-only light component (never
786
+ // SSR'd) so it does not collapse. Shadow hosts are NOT marked: a document
787
+ // rule would override the shadow author's own `:host { display: … }`, so
788
+ // shadow components control their host display via `:host` in `static styles`.
789
+ if (Ctor.shadow !== true && !this.hasAttribute('data-wj-host')) {
790
+ this.setAttribute('data-wj-host', '');
791
+ }
782
792
  if (Ctor.shadow === true) {
783
793
  const hadSSRShadow = !!this.shadowRoot;
784
794
  if (!this.shadowRoot) {
@@ -502,6 +502,24 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
502
502
  // this.hidden on the host). Reading after render() captures all three.
503
503
  // Appending keeps the original tag byte-identical when nothing changed.
504
504
  opening = appendReflectedAttrs(opening, instance, presentAttrNames);
505
+ // Mark LIGHT-DOM component hosts so the framework default
506
+ // `@layer webjs-host { :where([data-wj-host]) { display: block } }`
507
+ // (injected once in the document head) applies at first paint. A custom
508
+ // element is `display:inline` by default, which collapses a component used
509
+ // as a block container (a board / card) until an author style intervenes.
510
+ // The low-priority `@layer` keeps it overridable by any author style,
511
+ // INCLUDING Tailwind's layered utilities (`class="flex"` wins). Emitted
512
+ // uniformly regardless of elision, so the elision on-vs-off differential is
513
+ // preserved.
514
+ //
515
+ // Shadow hosts are NOT marked: a document-level rule targeting the host
516
+ // beats the shadow tree's own `:host { display: … }` (the encapsulation-
517
+ // context criterion outranks both layer and specificity for normal
518
+ // declarations), so marking them would silently override the shadow
519
+ // author's `:host` display. Shadow components set their own host display
520
+ // via `:host` in `static styles` (the idiomatic mechanism), which the
521
+ // framework must not clobber.
522
+ if (!isShadow) opening = withHostMarker(opening);
505
523
  // Render the template to HTML. injectDSD recurses on the result so
506
524
  // nested custom elements (e.g. <theme-toggle> inside <blog-shell>)
507
525
  // get their own DSD pass.
@@ -635,6 +653,14 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
635
653
  // path), not land in light DOM. Otherwise the client renders the error
636
654
  // into the shadow root while the light error box lingers underneath.
637
655
  const isShadowErr = /** @type any */ (Cls).shadow === true;
656
+ // Mark the LIGHT host here too, so a component whose SSR render() throws
657
+ // paints its error state as display:block (not the inline default),
658
+ // matching the success path. When an `async render()` rejects, it throws
659
+ // before the success-path withHostMarker (above) ran, so `opening` is still
660
+ // unmarked; when a later template render throws, the success marker already
661
+ // ran and this call is a no-op (withHostMarker is idempotent). Shadow hosts
662
+ // stay unmarked (their :host must win).
663
+ if (!isShadowErr) opening = withHostMarker(opening);
638
664
  let text;
639
665
  if (isShadowErr) {
640
666
  const rawStyles = /** @type any */ (Cls).styles;
@@ -1032,6 +1058,18 @@ function seedServerAttrs(instance, attrs) {
1032
1058
  * @param {Set<string>} presentAttrNames lowercased names already in the source tag
1033
1059
  * @returns {string}
1034
1060
  */
1061
+ /**
1062
+ * Add the component host marker (`data-wj-host`) to an opening tag, unless it
1063
+ * is already present. Insert before the closing `>` the same way
1064
+ * `appendReflectedAttrs` does. Idempotent so a re-processed tag is unchanged.
1065
+ * @param {string} opening the element's opening tag, ending in `>`
1066
+ * @returns {string}
1067
+ */
1068
+ function withHostMarker(opening) {
1069
+ if (/\sdata-wj-host(?=[\s>=])/i.test(opening)) return opening;
1070
+ return `${opening.slice(0, -1)} data-wj-host>`;
1071
+ }
1072
+
1035
1073
  function appendReflectedAttrs(opening, instance, presentAttrNames) {
1036
1074
  if (!instance || typeof instance.getAttributeNames !== 'function') return opening;
1037
1075
  let extra = '';
@@ -82,10 +82,40 @@ const STREAM_MIME = 'text/vnd.webjs-stream.html';
82
82
  * Parse HTML into a Document. Prefers Document.parseHTMLUnsafe (processes
83
83
  * Declarative Shadow DOM) over DOMParser (does NOT process DSD).
84
84
  *
85
+ * A partial-nav response (#936) is an INNER fragment that BEGINS with the
86
+ * `<!--wj:children:<path>-->` layout marker and carries no `<!doctype>`/`<html>`.
87
+ * Parsing such a fragment as a DOCUMENT hoists that leading comment OUT of
88
+ * `<body>` (the HTML parser's "before html" insertion mode makes a leading
89
+ * comment a child of the document, before `<html>`), so
90
+ * `collectChildrenSlots(doc.body)` never sees the opening marker, finds no
91
+ * shared slot, and `applySwap` falls to the destructive full-body swap that
92
+ * strips the head stylesheets and the outer layout. So a fragment is parsed in
93
+ * BODY (fragment) context instead, keeping the marker with its content.
94
+ * `body.setHTMLUnsafe` also processes Declarative Shadow DOM, so a shadow
95
+ * component inside the swapped content still re-attaches its root; the
96
+ * `<template>` path is the fallback for browsers without it (markers preserved,
97
+ * DSD not, which matches the pre-`setHTMLUnsafe` baseline).
98
+ *
85
99
  * @param {string} html
86
100
  * @returns {Document | null}
87
101
  */
88
102
  function parseHTML(html) {
103
+ const isFragment = !/^\s*(?:<!doctype|<html)/i.test(html);
104
+ if (isFragment && typeof document !== 'undefined' && document.implementation) {
105
+ try {
106
+ const doc = document.implementation.createHTMLDocument();
107
+ if (typeof doc.body.setHTMLUnsafe === 'function') {
108
+ doc.body.setHTMLUnsafe(html);
109
+ } else {
110
+ const t = doc.createElement('template');
111
+ t.innerHTML = html;
112
+ doc.body.appendChild(t.content);
113
+ }
114
+ return doc;
115
+ } catch {
116
+ // Fall through to a document parse (still functional, just the #936 path).
117
+ }
118
+ }
89
119
  if (typeof Document !== 'undefined' && typeof Document.parseHTMLUnsafe === 'function') {
90
120
  return Document.parseHTMLUnsafe(html);
91
121
  }
@@ -1279,9 +1309,19 @@ function prefetch(href) {
1279
1309
  return;
1280
1310
  }
1281
1311
 
1312
+ const have = buildHaveHeader();
1313
+ // #936: while the document is still parsing, the closing `<!--/wj:children-->`
1314
+ // marker at the bottom of the body may not exist yet, so `buildHaveHeader()`
1315
+ // returns '' meaning "markers not parsed yet", NOT "this page has no layout".
1316
+ // A touch-device viewport prefetch fires early enough (mid-parse) to hit this
1317
+ // window on real Android Chrome. Caching that empty-`have` response (a full
1318
+ // page) would later drive the destructive full-body swap fallback. Skip the
1319
+ // speculative fetch; the click path re-fetches with a correct `have` once the
1320
+ // document has parsed (and applySwap now falls back to a full load anyway).
1321
+ if (!have && typeof document !== 'undefined' && document.readyState === 'loading') return;
1322
+
1282
1323
  prefetchInflight.add(key);
1283
1324
  const headers = { 'x-webjs-router': '1', 'x-webjs-prefetch': '1' };
1284
- const have = buildHaveHeader();
1285
1325
  if (have) headers['x-webjs-have'] = have;
1286
1326
 
1287
1327
  fetch(href, { method: 'GET', headers, credentials: 'same-origin' })
@@ -2470,8 +2510,14 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
2470
2510
  return;
2471
2511
  }
2472
2512
 
2473
- // 3. Full body swap fallback. Use full head merge: different root
2474
- // layout, so stale head elements should be removed.
2513
+ // 3. Full body swap fallback: no shared layout marker (a genuine root-layout
2514
+ // change, or a same-layout nav whose markers could not be paired). Full head
2515
+ // merge, so a real root-layout change removes stale head elements. `mergeHead`
2516
+ // now PRESERVES stylesheets and `<style>` unconditionally (#936): even if the
2517
+ // client reaches this path with an incoming head that lacks the app's
2518
+ // `<link rel=stylesheet>` (a partial or mangled response, e.g. from a
2519
+ // mid-parse empty-`have` prefetch), the live CSS is never stripped, so the
2520
+ // swap can no longer leave the page unstyled.
2475
2521
  mergeHead(doc.head);
2476
2522
  // Persist permanent elements by node identity across the full-body
2477
2523
  // swap: move each live [data-webjs-permanent][id] node into the matching
@@ -3214,6 +3260,31 @@ function addNewHeadElements(newHead) {
3214
3260
  }
3215
3261
  }
3216
3262
 
3263
+ /**
3264
+ * Is `el` a stylesheet the head merge must never remove: a `<style>` or a
3265
+ * `<link rel~="stylesheet">`. WebJs ALWAYS keeps these on a soft nav, with no
3266
+ * opt-out, and that is a deliberate divergence from Turbo. Turbo removes a
3267
+ * stylesheet absent from the new head when it is tagged
3268
+ * `data-turbo-track="dynamic"`, which is sound in Turbo because a Turbo visit
3269
+ * always compares a COMPLETE old head to a COMPLETE new head, so "absent" means
3270
+ * "this page removed it". WebJs's `X-Webjs-Have` optimization returns a REDUCED
3271
+ * head (the shared app stylesheet is omitted because the client already has it),
3272
+ * so "absent from the incoming head" means "optimized away", NOT "removed". A
3273
+ * dynamic-removal opt-out would therefore re-introduce #936 (it would strip a
3274
+ * still-needed sheet on any partial response), and WebJs is Tailwind-first (one
3275
+ * global sheet, no page-specific sheets to drop), so the knob would be unsafe
3276
+ * and unused. Keeping every stylesheet is correct here; a genuinely changed one
3277
+ * is dropped by the deploy-level hard reload (build-id mismatch), not a soft swap.
3278
+ *
3279
+ * @param {Element} el
3280
+ * @returns {boolean}
3281
+ */
3282
+ function isPersistentHeadStyle(el) {
3283
+ if (el.tagName === 'STYLE') return true;
3284
+ return el.tagName === 'LINK' &&
3285
+ (el.getAttribute('rel') || '').toLowerCase().split(/\s+/).includes('stylesheet');
3286
+ }
3287
+
3217
3288
  /** @param {HTMLHeadElement} newHead */
3218
3289
  function mergeHead(newHead) {
3219
3290
  const currentHead = document.head;
@@ -3239,6 +3310,14 @@ function mergeHead(newHead) {
3239
3310
  if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
3240
3311
  if (el.tagName === 'BASE') continue;
3241
3312
  if (el.tagName === 'TITLE') continue;
3313
+ // #936: NEVER remove a stylesheet or a `<style>` on a soft nav (Turbo's
3314
+ // persistent-CSS model). The incoming head of a full-body-swap fallback can
3315
+ // legitimately lack the app's `<link rel=stylesheet>` (a partial or mangled
3316
+ // response, e.g. from a mid-parse empty-`have` prefetch): removing the live
3317
+ // one there leaves the whole page unstyled until a manual refresh, the
3318
+ // headline #936 symptom. Keeping it is safe: a genuinely stale sheet is
3319
+ // dropped by the deploy-level hard reload (build-id mismatch), not here.
3320
+ if (isPersistentHeadStyle(el)) continue;
3242
3321
  if (!newSet.has(outerHTMLForDiff(el))) el.remove();
3243
3322
  }
3244
3323
 
@@ -3473,11 +3552,13 @@ export {
3473
3552
  clearFormBusy as _clearFormBusy,
3474
3553
  collectChildrenSlots as _collectChildrenSlots,
3475
3554
  longestSharedPath as _longestSharedPath,
3555
+ parseHTML as _parseHTML,
3476
3556
  keyOf as _keyOf,
3477
3557
  diffElementInPlace as _diffElementInPlace,
3478
3558
  reconcileChildren as _reconcileChildren,
3479
3559
  onPopState as _onPopState,
3480
3560
  applySwap as _applySwap,
3561
+ buildHaveHeader as _buildHaveHeader,
3481
3562
  snapshotCache as _snapshotCache,
3482
3563
  prefetchCache as _prefetchCache,
3483
3564
  LIVE_ATTRS as _LIVE_ATTRS,