@webjsdev/core 0.7.34 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.34",
3
+ "version": "0.7.35",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
@@ -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';
@@ -2677,7 +2683,16 @@ function diffElementInPlace(dst, src) {
2677
2683
  // property change through `attributeChangedCallback`, so the component
2678
2684
  // re-renders ITSELF; the router must not touch its internals. This mirrors
2679
2685
  // Turbo/morphdom, which leave custom elements alone by default.
2680
- if (isHydratedComponent(dst)) return;
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
+ }
2681
2696
 
2682
2697
  // Recurse into children: collect both sides, run reconcileSiblings on
2683
2698
  // them with synthetic boundary markers. Cheap implementation: use
@@ -2702,6 +2717,161 @@ function isHydratedComponent(el) {
2702
2717
  return /** @type {any} */ (el)[Symbol.for('webjs.instance')] != null;
2703
2718
  }
2704
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
+
2705
2875
  /**
2706
2876
  * Reconcile dst's children to match src's children, in-place.
2707
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