@webjsdev/core 0.7.39 → 0.7.41

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.41",
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,13 @@ 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
+ hasFrameworkRenderedSubtree,
13
+ installSlotInterception,
14
+ installSlotSensors,
15
+ teardownSlotSensors,
16
+ reconnectSweep,
14
17
  } from './slot.js';
15
18
 
16
19
  const isBrowser = typeof window !== 'undefined' && typeof HTMLElement !== 'undefined';
@@ -818,35 +821,69 @@ class WebComponentBase extends Base {
818
821
  `For light DOM, use global CSS or <style> in render().`
819
822
  );
820
823
  }
821
- // Light-DOM slot lifecycle phase one. Three sub-paths:
824
+ // Light-DOM slot capture: ONCE per host lifetime (#1015). Three
825
+ // sub-paths, all of which resolve to "the record is populated
826
+ // exactly once and never re-captured":
822
827
  //
823
- // a. Reconnection. Slot state already exists from a prior mount.
828
+ // a. Reconnection. The record already exists from a prior mount.
824
829
  // The host still carries the rendered template DOM (plus
825
- // projected children) from before the disconnect. Skip
830
+ // placed slot children) from before the disconnect. Skip
826
831
  // capture (would wrongly hoover up rendered nodes) and skip
827
832
  // SSR adoption. clientRender will see the existing INSTANCE
828
833
  // and updateInstance instead of recreating; the DOM stays.
829
834
  //
830
835
  // b. SSR hydration (first mount, <!--webjs-hydrate--> marker
831
- // present). Children are already projected into
836
+ // present). Children are already inside their
832
837
  // <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.
838
+ // (injectDSD placed them). Adopt those assignments BEFORE
839
+ // _performRender so we retain references to the SSR'd nodes;
840
+ // the renderer's createInstance().replaceChildren() will
841
+ // detach them, but the slot-apply step re-attaches the same
842
+ // Node refs into the freshly-cloned slot. DOM identity
843
+ // preserved through the hydration round-trip.
839
844
  //
840
- // c. First mount, no SSR. Move authored children into the
841
- // assignment table before _performRender wipes the host.
845
+ // c. First mount, no SSR. Partition authored children into the
846
+ // record before _performRender wipes the host.
847
+ //
848
+ // After capture, installSlotInterception + installSlotSensors below make
849
+ // native DOM writes on a mounted host live (appendChild, slot= flips,
850
+ // innerHTML, HTMLSlotElement.assign), so light-DOM slots match shadow DOM
851
+ // through the standard API.
842
852
  if (hasSlotState(this)) {
843
- // (a) Reconnection. State already populated; nothing to do here.
844
- } else if (this.__isHydrating()) {
853
+ // (a) Reconnection. Record already populated. Sweep any direct child
854
+ // added by a raw bypass write while the host was disconnected (no
855
+ // sensor was live to catch it), then re-arm the sensors below.
856
+ reconnectSweep(this);
857
+ } else if (
858
+ this.__isHydrating() ||
859
+ this.hasAttribute('data-wj-serialized') ||
860
+ hasFrameworkRenderedSubtree(this)
861
+ ) {
862
+ // (b) Framework-rendered markup, from any of three signals: boot-time
863
+ // SSR hydration (the webjs-hydrate marker), the router's
864
+ // data-wj-serialized stamp (applySwap marks every host in a parsed
865
+ // doc, covering a snapshot restore whose serialized shape has no
866
+ // projected slot to detect), or the structural detector (own
867
+ // data-webjs-light[data-projection] slots, attributes only the
868
+ // renderer / SSR stamps). Adopt-not-capture: capturing restored
869
+ // post-hydration HTML would hoover the rendered tree into the
870
+ // record and duplicate content on the next render (the #1006
871
+ // shape, on the restore path).
872
+ this.removeAttribute('data-wj-serialized');
845
873
  ensureSlotState(this);
846
874
  adoptSSRAssignments(this);
847
875
  } else {
876
+ // (c) First mount with author-written children. Capture them.
848
877
  captureAuthoredChildren(this);
849
878
  }
879
+ // Install native-write interception AFTER capture (capture uses the
880
+ // host's still-native methods), then arm the sensors. Together they make
881
+ // appendChild / insertBefore / removeChild / innerHTML / slot= flips on a
882
+ // mounted light host drive the slot record, restoring full shadow-DOM
883
+ // parity through the standard DOM API. Interception installs once;
884
+ // sensors are armed on every connect and torn down on disconnect.
885
+ installSlotInterception(this);
886
+ installSlotSensors(this);
850
887
  }
851
888
 
852
889
  // Notify all controllers that the host is connected.
@@ -881,14 +918,10 @@ class WebComponentBase extends Base {
881
918
  } catch (err) {
882
919
  console.error(`[webjs] lifecycle hook threw during initial render:`, err);
883
920
  }
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
- }
921
+ // The renderer's slot parts place the record content as part of the
922
+ // commit; native DOM writes on the host (appendChild, slot= flips,
923
+ // innerHTML) drive the record live through the interception + sensors
924
+ // installed on connect. There is no WebJs-specific slot API.
892
925
  });
893
926
  }
894
927
 
@@ -929,10 +962,11 @@ class WebComponentBase extends Base {
929
962
  this.__hydrationObserver.disconnect();
930
963
  this.__hydrationObserver = null;
931
964
  }
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);
965
+ // Tear down the slot sensors, processing any queued records first (a bare
966
+ // disconnect() drops them). The per-host slot record + interception are
967
+ // preserved so a reconnection picks up where it left off (sensors re-arm in
968
+ // connectedCallback).
969
+ teardownSlotSensors(this);
936
970
  // Dispose the signal watcher so dependency edges drop. Without
937
971
  // this the element holds references to module-scope signals
938
972
  // (and vice versa) forever.
@@ -10,11 +10,28 @@ import {
10
10
  PROJECTION_FALLBACK,
11
11
  SLOT_FALLBACK_FRAG,
12
12
  SLOT_STATE,
13
- scheduleProjection,
14
- moveSlotChildrenToPending,
15
- ensureSlotState,
13
+ SLOT_OWNER,
14
+ RENDERING,
15
+ applySlotAssignments,
16
+ rescueAssignedNodes,
17
+ withRendererWrites,
18
+ drainRendererBackstop,
16
19
  } from './slot.js';
17
20
 
21
+ /**
22
+ * Open the renderer-write window on a light-DOM host while `fn` commits into
23
+ * it, so the host's patched slot-interception methods delegate to native and
24
+ * a renderer commit is never mistaken for authored content. A no-op (just runs
25
+ * `fn`) when `node` is not a slot host, so nested and non-host commits pay
26
+ * nothing. Covers the ASYNC commit paths (async directives, streaming) that
27
+ * run outside a synchronous render() call.
28
+ */
29
+ function commitInto(node, fn) {
30
+ const host = node && /** @type {any} */ (node)[SLOT_STATE] ? node : null;
31
+ if (!host) return fn();
32
+ return withRendererWrites(host, fn);
33
+ }
34
+
18
35
  /**
19
36
  * Client-side renderer with **fine-grained** updates.
20
37
  *
@@ -88,43 +105,67 @@ const INSTANCE = Symbol.for('webjs.instance');
88
105
  */
89
106
  export function render(value, container) {
90
107
  const host = /** @type any */ (container);
91
- const prev = host[INSTANCE];
108
+ // Open the renderer-write window for the whole commit: every host-receiver
109
+ // write below (and in createInstance / updateInstance / clearInstance / all
110
+ // part commits they reach synchronously) then bypasses the slot interception
111
+ // that is patched onto a light host. This is the single discriminator
112
+ // between a renderer commit and an author write.
113
+ const prevRendering = host[RENDERING];
114
+ host[RENDERING] = true;
115
+ try {
116
+ const prev = host[INSTANCE];
92
117
 
93
- if (isTemplate(value)) {
94
- const tr = /** @type {import('./html.js').TemplateResult} */ (value);
95
- if (prev && prev.strings === tr.strings) {
96
- updateInstance(prev, tr.values);
97
- return;
98
- }
99
- if (prev) clearInstance(prev, container);
118
+ if (isTemplate(value)) {
119
+ const tr = /** @type {import('./html.js').TemplateResult} */ (value);
120
+ if (prev && prev.strings === tr.strings) {
121
+ updateInstance(prev, tr.values);
122
+ return;
123
+ }
124
+ if (prev) clearInstance(prev, container);
125
+
126
+ // Light DOM hydration: if container has SSR content (marked by
127
+ // <!--webjs-hydrate-->), remove the marker and proceed with normal
128
+ // rendering. The content will be replaced with identical output -
129
+ // no visible flash because SSR and client render produce the same HTML.
130
+ const firstChild = container.firstChild;
131
+ if (firstChild && firstChild.nodeType === 8 && /** @type {Comment} */ (firstChild).data === 'webjs-hydrate') {
132
+ firstChild.remove();
133
+ }
100
134
 
101
- // Light DOM hydration: if container has SSR content (marked by
102
- // <!--webjs-hydrate-->), remove the marker and proceed with normal
103
- // rendering. The content will be replaced with identical output -
104
- // no visible flash because SSR and client render produce the same HTML.
105
- const firstChild = container.firstChild;
106
- if (firstChild && firstChild.nodeType === 8 && /** @type {Comment} */ (firstChild).data === 'webjs-hydrate') {
107
- firstChild.remove();
135
+ // Pre-set the symbol to an explicit null BEFORE the commit,
136
+ // UNCONDITIONALLY: if createInstance throws after its replaceChildren
137
+ // (e.g. inside the slot-part apply loop), the finally-drain must see
138
+ // "rendered, no instance" (discard the commit's records), never
139
+ // "never rendered" (fold and corrupt) and never a STALE cleared prev
140
+ // instance on the template-swap path (whose bookends would misclassify
141
+ // the half-committed new roots as unowned and fold them).
142
+ host[INSTANCE] = null;
143
+ const inst = createInstance(tr, container);
144
+ host[INSTANCE] = inst;
145
+ return;
108
146
  }
109
147
 
110
- const inst = createInstance(tr, container);
111
- host[INSTANCE] = inst;
112
- return;
113
- }
114
-
115
- // Non-template value: treat as a single text child.
116
- if (prev) clearInstance(prev, container);
117
- host[INSTANCE] = null;
118
- container.replaceChildren();
119
- if (value == null || value === false || value === true) return;
120
- if (Array.isArray(value)) {
121
- for (const v of value) {
122
- const text = document.createTextNode(String(v ?? ''));
123
- container.appendChild(text);
148
+ // Non-template value: treat as a single text child.
149
+ if (prev) clearInstance(prev, container);
150
+ host[INSTANCE] = null;
151
+ container.replaceChildren();
152
+ if (value == null || value === false || value === true) return;
153
+ if (Array.isArray(value)) {
154
+ for (const v of value) {
155
+ const text = document.createTextNode(String(v ?? ''));
156
+ container.appendChild(text);
157
+ }
158
+ return;
124
159
  }
125
- return;
160
+ container.appendChild(document.createTextNode(String(value)));
161
+ } finally {
162
+ host[RENDERING] = prevRendering;
163
+ // Outermost window closing: drain this commit's childList records off the
164
+ // slot backstop (drainRendererBackstop processes them with a renderer-output
165
+ // skip when an instance exists, else discards), so the backstop never folds
166
+ // renderer output. Mirrors withRendererWrites, used by the async paths.
167
+ if (!prevRendering) drainRendererBackstop(host);
126
168
  }
127
- container.appendChild(document.createTextNode(String(value)));
128
169
  }
129
170
 
130
171
  /* ================================================================
@@ -353,7 +394,7 @@ function compile(tr) {
353
394
  templateEl.innerHTML = html;
354
395
 
355
396
  // Mark every <slot> in the template for framework projection and
356
- // register a SLOT part for each so projectChildren can find them on
397
+ // register a SLOT part for each so the slot-apply step can find them on
357
398
  // clones. This runs BEFORE assignPaths so the sentinel attributes the
358
399
  // discovery step adds are picked up in the same path-recording walk.
359
400
  discoverSlots(templateEl.content, parts);
@@ -483,9 +524,21 @@ function createInstance(tr, container) {
483
524
  // Slot parts have no value-hole to drive applyPart from the loop above.
484
525
  // Apply them once now that the fragment is inserted into the live
485
526
  // container, so each slot can locate its host by walking parents and
486
- // schedule the first projection through slot.js.
527
+ // schedule the first projection through slot.js. Stamp each slot with the
528
+ // host whose TEMPLATE produced it (this container), so a FORWARDED slot
529
+ // (rendered here but nested inside a child component) routes to this host
530
+ // rather than to the child the structural walk would pick. Only an element
531
+ // container is a host; a fragment/shadow container leaves the structural
532
+ // path in place.
533
+ const ownerHost =
534
+ /** @type {any} */ (container).nodeType === 1 && /** @type {any} */ (container)[SLOT_STATE]
535
+ ? container
536
+ : null;
487
537
  for (const part of bound) {
488
- if (part.kind === 'slot') applyPart(part, undefined, undefined, []);
538
+ if (part.kind === 'slot') {
539
+ if (ownerHost && part.slotEl) /** @type {any} */ (part.slotEl)[SLOT_OWNER] = ownerHost;
540
+ applyPart(part, undefined, undefined, []);
541
+ }
489
542
  }
490
543
 
491
544
  return { strings: tr.strings, bound, lastValues, startNode, endNode };
@@ -582,8 +635,11 @@ function clearInstance(inst, container) {
582
635
  }
583
636
  }
584
637
  if (p.kind === 'slot') {
638
+ // Detach record-owned children before the teardown disposes the
639
+ // slot subtree; the record keeps the refs, so a re-created slot
640
+ // re-places the SAME nodes (children are values, #1015).
585
641
  const host = findSlotHost(p.slotEl);
586
- if (host) moveSlotChildrenToPending(host, p.slotEl);
642
+ if (host) rescueAssignedNodes(host, p.slotEl);
587
643
  }
588
644
  }
589
645
  /** @type any */ (container).replaceChildren();
@@ -662,20 +718,38 @@ function applyPart(part, value, _prev, allValues) {
662
718
  if (part.applied) break;
663
719
  const slotEl = part.slotEl;
664
720
  const finalize = () => {
665
- part.applied = true;
666
721
  const host = findSlotHost(slotEl);
667
- if (!host) return; // truly orphan slot
722
+ if (!host) {
723
+ part.applied = true;
724
+ return; // truly orphan slot (no owner, no structural host)
725
+ }
726
+ // A FORWARDED slot's owner is known immediately (the SLOT_OWNER
727
+ // stamp) but the child component has not yet PLACED the slot into
728
+ // the owner's subtree, so the owner's apply cannot find it yet.
729
+ // Re-defer until the child places it (host.contains becomes true);
730
+ // the child WILL place it, or the owner disconnects and the retries
731
+ // stop. A normal own slot is already inside its host, so this passes
732
+ // on the first call with no extra deferral.
733
+ if (!host.contains(slotEl)) {
734
+ if (host.isConnected) queueMicrotask(finalize);
735
+ else part.applied = true;
736
+ return;
737
+ }
738
+ part.applied = true;
668
739
  // Shadow DOM: native projection. Leave fallback in place.
669
740
  if (isInShadowRootEl(slotEl)) return;
670
741
  // Light DOM: harvest the cloned fallback into a holding
671
- // fragment, then trigger projection.
742
+ // fragment, then place the host's slot record (#1015). The
743
+ // application is deferred one microtask so the documented
744
+ // lifecycle timing holds: firstUpdated() sees the <slot>
745
+ // element itself, the populated content lands right after.
672
746
  const frag = document.createDocumentFragment();
673
747
  while (slotEl.firstChild) frag.appendChild(slotEl.firstChild);
674
748
  /** @type {any} */ (slotEl)[SLOT_FALLBACK_FRAG] = frag;
675
- scheduleProjection(host);
749
+ queueMicrotask(() => applySlotAssignments(host));
676
750
  };
677
751
  const directHost = findSlotHost(slotEl);
678
- if (directHost) {
752
+ if (directHost && directHost.contains(slotEl)) {
679
753
  finalize();
680
754
  } else {
681
755
  queueMicrotask(finalize);
@@ -697,6 +771,10 @@ function applyPart(part, value, _prev, allValues) {
697
771
  * @returns {Element | null}
698
772
  */
699
773
  function findSlotHost(slotEl) {
774
+ // Template-owner stamp wins (a forwarded slot's true host), else the
775
+ // nearest SLOT_STATE ancestor structurally.
776
+ const owner = /** @type any */ (slotEl)[SLOT_OWNER];
777
+ if (owner && owner.isConnected) return owner;
700
778
  let p = slotEl.parentElement;
701
779
  while (p) {
702
780
  if (/** @type any */ (p)[SLOT_STATE]) return p;
@@ -719,7 +797,14 @@ function isInShadowRootEl(el) {
719
797
  const parent = n.parentNode;
720
798
  if (!parent) return false;
721
799
  if (parent === n) return false;
722
- if (/** @type any */ (parent).host) return true;
800
+ // A real ShadowRoot is a DocumentFragment (nodeType 11) exposing its
801
+ // owner as `.host`. Checking `.host` truthiness ALONE misfires on
802
+ // ordinary elements: HTMLAnchorElement/HTMLAreaElement expose a
803
+ // URL-derived `.host` ('example.com'), so a slot nested inside an
804
+ // <a> card was misread as shadow DOM and its light-DOM application
805
+ // silently skipped (surfaced by #1015's removal of the redundant
806
+ // observer repair paths that used to mask it).
807
+ if (parent.nodeType === 11 && /** @type any */ (parent).host) return true;
723
808
  n = parent;
724
809
  }
725
810
  return false;
@@ -806,6 +891,22 @@ function applyChild(part, value) {
806
891
  * @param {unknown} value
807
892
  */
808
893
  function applyChildInner(part, value) {
894
+ // Open the renderer-write window around this commit. Most calls are
895
+ // synchronous inside render() (the window is already open, this nests
896
+ // harmlessly), but the async directive paths (until, watch, asyncAppend /
897
+ // asyncReplace, streaming) re-enter here from a promise / microtask OUTSIDE
898
+ // any render() window, so this is where those commits into a light host are
899
+ // marked as renderer writes rather than authored content.
900
+ return commitInto(part.marker && part.marker.parentNode, () =>
901
+ applyChildInnerRaw(part, value),
902
+ );
903
+ }
904
+
905
+ /**
906
+ * @param {Extract<BoundPart, {kind:'child'}>} part
907
+ * @param {unknown} value
908
+ */
909
+ function applyChildInnerRaw(part, value) {
809
910
  const marker = part.marker;
810
911
 
811
912
  // unsafeHTML directive: inject raw HTML string as DOM nodes.
@@ -1059,6 +1160,16 @@ function buildDetached(tr) {
1059
1160
  applyPart(bound[i], tr.values[i], undefined, tr.values);
1060
1161
  lastValues.push(tr.values[i]);
1061
1162
  }
1163
+ // Slot parts need their one-shot apply exactly like createInstance and the
1164
+ // nested-template path. The fragment is still detached here, so the
1165
+ // slot-part's own deferred finalize (a one-microtask retry when the parent
1166
+ // walk cannot reach a host yet) carries it: every caller inserts the
1167
+ // returned fragment synchronously in the same task, so the retry lands in
1168
+ // the live tree. Without this loop a <slot> inside a repeat() / array item
1169
+ // never finalizes and its content is never placeable.
1170
+ for (const p of bound) {
1171
+ if (p.kind === 'slot') applyPart(p, undefined, undefined, []);
1172
+ }
1062
1173
  const outFrag = document.createDocumentFragment();
1063
1174
  outFrag.appendChild(startNode);
1064
1175
  while (frag.firstChild) outFrag.appendChild(frag.firstChild);
@@ -1503,6 +1614,35 @@ function applyCache(part, inner) {
1503
1614
  // Reconcile values so any state changes since detachment apply.
1504
1615
  updateInstance(cached.inst, tr.values);
1505
1616
  part.child = cached.inst;
1617
+ // A re-attached instance may carry ALREADY-APPLIED slot parts, whose
1618
+ // finalize will never fire again, while the host's record moved on
1619
+ // during the stash (content for these slots was parked when an apply
1620
+ // ran with the slot unreachable). Re-run the apply for each owning
1621
+ // host so parked content is pulled back out. The collection walks the
1622
+ // re-attached DOM RANGE (not the instance tree): slot parts live on
1623
+ // whatever template level contains the <slot> (nested holes, repeat /
1624
+ // array items, streamed chunks), so a structural DOM walk is the only
1625
+ // shape that covers every composition uniformly. moveRange already
1626
+ // ran, so the range is live and each slot's parent walk reaches its
1627
+ // host.
1628
+ const hosts = new Set();
1629
+ for (
1630
+ let n = cached.inst.startNode.nextSibling;
1631
+ n && n !== cached.inst.endNode;
1632
+ n = n.nextSibling
1633
+ ) {
1634
+ if (n.nodeType !== 1) continue;
1635
+ const el = /** @type {Element} */ (n);
1636
+ if (el.matches('slot[data-webjs-light]')) {
1637
+ const h = findSlotHost(el);
1638
+ if (h) hosts.add(h);
1639
+ }
1640
+ for (const s of el.querySelectorAll('slot[data-webjs-light]')) {
1641
+ const h = findSlotHost(s);
1642
+ if (h) hosts.add(h);
1643
+ }
1644
+ }
1645
+ for (const h of hosts) applySlotAssignments(h);
1506
1646
  return;
1507
1647
  }
1508
1648
  }
@@ -1816,17 +1956,23 @@ async function consumeAsyncStream(state, part, dir) {
1816
1956
  const mapped = dir.mapper ? dir.mapper(result.value, i) : result.value;
1817
1957
  const newNodes = renderToNodes(mapped);
1818
1958
 
1819
- if (state.mode === 'replace') {
1820
- for (const n of state.nodes) {
1821
- if (n.parentNode) n.parentNode.removeChild(n);
1959
+ // This chunk commit runs in an async loop OUTSIDE any render() window,
1960
+ // so open the renderer-write window explicitly: without it, committing a
1961
+ // stream chunk into a light slot host would hit the patched insertBefore /
1962
+ // removeChild and fold the renderer's own output into `authored`.
1963
+ commitInto(marker.parentNode, () => {
1964
+ if (state.mode === 'replace') {
1965
+ for (const n of state.nodes) {
1966
+ if (n.parentNode) n.parentNode.removeChild(n);
1967
+ }
1968
+ state.nodes = [];
1822
1969
  }
1823
- state.nodes = [];
1824
- }
1825
1970
 
1826
- const frag = document.createDocumentFragment();
1827
- for (const n of newNodes) frag.appendChild(n);
1828
- marker.parentNode?.insertBefore(frag, marker);
1829
- state.nodes.push(...newNodes);
1971
+ const frag = document.createDocumentFragment();
1972
+ for (const n of newNodes) frag.appendChild(n);
1973
+ marker.parentNode?.insertBefore(frag, marker);
1974
+ state.nodes.push(...newNodes);
1975
+ });
1830
1976
 
1831
1977
  i++;
1832
1978
  }
@@ -1854,6 +2000,15 @@ function renderToNodes(value) {
1854
2000
  for (let i = 0; i < tr.values.length; i++) {
1855
2001
  applyPart(bound[i], tr.values[i], undefined, tr.values);
1856
2002
  }
2003
+ // Slot parts need their one-shot apply here too (same contract as
2004
+ // createInstance / nested templates / buildDetached): the caller
2005
+ // (consumeAsyncStream) inserts these nodes synchronously in the same
2006
+ // task, so the slot-part's one-microtask finalize retry lands in the
2007
+ // live tree. Without this, a <slot> inside streamed chunk content never
2008
+ // finalizes and its name suppresses parking forever.
2009
+ for (const p of bound) {
2010
+ if (p.kind === 'slot') applyPart(p, undefined, undefined, []);
2011
+ }
1857
2012
  return [...frag.childNodes];
1858
2013
  }
1859
2014
  if (Array.isArray(value)) {
@@ -486,6 +486,32 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
486
486
  seedServerAttrs(instance, attrMap);
487
487
  applyAttrsToInstance(instance, attrMap, Cls);
488
488
  for (const [k, v] of Object.entries(propValues)) instance[k] = v;
489
+ // Extract the authored inner HTML BEFORE the render (the injectDSD
490
+ // reorder): the source scan needs no render output, and hoisting it lets
491
+ // the light-DOM branch below project the authored children into the
492
+ // rendered slots. The shadow branch is a read-only peek (its authored
493
+ // children stay in place for native projection, and its edit keeps the
494
+ // old end).
495
+ let authoredInner = '';
496
+ let closeEnd = m.index + match.length;
497
+ if (!selfClose) {
498
+ const innerStart = m.index + match.length;
499
+ const closeIdx = findClosingTagInString(html, innerStart, tag);
500
+ if (closeIdx !== -1) {
501
+ authoredInner = html.slice(innerStart, closeIdx);
502
+ const closeRe = new RegExp(`</${escapeRegex(tag)}\\s*>`, 'i');
503
+ const tail = html.slice(closeIdx);
504
+ const closeMatch = closeRe.exec(tail);
505
+ const closeLen = closeMatch ? closeMatch[0].length : `</${tag}>`.length;
506
+ closeEnd = closeIdx + closeLen;
507
+ } else {
508
+ // Unclosed in source. Take rest of html as authored content
509
+ // and synthesize a closing tag on output.
510
+ authoredInner = html.slice(innerStart);
511
+ closeEnd = html.length;
512
+ }
513
+ }
514
+ const partitioned = partitionAuthoredBySlot(authoredInner);
489
515
  // Run the pre-render lifecycle (willUpdate, controllers' hostUpdate,
490
516
  // then reflect reflect:true props) so derived state computed there is
491
517
  // correct in the SSR'd HTML, matching how lit runs the update cycle at
@@ -561,37 +587,16 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
561
587
  continue;
562
588
  }
563
589
  //
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
590
+ // The authored inner HTML + slot partition were extracted BEFORE
591
+ // the render (the #1015 reorder, see above), so here:
592
+ // 1. Substitute each <slot> in the rendered output with a
568
593
  // framework-marked <slot data-webjs-light data-projection
569
594
  // ="actual|fallback"> element carrying projection or
570
595
  // fallback content per first-wins rule.
571
- // 4. Recursively run injectDSD on the substituted output so
596
+ // 2. Recursively run injectDSD on the substituted output so
572
597
  // nested custom elements (inside projected children) get
573
598
  // 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
- const innerWithSlots = substituteSlotsInRender(rawInner, partitioned);
599
+ const innerWithSlots = substituteSlotsInRender(rawInner, partitioned, tag);
595
600
  const innerProcessed = await injectDSD(innerWithSlots, ctx, [...ancestors, instance], dev);
596
601
  edits.push({
597
602
  start: m.index,
@@ -838,9 +843,11 @@ function extractSlotAttr(attrsRaw) {
838
843
  const m = /\bslot\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
839
844
  if (!m) return null;
840
845
  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;
846
+ // Per shadow DOM spec, slot="" (empty) and missing slot attribute both
847
+ // route to the default slot. `default` is the framework's reserved alias
848
+ // for it (#1015: the client record normalizes it identically, so both
849
+ // sides agree end to end).
850
+ return value === '' || value === 'default' ? null : value;
844
851
  }
845
852
 
846
853
  /**
@@ -943,11 +950,19 @@ function appendStringToMap(map, key, value) {
943
950
  * slots with the same name follow the first-wins rule per spec; later
944
951
  * same-named slots fall back regardless of available projection.
945
952
  *
953
+ * The `ownerTag` (the tag of the component whose template rendered these
954
+ * slots) is emitted as `data-wj-slot-owner` so the client resolves template
955
+ * ownership on hydration the same way the client renderer stamps SLOT_OWNER,
956
+ * which is what makes a FORWARDED slot (rendered by this component but nested
957
+ * inside a child) route to this component and not the child (#1023).
958
+ *
946
959
  * @param {string} rendered
947
960
  * @param {Map<string|null, string>} partitioned
961
+ * @param {string} ownerTag
948
962
  * @returns {string}
949
963
  */
950
- function substituteSlotsInRender(rendered, partitioned) {
964
+ function substituteSlotsInRender(rendered, partitioned, ownerTag) {
965
+ const ownerAttr = ownerTag ? ` data-wj-slot-owner="${escapeAttr(ownerTag)}"` : '';
951
966
  /** @type {Set<string|null>} */
952
967
  const consumedNames = new Set();
953
968
  let result = '';
@@ -982,14 +997,19 @@ function substituteSlotsInRender(rendered, partitioned) {
982
997
  totalEnd = closeIdx + closeLen;
983
998
  }
984
999
  }
985
- const projected = partitioned.get(name);
1000
+ // `default` and `''` are the reserved aliases for the default slot
1001
+ // (#1015), matching the client's keyOfName exactly: the LOOKUP key
1002
+ // normalizes, while the emitted name attribute stays as authored so the
1003
+ // output bytes are unchanged for every other app.
1004
+ const slotKey = name === 'default' || name === '' ? null : name;
1005
+ const projected = partitioned.get(slotKey);
986
1006
  const nameAttr = name !== null ? ` name="${escapeAttr(name)}"` : '';
987
1007
  const extraAttrs = otherAttrs ? ` ${otherAttrs}` : '';
988
- if (projected !== undefined && !consumedNames.has(name)) {
989
- consumedNames.add(name);
990
- result += `<slot data-webjs-light data-projection="actual"${nameAttr}${extraAttrs}>${projected}</slot>`;
1008
+ if (projected !== undefined && !consumedNames.has(slotKey)) {
1009
+ consumedNames.add(slotKey);
1010
+ result += `<slot data-webjs-light data-projection="actual"${ownerAttr}${nameAttr}${extraAttrs}>${projected}</slot>`;
991
1011
  } else {
992
- result += `<slot data-webjs-light data-projection="fallback"${nameAttr}${extraAttrs}>${fallback}</slot>`;
1012
+ result += `<slot data-webjs-light data-projection="fallback"${ownerAttr}${nameAttr}${extraAttrs}>${fallback}</slot>`;
993
1013
  }
994
1014
  cursor = totalEnd;
995
1015
  slotRe.lastIndex = totalEnd;