@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/dist/webjs-core-browser.js +3 -3
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +19 -19
- package/dist/webjs-core-testing.js.map +2 -2
- package/dist/webjs-core.js +5 -5
- package/dist/webjs-core.js.map +3 -3
- package/package.json +1 -1
- package/src/component.js +62 -28
- package/src/render-client.js +208 -53
- package/src/render-server.js +55 -35
- package/src/router-client.js +675 -324
- package/src/slot.js +1811 -389
- package/src/webjs-frame.js +1 -1
package/package.json
CHANGED
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
|
|
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.
|
|
828
|
+
// a. Reconnection. The record already exists from a prior mount.
|
|
824
829
|
// The host still carries the rendered template DOM (plus
|
|
825
|
-
//
|
|
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
|
|
836
|
+
// present). Children are already inside their
|
|
832
837
|
// <slot data-webjs-light data-projection="actual"> elements
|
|
833
|
-
//
|
|
834
|
-
// so we retain references to the SSR'd nodes;
|
|
835
|
-
// createInstance().replaceChildren() will
|
|
836
|
-
//
|
|
837
|
-
// the freshly-cloned slot. DOM identity
|
|
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.
|
|
841
|
-
//
|
|
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.
|
|
844
|
-
|
|
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
|
-
//
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
//
|
|
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
|
-
//
|
|
933
|
-
//
|
|
934
|
-
// reconnection picks up where it left off
|
|
935
|
-
|
|
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.
|
package/src/render-client.js
CHANGED
|
@@ -10,11 +10,28 @@ import {
|
|
|
10
10
|
PROJECTION_FALLBACK,
|
|
11
11
|
SLOT_FALLBACK_FRAG,
|
|
12
12
|
SLOT_STATE,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
|
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')
|
|
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)
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
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
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
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)) {
|
package/src/render-server.js
CHANGED
|
@@ -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
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
843
|
-
|
|
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
|
-
|
|
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(
|
|
989
|
-
consumedNames.add(
|
|
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;
|