Skip to content

Commit b07dda6

Browse files
johnjenkinsJohn Jenkinschristian-bromann
authored
fix(ssr): fixes for scoped: true components during SSR (#6311)
* fix(ssr): fixed for `scoped: true` components during SSR * chore: prettier * chore: added tests * Update src/runtime/client-hydrate.ts --------- Co-authored-by: John Jenkins <[email protected]> Co-authored-by: Christian Bromann <[email protected]>
1 parent 76656f6 commit b07dda6

File tree

10 files changed

+248
-29
lines changed

10 files changed

+248
-29
lines changed

src/runtime/client-hydrate.ts

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { BUILD } from '@app-data';
2-
import { plt, win } from '@platform';
2+
import { getHostRef, plt, win } from '@platform';
33
import { parsePropertyValue } from '@runtime';
44
import { CMP_FLAGS, MEMBER_FLAGS } from '@utils';
55

66
import type * as d from '../declarations';
7-
import { patchSlottedNode } from './dom-extras';
7+
import { internalCall, patchSlottedNode } from './dom-extras';
88
import { createTime } from './profile';
99
import {
1010
COMMENT_NODE_ID,
@@ -18,6 +18,7 @@ import {
1818
VNODE_FLAGS,
1919
} from './runtime-constants';
2020
import { addSlotRelocateNode, patchSlotNode } from './slot-polyfill-utils';
21+
import { getScopeId } from './styles';
2122
import { newVNode } from './vdom/h';
2223

2324
/**
@@ -129,6 +130,19 @@ export const initializeClientHydrate = (
129130
// 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)
130131
node['s-cr'] = hostElm['s-cr'];
131132
}
133+
} else if (childRenderNode.$tag$?.toString().includes('-') && !childRenderNode.$elm$.shadowRoot) {
134+
// if this child is a non-shadow component being added to a shadowDOM,
135+
// let's find and add its styles to the shadowRoot, so we don't get a visual flicker
136+
const cmpMeta = getHostRef(childRenderNode.$elm$);
137+
const scopeId = getScopeId(
138+
cmpMeta.$cmpMeta$,
139+
BUILD.mode ? childRenderNode.$elm$.getAttribute('s-mode') : undefined,
140+
);
141+
const styleSheet = win.document.querySelector(`style[sty-id="${scopeId}"]`);
142+
143+
if (styleSheet) {
144+
hostElm.shadowRoot.append(styleSheet.cloneNode(true));
145+
}
132146
}
133147

134148
if (childRenderNode.$tag$ === 'slot') {
@@ -150,7 +164,7 @@ export const initializeClientHydrate = (
150164
}
151165

152166
if (orgLocationNode && orgLocationNode.isConnected) {
153-
if (shadowRoot && orgLocationNode['s-en'] === '') {
167+
if (orgLocationNode.parentElement.shadowRoot && orgLocationNode['s-en'] === '') {
154168
// if this node is within a shadowDOM, with an original location home
155169
// we're safe to move it now
156170
orgLocationNode.parentNode.insertBefore(node, orgLocationNode.nextSibling);
@@ -166,7 +180,9 @@ export const initializeClientHydrate = (
166180
}
167181
}
168182
// Remove the original location from the map
169-
plt.$orgLocNodes$.delete(orgLocationId);
183+
if (orgLocationNode && !orgLocationNode['s-id']) {
184+
plt.$orgLocNodes$.delete(orgLocationId);
185+
}
170186
}
171187

172188
const hosts: d.HostElement[] = [];
@@ -203,16 +219,18 @@ export const initializeClientHydrate = (
203219
if (!hostEle.shadowRoot || !shadowRoot) {
204220
// Try to set an appropriate Content-position Reference (CR) node for this host element
205221

206-
// Is a CR already set on the host?
207-
slottedItem.slot['s-cr'] = hostEle['s-cr'];
208-
209-
if (!slottedItem.slot['s-cr'] && hostEle.shadowRoot) {
210-
// Host has shadowDOM - just use the host itself as the CR for native slotting
211-
slottedItem.slot['s-cr'] = hostEle;
212-
} else {
213-
// If all else fails - just set the CR as the first child
214-
// (9/10 if node['s-cr'] hasn't been set, the node will be at the element root)
215-
slottedItem.slot['s-cr'] = ((hostEle as any).__childNodes || hostEle.childNodes)[0];
222+
if (!slottedItem.slot['s-cr']) {
223+
// Is a CR already set on the host?
224+
slottedItem.slot['s-cr'] = hostEle['s-cr'];
225+
226+
if (!slottedItem.slot['s-cr'] && hostEle.shadowRoot) {
227+
// Host has shadowDOM - just use the host itself as the CR for native slotting
228+
slottedItem.slot['s-cr'] = hostEle;
229+
} else {
230+
// If all else fails - just set the CR as the first child
231+
// (9/10 if node['s-cr'] hasn't been set, the node will be at the element root)
232+
slottedItem.slot['s-cr'] = ((hostEle as any).__childNodes || hostEle.childNodes)[0];
233+
}
216234
}
217235
// Create our 'Original Location' node
218236
addSlotRelocateNode(slottedItem.node, slottedItem.slot, false, slottedItem.node['s-oo']);
@@ -237,7 +255,7 @@ export const initializeClientHydrate = (
237255
});
238256
}
239257

240-
if (BUILD.shadowDom && shadowRoot && !shadowRoot.childNodes.length) {
258+
if (BUILD.shadowDom && shadowRoot) {
241259
// For `scoped` shadowDOM rendering (not DSD);
242260
// Add all the root nodes in the shadowDOM (a root node can have a whole nested DOM tree)
243261
let rnIdex = 0;
@@ -255,7 +273,7 @@ export const initializeClientHydrate = (
255273
// we can safely leave it be, native behavior will mean it's hidden
256274
(node as HTMLElement).removeAttribute('hidden');
257275
} else if (
258-
node.nodeType === NODE_TYPE.CommentNode ||
276+
(node.nodeType === NODE_TYPE.CommentNode && !node.nodeValue) ||
259277
(node.nodeType === NODE_TYPE.TextNode && !(node as Text).wholeText.trim())
260278
) {
261279
// During `scoped` shadowDOM rendering, there's a bunch of comment nodes used for positioning / empty text nodes.
@@ -267,7 +285,6 @@ export const initializeClientHydrate = (
267285
}
268286
}
269287

270-
plt.$orgLocNodes$.delete(hostElm['s-id']);
271288
hostRef.$hostElement$ = hostElm;
272289
endHydrate();
273290
};
@@ -336,7 +353,7 @@ const clientHydrate = (
336353
parentVNode.$children$ = [];
337354
}
338355

339-
if (BUILD.scoped && scopeId) {
356+
if (BUILD.scoped && scopeId && childIdSplt[0] === hostId) {
340357
// Host is `scoped: true` - add that flag to the child.
341358
// It's used in 'set-accessor.ts' to make sure our scoped class is present
342359
node['s-si'] = scopeId;
@@ -509,7 +526,7 @@ const clientHydrate = (
509526
vnode.$index$ = '0';
510527
parentVNode.$children$ = [vnode];
511528
} else {
512-
if (node.nodeType === NODE_TYPE.TextNode && !(node as unknown as Text).wholeText.trim()) {
529+
if (node.nodeType === NODE_TYPE.TextNode && !(node as unknown as Text).wholeText.trim() && !node['s-nr']) {
513530
// empty white space is never accounted for from SSR so there's
514531
// no corresponding comment node giving it a position in the DOM.
515532
// It therefore gets slotted / clumped together at the end of the host.
@@ -613,13 +630,13 @@ function addSlot(
613630
childVNode.$elm$.setAttribute('name', slotName);
614631
}
615632

616-
if (parentNodeId && parentNodeId !== childVNode.$hostId$) {
633+
if (parentVNode.$elm$.shadowRoot && parentNodeId && parentNodeId !== childVNode.$hostId$) {
617634
// Shadow component's slot is placed inside a nested component's shadowDOM; it doesn't belong to this host - it was forwarded by the SSR markup.
618635
// Insert it in the root of this host; it's lightDOM. It doesn't really matter where in the host root; the component will take care of it.
619-
parentVNode.$elm$.insertBefore(slot, parentVNode.$elm$.children[0]);
636+
internalCall(parentVNode.$elm$, 'insertBefore')(slot, internalCall(parentVNode.$elm$, 'children')[0]);
620637
} else {
621638
// Insert the new slot element before the slot comment
622-
node.parentNode.insertBefore(slot, node);
639+
internalCall(internalCall(node, 'parentNode') as d.RenderNode, 'insertBefore')(slot, node);
623640
}
624641
addSlottedNodes(slottedNodes, slotId, slotName, node, childVNode.$hostId$);
625642

@@ -684,8 +701,9 @@ const addSlottedNodes = (
684701
(((slottedNode['getAttribute'] && slottedNode.getAttribute('slot')) || slottedNode['s-sn']) === slotName ||
685702
(slotName === '' &&
686703
!slottedNode['s-sn'] &&
687-
((slottedNode.nodeType === NODE_TYPE.CommentNode && slottedNode.nodeValue.indexOf('.') !== 1) ||
688-
slottedNode.nodeType === NODE_TYPE.TextNode)))
704+
(slottedNode.nodeType === NODE_TYPE.CommentNode ||
705+
slottedNode.nodeType === NODE_TYPE.TextNode ||
706+
slottedNode.tagName === 'SLOT')))
689707
) {
690708
slottedNode['s-sn'] = slotName;
691709
slottedNodes[slotNodeId as any].push({ slot: slotNode, node: slottedNode, hostId });

src/runtime/connected-callback.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ export const connectedCallback = (elm: d.HostElement) => {
5757
cmpMeta.$flags$ & (CMP_FLAGS.hasSlotRelocation | CMP_FLAGS.needsShadowDomShim))
5858
) {
5959
setContentReference(elm);
60+
} else if (BUILD.hydrateClientSide && !(cmpMeta.$flags$ & CMP_FLAGS.hasSlotRelocation)) {
61+
const commendPlaceholder = elm.firstChild as d.RenderNode;
62+
if (
63+
commendPlaceholder?.nodeType === NODE_TYPE.CommentNode &&
64+
!commendPlaceholder['s-cn'] &&
65+
!commendPlaceholder.nodeValue
66+
) {
67+
// if the first child is a comment node that was created by the
68+
// setContentReference() function during SSR, remove it now as
69+
// this component does not need slot relocation and can cause hydration issues
70+
elm.removeChild(commendPlaceholder);
71+
}
6072
}
6173
}
6274

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,16 @@ describe('hydrate, shadow in shadow', () => {
6060
<mock:shadow-root>
6161
<slot></slot>
6262
</mock:shadow-root>
63-
<slot></slot>
6463
</cmp-b>
6564
</mock:shadow-root>
6665
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>
7273
</cmp-a>
7374
`);
7475
});

src/runtime/update-component.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,13 @@ export const appDidLoad = (who: string) => {
407407
plt.$flags$ |= PLATFORM_FLAGS.appLoaded;
408408
}
409409
nextTick(() => emitEvent(win, 'appload', { detail: { namespace: NAMESPACE } }));
410+
if (BUILD.hydrateClientSide) {
411+
// we can now clear out the original location map
412+
// used by SSR so as to not cause memory leaks
413+
if (plt.$orgLocNodes$?.size) {
414+
plt.$orgLocNodes$.clear();
415+
}
416+
}
410417

411418
if (BUILD.profile && performance.measure) {
412419
performance.measure(`[Stencil] ${NAMESPACE} initial load (by ${who})`, 'st:app:start');

src/runtime/vdom/set-accessor.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,15 @@ export const setAccessor = (
5050
const oldClasses = parseClassList(oldValue);
5151
let newClasses = parseClassList(newValue);
5252

53-
if (BUILD.hydrateClientSide && elm['s-si'] && initialRender) {
53+
if (BUILD.hydrateClientSide && (elm['s-si'] || elm['s-sc']) && initialRender) {
5454
// for `scoped: true` components, new nodes after initial hydration
5555
// from SSR don't have the slotted class added. Let's add that now
56-
newClasses.push(elm['s-si']);
56+
const scopeId = elm['s-sc'] || elm['s-si'];
57+
newClasses.push(scopeId);
5758
oldClasses.forEach((c) => {
58-
if (c.startsWith(elm['s-si'])) newClasses.push(c);
59+
if (c.startsWith(scopeId)) newClasses.push(c);
5960
});
60-
newClasses = [...new Set(newClasses)];
61+
newClasses = [...new Set(newClasses)].filter((c) => c);
6162
classList.add(...newClasses);
6263
} else {
6364
classList.remove(...oldClasses.filter((c) => c && !newClasses.includes(c)));

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

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,4 +423,82 @@ describe('Sanity check SSR > Client hydration', () => {
423423
expect((nestedCmp.childNodes[0] as HTMLElement).tagName).toBe('SLOT');
424424
expect(nestedCmp.childNodes[1].textContent).toBe('after');
425425
});
426+
427+
it('renders slots nodes appropriately in a `scoped: true` child with `serializeShadowRoot: "scoped"` parent', async () => {
428+
if (document.querySelector('#stage')) {
429+
document.querySelector('#stage')?.remove();
430+
await browser.waitUntil(async () => !document.querySelector('#stage'));
431+
}
432+
const { html } = await renderToString(
433+
`
434+
<div>
435+
<shadow-ssr-parent-cmp>
436+
<div slot="things">one</div>
437+
<div slot="things">2</div>
438+
<div slot="things">3</div>
439+
</shadow-ssr-parent-cmp>
440+
</div>`,
441+
{
442+
fullDocument: true,
443+
serializeShadowRoot: 'scoped',
444+
},
445+
);
446+
const stage = document.createElement('div');
447+
stage.setAttribute('id', 'stage');
448+
stage.setHTMLUnsafe(html);
449+
document.body.appendChild(stage);
450+
451+
// @ts-expect-error resolved through WDIO
452+
const { defineCustomElements } = await import('/dist/loader/index.js');
453+
defineCustomElements().catch(console.error);
454+
455+
// wait for Stencil to take over and reconcile
456+
await browser.waitUntil(async () => customElements.get('shadow-ssr-parent-cmp'));
457+
expect(typeof customElements.get('shadow-ssr-parent-cmp')).toBe('function');
458+
459+
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+
464+
// check that <style> tag for `scoped-cmp` gets added
465+
expect(wrapCmp.shadowRoot.querySelector('style[sty-id="sc-scoped-ssr-child-cmp"]')).toBeTruthy();
466+
});
467+
468+
it('slots nodes appropriately in a `scoped: true` parent with `serializeShadowRoot: "scoped"` child', async () => {
469+
if (document.querySelector('#stage')) {
470+
document.querySelector('#stage')?.remove();
471+
await browser.waitUntil(async () => !document.querySelector('#stage'));
472+
}
473+
const { html } = await renderToString(
474+
`
475+
<div>
476+
<scoped-ssr-parent-cmp>
477+
<div slot="things">one</div>
478+
<div slot="things">2</div>
479+
<div slot="things">3</div>
480+
</scoped-ssr-parent-cmp>
481+
</div>`,
482+
{
483+
fullDocument: true,
484+
serializeShadowRoot: 'scoped',
485+
},
486+
);
487+
const stage = document.createElement('div');
488+
stage.setAttribute('id', 'stage');
489+
stage.setHTMLUnsafe(html);
490+
document.body.appendChild(stage);
491+
492+
// @ts-expect-error resolved through WDIO
493+
const { defineCustomElements } = await import('/dist/loader/index.js');
494+
defineCustomElements().catch(console.error);
495+
496+
// wait for Stencil to take over and reconcile
497+
await browser.waitUntil(async () => customElements.get('scoped-ssr-parent-cmp'));
498+
expect(typeof customElements.get('scoped-ssr-parent-cmp')).toBe('function');
499+
500+
const wrapCmp = document.querySelector('scoped-ssr-parent-cmp');
501+
expect(wrapCmp.childNodes.length).toBe(3);
502+
expect(wrapCmp.textContent).toBe('one23');
503+
});
426504
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Component, h, Host } from '@stencil/core';
2+
3+
@Component({
4+
tag: 'scoped-ssr-child-cmp',
5+
scoped: true,
6+
styles: `
7+
:host {
8+
display: block;
9+
border: 3px solid red;
10+
}
11+
`,
12+
})
13+
export class MyApp {
14+
render() {
15+
return (
16+
<Host>
17+
<div>
18+
<slot />
19+
</div>
20+
</Host>
21+
);
22+
}
23+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Component, h, Host } from '@stencil/core';
2+
3+
@Component({
4+
tag: 'scoped-ssr-parent-cmp',
5+
shadow: true,
6+
styles: `
7+
:host {
8+
display: block;
9+
border: 3px solid blue;
10+
}
11+
`,
12+
})
13+
export class MyApp {
14+
render() {
15+
return (
16+
<Host>
17+
<div>
18+
<shadow-ssr-child-cmp>
19+
<slot name="things" />
20+
</shadow-ssr-child-cmp>
21+
<div>
22+
<slot />
23+
</div>
24+
</div>
25+
</Host>
26+
);
27+
}
28+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Component, h, Host } from '@stencil/core';
2+
3+
@Component({
4+
tag: 'shadow-ssr-child-cmp',
5+
scoped: true,
6+
styles: `
7+
:host {
8+
display: block;
9+
border: 3px solid red;
10+
}
11+
`,
12+
})
13+
export class MyApp {
14+
render() {
15+
return (
16+
<Host>
17+
<div>
18+
<slot />
19+
</div>
20+
</Host>
21+
);
22+
}
23+
}

0 commit comments

Comments
 (0)