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