Skip to content

Commit c69615b

Browse files
committed
feat(core): slot lifecycle in WebComponent + per-instance fallback clone
Third slice. Wires the slot runtime into the WebComponent lifecycle and refines render-client.js's slot bind so fallback content is captured once at compile time and cloned freshly per instance. component.js: - Light-DOM activation now runs in two phases: Phase 1 (before _performRender): if not hydrating, call captureAuthoredChildren(host) to move authored children into the slot state's assignment table. The renderer's replaceChildren() call would otherwise destroy them on first render. Phase 2 (after _performRender, slots are now live DOM): if the host was hydrating, call adoptSSRAssignments(host) to record SSR-placed children so the first projection pass is a no-op. Then call attachSlotObservers(host) so future authored-child mutations and slot-name changes drive incremental projection. - New __isHydrating() helper inspects this.firstChild for the framework's <!--webjs-hydrate--> marker and records the result on __hydratedAtActivate so phase two can branch correctly. - disconnectedCallback now calls detachSlotObservers(host) for light-DOM hosts. The per-host slot state (assignment table, pending fragments, last snapshots) is preserved across disconnect, so a re-attached element picks up where it left off. render-client.js: - discoverSlots() now MOVES the slot's authored children into a fallbackTemplate DocumentFragment stored on the SLOT PartDescriptor. The cached templateEl's slot becomes empty, so every clone starts empty too. This eliminates the cloning-the- fallback-out-of-the-slot dance the previous bind step did. - bindPart for slot now clones the descriptor's fallbackTemplate into a per-instance holding fragment and stamps it on the slot via SLOT_FALLBACK_FRAG for slot.js to swap in. The slot itself is left untouched at bind time, which makes hydration trivially correct (SSR-projected children stay in place; the slot-part just sets up its fallback supply for later transitions). - PartDescriptor typedef gains an optional fallbackTemplate field. Verified across 115 existing core unit tests (component, render-client, render-server, directives, registry, css, html, context, task, suspense, repeat, testing). All pass. What remains: render-server.js injectDSD upgrade for SSR slot substitution (Task #13), then the 62-case test suite (Task #14), then docs (Task #15).
1 parent 3258888 commit c69615b

2 files changed

Lines changed: 94 additions & 11 deletions

File tree

packages/core/src/component.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { render as clientRender } from './render-client.js';
22
import { isCSS, adoptStyles } from './css.js';
33
import { register, tagOf } from './registry.js';
4+
import {
5+
captureAuthoredChildren,
6+
adoptSSRAssignments,
7+
attachSlotObservers,
8+
detachSlotObservers,
9+
ensureSlotState,
10+
} from './slot.js';
411

512
const isBrowser = typeof window !== 'undefined' && typeof HTMLElement !== 'undefined';
613

@@ -381,6 +388,22 @@ export class WebComponent extends Base {
381388
`For light DOM, use global CSS or <style> in render().`
382389
);
383390
}
391+
// Light-DOM slot lifecycle phase one. Capture authored children
392+
// into the slot-state's assignment table BEFORE _performRender
393+
// runs, since the renderer's first clientRender call will
394+
// replaceChildren() on the host and would otherwise destroy them.
395+
//
396+
// Detect SSR hydration via the framework's <!--webjs-hydrate-->
397+
// marker: in that case the SSR pipeline already placed projected
398+
// children inside <slot data-webjs-light data-projection="actual">
399+
// elements, so we ensure slot state exists (so findSlotHost
400+
// succeeds) and defer to adoptSSRAssignments after the renderer
401+
// has hydrated the template. Otherwise capture in place.
402+
if (this.__isHydrating()) {
403+
ensureSlotState(this);
404+
} else {
405+
captureAuthoredChildren(this);
406+
}
384407
}
385408

386409
// Notify all controllers that the host is connected.
@@ -393,6 +416,41 @@ export class WebComponent extends Base {
393416
// light DOM, existing shadow root for shadow DOM) and hydrates
394417
// instead of replacing: binding events without touching the DOM.
395418
this._performRender();
419+
420+
// Light-DOM slot lifecycle phase two. With the rendered template
421+
// (and therefore the live <slot> elements) now in the DOM, hook up
422+
// the mutation observers so future authored-child mutations and
423+
// slot-name changes drive incremental projection. For hydrated
424+
// hosts, also adopt the SSR-placed slot assignments so the first
425+
// projection pass is a no-op.
426+
if (this._renderRoot === this) {
427+
if (this.__hydratedAtActivate) {
428+
adoptSSRAssignments(this);
429+
this.__hydratedAtActivate = false;
430+
}
431+
attachSlotObservers(this);
432+
}
433+
}
434+
435+
/**
436+
* True when this host's first child is the framework's hydration
437+
* marker, meaning the SSR pipeline already rendered the template's
438+
* shape (including <slot data-webjs-light> elements with their
439+
* projected children inside) and the client should bind events
440+
* without re-creating DOM. Sets __hydratedAtActivate as a side
441+
* effect so the post-render path picks up the SSR assignment table.
442+
*
443+
* @returns {boolean}
444+
* @private
445+
*/
446+
__isHydrating() {
447+
const first = this.firstChild;
448+
const isHydrate =
449+
first != null &&
450+
first.nodeType === 8 &&
451+
/** @type {Comment} */ (first).data === 'webjs-hydrate';
452+
if (isHydrate) this.__hydratedAtActivate = true;
453+
return isHydrate;
396454
}
397455

398456

@@ -411,6 +469,10 @@ export class WebComponent extends Base {
411469
this.__hydrationObserver.disconnect();
412470
this.__hydrationObserver = null;
413471
}
472+
// Pause slot observers. The per-host state (assignment table,
473+
// pending fragments, last snapshots) is preserved so a subsequent
474+
// reconnection picks up where it left off.
475+
if (this._renderRoot === this) detachSlotObservers(this);
414476
for (const c of this.__controllers) {
415477
if (c.onUnmount) c.onUnmount();
416478
}

packages/core/src/render-client.js

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const INSTANCE = Symbol.for('webjs.instance');
5050
* name?: string,
5151
* statics?: string[],
5252
* group?: number[],
53+
* fallbackTemplate?: DocumentFragment,
5354
* }} PartDescriptor
5455
*
5556
* @typedef {{
@@ -348,12 +349,18 @@ function compile(tr) {
348349
* recognise it as a framework-managed light-DOM slot.
349350
* 2. Add a sentinel attribute (`data-MARKER<idx>`) so the subsequent
350351
* assignPaths walk records the slot's path into the new SLOT part.
351-
* 3. Push a SLOT part descriptor onto the parts list.
352+
* 3. Move the slot's authored children into a `fallbackTemplate`
353+
* DocumentFragment stored on the PartDescriptor. The slot in the
354+
* cached template becomes empty, so every clone starts empty too.
355+
* bindPart clones a fresh fallback fragment per instance from this
356+
* template, giving each instance an independent fallback supply
357+
* that slot.js swaps in via the SLOT_FALLBACK_FRAG symbol.
352358
*
353-
* The slot's authored inner content stays in place in the template; it
354-
* becomes the fallback content cloned along with the rest of the template
355-
* on every instantiation. The slot-part's bind step at createInstance
356-
* moves those cloned nodes into a holding fragment owned by the part.
359+
* Fallback content with template holes (`<slot>fallback ${x}</slot>`)
360+
* is captured as a static-HTML snapshot of the template state at
361+
* compile time. Dynamic holes inside fallback content are not
362+
* re-bound per instance in v1; authors should put dynamic content
363+
* outside the slot.
357364
*
358365
* @param {DocumentFragment} root
359366
* @param {PartDescriptor[]} parts
@@ -364,7 +371,9 @@ function discoverSlots(root, parts) {
364371
slot.setAttribute(LIGHT_SLOT_ATTR, '');
365372
const partIdx = parts.length;
366373
slot.setAttribute(`data-${MARKER}${partIdx}`, '');
367-
parts.push({ kind: 'slot', path: [] });
374+
const fallbackTemplate = document.createDocumentFragment();
375+
while (slot.firstChild) fallbackTemplate.appendChild(slot.firstChild);
376+
parts.push({ kind: 'slot', path: [], fallbackTemplate });
368377
}
369378
}
370379

@@ -483,12 +492,24 @@ function bindPart(p, root) {
483492
if (p.kind === 'bool') return { kind: 'bool', el, name: p.name || '' };
484493
if (p.kind === 'slot') {
485494
const slotEl = /** @type {HTMLSlotElement} */ (el);
486-
// Move the slot's fallback content (cloned from the template) into a
487-
// holding fragment that slot.js can swap back in when the slot
488-
// transitions to data-projection="fallback". slot.js looks this up
489-
// via the SLOT_FALLBACK_FRAG symbol on the slot element.
495+
// Build a per-instance holding fragment by cloning the PartDescriptor's
496+
// fallback template (captured once at compile time). slot.js swaps
497+
// this fragment in via SLOT_FALLBACK_FRAG when projection state
498+
// transitions to "fallback". The cloned slot in `root` starts empty
499+
// (discoverSlots() moved the original children to the template), so
500+
// there is nothing to extract from the slot itself.
501+
//
502+
// Hydration case: if the slot already carries data-projection="actual"
503+
// from the SSR pipeline, its children are the SSR-projected nodes and
504+
// must be left in place. The component lifecycle (component.js) calls
505+
// adoptSSRAssignments() to record those children in the host state
506+
// so the first projection pass is a no-op.
490507
const frag = document.createDocumentFragment();
491-
while (slotEl.firstChild) frag.appendChild(slotEl.firstChild);
508+
if (p.fallbackTemplate) {
509+
for (const node of p.fallbackTemplate.childNodes) {
510+
frag.appendChild(node.cloneNode(true));
511+
}
512+
}
492513
/** @type {any} */ (slotEl)[SLOT_FALLBACK_FRAG] = frag;
493514
return { kind: 'slot', slotEl, applied: false };
494515
}

0 commit comments

Comments
 (0)