@webjsdev/core 0.7.39 → 0.7.40

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.39",
3
+ "version": "0.7.40",
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
@@ -7,10 +7,11 @@ import { Signal } from './signal.js';
7
7
  import {
8
8
  captureAuthoredChildren,
9
9
  adoptSSRAssignments,
10
- attachSlotObservers,
11
- detachSlotObservers,
12
10
  ensureSlotState,
13
11
  hasSlotState,
12
+ slotsView,
13
+ hasSlotContent,
14
+ setSlotContent,
14
15
  } from './slot.js';
15
16
 
16
17
  const isBrowser = typeof window !== 'undefined' && typeof HTMLElement !== 'undefined';
@@ -818,29 +819,35 @@ class WebComponentBase extends Base {
818
819
  `For light DOM, use global CSS or <style> in render().`
819
820
  );
820
821
  }
821
- // Light-DOM slot lifecycle phase one. Three sub-paths:
822
+ // Light-DOM slot capture: ONCE per host lifetime (#1015). Three
823
+ // sub-paths, all of which resolve to "the record is populated
824
+ // exactly once and never re-captured":
822
825
  //
823
- // a. Reconnection. Slot state already exists from a prior mount.
826
+ // a. Reconnection. The record already exists from a prior mount.
824
827
  // The host still carries the rendered template DOM (plus
825
- // projected children) from before the disconnect. Skip
828
+ // placed slot children) from before the disconnect. Skip
826
829
  // capture (would wrongly hoover up rendered nodes) and skip
827
830
  // SSR adoption. clientRender will see the existing INSTANCE
828
831
  // and updateInstance instead of recreating; the DOM stays.
829
832
  //
830
833
  // b. SSR hydration (first mount, <!--webjs-hydrate--> marker
831
- // present). Children are already projected into
834
+ // present). Children are already inside their
832
835
  // <slot data-webjs-light data-projection="actual"> elements
833
- // by injectDSD. Adopt those assignments BEFORE _performRender
834
- // so we retain references to the SSR'd nodes; the renderer's
835
- // createInstance().replaceChildren() will detach them, but
836
- // projection re-attaches by moving the same Node refs into
837
- // the freshly-cloned slot. DOM identity preserved through the
838
- // hydration round-trip.
836
+ // (injectDSD placed them). Adopt those assignments BEFORE
837
+ // _performRender so we retain references to the SSR'd nodes;
838
+ // the renderer's createInstance().replaceChildren() will
839
+ // detach them, but the slot-apply step re-attaches the same
840
+ // Node refs into the freshly-cloned slot. DOM identity
841
+ // preserved through the hydration round-trip.
839
842
  //
840
- // c. First mount, no SSR. Move authored children into the
841
- // assignment table before _performRender wipes the host.
843
+ // c. First mount, no SSR. Partition authored children into the
844
+ // record before _performRender wipes the host.
845
+ //
846
+ // There are NO mutation observers: an external appendChild or a
847
+ // slot=""-attribute flip after mount is inert by design, and the
848
+ // dynamic path is setSlotContent() (children as values).
842
849
  if (hasSlotState(this)) {
843
- // (a) Reconnection. State already populated; nothing to do here.
850
+ // (a) Reconnection. Record already populated; nothing to do here.
844
851
  } else if (this.__isHydrating()) {
845
852
  ensureSlotState(this);
846
853
  adoptSSRAssignments(this);
@@ -881,17 +888,50 @@ class WebComponentBase extends Base {
881
888
  } catch (err) {
882
889
  console.error(`[webjs] lifecycle hook threw during initial render:`, err);
883
890
  }
884
- // Light-DOM slot lifecycle phase two: after the first render
885
- // commits, the live <slot> elements exist. Attach mutation
886
- // observers so authored-child + slot-name changes drive
887
- // incremental projection. (Shadow DOM uses native slot
888
- // projection; nothing to attach.)
889
- if (this._renderRoot === this) {
890
- attachSlotObservers(this);
891
- }
891
+ // No slot observers to attach (#1015): the renderer's slot parts
892
+ // place the record content as part of the commit, and the only
893
+ // dynamic path is setSlotContent().
892
894
  });
893
895
  }
894
896
 
897
+ /**
898
+ * Read view of this host's slot record (#1015): captured (or
899
+ * programmatically set) children per slot name, e.g.
900
+ * `this.slots.default` / `this.slots.header`. Fresh arrays each read.
901
+ * Enables conditional-on-slot rendering:
902
+ * `this.slots.header ? html\`...\` : ''`.
903
+ *
904
+ * @returns {Record<string, Node[]>}
905
+ */
906
+ get slots() {
907
+ return slotsView(this);
908
+ }
909
+
910
+ /**
911
+ * Does this host's slot record carry content for `name` (#1015)?
912
+ * `hasSlot()` / `hasSlot('default')` query the default slot.
913
+ *
914
+ * @param {string | null} [name]
915
+ * @returns {boolean}
916
+ */
917
+ hasSlot(name) {
918
+ return hasSlotContent(this, name);
919
+ }
920
+
921
+ /**
922
+ * Replace a slot's content (#1015): the ONE dynamic path for slotted
923
+ * children (external appendChild / slot="" flips after mount are inert
924
+ * by design). Accepts a Node, Node[], a string, or null to reset the
925
+ * slot to its fallback content. Fires `slotchange` when the assignment
926
+ * changes.
927
+ *
928
+ * @param {string | null} name
929
+ * @param {Node | Node[] | string | null} value
930
+ */
931
+ setSlotContent(name, value) {
932
+ setSlotContent(this, name, value);
933
+ }
934
+
895
935
  /**
896
936
  * True when this host's first child is the framework's hydration
897
937
  * marker, meaning the SSR pipeline already rendered the template's
@@ -929,10 +969,8 @@ class WebComponentBase extends Base {
929
969
  this.__hydrationObserver.disconnect();
930
970
  this.__hydrationObserver = null;
931
971
  }
932
- // Pause slot observers. The per-host state (assignment table,
933
- // pending fragments, last snapshots) is preserved so a subsequent
934
- // reconnection picks up where it left off.
935
- if (this._renderRoot === this) detachSlotObservers(this);
972
+ // No slot observers to detach (#1015). The per-host slot record is
973
+ // preserved so a subsequent reconnection picks up where it left off.
936
974
  // Dispose the signal watcher so dependency edges drop. Without
937
975
  // this the element holds references to module-scope signals
938
976
  // (and vice versa) forever.
@@ -10,8 +10,8 @@ import {
10
10
  PROJECTION_FALLBACK,
11
11
  SLOT_FALLBACK_FRAG,
12
12
  SLOT_STATE,
13
- scheduleProjection,
14
- moveSlotChildrenToPending,
13
+ applySlotAssignments,
14
+ rescueAssignedNodes,
15
15
  ensureSlotState,
16
16
  } from './slot.js';
17
17
 
@@ -353,7 +353,7 @@ function compile(tr) {
353
353
  templateEl.innerHTML = html;
354
354
 
355
355
  // Mark every <slot> in the template for framework projection and
356
- // register a SLOT part for each so projectChildren can find them on
356
+ // register a SLOT part for each so the slot-apply step can find them on
357
357
  // clones. This runs BEFORE assignPaths so the sentinel attributes the
358
358
  // discovery step adds are picked up in the same path-recording walk.
359
359
  discoverSlots(templateEl.content, parts);
@@ -582,8 +582,11 @@ function clearInstance(inst, container) {
582
582
  }
583
583
  }
584
584
  if (p.kind === 'slot') {
585
+ // Detach record-owned children before the teardown disposes the
586
+ // slot subtree; the record keeps the refs, so a re-created slot
587
+ // re-places the SAME nodes (children are values, #1015).
585
588
  const host = findSlotHost(p.slotEl);
586
- if (host) moveSlotChildrenToPending(host, p.slotEl);
589
+ if (host) rescueAssignedNodes(host, p.slotEl);
587
590
  }
588
591
  }
589
592
  /** @type any */ (container).replaceChildren();
@@ -668,11 +671,14 @@ function applyPart(part, value, _prev, allValues) {
668
671
  // Shadow DOM: native projection. Leave fallback in place.
669
672
  if (isInShadowRootEl(slotEl)) return;
670
673
  // Light DOM: harvest the cloned fallback into a holding
671
- // fragment, then trigger projection.
674
+ // fragment, then place the host's slot record (#1015). The
675
+ // application is deferred one microtask so the documented
676
+ // lifecycle timing holds: firstUpdated() sees the <slot>
677
+ // element itself, the populated content lands right after.
672
678
  const frag = document.createDocumentFragment();
673
679
  while (slotEl.firstChild) frag.appendChild(slotEl.firstChild);
674
680
  /** @type {any} */ (slotEl)[SLOT_FALLBACK_FRAG] = frag;
675
- scheduleProjection(host);
681
+ queueMicrotask(() => applySlotAssignments(host));
676
682
  };
677
683
  const directHost = findSlotHost(slotEl);
678
684
  if (directHost) {
@@ -719,7 +725,14 @@ function isInShadowRootEl(el) {
719
725
  const parent = n.parentNode;
720
726
  if (!parent) return false;
721
727
  if (parent === n) return false;
722
- if (/** @type any */ (parent).host) return true;
728
+ // A real ShadowRoot is a DocumentFragment (nodeType 11) exposing its
729
+ // owner as `.host`. Checking `.host` truthiness ALONE misfires on
730
+ // ordinary elements: HTMLAnchorElement/HTMLAreaElement expose a
731
+ // URL-derived `.host` ('example.com'), so a slot nested inside an
732
+ // <a> card was misread as shadow DOM and its light-DOM application
733
+ // silently skipped (surfaced by #1015's removal of the redundant
734
+ // observer repair paths that used to mask it).
735
+ if (parent.nodeType === 11 && /** @type any */ (parent).host) return true;
723
736
  n = parent;
724
737
  }
725
738
  return false;
@@ -8,6 +8,7 @@ import { isSuspense } from './suspense.js';
8
8
  import { unsafeHTML, isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from './directives.js';
9
9
  import { stringify, parse } from './serialize.js';
10
10
  import { cspNonce } from './csp-nonce.js';
11
+ import { ensureSlotState } from './slot.js';
11
12
 
12
13
  /**
13
14
  * Render a TemplateResult (or any renderable value) to an HTML string.
@@ -486,6 +487,48 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
486
487
  seedServerAttrs(instance, attrMap);
487
488
  applyAttrsToInstance(instance, attrMap, Cls);
488
489
  for (const [k, v] of Object.entries(propValues)) instance[k] = v;
490
+ // Extract the authored inner HTML BEFORE the render (#1015, the
491
+ // injectDSD reorder): the source scan needs no render output, and
492
+ // hoisting it lets the slot record be seeded so `this.hasSlot()` /
493
+ // `this.slots` inside render() see the authored children at SSR
494
+ // exactly as they will on the client (conditional-on-slot parity).
495
+ // The light-DOM branch below reuses these for the slot pipeline;
496
+ // the shadow branch is a read-only peek (its authored children stay
497
+ // in place for native projection, and its edit keeps the old end).
498
+ let authoredInner = '';
499
+ let closeEnd = m.index + match.length;
500
+ if (!selfClose) {
501
+ const innerStart = m.index + match.length;
502
+ const closeIdx = findClosingTagInString(html, innerStart, tag);
503
+ if (closeIdx !== -1) {
504
+ authoredInner = html.slice(innerStart, closeIdx);
505
+ const closeRe = new RegExp(`</${escapeRegex(tag)}\\s*>`, 'i');
506
+ const tail = html.slice(closeIdx);
507
+ const closeMatch = closeRe.exec(tail);
508
+ const closeLen = closeMatch ? closeMatch[0].length : `</${tag}>`.length;
509
+ closeEnd = closeIdx + closeLen;
510
+ } else {
511
+ // Unclosed in source. Take rest of html as authored content
512
+ // and synthesize a closing tag on output.
513
+ authoredInner = html.slice(innerStart);
514
+ closeEnd = html.length;
515
+ }
516
+ }
517
+ const partitioned = partitionAuthoredBySlot(authoredInner);
518
+ // Seed the slot record for SSR-side reads: LIGHT DOM only, matching the
519
+ // client (which only creates slot state on the light-DOM path, since
520
+ // shadow slots are native browser projection). Seeding shadow here
521
+ // would make hasSlot() true at SSR and false after hydration, flipping
522
+ // conditional-on-slot markup on the first client render. At SSR the
523
+ // record value is the authored RAW HTML (one string per name; there is
524
+ // no DOM to hold Nodes), so PRESENCE and KEYS match the client record,
525
+ // which is the conditional-on-slot contract. Node access is client-side.
526
+ if (!isShadow) {
527
+ const slotState = ensureSlotState(instance);
528
+ for (const [name, htmlChunk] of partitioned) {
529
+ if (htmlChunk && htmlChunk.length) slotState.assignedByName.set(name, [htmlChunk]);
530
+ }
531
+ }
489
532
  // Run the pre-render lifecycle (willUpdate, controllers' hostUpdate,
490
533
  // then reflect reflect:true props) so derived state computed there is
491
534
  // correct in the SSR'd HTML, matching how lit runs the update cycle at
@@ -561,36 +604,15 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
561
604
  continue;
562
605
  }
563
606
  //
564
- // 1. Find the matching closing tag in the source HTML (depth-
565
- // tracked for nested same-tag elements).
566
- // 2. Extract authored inner HTML, partition by slot="" attr.
567
- // 3. Substitute each <slot> in the rendered output with a
607
+ // The authored inner HTML + slot partition were extracted BEFORE
608
+ // the render (the #1015 reorder, see above), so here:
609
+ // 1. Substitute each <slot> in the rendered output with a
568
610
  // framework-marked <slot data-webjs-light data-projection
569
611
  // ="actual|fallback"> element carrying projection or
570
612
  // fallback content per first-wins rule.
571
- // 4. Recursively run injectDSD on the substituted output so
613
+ // 2. Recursively run injectDSD on the substituted output so
572
614
  // nested custom elements (inside projected children) get
573
615
  // their own DSD pass.
574
- let authoredInner = '';
575
- let closeEnd = m.index + match.length;
576
- if (!selfClose) {
577
- const innerStart = m.index + match.length;
578
- const closeIdx = findClosingTagInString(html, innerStart, tag);
579
- if (closeIdx !== -1) {
580
- authoredInner = html.slice(innerStart, closeIdx);
581
- const closeRe = new RegExp(`</${escapeRegex(tag)}\\s*>`, 'i');
582
- const tail = html.slice(closeIdx);
583
- const closeMatch = closeRe.exec(tail);
584
- const closeLen = closeMatch ? closeMatch[0].length : `</${tag}>`.length;
585
- closeEnd = closeIdx + closeLen;
586
- } else {
587
- // Unclosed in source. Take rest of html as authored content
588
- // and synthesize a closing tag on output.
589
- authoredInner = html.slice(innerStart);
590
- closeEnd = html.length;
591
- }
592
- }
593
- const partitioned = partitionAuthoredBySlot(authoredInner);
594
616
  const innerWithSlots = substituteSlotsInRender(rawInner, partitioned);
595
617
  const innerProcessed = await injectDSD(innerWithSlots, ctx, [...ancestors, instance], dev);
596
618
  edits.push({
@@ -838,9 +860,11 @@ function extractSlotAttr(attrsRaw) {
838
860
  const m = /\bslot\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
839
861
  if (!m) return null;
840
862
  const value = m[1] ?? m[2] ?? m[3] ?? '';
841
- // Per shadow DOM spec, slot="" (empty) and missing slot attribute
842
- // both route to the default slot.
843
- return value === '' ? null : value;
863
+ // Per shadow DOM spec, slot="" (empty) and missing slot attribute both
864
+ // route to the default slot. `default` is the framework's reserved alias
865
+ // for it (#1015: the client record normalizes it identically, so both
866
+ // sides agree end to end).
867
+ return value === '' || value === 'default' ? null : value;
844
868
  }
845
869
 
846
870
  /**
@@ -982,11 +1006,16 @@ function substituteSlotsInRender(rendered, partitioned) {
982
1006
  totalEnd = closeIdx + closeLen;
983
1007
  }
984
1008
  }
985
- const projected = partitioned.get(name);
1009
+ // `default` and `''` are the reserved aliases for the default slot
1010
+ // (#1015), matching the client's keyOfName exactly: the LOOKUP key
1011
+ // normalizes, while the emitted name attribute stays as authored so the
1012
+ // output bytes are unchanged for every other app.
1013
+ const slotKey = name === 'default' || name === '' ? null : name;
1014
+ const projected = partitioned.get(slotKey);
986
1015
  const nameAttr = name !== null ? ` name="${escapeAttr(name)}"` : '';
987
1016
  const extraAttrs = otherAttrs ? ` ${otherAttrs}` : '';
988
- if (projected !== undefined && !consumedNames.has(name)) {
989
- consumedNames.add(name);
1017
+ if (projected !== undefined && !consumedNames.has(slotKey)) {
1018
+ consumedNames.add(slotKey);
990
1019
  result += `<slot data-webjs-light data-projection="actual"${nameAttr}${extraAttrs}>${projected}</slot>`;
991
1020
  } else {
992
1021
  result += `<slot data-webjs-light data-projection="fallback"${nameAttr}${extraAttrs}>${fallback}</slot>`;