Skip to content

Commit fd4b892

Browse files
johnjenkinsJohn Jenkins
andauthored
fix(ssr): scoped: true components forwarded slots (#6340)
* fix(ssr): `scoped: true` slot forwarding * chore: fix tests --------- Co-authored-by: John Jenkins <[email protected]>
1 parent bfbd683 commit fd4b892

File tree

6 files changed

+102
-41
lines changed

6 files changed

+102
-41
lines changed

src/runtime/client-hydrate.ts

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,11 @@ export const initializeClientHydrate = (
130130
// If we don't, `vdom-render.ts` will try to add nodes to it (and because it may be a comment node, it will error)
131131
node['s-cr'] = hostElm['s-cr'];
132132
}
133-
} else if (childRenderNode.$tag$?.toString().includes('-') && !childRenderNode.$elm$.shadowRoot) {
133+
} else if (
134+
childRenderNode.$tag$?.toString().includes('-') &&
135+
childRenderNode.$tag$ !== 'slot-fb' &&
136+
!childRenderNode.$elm$.shadowRoot
137+
) {
134138
// if this child is a non-shadow component being added to a shadowDOM,
135139
// let's find and add its styles to the shadowRoot, so we don't get a visual flicker
136140
const cmpMeta = getHostRef(childRenderNode.$elm$);
@@ -215,6 +219,11 @@ export const initializeClientHydrate = (
215219

216220
const hostEle = hosts[slottedItem.hostId as any];
217221

222+
if (hostEle.shadowRoot && slottedItem.node.parentElement !== hostEle) {
223+
// shadowDOM - move the item to the element root for native slotting
224+
hostEle.appendChild(slottedItem.node);
225+
}
226+
218227
// This node is either slotted in a non-shadow host, OR *that* host is nested in a non-shadow host
219228
if (!hostEle.shadowRoot || !shadowRoot) {
220229
// Try to set an appropriate Content-position Reference (CR) node for this host element
@@ -235,16 +244,22 @@ export const initializeClientHydrate = (
235244
// Create our 'Original Location' node
236245
addSlotRelocateNode(slottedItem.node, slottedItem.slot, false, slottedItem.node['s-oo']);
237246

247+
if (
248+
slottedItem.node.parentElement?.shadowRoot &&
249+
slottedItem.node['getAttribute'] &&
250+
slottedItem.node.getAttribute('slot')
251+
) {
252+
// Remove the `slot` attribute from the slotted node:
253+
// if it's projected from a scoped component into a shadowRoot it's slot attribute will cause it to be hidden.
254+
// scoped components use the `s-sn` attribute to identify slotted nodes
255+
slottedItem.node.removeAttribute('slot');
256+
}
257+
238258
if (BUILD.experimentalSlotFixes) {
239259
// patch this node for accessors like `nextSibling` (et al)
240260
patchSlottedNode(slottedItem.node);
241261
}
242262
}
243-
244-
if (hostEle.shadowRoot && slottedItem.node.parentElement !== hostEle) {
245-
// shadowDOM - move the item to the element root for native slotting
246-
hostEle.appendChild(slottedItem.node);
247-
}
248263
}
249264
}
250265

@@ -693,22 +708,28 @@ const addSlottedNodes = (
693708
let slottedNode = slotNode.nextSibling as d.RenderNode;
694709
slottedNodes[slotNodeId as any] = slottedNodes[slotNodeId as any] || [];
695710

696-
// Looking for nodes that match this slot's name,
697-
// OR are text / comment nodes and the slot is a default slot (no name) - text / comments cannot be direct descendants of *named* slots.
698-
// Also ignore slot fallback nodes - they're not part of the lightDOM
699-
while (
700-
slottedNode &&
701-
(((slottedNode['getAttribute'] && slottedNode.getAttribute('slot')) || slottedNode['s-sn']) === slotName ||
702-
(slotName === '' &&
703-
!slottedNode['s-sn'] &&
704-
(slottedNode.nodeType === NODE_TYPE.CommentNode ||
705-
slottedNode.nodeType === NODE_TYPE.TextNode ||
706-
slottedNode.tagName === 'SLOT')))
707-
) {
708-
slottedNode['s-sn'] = slotName;
709-
slottedNodes[slotNodeId as any].push({ slot: slotNode, node: slottedNode, hostId });
710-
slottedNode = slottedNode.nextSibling as d.RenderNode;
711-
}
711+
// stop if we find another slot node (as subsequent nodes will belong to that slot)
712+
if (!slottedNode || slottedNode.nodeValue?.startsWith(SLOT_NODE_ID + '.')) return;
713+
714+
// Loop through the next siblings of the slot node, looking for nodes that match this slot's name
715+
do {
716+
if (
717+
slottedNode &&
718+
(((slottedNode['getAttribute'] && slottedNode.getAttribute('slot')) || slottedNode['s-sn']) === slotName ||
719+
(slotName === '' &&
720+
!slottedNode['s-sn'] &&
721+
(!slottedNode['getAttribute'] || !slottedNode.getAttribute('slot')) &&
722+
(slottedNode.nodeType === NODE_TYPE.CommentNode || slottedNode.nodeType === NODE_TYPE.TextNode)))
723+
) {
724+
// Looking for nodes that match this slot's name,
725+
// OR are text / comment nodes and the slot is a default slot (no name) - text / comments cannot be direct descendants of *named* slots.
726+
// Also ignore slot fallback nodes - they're not part of the lightDOM
727+
slottedNode['s-sn'] = slotName;
728+
slottedNodes[slotNodeId as any].push({ slot: slotNode, node: slottedNode, hostId });
729+
}
730+
slottedNode = slottedNode?.nextSibling as d.RenderNode;
731+
// continue *unless* we find another slot node (as subsequent nodes will belong to that slot)
732+
} while (slottedNode && !slottedNode.nodeValue?.startsWith(SLOT_NODE_ID + '.'));
712733
};
713734

714735
/**

src/runtime/test/hydrate-shadow-in-shadow.spec.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,16 +60,15 @@ describe('hydrate, shadow in shadow', () => {
6060
<mock:shadow-root>
6161
<slot></slot>
6262
</mock:shadow-root>
63+
<slot></slot>
6364
</cmp-b>
6465
</mock:shadow-root>
6566
light-dom
66-
<slot></slot>
6767
</cmp-a>
6868
`);
6969
expect(clientHydrated.root).toEqualLightHtml(`
7070
<cmp-a class="hydrated">
7171
light-dom
72-
<slot></slot>
7372
</cmp-a>
7473
`);
7574
});

test/wdio/ssr-hydration/cmp.test.tsx

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ describe('Sanity check SSR > Client hydration', () => {
424424
expect(nestedCmp.childNodes[1].textContent).toBe('after');
425425
});
426426

427-
it('renders slot nodes appropriately in a `scoped: true` child with `serializeShadowRoot: "scoped"` parent', async () => {
427+
it('adds nested scoped styles to parent shadow root', async () => {
428428
if (document.querySelector('#stage')) {
429429
document.querySelector('#stage')?.remove();
430430
await browser.waitUntil(async () => !document.querySelector('#stage'));
@@ -457,15 +457,11 @@ describe('Sanity check SSR > Client hydration', () => {
457457
expect(typeof customElements.get('shadow-ssr-parent-cmp')).toBe('function');
458458

459459
const wrapCmp = document.querySelector('shadow-ssr-parent-cmp');
460-
const nestedCmp = wrapCmp.shadowRoot.querySelector('scoped-ssr-child-cmp');
461-
expect(nestedCmp.childNodes.length).toBe(1);
462-
expect((nestedCmp.childNodes[0] as HTMLElement).tagName).toBe('SLOT');
463-
464460
// check that <style> tag for `scoped-cmp` gets added
465461
expect(wrapCmp.shadowRoot.querySelector('style[sty-id="sc-scoped-ssr-child-cmp"]')).toBeTruthy();
466462
});
467463

468-
it('slots nodes appropriately in a `scoped: true` parent with `serializeShadowRoot: "scoped"` child', async () => {
464+
it('scoped components forward slots into shadow components', async () => {
469465
if (document.querySelector('#stage')) {
470466
document.querySelector('#stage')?.remove();
471467
await browser.waitUntil(async () => !document.querySelector('#stage'));
@@ -474,11 +470,52 @@ describe('Sanity check SSR > Client hydration', () => {
474470
`
475471
<div>
476472
<scoped-ssr-parent-cmp>
473+
<!-- 1 -->
474+
2
475+
<div>3</div>
476+
<!-- 4 -->
477+
</scoped-ssr-parent-cmp
478+
</div>`,
479+
{
480+
fullDocument: true,
481+
serializeShadowRoot: 'scoped',
482+
},
483+
);
484+
const stage = document.createElement('div');
485+
stage.setAttribute('id', 'stage');
486+
stage.setHTMLUnsafe(html);
487+
document.body.appendChild(stage);
488+
489+
// @ts-expect-error resolved through WDIO
490+
const { defineCustomElements } = await import('/dist/loader/index.js');
491+
defineCustomElements().catch(console.error);
492+
493+
// wait for Stencil to take over and reconcile
494+
await browser.waitUntil(async () => customElements.get('shadow-ssr-parent-cmp'));
495+
expect(typeof customElements.get('scoped-ssr-parent-cmp')).toBe('function');
496+
497+
const wrapCmp = document.querySelector('scoped-ssr-parent-cmp');
498+
const children = wrapCmp.childNodes;
499+
// check that <style> tag for `scoped-cmp` gets added
500+
expect(children.length).toBe(4);
501+
expect(children[0].nodeValue).toBe(' 1 ');
502+
expect(children[1].textContent).toBe(' 2 ');
503+
expect(children[2].textContent).toBe('3');
504+
expect((children[2] as Element).checkVisibility()).toBe(true);
505+
expect(children[3].nodeValue).toBe(' 4 ');
506+
});
507+
508+
it('slots nodes appropriately in a `scoped: true` parent with `serializeShadowRoot: "scoped"` child', async () => {
509+
if (document.querySelector('#stage')) {
510+
document.querySelector('#stage')?.remove();
511+
await browser.waitUntil(async () => !document.querySelector('#stage'));
512+
}
513+
const { html } = await renderToString(
514+
`<scoped-ssr-parent-cmp>
477515
<div slot="things">one</div>
478516
<div slot="things">2</div>
479517
<div slot="things">3</div>
480-
</scoped-ssr-parent-cmp>
481-
</div>`,
518+
</scoped-ssr-parent-cmp>`,
482519
{
483520
fullDocument: true,
484521
serializeShadowRoot: 'scoped',
@@ -500,6 +537,9 @@ describe('Sanity check SSR > Client hydration', () => {
500537
const wrapCmp = document.querySelector('scoped-ssr-parent-cmp');
501538
expect(wrapCmp.childNodes.length).toBe(3);
502539
expect(wrapCmp.textContent).toBe('one23');
540+
expect(wrapCmp.children[0].checkVisibility()).toBe(true);
541+
expect(wrapCmp.children[1].checkVisibility()).toBe(true);
542+
expect(wrapCmp.children[2].checkVisibility()).toBe(true);
503543
});
504544

505545
it('correctly renders a slow to hydrate component with a prop', async () => {

test/wdio/ssr-hydration/order-cmp.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,8 @@ export class MyApp {
1414
render() {
1515
return (
1616
<Host>
17-
<div>
18-
<slot />
19-
</div>
17+
Order component. Shadow.
18+
<slot />
2019
</Host>
2120
);
2221
}

test/wdio/ssr-hydration/scoped-parent-cmp.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Component, h, Host } from '@stencil/core';
22

33
@Component({
44
tag: 'scoped-ssr-parent-cmp',
5-
shadow: true,
5+
scoped: true,
66
styles: `
77
:host {
88
display: block;
@@ -15,12 +15,11 @@ export class MyApp {
1515
return (
1616
<Host>
1717
<div>
18+
Scoped parent with named slot.
1819
<shadow-ssr-child-cmp>
20+
<slot />
1921
<slot name="things" />
2022
</shadow-ssr-child-cmp>
21-
<div>
22-
<slot />
23-
</div>
2423
</div>
2524
</Host>
2625
);

test/wdio/ssr-hydration/shadow-child-cmp.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Component, h, Host } from '@stencil/core';
22

33
@Component({
44
tag: 'shadow-ssr-child-cmp',
5-
scoped: true,
5+
shadow: true,
66
styles: `
77
:host {
88
display: block;
@@ -15,7 +15,10 @@ export class MyApp {
1515
return (
1616
<Host>
1717
<div>
18-
<slot />
18+
Shadow Child 1.
19+
<ssr-order-cmp>
20+
<slot />
21+
</ssr-order-cmp>
1922
</div>
2023
</Host>
2124
);

0 commit comments

Comments
 (0)