Skip to content

Commit 595da6a

Browse files
committed
feat(core): SSR slot substitution in injectDSD
Fourth slice. Upgrades injectDSD to project authored children into <slot> positions during server-side rendering for light-DOM WebComponents, with full parity to the client-side projection rules and the shadow-DOM <slot> spec. When a light-DOM component's render() output contains <slot> tags, injectDSD now: 1. Finds the source HTML's matching closing tag for the custom element by walking forward with depth tracking for nested same-tag elements. 2. Extracts the authored inner HTML between the element's opening and closing tags. 3. Partitions the authored HTML by each top-level child's slot="" attribute. Text nodes, comment nodes, and elements without slot="" all route to the default-slot bucket. 4. Walks the rendered template's <slot> tags in document order and substitutes each with a framework-marked <slot data-webjs-light data-projection="actual"|"fallback"> element carrying either the projected children or the slot's authored fallback content. Multiple slots with the same name follow the first-wins rule per spec. 5. Emits one edit spanning the entire opening-to-closing range of the source element. Inner custom elements among authored children are processed via the recursive injectDSD call on the substituted output, not by the outer loop (a new sort + overlap filter drops the duplicate inner edits that were enumerated against the original html before substitution). When a component's render() output has NO <slot> tags, the old SSR shape is preserved unchanged: edit at the opening tag only, leave authored children adjacent to the rendered template, closing tag untouched. This keeps existing components that never used slots behaving exactly as before. Shadow DOM components are completely unaffected. Their native <slot> elements live inside the DSD <template shadowrootmode="open"> block and the browser handles projection from the host's light-DOM children into the shadow tree natively. No framework substitution there. New helpers in render-server.js: isVoidElement(tag) tag is a void element (br, img...). findClosingTagInString(html, ...) depth-tracked matching close tag. extractSlotAttr(attrsRaw) pulls slot="..." value or null. partitionAuthoredBySlot(html) groups authored inner HTML by slot. appendStringToMap(map, k, v) concatenating map insert helper. substituteSlotsInRender(...) walks <slot> tags, emits framework marker variants with projection or fallback content, first-wins per name across the document. End-to-end smoke (server-only): <my-card> <h2 slot="header">Title</h2> <p>Body</p> <span slot="footer">Foot</span> </my-card> renders to <my-card><!--webjs-hydrate--> <div class="card"> <header><slot data-webjs-light data-projection="actual" name="header"> <h2 slot="header">Title</h2> </slot></header> <main><slot data-webjs-light data-projection="actual"> <p>Body</p> </slot></main> <footer><slot data-webjs-light data-projection="actual" name="footer"> <span slot="footer">Foot</span> </slot></footer> </div> </my-card> Fallback content surfaces correctly when no children match; first-wins holds across duplicate same-named slots; shadow DOM passthrough is unchanged. 127 existing unit tests across component, render-client, render-server, directives, registry, css, html, context, task, suspense, repeat, testing, blog-smoke, json-negotiation, and light-dom-ssr all pass. What remains: the 62-case test suite (Task #14) and docs + convention rule (Task #15).
1 parent c69615b commit 595da6a

1 file changed

Lines changed: 300 additions & 15 deletions

File tree

packages/core/src/render-server.js

Lines changed: 300 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -254,44 +254,329 @@ async function injectDSD(html, ctx) {
254254
applyAttrsToInstance(instance, attrMap, Cls);
255255
let tpl = instance.render ? instance.render() : '';
256256
if (tpl && typeof tpl.then === 'function') tpl = await tpl;
257-
// Render the template to HTML, then recursively inject DSD for
258-
// any nested custom elements (e.g. <theme-toggle> inside <blog-shell>).
257+
// Render the template to HTML. injectDSD recurses on the result so
258+
// nested custom elements (e.g. <theme-toggle> inside <blog-shell>)
259+
// get their own DSD pass.
259260
const rawInner = await render(tpl, ctx);
260-
const inner = await injectDSD(rawInner, ctx);
261261

262262
if (isShadow) {
263-
// Shadow DOM: wrap in Declarative Shadow DOM template
264-
/** @type {any} */
263+
// Shadow DOM: native <slot> stays as-is in the DSD template. The
264+
// browser handles projection from the host's light-DOM children
265+
// into the shadow tree natively. No framework substitution here.
266+
const innerProcessed = await injectDSD(rawInner, ctx);
265267
const rawStyles = /** @type any */ (Cls).styles;
266268
const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
267269
const styleStr = stylesToString(styleList);
268270
edits.push({
269271
start: m.index,
270272
end: m.index + match.length,
271-
text: `${opening}<template shadowrootmode="open">${styleStr}${inner}</template>`,
273+
text: `${opening}<template shadowrootmode="open">${styleStr}${innerProcessed}</template>`,
272274
});
273275
} else {
274-
// Light DOM: render content directly as children, add hydration marker
275-
edits.push({
276-
start: m.index,
277-
end: m.index + match.length,
278-
text: `${opening}<!--webjs-hydrate-->${inner}`,
279-
});
276+
// Light DOM. Two sub-paths.
277+
const hasSlots = /<slot[\s>/]/i.test(rawInner);
278+
if (!hasSlots) {
279+
// Old path. No slot in the rendered template, so the SSR output
280+
// keeps the historical shape: edit at the opening tag only,
281+
// leave authored children adjacent to the rendered template,
282+
// closing tag untouched. Nested custom elements in the source
283+
// html are matched independently in the outer loop.
284+
const innerProcessed = await injectDSD(rawInner, ctx);
285+
edits.push({
286+
start: m.index,
287+
end: m.index + match.length,
288+
text: `${opening}<!--webjs-hydrate-->${innerProcessed}`,
289+
});
290+
} else {
291+
// Slot path. Extract the authored inner HTML from the source
292+
// (between this element's opening and closing tags), partition
293+
// it by slot="" attribute, substitute each <slot> tag in the
294+
// rendered template with projected (or fallback) content, and
295+
// emit one edit that spans the entire opening-to-closing range.
296+
// The recursive injectDSD call walks the substituted output so
297+
// nested custom elements inside projected children get their
298+
// own DSD pass. Inner matches already enumerated for this
299+
// element's authored span are dropped by the non-overlap
300+
// filter below.
301+
let authoredInner = '';
302+
let closeEnd = m.index + match.length;
303+
if (!selfClose) {
304+
const innerStart = m.index + match.length;
305+
const closeIdx = findClosingTagInString(html, innerStart, tag);
306+
if (closeIdx !== -1) {
307+
authoredInner = html.slice(innerStart, closeIdx);
308+
const closeRe = new RegExp(`</${escapeRegex(tag)}\\s*>`, 'i');
309+
const tail = html.slice(closeIdx);
310+
const closeMatch = closeRe.exec(tail);
311+
const closeLen = closeMatch ? closeMatch[0].length : `</${tag}>`.length;
312+
closeEnd = closeIdx + closeLen;
313+
} else {
314+
// Unclosed in source. Consume the rest as authored content
315+
// and synthesize the closing tag on output.
316+
authoredInner = html.slice(innerStart);
317+
closeEnd = html.length;
318+
}
319+
}
320+
const partitioned = partitionAuthoredBySlot(authoredInner);
321+
const innerWithSlots = substituteSlotsInRender(rawInner, partitioned);
322+
const innerProcessed = await injectDSD(innerWithSlots, ctx);
323+
edits.push({
324+
start: m.index,
325+
end: closeEnd,
326+
text: `${opening}<!--webjs-hydrate-->${innerProcessed}</${tag}>`,
327+
});
328+
}
280329
}
281330
} catch (e) {
282331
console.error(`[webjs] SSR failed for <${tag}>:`, e);
283332
}
284333
}
285334
if (!edits.length) return html;
286-
// Apply edits from last to first to keep indices stable.
335+
336+
// Drop edits whose range lives inside an earlier edit's range. This
337+
// happens when an outer custom element with <slot> in its render takes
338+
// an edit that spans its opening + closing tags (covering inner custom
339+
// elements among authored children); the inner matches were enumerated
340+
// independently against the original html, but those inner elements
341+
// are processed by the recursive injectDSD call on innerWithSlots.
342+
// Keeping both edits would double-process them and corrupt the output.
343+
edits.sort((a, b) => a.start - b.start);
344+
/** @type {{start:number, end:number, text:string}[]} */
345+
const filtered = [];
346+
let consumedTo = -1;
347+
for (const e of edits) {
348+
if (e.start >= consumedTo) {
349+
filtered.push(e);
350+
consumedTo = e.end;
351+
}
352+
}
353+
// Apply edits from last to first so indices stay stable.
287354
let out = html;
288-
for (let i = edits.length - 1; i >= 0; i--) {
289-
const { start, end, text } = edits[i];
355+
for (let i = filtered.length - 1; i >= 0; i--) {
356+
const { start, end, text } = filtered[i];
290357
out = out.slice(0, start) + text + out.slice(end);
291358
}
292359
return out;
293360
}
294361

362+
// ---------------------------------------------------------------------------
363+
// Slot SSR helpers
364+
// ---------------------------------------------------------------------------
365+
366+
const VOID_ELEMENTS = new Set([
367+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
368+
'link', 'meta', 'param', 'source', 'track', 'wbr',
369+
]);
370+
371+
/** @param {string} tag @returns {boolean} */
372+
function isVoidElement(tag) {
373+
return VOID_ELEMENTS.has(tag.toLowerCase());
374+
}
375+
376+
/**
377+
* Find the position of the matching closing tag for `tagName` starting
378+
* from `fromIndex` in `html`. Handles nested same-tag elements via depth
379+
* tracking. Returns the index of the `<` of `</tagName>`, or -1 if
380+
* unclosed.
381+
*
382+
* @param {string} html
383+
* @param {number} fromIndex
384+
* @param {string} tagName
385+
* @returns {number}
386+
*/
387+
function findClosingTagInString(html, fromIndex, tagName) {
388+
const esc = escapeRegex(tagName);
389+
// Match same-name opening tags. Followed by a name-boundary character
390+
// so we don't accept <table> as opening <tab>.
391+
const openRe = new RegExp(`<${esc}(?:[\\s>/])`, 'gi');
392+
const closeRe = new RegExp(`</${esc}\\s*>`, 'gi');
393+
openRe.lastIndex = fromIndex;
394+
closeRe.lastIndex = fromIndex;
395+
let depth = 1;
396+
while (depth > 0) {
397+
const o = openRe.exec(html);
398+
const c = closeRe.exec(html);
399+
if (!c) return -1;
400+
if (o && o.index < c.index) {
401+
depth++;
402+
closeRe.lastIndex = o.index + 1;
403+
} else {
404+
depth--;
405+
if (depth === 0) return c.index;
406+
openRe.lastIndex = c.index + 1;
407+
}
408+
}
409+
return -1;
410+
}
411+
412+
/**
413+
* Extract the `slot` attribute value from an attribute string. Returns
414+
* null when the attribute is absent.
415+
*
416+
* @param {string} attrsRaw
417+
* @returns {string | null}
418+
*/
419+
function extractSlotAttr(attrsRaw) {
420+
const m = /\bslot\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
421+
if (!m) return null;
422+
return m[1] ?? m[2] ?? m[3] ?? null;
423+
}
424+
425+
/**
426+
* Partition authored inner HTML by each top-level child's `slot=""`
427+
* attribute. Text nodes, comment nodes, and elements without `slot=""`
428+
* all route to the default-slot key (null).
429+
*
430+
* Returns a Map keyed by slot name (null for default) whose values are
431+
* the concatenated HTML strings for that slot in source order.
432+
*
433+
* @param {string} html
434+
* @returns {Map<string|null, string>}
435+
*/
436+
function partitionAuthoredBySlot(html) {
437+
/** @type {Map<string|null, string>} */
438+
const groups = new Map();
439+
let defaultBuf = '';
440+
let cursor = 0;
441+
while (cursor < html.length) {
442+
const lt = html.indexOf('<', cursor);
443+
if (lt === -1) {
444+
defaultBuf += html.slice(cursor);
445+
break;
446+
}
447+
if (lt > cursor) defaultBuf += html.slice(cursor, lt);
448+
const rest = html.slice(lt);
449+
if (rest.startsWith('<!--')) {
450+
const end = html.indexOf('-->', lt + 4);
451+
if (end === -1) {
452+
defaultBuf += rest;
453+
cursor = html.length;
454+
break;
455+
}
456+
defaultBuf += html.slice(lt, end + 3);
457+
cursor = end + 3;
458+
continue;
459+
}
460+
if (rest.startsWith('<!') || rest.startsWith('</')) {
461+
const end = html.indexOf('>', lt);
462+
if (end === -1) {
463+
defaultBuf += rest;
464+
cursor = html.length;
465+
break;
466+
}
467+
defaultBuf += html.slice(lt, end + 1);
468+
cursor = end + 1;
469+
continue;
470+
}
471+
const tagMatch = /^<([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>])*?)(\/?)>/.exec(rest);
472+
if (!tagMatch) {
473+
defaultBuf += '<';
474+
cursor = lt + 1;
475+
continue;
476+
}
477+
const [tagFull, tagName, attrsRaw, selfCloseSlash] = tagMatch;
478+
const lower = tagName.toLowerCase();
479+
const isSelfClose = !!selfCloseSlash || isVoidElement(lower);
480+
const slotAttr = extractSlotAttr(attrsRaw);
481+
let elemEnd;
482+
if (isSelfClose) {
483+
elemEnd = lt + tagFull.length;
484+
} else {
485+
const innerStart = lt + tagFull.length;
486+
const closeIdx = findClosingTagInString(html, innerStart, lower);
487+
if (closeIdx === -1) {
488+
// Unclosed element. Take to end of html.
489+
const elementHTML = html.slice(lt);
490+
if (slotAttr !== null) appendStringToMap(groups, slotAttr, elementHTML);
491+
else defaultBuf += elementHTML;
492+
cursor = html.length;
493+
continue;
494+
}
495+
const closeRe = new RegExp(`</${escapeRegex(lower)}\\s*>`, 'i');
496+
const tail = html.slice(closeIdx);
497+
const closeMatch = closeRe.exec(tail);
498+
const closeLen = closeMatch ? closeMatch[0].length : `</${lower}>`.length;
499+
elemEnd = closeIdx + closeLen;
500+
}
501+
const elementHTML = html.slice(lt, elemEnd);
502+
if (slotAttr !== null) appendStringToMap(groups, slotAttr, elementHTML);
503+
else defaultBuf += elementHTML;
504+
cursor = elemEnd;
505+
}
506+
if (defaultBuf.length > 0) groups.set(null, defaultBuf);
507+
return groups;
508+
}
509+
510+
/** Append a string to a Map<K, string>, concatenating if the key exists. */
511+
function appendStringToMap(map, key, value) {
512+
const existing = map.get(key);
513+
if (existing !== undefined) map.set(key, existing + value);
514+
else map.set(key, value);
515+
}
516+
517+
/**
518+
* Substitute every `<slot>` tag in `rendered` with a framework-marked
519+
* `<slot data-webjs-light data-projection="actual|fallback">` element
520+
* carrying either the projected children for that slot (from
521+
* `partitioned`) or the slot's authored fallback content. Multiple
522+
* slots with the same name follow the first-wins rule per spec; later
523+
* same-named slots fall back regardless of available projection.
524+
*
525+
* @param {string} rendered
526+
* @param {Map<string|null, string>} partitioned
527+
* @returns {string}
528+
*/
529+
function substituteSlotsInRender(rendered, partitioned) {
530+
/** @type {Set<string|null>} */
531+
const consumedNames = new Set();
532+
let result = '';
533+
let cursor = 0;
534+
const slotRe = /<slot((?:"[^"]*"|'[^']*'|[^>])*?)(\/?)>/gi;
535+
let m;
536+
while ((m = slotRe.exec(rendered)) !== null) {
537+
result += rendered.slice(cursor, m.index);
538+
const [fullOpen, attrsRaw, selfCloseSlash] = m;
539+
const isSelfClose = !!selfCloseSlash;
540+
const nameMatch = /\bname\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
541+
const name = nameMatch ? (nameMatch[1] ?? nameMatch[2] ?? nameMatch[3]) : null;
542+
// Strip the `name` attribute from the carried-through attribute
543+
// string so we can re-add it (with escaping) on the framework slot.
544+
const otherAttrs = attrsRaw.replace(/\bname\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/i, '').trim();
545+
let fallback = '';
546+
let totalEnd;
547+
if (isSelfClose) {
548+
totalEnd = m.index + fullOpen.length;
549+
} else {
550+
const innerStart = m.index + fullOpen.length;
551+
const closeIdx = findClosingTagInString(rendered, innerStart, 'slot');
552+
if (closeIdx === -1) {
553+
fallback = rendered.slice(innerStart);
554+
totalEnd = rendered.length;
555+
} else {
556+
fallback = rendered.slice(innerStart, closeIdx);
557+
const closeRe = /<\/slot\s*>/i;
558+
const tail = rendered.slice(closeIdx);
559+
const closeMatch = closeRe.exec(tail);
560+
const closeLen = closeMatch ? closeMatch[0].length : '</slot>'.length;
561+
totalEnd = closeIdx + closeLen;
562+
}
563+
}
564+
const projected = partitioned.get(name);
565+
const nameAttr = name !== null ? ` name="${escapeAttr(name)}"` : '';
566+
const extraAttrs = otherAttrs ? ` ${otherAttrs}` : '';
567+
if (projected !== undefined && !consumedNames.has(name)) {
568+
consumedNames.add(name);
569+
result += `<slot data-webjs-light data-projection="actual"${nameAttr}${extraAttrs}>${projected}</slot>`;
570+
} else {
571+
result += `<slot data-webjs-light data-projection="fallback"${nameAttr}${extraAttrs}>${fallback}</slot>`;
572+
}
573+
cursor = totalEnd;
574+
slotRe.lastIndex = totalEnd;
575+
}
576+
result += rendered.slice(cursor);
577+
return result;
578+
}
579+
295580
/** @param {string} s */
296581
function escapeRegex(s) {
297582
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

0 commit comments

Comments
 (0)