@webjsdev/core 0.7.33 → 0.7.35
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.js +4 -4
- package/dist/webjs-core.js.map +3 -3
- package/package.json +1 -1
- package/src/router-client.js +200 -0
- package/src/slot.js +3 -3
package/package.json
CHANGED
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';
|
|
@@ -2666,12 +2672,206 @@ function diffElementInPlace(dst, src) {
|
|
|
2666
2672
|
// The attribute version is skipped above; we deliberately do nothing
|
|
2667
2673
|
// here so the user's typing / checking is never blown away.
|
|
2668
2674
|
|
|
2675
|
+
// A hydrated component OWNS its rendered subtree. The client renderer
|
|
2676
|
+
// stashes the live template instance (lit-html parts holding DIRECT
|
|
2677
|
+
// references to the rendered nodes) on the host under
|
|
2678
|
+
// `Symbol.for('webjs.instance')`. Recursing into those children would
|
|
2679
|
+
// import/remove/reorder the very nodes the parts still point at, so the
|
|
2680
|
+
// component's next reactive update would write into detached nodes and
|
|
2681
|
+
// silently do nothing (a dead click after a soft nav, #906). Treat the
|
|
2682
|
+
// component as opaque: the attribute sync above already drove any reactive
|
|
2683
|
+
// property change through `attributeChangedCallback`, so the component
|
|
2684
|
+
// re-renders ITSELF; the router must not touch its internals. This mirrors
|
|
2685
|
+
// Turbo/morphdom, which leave custom elements alone by default.
|
|
2686
|
+
//
|
|
2687
|
+
// One carve-out (#908): a light-DOM component's projected <slot> content is
|
|
2688
|
+
// page-authored (moved into the slot by the slot runtime), NOT render-owned,
|
|
2689
|
+
// so a reused component would otherwise keep showing STALE slotted content
|
|
2690
|
+
// when the nav supplies different content. Re-project ONLY those slot
|
|
2691
|
+
// children; the render-owned nodes stay untouched, so #906 does not regress.
|
|
2692
|
+
if (isHydratedComponent(dst)) {
|
|
2693
|
+
reprojectSlottedContent(dst, src);
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2669
2697
|
// Recurse into children: collect both sides, run reconcileSiblings on
|
|
2670
2698
|
// them with synthetic boundary markers. Cheap implementation: use
|
|
2671
2699
|
// virtual ranges instead of inserting real comment markers.
|
|
2672
2700
|
reconcileChildren(dst, src);
|
|
2673
2701
|
}
|
|
2674
2702
|
|
|
2703
|
+
/**
|
|
2704
|
+
* True when `el` carries a live client-side render instance, i.e. a webjs
|
|
2705
|
+
* component whose `render()` produced the current children and owns them via
|
|
2706
|
+
* lit-html parts. The router must not reconcile INTO such an element (#906).
|
|
2707
|
+
*
|
|
2708
|
+
* Detected via the render-client instance symbol rather than a `customElements`
|
|
2709
|
+
* lookup so it fires only for elements that have actually rendered client-side:
|
|
2710
|
+
* a not-yet-upgraded or purely display-only custom element (no client render,
|
|
2711
|
+
* no parts to corrupt) stays fully reconcilable.
|
|
2712
|
+
*
|
|
2713
|
+
* @param {Element} el
|
|
2714
|
+
* @returns {boolean}
|
|
2715
|
+
*/
|
|
2716
|
+
function isHydratedComponent(el) {
|
|
2717
|
+
return /** @type {any} */ (el)[Symbol.for('webjs.instance')] != null;
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
/**
|
|
2721
|
+
* True when `slot` belongs directly to `host`, i.e. no OTHER custom element
|
|
2722
|
+
* sits between them. A slot nested inside a child custom element belongs to
|
|
2723
|
+
* THAT component (its own slot state owns it), so the host must not touch it.
|
|
2724
|
+
*
|
|
2725
|
+
* @param {Element} slot
|
|
2726
|
+
* @param {Element} host
|
|
2727
|
+
* @returns {boolean}
|
|
2728
|
+
*/
|
|
2729
|
+
function isOwnLightSlot(slot, host) {
|
|
2730
|
+
for (let p = slot.parentElement; p && p !== host; p = p.parentElement) {
|
|
2731
|
+
if (p.tagName.includes('-')) return false;
|
|
2732
|
+
}
|
|
2733
|
+
return true;
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
/**
|
|
2737
|
+
* Group a component's own `data-projection="actual"` light slots by name,
|
|
2738
|
+
* first-wins (mirroring the slot runtime + SSR first-wins rule). Slots nested
|
|
2739
|
+
* inside a child custom element are excluded (they belong to that child).
|
|
2740
|
+
*
|
|
2741
|
+
* @param {Element} host
|
|
2742
|
+
* @returns {Map<string|null, HTMLSlotElement>}
|
|
2743
|
+
*/
|
|
2744
|
+
function ownActualLightSlots(host) {
|
|
2745
|
+
/** @type {Map<string|null, HTMLSlotElement>} */
|
|
2746
|
+
const byName = new Map();
|
|
2747
|
+
const sel = `slot[${LIGHT_SLOT_ATTR}][${PROJECTION_ATTR}="${PROJECTION_ACTUAL}"]`;
|
|
2748
|
+
for (const slot of host.querySelectorAll(sel)) {
|
|
2749
|
+
const s = /** @type {HTMLSlotElement} */ (slot);
|
|
2750
|
+
if (!isOwnLightSlot(s, host)) continue;
|
|
2751
|
+
const name = s.getAttribute('name') || null;
|
|
2752
|
+
if (!byName.has(name)) byName.set(name, s);
|
|
2753
|
+
}
|
|
2754
|
+
return byName;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
/**
|
|
2758
|
+
* The component's own light slot for `name`, whatever its projection state
|
|
2759
|
+
* (first-wins by document order). Used to resolve the slot to fill on a
|
|
2760
|
+
* fallback->actual transition, where the target slot is currently `fallback`
|
|
2761
|
+
* so it is absent from `ownActualLightSlots`. Nested-child slots are excluded.
|
|
2762
|
+
*
|
|
2763
|
+
* @param {Element} host
|
|
2764
|
+
* @param {string|null} name
|
|
2765
|
+
* @returns {HTMLSlotElement | null}
|
|
2766
|
+
*/
|
|
2767
|
+
function ownLightSlotForName(host, name) {
|
|
2768
|
+
for (const slot of host.querySelectorAll(`slot[${LIGHT_SLOT_ATTR}]`)) {
|
|
2769
|
+
const s = /** @type {HTMLSlotElement} */ (slot);
|
|
2770
|
+
if (!isOwnLightSlot(s, host)) continue;
|
|
2771
|
+
if ((s.getAttribute('name') || null) === name) return s;
|
|
2772
|
+
}
|
|
2773
|
+
return null;
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
/**
|
|
2777
|
+
* Per-index node-identity equality of two node lists. Used to decide whether a
|
|
2778
|
+
* slot's assigned-node SET actually changed (so `slotchange` should fire); an
|
|
2779
|
+
* in-place text edit that reuses the same node is NOT a set change.
|
|
2780
|
+
*
|
|
2781
|
+
* @param {ArrayLike<Node>} a
|
|
2782
|
+
* @param {ArrayLike<Node>} b
|
|
2783
|
+
* @returns {boolean}
|
|
2784
|
+
*/
|
|
2785
|
+
function sameNodeList(a, b) {
|
|
2786
|
+
if (a.length !== b.length) return false;
|
|
2787
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
2788
|
+
return true;
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
/**
|
|
2792
|
+
* Re-project the page-authored slotted content of a REUSED hydrated light-DOM
|
|
2793
|
+
* component across a soft nav (#908), without touching its render-owned
|
|
2794
|
+
* subtree.
|
|
2795
|
+
*
|
|
2796
|
+
* The #906 guard treats a hydrated component as opaque so the router never
|
|
2797
|
+
* corrupts its lit-html-owned nodes. But the projected children inside a
|
|
2798
|
+
* light-DOM `<slot data-webjs-light data-projection="actual">` are
|
|
2799
|
+
* page-authored (moved there by the slot runtime), NOT held by lit-html parts,
|
|
2800
|
+
* so reconciling ONLY those children is safe and cannot reintroduce #906. Both
|
|
2801
|
+
* the live DOM and the incoming SSR HTML carry the same slot markers
|
|
2802
|
+
* (render-server emits them), so slots pair up by name + document order.
|
|
2803
|
+
*
|
|
2804
|
+
* Three cases, by how a slot's projection state changes across the nav:
|
|
2805
|
+
* - actual->actual (content changed): identity-preserving `reconcileChildren`
|
|
2806
|
+
* on the page-authored slot children, exactly as #908 shipped.
|
|
2807
|
+
* - actual->fallback (content REMOVED) and fallback->actual (content ADDED):
|
|
2808
|
+
* a slot's fallback is RENDER-OWNED (the compiled fallback template held by
|
|
2809
|
+
* the slot-part), so these are NOT a raw reconcile. Drive them through the
|
|
2810
|
+
* slot runtime's own primitives (`applyFallback` / `applyActualAssignment`)
|
|
2811
|
+
* on the ONE resolved own slot, which restore or swap the render-owned
|
|
2812
|
+
* fallback without reconciling any lit-html part (#912). This is applied
|
|
2813
|
+
* surgically to the target slot, NOT via a whole-host `projectChildren`
|
|
2814
|
+
* pass: `projectChildren` selects EVERY `data-webjs-light` slot in the
|
|
2815
|
+
* subtree (including a nested child component's), so running it here would
|
|
2816
|
+
* let this component's assignment reach into a nested child's same-named
|
|
2817
|
+
* slot and clobber its render-owned nodes (the #906 hazard, one level down).
|
|
2818
|
+
*
|
|
2819
|
+
* @param {Element} dst Live hydrated component host.
|
|
2820
|
+
* @param {Element} src Incoming SSR copy of the same component.
|
|
2821
|
+
*/
|
|
2822
|
+
function reprojectSlottedContent(dst, src) {
|
|
2823
|
+
// Only a light-DOM component that tracks slot assignments has projected
|
|
2824
|
+
// page-authored content to update. No slot state (no <slot>, or a shadow-DOM
|
|
2825
|
+
// component whose slotted nodes are ordinary light children) means nothing
|
|
2826
|
+
// to re-project here.
|
|
2827
|
+
const state = /** @type {any} */ (dst)[SLOT_STATE];
|
|
2828
|
+
if (!state) return;
|
|
2829
|
+
|
|
2830
|
+
const liveSlots = ownActualLightSlots(dst);
|
|
2831
|
+
const incSlots = ownActualLightSlots(src);
|
|
2832
|
+
if (liveSlots.size === 0 && incSlots.size === 0) return;
|
|
2833
|
+
|
|
2834
|
+
// A slot name is `actual` on the live side, the incoming side, or both. Walk
|
|
2835
|
+
// the union so a boundary transition (present on only one side) is handled.
|
|
2836
|
+
const names = new Set([...liveSlots.keys(), ...incSlots.keys()]);
|
|
2837
|
+
for (const name of names) {
|
|
2838
|
+
const liveSlot = liveSlots.get(name);
|
|
2839
|
+
const incSlot = incSlots.get(name);
|
|
2840
|
+
if (liveSlot && incSlot) {
|
|
2841
|
+
// actual->actual: the slot's children are page-authored, so
|
|
2842
|
+
// reconcileChildren is safe: it preserves node identity where it can and
|
|
2843
|
+
// never touches lit-html parts. Keep the slot runtime's assignment
|
|
2844
|
+
// bookkeeping in sync so a later re-render materialises THESE nodes.
|
|
2845
|
+
const prevAssigned = state.lastSnapshot.get(liveSlot) || [];
|
|
2846
|
+
reconcileChildren(liveSlot, incSlot);
|
|
2847
|
+
const children = [...liveSlot.childNodes];
|
|
2848
|
+
state.assignedByName.set(name, children);
|
|
2849
|
+
state.lastSnapshot.set(liveSlot, children.slice());
|
|
2850
|
+
// Fire slotchange on an assigned-node-SET change (add / remove / reorder),
|
|
2851
|
+
// not an in-place text edit that reuses the same node: that matches the
|
|
2852
|
+
// spec and keeps this branch uniform with the boundary branches below.
|
|
2853
|
+
if (!sameNodeList(prevAssigned, children)) fireSlotChange(liveSlot);
|
|
2854
|
+
} else if (liveSlot && !incSlot) {
|
|
2855
|
+
// actual->fallback: incoming REMOVED this slot's content. Clear the
|
|
2856
|
+
// assignment and flip THIS slot to fallback; applyFallback restores the
|
|
2857
|
+
// render-owned fallback from the slot-part holding fragment.
|
|
2858
|
+
state.assignedByName.delete(name);
|
|
2859
|
+
if (applyFallback(state, liveSlot)) fireSlotChange(liveSlot);
|
|
2860
|
+
} else {
|
|
2861
|
+
// fallback->actual: incoming ADDED content where the live slot shows
|
|
2862
|
+
// fallback. The target slot is currently fallback, so resolve it directly
|
|
2863
|
+
// (it is absent from the actual-only map) and fill it with the imported
|
|
2864
|
+
// page-authored nodes; applyActualAssignment tucks the render-owned
|
|
2865
|
+
// fallback into the holding fragment first.
|
|
2866
|
+
const targetSlot = ownLightSlotForName(dst, name);
|
|
2867
|
+
if (!targetSlot) continue;
|
|
2868
|
+
const nodes = [...incSlot.childNodes].map((n) => document.importNode(n, true));
|
|
2869
|
+
state.assignedByName.set(name, nodes);
|
|
2870
|
+
if (applyActualAssignment(state, targetSlot, nodes)) fireSlotChange(targetSlot);
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2675
2875
|
/**
|
|
2676
2876
|
* Reconcile dst's children to match src's children, in-place.
|
|
2677
2877
|
*
|
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
|
|