@webjsdev/core 0.7.34 → 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/dist/webjs-core-browser.js +3 -3
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +16 -16
- package/dist/webjs-core-testing.js.map +2 -2
- package/dist/webjs-core.js +4 -4
- package/dist/webjs-core.js.map +3 -3
- package/package.json +1 -1
- package/src/component.js +10 -0
- package/src/render-server.js +38 -0
- package/src/router-client.js +255 -4
- package/src/slot.js +3 -3
package/package.json
CHANGED
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) {
|
package/src/render-server.js
CHANGED
|
@@ -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 = '';
|
package/src/router-client.js
CHANGED
|
@@ -14,6 +14,12 @@ import './webjs-suspense.js';
|
|
|
14
14
|
// Ingest SSR action seeds (#472) from an incoming soft-nav document before its
|
|
15
15
|
// components hydrate, so a navigated async component resolves from the seed.
|
|
16
16
|
import { scanSeeds } from './action-seed-client.js';
|
|
17
|
+
// Slot-runtime constants for re-projecting page-authored slotted content of a
|
|
18
|
+
// reused hydrated light-DOM component across a soft nav (#908).
|
|
19
|
+
import {
|
|
20
|
+
SLOT_STATE, LIGHT_SLOT_ATTR, PROJECTION_ATTR, PROJECTION_ACTUAL,
|
|
21
|
+
applyActualAssignment, applyFallback, fireSlotChange,
|
|
22
|
+
} from './slot.js';
|
|
17
23
|
|
|
18
24
|
/** The content type a content-negotiated stream-action response carries (#248). */
|
|
19
25
|
const STREAM_MIME = 'text/vnd.webjs-stream.html';
|
|
@@ -76,10 +82,40 @@ const STREAM_MIME = 'text/vnd.webjs-stream.html';
|
|
|
76
82
|
* Parse HTML into a Document. Prefers Document.parseHTMLUnsafe (processes
|
|
77
83
|
* Declarative Shadow DOM) over DOMParser (does NOT process DSD).
|
|
78
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
|
+
*
|
|
79
99
|
* @param {string} html
|
|
80
100
|
* @returns {Document | null}
|
|
81
101
|
*/
|
|
82
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
|
+
}
|
|
83
119
|
if (typeof Document !== 'undefined' && typeof Document.parseHTMLUnsafe === 'function') {
|
|
84
120
|
return Document.parseHTMLUnsafe(html);
|
|
85
121
|
}
|
|
@@ -1273,9 +1309,19 @@ function prefetch(href) {
|
|
|
1273
1309
|
return;
|
|
1274
1310
|
}
|
|
1275
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
|
+
|
|
1276
1323
|
prefetchInflight.add(key);
|
|
1277
1324
|
const headers = { 'x-webjs-router': '1', 'x-webjs-prefetch': '1' };
|
|
1278
|
-
const have = buildHaveHeader();
|
|
1279
1325
|
if (have) headers['x-webjs-have'] = have;
|
|
1280
1326
|
|
|
1281
1327
|
fetch(href, { method: 'GET', headers, credentials: 'same-origin' })
|
|
@@ -2464,8 +2510,14 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
2464
2510
|
return;
|
|
2465
2511
|
}
|
|
2466
2512
|
|
|
2467
|
-
// 3. Full body swap fallback
|
|
2468
|
-
//
|
|
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.
|
|
2469
2521
|
mergeHead(doc.head);
|
|
2470
2522
|
// Persist permanent elements by node identity across the full-body
|
|
2471
2523
|
// swap: move each live [data-webjs-permanent][id] node into the matching
|
|
@@ -2677,7 +2729,16 @@ function diffElementInPlace(dst, src) {
|
|
|
2677
2729
|
// property change through `attributeChangedCallback`, so the component
|
|
2678
2730
|
// re-renders ITSELF; the router must not touch its internals. This mirrors
|
|
2679
2731
|
// Turbo/morphdom, which leave custom elements alone by default.
|
|
2680
|
-
|
|
2732
|
+
//
|
|
2733
|
+
// One carve-out (#908): a light-DOM component's projected <slot> content is
|
|
2734
|
+
// page-authored (moved into the slot by the slot runtime), NOT render-owned,
|
|
2735
|
+
// so a reused component would otherwise keep showing STALE slotted content
|
|
2736
|
+
// when the nav supplies different content. Re-project ONLY those slot
|
|
2737
|
+
// children; the render-owned nodes stay untouched, so #906 does not regress.
|
|
2738
|
+
if (isHydratedComponent(dst)) {
|
|
2739
|
+
reprojectSlottedContent(dst, src);
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
2681
2742
|
|
|
2682
2743
|
// Recurse into children: collect both sides, run reconcileSiblings on
|
|
2683
2744
|
// them with synthetic boundary markers. Cheap implementation: use
|
|
@@ -2702,6 +2763,161 @@ function isHydratedComponent(el) {
|
|
|
2702
2763
|
return /** @type {any} */ (el)[Symbol.for('webjs.instance')] != null;
|
|
2703
2764
|
}
|
|
2704
2765
|
|
|
2766
|
+
/**
|
|
2767
|
+
* True when `slot` belongs directly to `host`, i.e. no OTHER custom element
|
|
2768
|
+
* sits between them. A slot nested inside a child custom element belongs to
|
|
2769
|
+
* THAT component (its own slot state owns it), so the host must not touch it.
|
|
2770
|
+
*
|
|
2771
|
+
* @param {Element} slot
|
|
2772
|
+
* @param {Element} host
|
|
2773
|
+
* @returns {boolean}
|
|
2774
|
+
*/
|
|
2775
|
+
function isOwnLightSlot(slot, host) {
|
|
2776
|
+
for (let p = slot.parentElement; p && p !== host; p = p.parentElement) {
|
|
2777
|
+
if (p.tagName.includes('-')) return false;
|
|
2778
|
+
}
|
|
2779
|
+
return true;
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
/**
|
|
2783
|
+
* Group a component's own `data-projection="actual"` light slots by name,
|
|
2784
|
+
* first-wins (mirroring the slot runtime + SSR first-wins rule). Slots nested
|
|
2785
|
+
* inside a child custom element are excluded (they belong to that child).
|
|
2786
|
+
*
|
|
2787
|
+
* @param {Element} host
|
|
2788
|
+
* @returns {Map<string|null, HTMLSlotElement>}
|
|
2789
|
+
*/
|
|
2790
|
+
function ownActualLightSlots(host) {
|
|
2791
|
+
/** @type {Map<string|null, HTMLSlotElement>} */
|
|
2792
|
+
const byName = new Map();
|
|
2793
|
+
const sel = `slot[${LIGHT_SLOT_ATTR}][${PROJECTION_ATTR}="${PROJECTION_ACTUAL}"]`;
|
|
2794
|
+
for (const slot of host.querySelectorAll(sel)) {
|
|
2795
|
+
const s = /** @type {HTMLSlotElement} */ (slot);
|
|
2796
|
+
if (!isOwnLightSlot(s, host)) continue;
|
|
2797
|
+
const name = s.getAttribute('name') || null;
|
|
2798
|
+
if (!byName.has(name)) byName.set(name, s);
|
|
2799
|
+
}
|
|
2800
|
+
return byName;
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
/**
|
|
2804
|
+
* The component's own light slot for `name`, whatever its projection state
|
|
2805
|
+
* (first-wins by document order). Used to resolve the slot to fill on a
|
|
2806
|
+
* fallback->actual transition, where the target slot is currently `fallback`
|
|
2807
|
+
* so it is absent from `ownActualLightSlots`. Nested-child slots are excluded.
|
|
2808
|
+
*
|
|
2809
|
+
* @param {Element} host
|
|
2810
|
+
* @param {string|null} name
|
|
2811
|
+
* @returns {HTMLSlotElement | null}
|
|
2812
|
+
*/
|
|
2813
|
+
function ownLightSlotForName(host, name) {
|
|
2814
|
+
for (const slot of host.querySelectorAll(`slot[${LIGHT_SLOT_ATTR}]`)) {
|
|
2815
|
+
const s = /** @type {HTMLSlotElement} */ (slot);
|
|
2816
|
+
if (!isOwnLightSlot(s, host)) continue;
|
|
2817
|
+
if ((s.getAttribute('name') || null) === name) return s;
|
|
2818
|
+
}
|
|
2819
|
+
return null;
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
/**
|
|
2823
|
+
* Per-index node-identity equality of two node lists. Used to decide whether a
|
|
2824
|
+
* slot's assigned-node SET actually changed (so `slotchange` should fire); an
|
|
2825
|
+
* in-place text edit that reuses the same node is NOT a set change.
|
|
2826
|
+
*
|
|
2827
|
+
* @param {ArrayLike<Node>} a
|
|
2828
|
+
* @param {ArrayLike<Node>} b
|
|
2829
|
+
* @returns {boolean}
|
|
2830
|
+
*/
|
|
2831
|
+
function sameNodeList(a, b) {
|
|
2832
|
+
if (a.length !== b.length) return false;
|
|
2833
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
2834
|
+
return true;
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
/**
|
|
2838
|
+
* Re-project the page-authored slotted content of a REUSED hydrated light-DOM
|
|
2839
|
+
* component across a soft nav (#908), without touching its render-owned
|
|
2840
|
+
* subtree.
|
|
2841
|
+
*
|
|
2842
|
+
* The #906 guard treats a hydrated component as opaque so the router never
|
|
2843
|
+
* corrupts its lit-html-owned nodes. But the projected children inside a
|
|
2844
|
+
* light-DOM `<slot data-webjs-light data-projection="actual">` are
|
|
2845
|
+
* page-authored (moved there by the slot runtime), NOT held by lit-html parts,
|
|
2846
|
+
* so reconciling ONLY those children is safe and cannot reintroduce #906. Both
|
|
2847
|
+
* the live DOM and the incoming SSR HTML carry the same slot markers
|
|
2848
|
+
* (render-server emits them), so slots pair up by name + document order.
|
|
2849
|
+
*
|
|
2850
|
+
* Three cases, by how a slot's projection state changes across the nav:
|
|
2851
|
+
* - actual->actual (content changed): identity-preserving `reconcileChildren`
|
|
2852
|
+
* on the page-authored slot children, exactly as #908 shipped.
|
|
2853
|
+
* - actual->fallback (content REMOVED) and fallback->actual (content ADDED):
|
|
2854
|
+
* a slot's fallback is RENDER-OWNED (the compiled fallback template held by
|
|
2855
|
+
* the slot-part), so these are NOT a raw reconcile. Drive them through the
|
|
2856
|
+
* slot runtime's own primitives (`applyFallback` / `applyActualAssignment`)
|
|
2857
|
+
* on the ONE resolved own slot, which restore or swap the render-owned
|
|
2858
|
+
* fallback without reconciling any lit-html part (#912). This is applied
|
|
2859
|
+
* surgically to the target slot, NOT via a whole-host `projectChildren`
|
|
2860
|
+
* pass: `projectChildren` selects EVERY `data-webjs-light` slot in the
|
|
2861
|
+
* subtree (including a nested child component's), so running it here would
|
|
2862
|
+
* let this component's assignment reach into a nested child's same-named
|
|
2863
|
+
* slot and clobber its render-owned nodes (the #906 hazard, one level down).
|
|
2864
|
+
*
|
|
2865
|
+
* @param {Element} dst Live hydrated component host.
|
|
2866
|
+
* @param {Element} src Incoming SSR copy of the same component.
|
|
2867
|
+
*/
|
|
2868
|
+
function reprojectSlottedContent(dst, src) {
|
|
2869
|
+
// Only a light-DOM component that tracks slot assignments has projected
|
|
2870
|
+
// page-authored content to update. No slot state (no <slot>, or a shadow-DOM
|
|
2871
|
+
// component whose slotted nodes are ordinary light children) means nothing
|
|
2872
|
+
// to re-project here.
|
|
2873
|
+
const state = /** @type {any} */ (dst)[SLOT_STATE];
|
|
2874
|
+
if (!state) return;
|
|
2875
|
+
|
|
2876
|
+
const liveSlots = ownActualLightSlots(dst);
|
|
2877
|
+
const incSlots = ownActualLightSlots(src);
|
|
2878
|
+
if (liveSlots.size === 0 && incSlots.size === 0) return;
|
|
2879
|
+
|
|
2880
|
+
// A slot name is `actual` on the live side, the incoming side, or both. Walk
|
|
2881
|
+
// the union so a boundary transition (present on only one side) is handled.
|
|
2882
|
+
const names = new Set([...liveSlots.keys(), ...incSlots.keys()]);
|
|
2883
|
+
for (const name of names) {
|
|
2884
|
+
const liveSlot = liveSlots.get(name);
|
|
2885
|
+
const incSlot = incSlots.get(name);
|
|
2886
|
+
if (liveSlot && incSlot) {
|
|
2887
|
+
// actual->actual: the slot's children are page-authored, so
|
|
2888
|
+
// reconcileChildren is safe: it preserves node identity where it can and
|
|
2889
|
+
// never touches lit-html parts. Keep the slot runtime's assignment
|
|
2890
|
+
// bookkeeping in sync so a later re-render materialises THESE nodes.
|
|
2891
|
+
const prevAssigned = state.lastSnapshot.get(liveSlot) || [];
|
|
2892
|
+
reconcileChildren(liveSlot, incSlot);
|
|
2893
|
+
const children = [...liveSlot.childNodes];
|
|
2894
|
+
state.assignedByName.set(name, children);
|
|
2895
|
+
state.lastSnapshot.set(liveSlot, children.slice());
|
|
2896
|
+
// Fire slotchange on an assigned-node-SET change (add / remove / reorder),
|
|
2897
|
+
// not an in-place text edit that reuses the same node: that matches the
|
|
2898
|
+
// spec and keeps this branch uniform with the boundary branches below.
|
|
2899
|
+
if (!sameNodeList(prevAssigned, children)) fireSlotChange(liveSlot);
|
|
2900
|
+
} else if (liveSlot && !incSlot) {
|
|
2901
|
+
// actual->fallback: incoming REMOVED this slot's content. Clear the
|
|
2902
|
+
// assignment and flip THIS slot to fallback; applyFallback restores the
|
|
2903
|
+
// render-owned fallback from the slot-part holding fragment.
|
|
2904
|
+
state.assignedByName.delete(name);
|
|
2905
|
+
if (applyFallback(state, liveSlot)) fireSlotChange(liveSlot);
|
|
2906
|
+
} else {
|
|
2907
|
+
// fallback->actual: incoming ADDED content where the live slot shows
|
|
2908
|
+
// fallback. The target slot is currently fallback, so resolve it directly
|
|
2909
|
+
// (it is absent from the actual-only map) and fill it with the imported
|
|
2910
|
+
// page-authored nodes; applyActualAssignment tucks the render-owned
|
|
2911
|
+
// fallback into the holding fragment first.
|
|
2912
|
+
const targetSlot = ownLightSlotForName(dst, name);
|
|
2913
|
+
if (!targetSlot) continue;
|
|
2914
|
+
const nodes = [...incSlot.childNodes].map((n) => document.importNode(n, true));
|
|
2915
|
+
state.assignedByName.set(name, nodes);
|
|
2916
|
+
if (applyActualAssignment(state, targetSlot, nodes)) fireSlotChange(targetSlot);
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2705
2921
|
/**
|
|
2706
2922
|
* Reconcile dst's children to match src's children, in-place.
|
|
2707
2923
|
*
|
|
@@ -3044,6 +3260,31 @@ function addNewHeadElements(newHead) {
|
|
|
3044
3260
|
}
|
|
3045
3261
|
}
|
|
3046
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
|
+
|
|
3047
3288
|
/** @param {HTMLHeadElement} newHead */
|
|
3048
3289
|
function mergeHead(newHead) {
|
|
3049
3290
|
const currentHead = document.head;
|
|
@@ -3069,6 +3310,14 @@ function mergeHead(newHead) {
|
|
|
3069
3310
|
if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
|
|
3070
3311
|
if (el.tagName === 'BASE') continue;
|
|
3071
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;
|
|
3072
3321
|
if (!newSet.has(outerHTMLForDiff(el))) el.remove();
|
|
3073
3322
|
}
|
|
3074
3323
|
|
|
@@ -3303,11 +3552,13 @@ export {
|
|
|
3303
3552
|
clearFormBusy as _clearFormBusy,
|
|
3304
3553
|
collectChildrenSlots as _collectChildrenSlots,
|
|
3305
3554
|
longestSharedPath as _longestSharedPath,
|
|
3555
|
+
parseHTML as _parseHTML,
|
|
3306
3556
|
keyOf as _keyOf,
|
|
3307
3557
|
diffElementInPlace as _diffElementInPlace,
|
|
3308
3558
|
reconcileChildren as _reconcileChildren,
|
|
3309
3559
|
onPopState as _onPopState,
|
|
3310
3560
|
applySwap as _applySwap,
|
|
3561
|
+
buildHaveHeader as _buildHaveHeader,
|
|
3311
3562
|
snapshotCache as _snapshotCache,
|
|
3312
3563
|
prefetchCache as _prefetchCache,
|
|
3313
3564
|
LIVE_ATTRS as _LIVE_ATTRS,
|
package/src/slot.js
CHANGED
|
@@ -709,7 +709,7 @@ function handleSlotRemoved(state, slot) {
|
|
|
709
709
|
* @returns {boolean} True if the slot's assignment changed compared to
|
|
710
710
|
* its last snapshot (so slotchange should fire).
|
|
711
711
|
*/
|
|
712
|
-
function applyActualAssignment(state, slot, assigned) {
|
|
712
|
+
export function applyActualAssignment(state, slot, assigned) {
|
|
713
713
|
const wasFallback = slot.getAttribute(PROJECTION_ATTR) !== PROJECTION_ACTUAL;
|
|
714
714
|
const prev = state.lastSnapshot.get(slot) || [];
|
|
715
715
|
const equal = !wasFallback && arraysEqual(prev, assigned);
|
|
@@ -747,7 +747,7 @@ function applyActualAssignment(state, slot, assigned) {
|
|
|
747
747
|
* @returns {boolean} True if the slot transitioned from actual to
|
|
748
748
|
* fallback this pass.
|
|
749
749
|
*/
|
|
750
|
-
function applyFallback(state, slot) {
|
|
750
|
+
export function applyFallback(state, slot) {
|
|
751
751
|
const wasActual = slot.getAttribute(PROJECTION_ATTR) === PROJECTION_ACTUAL;
|
|
752
752
|
slot.setAttribute(PROJECTION_ATTR, PROJECTION_FALLBACK);
|
|
753
753
|
if (!wasActual) {
|
|
@@ -841,7 +841,7 @@ export function moveSlotChildrenToPending(host, slot) {
|
|
|
841
841
|
// ---------------------------------------------------------------------------
|
|
842
842
|
|
|
843
843
|
/** Fire a `slotchange` event on the slot (bubbles, not composed; per spec). */
|
|
844
|
-
function fireSlotChange(slot) {
|
|
844
|
+
export function fireSlotChange(slot) {
|
|
845
845
|
slot.dispatchEvent(new Event('slotchange', { bubbles: true, composed: false }));
|
|
846
846
|
}
|
|
847
847
|
|