@webjsdev/core 0.7.40 → 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.40",
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
@@ -9,9 +9,11 @@ import {
9
9
  adoptSSRAssignments,
10
10
  ensureSlotState,
11
11
  hasSlotState,
12
- slotsView,
13
- hasSlotContent,
14
- setSlotContent,
12
+ hasFrameworkRenderedSubtree,
13
+ installSlotInterception,
14
+ installSlotSensors,
15
+ teardownSlotSensors,
16
+ reconnectSweep,
15
17
  } from './slot.js';
16
18
 
17
19
  const isBrowser = typeof window !== 'undefined' && typeof HTMLElement !== 'undefined';
@@ -843,17 +845,45 @@ class WebComponentBase extends Base {
843
845
  // c. First mount, no SSR. Partition authored children into the
844
846
  // record before _performRender wipes the host.
845
847
  //
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).
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.
849
852
  if (hasSlotState(this)) {
850
- // (a) Reconnection. Record already populated; nothing to do here.
851
- } 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');
852
873
  ensureSlotState(this);
853
874
  adoptSSRAssignments(this);
854
875
  } else {
876
+ // (c) First mount with author-written children. Capture them.
855
877
  captureAuthoredChildren(this);
856
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);
857
887
  }
858
888
 
859
889
  // Notify all controllers that the host is connected.
@@ -888,50 +918,13 @@ class WebComponentBase extends Base {
888
918
  } catch (err) {
889
919
  console.error(`[webjs] lifecycle hook threw during initial render:`, err);
890
920
  }
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().
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.
894
925
  });
895
926
  }
896
927
 
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
-
935
928
  /**
936
929
  * True when this host's first child is the framework's hydration
937
930
  * marker, meaning the SSR pipeline already rendered the template's
@@ -969,8 +962,11 @@ class WebComponentBase extends Base {
969
962
  this.__hydrationObserver.disconnect();
970
963
  this.__hydrationObserver = null;
971
964
  }
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.
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);
974
970
  // Dispose the signal watcher so dependency edges drop. Without
975
971
  // this the element holds references to module-scope signals
976
972
  // (and vice versa) forever.
@@ -10,11 +10,28 @@ import {
10
10
  PROJECTION_FALLBACK,
11
11
  SLOT_FALLBACK_FRAG,
12
12
  SLOT_STATE,
13
+ SLOT_OWNER,
14
+ RENDERING,
13
15
  applySlotAssignments,
14
16
  rescueAssignedNodes,
15
- ensureSlotState,
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
  /* ================================================================
@@ -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 };
@@ -665,9 +718,24 @@ function applyPart(part, value, _prev, allValues) {
665
718
  if (part.applied) break;
666
719
  const slotEl = part.slotEl;
667
720
  const finalize = () => {
668
- part.applied = true;
669
721
  const host = findSlotHost(slotEl);
670
- 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;
671
739
  // Shadow DOM: native projection. Leave fallback in place.
672
740
  if (isInShadowRootEl(slotEl)) return;
673
741
  // Light DOM: harvest the cloned fallback into a holding
@@ -681,7 +749,7 @@ function applyPart(part, value, _prev, allValues) {
681
749
  queueMicrotask(() => applySlotAssignments(host));
682
750
  };
683
751
  const directHost = findSlotHost(slotEl);
684
- if (directHost) {
752
+ if (directHost && directHost.contains(slotEl)) {
685
753
  finalize();
686
754
  } else {
687
755
  queueMicrotask(finalize);
@@ -703,6 +771,10 @@ function applyPart(part, value, _prev, allValues) {
703
771
  * @returns {Element | null}
704
772
  */
705
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;
706
778
  let p = slotEl.parentElement;
707
779
  while (p) {
708
780
  if (/** @type any */ (p)[SLOT_STATE]) return p;
@@ -819,6 +891,22 @@ function applyChild(part, value) {
819
891
  * @param {unknown} value
820
892
  */
821
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) {
822
910
  const marker = part.marker;
823
911
 
824
912
  // unsafeHTML directive: inject raw HTML string as DOM nodes.
@@ -1072,6 +1160,16 @@ function buildDetached(tr) {
1072
1160
  applyPart(bound[i], tr.values[i], undefined, tr.values);
1073
1161
  lastValues.push(tr.values[i]);
1074
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
+ }
1075
1173
  const outFrag = document.createDocumentFragment();
1076
1174
  outFrag.appendChild(startNode);
1077
1175
  while (frag.firstChild) outFrag.appendChild(frag.firstChild);
@@ -1516,6 +1614,35 @@ function applyCache(part, inner) {
1516
1614
  // Reconcile values so any state changes since detachment apply.
1517
1615
  updateInstance(cached.inst, tr.values);
1518
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);
1519
1646
  return;
1520
1647
  }
1521
1648
  }
@@ -1829,17 +1956,23 @@ async function consumeAsyncStream(state, part, dir) {
1829
1956
  const mapped = dir.mapper ? dir.mapper(result.value, i) : result.value;
1830
1957
  const newNodes = renderToNodes(mapped);
1831
1958
 
1832
- if (state.mode === 'replace') {
1833
- for (const n of state.nodes) {
1834
- 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 = [];
1835
1969
  }
1836
- state.nodes = [];
1837
- }
1838
1970
 
1839
- const frag = document.createDocumentFragment();
1840
- for (const n of newNodes) frag.appendChild(n);
1841
- marker.parentNode?.insertBefore(frag, marker);
1842
- 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
+ });
1843
1976
 
1844
1977
  i++;
1845
1978
  }
@@ -1867,6 +2000,15 @@ function renderToNodes(value) {
1867
2000
  for (let i = 0; i < tr.values.length; i++) {
1868
2001
  applyPart(bound[i], tr.values[i], undefined, tr.values);
1869
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
+ }
1870
2012
  return [...frag.childNodes];
1871
2013
  }
1872
2014
  if (Array.isArray(value)) {
@@ -8,7 +8,6 @@ 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';
12
11
 
13
12
  /**
14
13
  * Render a TemplateResult (or any renderable value) to an HTML string.
@@ -487,14 +486,12 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
487
486
  seedServerAttrs(instance, attrMap);
488
487
  applyAttrsToInstance(instance, attrMap, Cls);
489
488
  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).
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).
498
495
  let authoredInner = '';
499
496
  let closeEnd = m.index + match.length;
500
497
  if (!selfClose) {
@@ -515,20 +512,6 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
515
512
  }
516
513
  }
517
514
  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
- }
532
515
  // Run the pre-render lifecycle (willUpdate, controllers' hostUpdate,
533
516
  // then reflect reflect:true props) so derived state computed there is
534
517
  // correct in the SSR'd HTML, matching how lit runs the update cycle at
@@ -613,7 +596,7 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
613
596
  // 2. Recursively run injectDSD on the substituted output so
614
597
  // nested custom elements (inside projected children) get
615
598
  // their own DSD pass.
616
- const innerWithSlots = substituteSlotsInRender(rawInner, partitioned);
599
+ const innerWithSlots = substituteSlotsInRender(rawInner, partitioned, tag);
617
600
  const innerProcessed = await injectDSD(innerWithSlots, ctx, [...ancestors, instance], dev);
618
601
  edits.push({
619
602
  start: m.index,
@@ -967,11 +950,19 @@ function appendStringToMap(map, key, value) {
967
950
  * slots with the same name follow the first-wins rule per spec; later
968
951
  * same-named slots fall back regardless of available projection.
969
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
+ *
970
959
  * @param {string} rendered
971
960
  * @param {Map<string|null, string>} partitioned
961
+ * @param {string} ownerTag
972
962
  * @returns {string}
973
963
  */
974
- function substituteSlotsInRender(rendered, partitioned) {
964
+ function substituteSlotsInRender(rendered, partitioned, ownerTag) {
965
+ const ownerAttr = ownerTag ? ` data-wj-slot-owner="${escapeAttr(ownerTag)}"` : '';
975
966
  /** @type {Set<string|null>} */
976
967
  const consumedNames = new Set();
977
968
  let result = '';
@@ -1016,9 +1007,9 @@ function substituteSlotsInRender(rendered, partitioned) {
1016
1007
  const extraAttrs = otherAttrs ? ` ${otherAttrs}` : '';
1017
1008
  if (projected !== undefined && !consumedNames.has(slotKey)) {
1018
1009
  consumedNames.add(slotKey);
1019
- result += `<slot data-webjs-light data-projection="actual"${nameAttr}${extraAttrs}>${projected}</slot>`;
1010
+ result += `<slot data-webjs-light data-projection="actual"${ownerAttr}${nameAttr}${extraAttrs}>${projected}</slot>`;
1020
1011
  } else {
1021
- 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>`;
1022
1013
  }
1023
1014
  cursor = totalEnd;
1024
1015
  slotRe.lastIndex = totalEnd;