Skip to content

Commit 26e4aa3

Browse files
johnjenkinsJohn Jenkinschristian-bromann
authoredFeb 11, 2025··
feat(ssr): shadow dom components can render as declarative-shadow-dom or as 'scoped' (#6147)
* feat(ssr): `shadow: true` can now render as dsd or 'scoped' * chore: doc * chore: typo * Update src/declarations/stencil-public-compiler.ts Co-authored-by: Christian Bromann <git@bromann.dev> * Update src/declarations/stencil-public-compiler.ts Co-authored-by: Christian Bromann <git@bromann.dev> * Update src/declarations/stencil-public-compiler.ts Co-authored-by: Christian Bromann <git@bromann.dev> * chore: tidy up docs * chore: update defaults * chore: wip tests * chore: test and lint * chore: unit test * Update src/runtime/client-hydrate.ts Co-authored-by: Christian Bromann <git@bromann.dev> * Update src/runtime/client-hydrate.ts Co-authored-by: Christian Bromann <git@bromann.dev> * Update src/runtime/client-hydrate.ts Co-authored-by: Christian Bromann <git@bromann.dev> * chore: fixup tests and suggestions --------- Co-authored-by: John Jenkins <john.jenkins@nanoporetech.com> Co-authored-by: Christian Bromann <git@bromann.dev>
1 parent 3281159 commit 26e4aa3

File tree

29 files changed

+592
-162
lines changed

29 files changed

+592
-162
lines changed
 

‎src/compiler/style/css-to-esm.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ const transformCssToEsmModule = (input: d.TransformCssToEsmInput): d.TransformCs
115115

116116
if (isString(input.tag) && input.encapsulation === 'scoped') {
117117
const scopeId = getScopeId(input.tag, input.mode);
118-
results.styleText = scopeCss(results.styleText, scopeId);
118+
results.styleText = scopeCss(results.styleText, scopeId, false);
119119
}
120120

121121
const cssImports = getCssToEsmImports(varNames, results.styleText, input.file, input.mode);

‎src/compiler/transformers/add-static-style.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ const createStyleLiteral = (cmp: d.ComponentCompilerMeta, style: d.StyleCompiler
134134
if (cmp.encapsulation === 'scoped') {
135135
// scope the css first
136136
const scopeId = getScopeId(cmp.tagName, style.modeName);
137-
return ts.factory.createStringLiteral(scopeCss(style.styleStr, scopeId));
137+
return ts.factory.createStringLiteral(scopeCss(style.styleStr, scopeId, false));
138138
}
139139

140140
return ts.factory.createStringLiteral(style.styleStr);

‎src/compiler/transformers/component-native/native-static-style.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ const createStyleLiteral = (cmp: d.ComponentCompilerMeta, style: d.StyleCompiler
9696
if (cmp.encapsulation === 'scoped') {
9797
// scope the css first
9898
const scopeId = getScopeId(cmp.tagName, style.modeName);
99-
return ts.factory.createStringLiteral(scopeCss(style.styleStr, scopeId));
99+
return ts.factory.createStringLiteral(scopeCss(style.styleStr, scopeId, false));
100100
}
101101

102102
return ts.factory.createStringLiteral(style.styleStr);

‎src/declarations/stencil-public-compiler.ts

+27-6
Original file line numberDiff line numberDiff line change
@@ -946,12 +946,33 @@ export interface SerializeDocumentOptions extends HydrateDocumentOptions {
946946
*/
947947
removeHtmlComments?: boolean;
948948
/**
949-
* If set to `true` the component will be rendered within a Declarative Shadow DOM.
950-
* If set to `false` Stencil will ignore the contents of the shadow root and render the
951-
* element as given in provided template.
952-
* @default true
953-
*/
954-
serializeShadowRoot?: boolean;
949+
* Configure how Stencil serializes the components shadow root.
950+
* - If set to `declarative-shadow-dom` the component will be rendered within a Declarative Shadow DOM.
951+
* - If set to `scoped` Stencil will render the contents of the shadow root as a `scoped: true` component
952+
* and the shadow DOM will be created during client-side hydration.
953+
* - Alternatively you can mix and match the two by providing an object with `declarative-shadow-dom` and `scoped` keys,
954+
* the value arrays containing the tag names of the components that should be rendered in that mode.
955+
*
956+
* Examples:
957+
* - `{ 'declarative-shadow-dom': ['my-component-1', 'another-component'], default: 'scoped' }`
958+
* Render all components as `scoped` apart from `my-component-1` and `another-component`
959+
* - `{ 'scoped': ['an-option-component'], default: 'declarative-shadow-dom' }`
960+
* Render all components within `declarative-shadow-dom` apart from `an-option-component`
961+
* - `'scoped'` Render all components as `scoped`
962+
* - `false` disables shadow root serialization
963+
*
964+
* *NOTE* `true` has been deprecated in favor of `declarative-shadow-dom` and `scoped`
965+
* @default 'declarative-shadow-dom'
966+
*/
967+
serializeShadowRoot?:
968+
| 'declarative-shadow-dom'
969+
| 'scoped'
970+
| {
971+
'declarative-shadow-dom'?: string[];
972+
scoped?: string[];
973+
default: 'declarative-shadow-dom' | 'scoped';
974+
}
975+
| boolean;
955976
/**
956977
* The `fullDocument` flag determines the format of the rendered output. Set it to true to
957978
* generate a complete HTML document, or false to render only the component.

‎src/hydrate/platform/hydrate-app.ts

+57
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { globalScripts } from '@app-globals';
22
import { addHostEventListeners, doc, getHostRef, loadModule, plt, registerHost } from '@platform';
33
import { connectedCallback, insertVdomAnnotations } from '@runtime';
4+
import { CMP_FLAGS } from '@utils';
45

56
import type * as d from '../../declarations';
67
import { proxyHostElement } from './proxy-host-element';
@@ -84,6 +85,24 @@ export function hydrateApp(
8485

8586
if (Cstr != null && Cstr.cmpMeta != null) {
8687
// we found valid component metadata
88+
89+
if (
90+
opts.serializeShadowRoot !== false &&
91+
!!(Cstr.cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation) &&
92+
tagRequiresScoped(elm.tagName, opts.serializeShadowRoot)
93+
) {
94+
// this component requires scoped css encapsulation during SSR
95+
const cmpMeta = Cstr.cmpMeta;
96+
cmpMeta.$flags$ |= CMP_FLAGS.shadowNeedsScopedCss;
97+
98+
// 'cmpMeta' is a getter only, so needs redefining
99+
Object.defineProperty(Cstr as any, 'cmpMeta', {
100+
get: function (this: any) {
101+
return cmpMeta;
102+
},
103+
});
104+
}
105+
87106
createdElements.add(elm);
88107
elm.connectedCallback = patchedConnectedCallback;
89108

@@ -333,3 +352,41 @@ function waitingOnElementMsg(waitingElement: HTMLElement) {
333352
function waitingOnElementsMsg(waitingElements: Set<HTMLElement>) {
334353
return Array.from(waitingElements).map(waitingOnElementMsg);
335354
}
355+
356+
/**
357+
* Determines if the tag requires a declarative shadow dom
358+
* or a scoped / light dom during SSR.
359+
*
360+
* @param tagName - component tag name
361+
* @param opts - serializeShadowRoot options
362+
* @returns `true` when the tag requires a scoped / light dom during SSR
363+
*/
364+
export function tagRequiresScoped(tagName: string, opts: d.HydrateFactoryOptions['serializeShadowRoot']) {
365+
if (typeof opts === 'string') {
366+
return opts === 'scoped';
367+
}
368+
369+
if (typeof opts === 'boolean') {
370+
return opts === true ? false : true;
371+
}
372+
373+
if (typeof opts === 'object') {
374+
tagName = tagName.toLowerCase();
375+
376+
if (Array.isArray(opts['declarative-shadow-dom']) && opts['declarative-shadow-dom'].includes(tagName)) {
377+
// if the tag is in the dsd array, return dsd
378+
return false;
379+
} else if (
380+
(!Array.isArray(opts.scoped) || !opts.scoped.includes(tagName)) &&
381+
opts.default === 'declarative-shadow-dom'
382+
) {
383+
// if the tag is not in the scoped array and the default is dsd, return dsd
384+
return false;
385+
} else {
386+
// otherwise, return scoped
387+
return true;
388+
}
389+
}
390+
391+
return false;
392+
}

‎src/hydrate/platform/proxy-host-element.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,14 @@ export function proxyHostElement(elm: d.HostElement, cstr: d.ComponentConstructo
1616
}
1717

1818
/**
19-
* Only attach shadow root if there isn't one already
19+
* Only attach shadow root if there isn't one already and
20+
* the component is rendering DSD (not scoped) during SSR
2021
*/
21-
if (!elm.shadowRoot && !!(cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation)) {
22+
if (
23+
!elm.shadowRoot &&
24+
!!(cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation) &&
25+
!(cmpMeta.$flags$ & CMP_FLAGS.shadowNeedsScopedCss)
26+
) {
2227
if (BUILD.shadowDelegatesFocus) {
2328
elm.attachShadow({
2429
mode: 'open',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const globalScripts = /* default */ () => {
2+
/**/
3+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { tagRequiresScoped as TypeTagRequiresScoped } from '../hydrate-app';
2+
3+
describe('tagRequiresScoped', () => {
4+
let tagRequiresScoped: typeof TypeTagRequiresScoped;
5+
6+
beforeEach(async () => {
7+
tagRequiresScoped = require('../hydrate-app').tagRequiresScoped;
8+
});
9+
10+
afterEach(async () => {
11+
jest.resetModules();
12+
});
13+
14+
it('should return true for a component with serializeShadowRoot: true', () => {
15+
expect(tagRequiresScoped('cmp-a', true)).toBe(false);
16+
});
17+
18+
it('should return false for a component serializeShadowRoot: false', () => {
19+
expect(tagRequiresScoped('cmp-b', false)).toBe(true);
20+
});
21+
22+
it('should return false for a component with serializeShadowRoot: undefined', () => {
23+
expect(tagRequiresScoped('cmp-c', undefined)).toBe(false);
24+
});
25+
26+
it('should return true for a component with serializeShadowRoot: "scoped"', () => {
27+
expect(tagRequiresScoped('cmp-d', 'scoped')).toBe(true);
28+
});
29+
30+
it('should return false for a component with serializeShadowRoot: "declarative-shadow-dom"', () => {
31+
expect(tagRequiresScoped('cmp-e', 'declarative-shadow-dom')).toBe(false);
32+
});
33+
34+
it('should return true for a component when tag is in scoped list', () => {
35+
expect(tagRequiresScoped('cmp-f', { scoped: ['cmp-f'], default: 'scoped' })).toBe(true);
36+
});
37+
38+
it('should return false for a component when tag is not scoped list', () => {
39+
expect(tagRequiresScoped('cmp-g', { scoped: ['cmp-f'], default: 'declarative-shadow-dom' })).toBe(false);
40+
});
41+
42+
it('should return true for a component when default is scoped', () => {
43+
expect(tagRequiresScoped('cmp-g', { 'declarative-shadow-dom': ['cmp-f'], default: 'scoped' })).toBe(true);
44+
});
45+
46+
it('should return false for a component when default is declarative-shadow-dom', () => {
47+
expect(tagRequiresScoped('cmp-g', { 'declarative-shadow-dom': ['cmp-f'], default: 'declarative-shadow-dom' })).toBe(
48+
false,
49+
);
50+
});
51+
});

‎src/hydrate/runner/render.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ export function renderToString(
4848
/**
4949
* Defines whether we render the shadow root as a declarative shadow root or as scoped shadow root.
5050
*/
51-
opts.serializeShadowRoot = typeof opts.serializeShadowRoot === 'boolean' ? opts.serializeShadowRoot : true;
51+
opts.serializeShadowRoot =
52+
typeof opts.serializeShadowRoot === 'undefined' ? 'declarative-shadow-dom' : opts.serializeShadowRoot;
5253
/**
5354
* Make sure we wait for components to be hydrated.
5455
*/

‎src/mock-doc/node.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ export class MockElement extends MockNode {
308308
*
309309
* For example:
310310
* calling `renderToString('<my-component></my-component>', {
311-
* serializeShadowRoot: false
311+
* serializeShadowRoot: 'scoped'
312312
* })`
313313
*/
314314
delete this.__shadowRoot;

‎src/mock-doc/serialize-node.ts

+12-3
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ function normalizeSerializationOptions(opts: Partial<SerializeNodeToHtmlOptions>
3636
removeBooleanAttributeQuotes:
3737
typeof opts.removeBooleanAttributeQuotes !== 'boolean' ? false : opts.removeBooleanAttributeQuotes,
3838
removeHtmlComments: typeof opts.removeHtmlComments !== 'boolean' ? false : opts.removeHtmlComments,
39-
serializeShadowRoot: typeof opts.serializeShadowRoot !== 'boolean' ? true : opts.serializeShadowRoot,
39+
serializeShadowRoot:
40+
typeof opts.serializeShadowRoot === 'undefined' ? 'declarative-shadow-dom' : opts.serializeShadowRoot,
4041
fullDocument: typeof opts.fullDocument !== 'boolean' ? true : opts.fullDocument,
4142
} as const;
4243
}
@@ -243,7 +244,7 @@ function* streamToHtml(
243244

244245
if (EMPTY_ELEMENTS.has(tagName) === false) {
245246
const shadowRoot = (node as HTMLElement).shadowRoot;
246-
if (shadowRoot != null && opts.serializeShadowRoot) {
247+
if (shadowRoot != null && opts.serializeShadowRoot !== false) {
247248
output.indent = output.indent + (opts.indentSpaces ?? 0);
248249

249250
yield* streamToHtml(shadowRoot, opts, output);
@@ -681,6 +682,14 @@ export interface SerializeNodeToHtmlOptions {
681682
removeBooleanAttributeQuotes?: boolean;
682683
removeEmptyAttributes?: boolean;
683684
removeHtmlComments?: boolean;
684-
serializeShadowRoot?: boolean;
685+
serializeShadowRoot?:
686+
| 'declarative-shadow-dom'
687+
| 'scoped'
688+
| {
689+
'declarative-shadow-dom'?: string[];
690+
scoped?: string[];
691+
default: 'declarative-shadow-dom' | 'scoped';
692+
}
693+
| boolean;
685694
fullDocument?: boolean;
686695
}

‎src/runtime/bootstrap-custom-element.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
import { computeMode } from './mode';
2525
import { proxyComponent } from './proxy-component';
2626
import { PROXY_FLAGS } from './runtime-constants';
27-
import { attachStyles, getScopeId, registerStyle } from './styles';
27+
import { attachStyles, getScopeId, hydrateScopedToShadow, registerStyle } from './styles';
2828

2929
export const defineCustomElement = (Cstr: any, compactMeta: d.ComponentRuntimeMetaCompact) => {
3030
customElements.define(compactMeta[1], proxyCustomElement(Cstr, compactMeta) as CustomElementConstructor);
@@ -74,6 +74,10 @@ export const proxyCustomElement = (Cstr: any, compactMeta: d.ComponentRuntimeMet
7474
}
7575
}
7676

77+
if (BUILD.hydrateClientSide && BUILD.shadowDom) {
78+
hydrateScopedToShadow();
79+
}
80+
7781
const originalConnectedCallback = Cstr.prototype.connectedCallback;
7882
const originalDisconnectedCallback = Cstr.prototype.disconnectedCallback;
7983
Object.assign(Cstr.prototype, {

‎src/runtime/bootstrap-lazy.ts

+5
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { hmrStart } from './hmr-component';
1717
import { createTime, installDevTools } from './profile';
1818
import { proxyComponent } from './proxy-component';
1919
import { HYDRATED_CSS, PLATFORM_FLAGS, PROXY_FLAGS, SLOT_FB_CSS } from './runtime-constants';
20+
import { hydrateScopedToShadow } from './styles';
2021
import { appDidLoad } from './update-component';
2122
export { setNonce } from '@platform';
2223

@@ -50,6 +51,10 @@ export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d.
5051
plt.$flags$ |= PLATFORM_FLAGS.appLoaded;
5152
}
5253

54+
if (BUILD.hydrateClientSide && BUILD.shadowDom) {
55+
hydrateScopedToShadow();
56+
}
57+
5358
let hasSlotRelocation = false;
5459
lazyBundles.map((lazyBundle) => {
5560
lazyBundle[1].map((compactMeta) => {

‎src/runtime/client-hydrate.ts

+52-7
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export const initializeClientHydrate = (
6464
}
6565
}
6666

67-
if (!plt.$orgLocNodes$) {
67+
if (!plt.$orgLocNodes$ || !plt.$orgLocNodes$.size) {
6868
// This is the first pass over of this whole document;
6969
// does a scrape to construct a 'bare-bones' tree of what elements we have and where content has been moved from
7070
initializeDocumentHydrate(doc.body, (plt.$orgLocNodes$ = new Map()));
@@ -214,15 +214,36 @@ export const initializeClientHydrate = (
214214
});
215215
}
216216

217-
if (BUILD.shadowDom && shadowRoot) {
217+
if (BUILD.shadowDom && shadowRoot && !shadowRoot.childNodes.length) {
218+
// For `scoped` shadowDOM rendering (not DSD);
218219
// Add all the root nodes in the shadowDOM (a root node can have a whole nested DOM tree)
219220
let rnIdex = 0;
220221
const rnLen = shadowRootNodes.length;
221-
for (rnIdex; rnIdex < rnLen; rnIdex++) {
222-
shadowRoot.appendChild(shadowRootNodes[rnIdex] as any);
222+
if (rnLen) {
223+
for (rnIdex; rnIdex < rnLen; rnIdex++) {
224+
shadowRoot.appendChild(shadowRootNodes[rnIdex]);
225+
}
226+
227+
Array.from(hostElm.childNodes).forEach((node) => {
228+
if (typeof (node as d.RenderNode)['s-sn'] !== 'string') {
229+
if (node.nodeType === NODE_TYPE.ElementNode && (node as HTMLElement).slot && (node as HTMLElement).hidden) {
230+
// this is a slotted node that doesn't have a home ... yet.
231+
// we can safely leave it be, native behavior will mean it's hidden
232+
(node as HTMLElement).removeAttribute('hidden');
233+
} else if (
234+
node.nodeType === NODE_TYPE.CommentNode ||
235+
(node.nodeType === NODE_TYPE.TextNode && !(node as Text).wholeText.trim())
236+
) {
237+
// During `scoped` shadowDOM rendering, there's a bunch of comment nodes used for positioning / empty text nodes.
238+
// Let's tidy them up now to stop frameworks complaining about DOM mismatches.
239+
node.parentNode.removeChild(node);
240+
}
241+
}
242+
});
223243
}
224244
}
225245

246+
plt.$orgLocNodes$.delete(hostElm['s-id']);
226247
hostRef.$hostElement$ = hostElm;
227248
endHydrate();
228249
};
@@ -391,7 +412,7 @@ const clientHydrate = (
391412
});
392413

393414
if (childNodeType === TEXT_NODE_ID) {
394-
childVNode.$elm$ = node.nextSibling as any;
415+
childVNode.$elm$ = findCorrespondingNode(node, NODE_TYPE.TextNode) as d.RenderNode;
395416

396417
if (childVNode.$elm$ && childVNode.$elm$.nodeType === NODE_TYPE.TextNode) {
397418
childVNode.$text$ = childVNode.$elm$.textContent;
@@ -415,7 +436,7 @@ const clientHydrate = (
415436
}
416437
}
417438
} else if (childNodeType === COMMENT_NODE_ID) {
418-
childVNode.$elm$ = node.nextSibling as any;
439+
childVNode.$elm$ = findCorrespondingNode(node, NODE_TYPE.CommentNode) as d.RenderNode;
419440

420441
if (childVNode.$elm$ && childVNode.$elm$.nodeType === NODE_TYPE.CommentNode) {
421442
// A non-Stencil comment node
@@ -463,6 +484,14 @@ const clientHydrate = (
463484
vnode.$elm$ = node;
464485
vnode.$index$ = '0';
465486
parentVNode.$children$ = [vnode];
487+
} else {
488+
if (node.nodeType === NODE_TYPE.TextNode && !(node as unknown as Text).wholeText.trim()) {
489+
// empty white space is never accounted for from SSR so there's
490+
// no corresponding comment node giving it a position in the DOM.
491+
// It therefore gets slotted / clumped together at the end of the host.
492+
// It's cleaner to remove. Ideally, SSR is rendered with `prettyHtml: false`
493+
node.remove();
494+
}
466495
}
467496

468497
return parentVNode;
@@ -471,7 +500,7 @@ const clientHydrate = (
471500
/**
472501
* Recursively locate any comments representing an 'original location' for a node; in a node's children or shadowRoot children.
473502
* Creates a map of component IDs and 'original location' ID's which are derived from comment nodes placed by 'vdom-annotations.ts'.
474-
* Each 'original location' relates to lightDOM node that was moved deeper into the SSR markup. e.g. `<!--o.1-->` maps to `<div c-id="0.1">`
503+
* Each 'original location' relates to a lightDOM node that was moved deeper into the SSR markup. e.g. `<!--o.1-->` maps to `<div c-id="0.1">`
475504
*
476505
* @param node The node to search.
477506
* @param orgLocNodes A map of the original location annotations and the current node being searched.
@@ -639,6 +668,22 @@ const addSlottedNodes = (
639668
}
640669
};
641670

671+
/**
672+
* Steps through the node's siblings to find the next node of a specific type, with a value.
673+
* e.g. when we find a position comment `<!--t.1-->`, we need to find the next text node with a value.
674+
* (it's a guard against whitespace which is never accounted for in the SSR output)
675+
* @param node - the starting node
676+
* @param type - the type of node to find
677+
* @returns the first corresponding node of the type
678+
*/
679+
const findCorrespondingNode = (node: Node, type: NODE_TYPE.CommentNode | NODE_TYPE.TextNode) => {
680+
let sibling = node;
681+
do {
682+
sibling = sibling.nextSibling;
683+
} while (sibling && (sibling.nodeType !== type || !sibling.nodeValue));
684+
return sibling;
685+
};
686+
642687
type SlottedNodes = Array<{ slot: d.RenderNode; node: d.RenderNode; hostId: string }>;
643688

644689
interface RenderNodeData extends d.VNode {

‎src/runtime/initialize-component.ts

+3-9
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { consoleError, loadModule, styles } from '@platform';
33
import { CMP_FLAGS, HOST_FLAGS } from '@utils';
44

55
import type * as d from '../declarations';
6+
import { scopeCss } from '../utils/shadow-css';
67
import { computeMode } from './mode';
78
import { createTime, uniqueTime } from './profile';
89
import { proxyComponent } from './proxy-component';
@@ -155,16 +156,9 @@ export const initializeComponent = async (
155156
if (!styles.has(scopeId)) {
156157
const endRegisterStyles = createTime('registerStyles', cmpMeta.$tagName$);
157158

158-
if (
159-
!BUILD.hydrateServerSide &&
160-
BUILD.shadowDom &&
161-
// TODO(STENCIL-854): Remove code related to legacy shadowDomShim field
162-
BUILD.shadowDomShim &&
163-
cmpMeta.$flags$ & CMP_FLAGS.needsShadowDomShim
164-
) {
165-
style = await import('@utils/shadow-css').then((m) => m.scopeCss(style, scopeId));
159+
if (BUILD.hydrateServerSide && BUILD.shadowDom && cmpMeta.$flags$ & CMP_FLAGS.shadowNeedsScopedCss) {
160+
style = scopeCss(style, scopeId, true);
166161
}
167-
168162
registerStyle(scopeId, style, !!(cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation));
169163
endRegisterStyles();
170164
}

‎src/runtime/styles.ts

+44-4
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export const addStyle = (styleContainerNode: any, cmpMeta: d.ComponentRuntimeMet
8686

8787
if (
8888
(BUILD.hydrateServerSide || BUILD.hotModuleReplacement) &&
89-
cmpMeta.$flags$ & CMP_FLAGS.scopedCssEncapsulation
89+
(cmpMeta.$flags$ & CMP_FLAGS.scopedCssEncapsulation || cmpMeta.$flags$ & CMP_FLAGS.shadowNeedsScopedCss)
9090
) {
9191
styleElm.setAttribute(HYDRATED_STYLE_ID, scopeId);
9292
}
@@ -147,7 +147,7 @@ export const addStyle = (styleContainerNode: any, cmpMeta: d.ComponentRuntimeMet
147147
/**
148148
* attach styles at the beginning of a shadow root node if we render shadow components
149149
*/
150-
if (cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation && styleContainerNode.nodeName !== 'HEAD') {
150+
if (cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation) {
151151
styleContainerNode.insertBefore(styleElm, null);
152152
}
153153
}
@@ -188,8 +188,8 @@ export const attachStyles = (hostRef: d.HostRef) => {
188188
if (
189189
(BUILD.shadowDom || BUILD.scoped) &&
190190
BUILD.cssAnnotations &&
191-
flags & CMP_FLAGS.needsScopedEncapsulation &&
192-
flags & CMP_FLAGS.scopedCssEncapsulation
191+
((flags & CMP_FLAGS.needsScopedEncapsulation && flags & CMP_FLAGS.scopedCssEncapsulation) ||
192+
flags & CMP_FLAGS.shadowNeedsScopedCss)
193193
) {
194194
// only required when we're NOT using native shadow dom (slot)
195195
// or this browser doesn't support native shadow dom
@@ -214,6 +214,46 @@ export const attachStyles = (hostRef: d.HostRef) => {
214214
export const getScopeId = (cmp: d.ComponentRuntimeMeta, mode?: string) =>
215215
'sc-' + (BUILD.mode && mode && cmp.$flags$ & CMP_FLAGS.hasMode ? cmp.$tagName$ + '-' + mode : cmp.$tagName$);
216216

217+
/**
218+
* Convert a 'scoped' CSS string to one appropriate for use in the shadow DOM.
219+
*
220+
* Given a 'scoped' CSS string that looks like this:
221+
*
222+
* ```
223+
* /*!@div*\/div.class-name { display: flex };
224+
* ```
225+
*
226+
* Convert it to a 'shadow' appropriate string, like so:
227+
*
228+
* ```
229+
* /*!@div*\/div.class-name { display: flex }
230+
* ─┬─ ────────┬────────
231+
* │ │
232+
* │ ┌─────────────────┘
233+
* ▼ ▼
234+
* div{ display: flex }
235+
* ```
236+
*
237+
* Note that forward-slashes in the above are escaped so they don't end the
238+
* comment.
239+
*
240+
* @param css a CSS string to convert
241+
* @returns the converted string
242+
*/
243+
export const convertScopedToShadow = (css: string) => css.replace(/\/\*!@([^\/]+)\*\/[^\{]+\{/g, '$1{');
244+
245+
/**
246+
* Hydrate styles after SSR for components *not* using DSD. Convert 'scoped' styles to 'shadow'
247+
* and add them to a constructable stylesheet.
248+
*/
249+
export const hydrateScopedToShadow = () => {
250+
const styles = doc.querySelectorAll(`[${HYDRATED_STYLE_ID}]`);
251+
let i = 0;
252+
for (; i < styles.length; i++) {
253+
registerStyle(styles[i].getAttribute(HYDRATED_STYLE_ID), convertScopedToShadow(styles[i].innerHTML), true);
254+
}
255+
};
256+
217257
declare global {
218258
export interface CSSStyleSheet {
219259
replaceSync(cssText: string): void;

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

-1
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ describe('hydrate, shadow in shadow', () => {
6060
<mock:shadow-root>
6161
<slot></slot>
6262
</mock:shadow-root>
63-
<!---->
6463
<slot></slot>
6564
</cmp-b>
6665
</mock:shadow-root>

‎src/runtime/vdom/vdom-render.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -1021,7 +1021,10 @@ render() {
10211021
scopeId = hostElm['s-sc'];
10221022
}
10231023

1024-
useNativeShadowDom = supportsShadow && (cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation) !== 0;
1024+
useNativeShadowDom =
1025+
supportsShadow &&
1026+
!!(cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation) &&
1027+
!(cmpMeta.$flags$ & CMP_FLAGS.shadowNeedsScopedCss);
10251028
if (BUILD.slotRelocation) {
10261029
contentRef = hostElm['s-cr'];
10271030

‎src/testing/jest/jest-27-and-under/matchers/html.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export function toEqualLightHtml(input: string | HTMLElement | ShadowRoot, shoul
1212
export function compareHtml(
1313
input: string | HTMLElement | ShadowRoot,
1414
shouldEqual: string,
15-
serializeShadowRoot: boolean,
15+
serializeShadowRoot: d.SerializeDocumentOptions['serializeShadowRoot'],
1616
) {
1717
if (input == null) {
1818
throw new Error(`expect toEqualHtml() value is "${input}"`);

‎src/testing/jest/jest-28/matchers/html.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export function toEqualLightHtml(input: string | HTMLElement | ShadowRoot, shoul
1212
export function compareHtml(
1313
input: string | HTMLElement | ShadowRoot,
1414
shouldEqual: string,
15-
serializeShadowRoot: boolean,
15+
serializeShadowRoot: d.SerializeDocumentOptions['serializeShadowRoot'],
1616
) {
1717
if (input == null) {
1818
throw new Error(`expect toEqualHtml() value is "${input}"`);

‎src/testing/jest/jest-29/matchers/html.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export function toEqualLightHtml(input: string | HTMLElement | ShadowRoot, shoul
1212
export function compareHtml(
1313
input: string | HTMLElement | ShadowRoot,
1414
shouldEqual: string,
15-
serializeShadowRoot: boolean,
15+
serializeShadowRoot: d.SerializeDocumentOptions['serializeShadowRoot'],
1616
) {
1717
if (input == null) {
1818
throw new Error(`expect toEqualHtml() value is "${input}"`);

‎src/utils/constants.ts

+6
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@ export const enum CMP_FLAGS {
113113
* options passed to the `@Component` decorator.
114114
*/
115115
formAssociated = 1 << 6,
116+
117+
/**
118+
* Determines if a `shadow: true` component needs
119+
* to have its styles scoped during SSR as opposed to using DSD.
120+
*/
121+
shadowNeedsScopedCss = 1 << 7,
116122
}
117123

118124
/**

‎src/utils/shadow-css.ts

+55-6
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,13 @@ const scopeSelector = (selector: string, scopeSelectorText: string, hostSelector
425425
.join(', ');
426426
};
427427

428-
const scopeSelectors = (cssText: string, scopeSelectorText: string, hostSelector: string, slotSelector: string) => {
428+
const scopeSelectors = (
429+
cssText: string,
430+
scopeSelectorText: string,
431+
hostSelector: string,
432+
slotSelector: string,
433+
commentOriginalSelector: boolean,
434+
) => {
429435
return processRules(cssText, (rule: CssRule) => {
430436
let selector = rule.selector;
431437
let content = rule.content;
@@ -437,7 +443,7 @@ const scopeSelectors = (cssText: string, scopeSelectorText: string, hostSelector
437443
rule.selector.startsWith('@page') ||
438444
rule.selector.startsWith('@document')
439445
) {
440-
content = scopeSelectors(rule.content, scopeSelectorText, hostSelector, slotSelector);
446+
content = scopeSelectors(rule.content, scopeSelectorText, hostSelector, slotSelector, commentOriginalSelector);
441447
}
442448

443449
const cssRule: CssRule = {
@@ -448,7 +454,13 @@ const scopeSelectors = (cssText: string, scopeSelectorText: string, hostSelector
448454
});
449455
};
450456

451-
const scopeCssText = (cssText: string, scopeId: string, hostScopeId: string, slotScopeId: string) => {
457+
const scopeCssText = (
458+
cssText: string,
459+
scopeId: string,
460+
hostScopeId: string,
461+
slotScopeId: string,
462+
commentOriginalSelector: boolean,
463+
) => {
452464
cssText = insertPolyfillHostInCssText(cssText);
453465
cssText = convertColonHost(cssText);
454466
cssText = convertColonHostContext(cssText);
@@ -458,7 +470,7 @@ const scopeCssText = (cssText: string, scopeId: string, hostScopeId: string, slo
458470
cssText = convertShadowDOMSelectors(cssText);
459471

460472
if (scopeId) {
461-
cssText = scopeSelectors(cssText, scopeId, hostScopeId, slotScopeId);
473+
cssText = scopeSelectors(cssText, scopeId, hostScopeId, slotScopeId, commentOriginalSelector);
462474
}
463475

464476
cssText = replaceShadowCssHost(cssText, hostScopeId);
@@ -487,16 +499,53 @@ const replaceShadowCssHost = (cssText: string, hostScopeId: string) => {
487499
return cssText.replace(/-shadowcsshost-no-combinator/g, `.${hostScopeId}`);
488500
};
489501

490-
export const scopeCss = (cssText: string, scopeId: string) => {
502+
export const scopeCss = (cssText: string, scopeId: string, commentOriginalSelector: boolean) => {
491503
const hostScopeId = scopeId + '-h';
492504
const slotScopeId = scopeId + '-s';
493505

494506
const commentsWithHash = extractCommentsWithHash(cssText);
495507

496508
cssText = stripComments(cssText);
497-
const scoped = scopeCssText(cssText, scopeId, hostScopeId, slotScopeId);
509+
const orgSelectors: {
510+
placeholder: string;
511+
comment: string;
512+
}[] = [];
513+
514+
if (commentOriginalSelector) {
515+
const processCommentedSelector = (rule: CssRule) => {
516+
const placeholder = `/*!@___${orgSelectors.length}___*/`;
517+
const comment = `/*!@${rule.selector}*/`;
518+
519+
orgSelectors.push({ placeholder, comment });
520+
rule.selector = placeholder + rule.selector;
521+
return rule;
522+
};
523+
524+
cssText = processRules(cssText, (rule) => {
525+
if (rule.selector[0] !== '@') {
526+
return processCommentedSelector(rule);
527+
} else if (
528+
rule.selector.startsWith('@media') ||
529+
rule.selector.startsWith('@supports') ||
530+
rule.selector.startsWith('@page') ||
531+
rule.selector.startsWith('@document')
532+
) {
533+
rule.content = processRules(rule.content, processCommentedSelector);
534+
return rule;
535+
}
536+
return rule;
537+
});
538+
}
539+
540+
const scoped = scopeCssText(cssText, scopeId, hostScopeId, slotScopeId, commentOriginalSelector);
498541
cssText = [scoped.cssText, ...commentsWithHash].join('\n');
499542

543+
if (commentOriginalSelector) {
544+
orgSelectors.forEach(({ placeholder, comment }) => {
545+
cssText = cssText.replace(placeholder, comment);
546+
});
547+
}
548+
500549
scoped.slottedSelectors.forEach((slottedSelector) => {
501550
const regex = new RegExp(escapeRegExpSpecialCharacters(slottedSelector.orgSelector), 'g');
502551
cssText = cssText.replace(regex, slottedSelector.updatedSelector);

‎src/utils/test/scope-css.spec.ts

+17-2
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
import { scopeCss } from '../shadow-css';
1515

1616
describe('ShadowCss', function () {
17-
function s(cssText: string, scopeId: string) {
18-
const shim = scopeCss(cssText, scopeId);
17+
function s(cssText: string, scopeId: string, commentOriginalSelector = false) {
18+
const shim = scopeCss(cssText, scopeId, commentOriginalSelector);
1919

2020
const nlRegexp = /\n/g;
2121
return normalizeCSS(shim.replace(nlRegexp, ''));
@@ -25,6 +25,21 @@ describe('ShadowCss', function () {
2525
expect(s('', 'a')).toEqual('');
2626
});
2727

28+
it('should handle empty string, commented org selector', () => {
29+
expect(s('', 'a', true)).toEqual('');
30+
});
31+
32+
it('div', () => {
33+
const r = s('div {}', 'sc-ion-tag', true);
34+
expect(r).toEqual('/*!@div*/div.sc-ion-tag {}');
35+
});
36+
37+
it('should add an attribute to every rule, commented org selector', () => {
38+
const css = 'one {color: red;}two {color: red;}';
39+
const expected = '/*!@one*/one.a {color:red;}/*!@two*/two.a {color:red;}';
40+
expect(s(css, 'a', true)).toEqual(expected);
41+
});
42+
2843
it('should add an attribute to every rule', () => {
2944
const css = 'one {color: red;}two {color: red;}';
3045
const expected = 'one.a {color:red;}two.a {color:red;}';

‎test/end-to-end/src/scoped-hydration/scoped-hydration.e2e.ts

+9-17
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ describe('`scoped: true` hydration checks', () => {
2828
const page = await newE2EPage({ html, url: 'https://stencil.com' });
2929
const styles = await page.findAll('style');
3030
expect(styles.length).toBe(3);
31-
expect(styles[0].textContent).toContain(`.sc-non-shadow-child-h`);
32-
expect(styles[1].textContent).not.toContain(`.sc-non-shadow-child-h`);
31+
expect(styles[1].textContent).toContain(`.sc-non-shadow-child-h`);
32+
expect(styles[0].textContent).not.toContain(`.sc-non-shadow-child-h`);
3333
expect(styles[2].textContent).not.toContain(`.sc-non-shadow-child-h`);
3434
});
3535

@@ -154,29 +154,21 @@ describe('`scoped: true` hydration checks', () => {
154154
await page.evaluate(() => {
155155
(window as any).root = document.querySelector('hydrated-sibling-accessors');
156156
});
157-
expect(await page.evaluate(() => root.firstChild.nextSibling.textContent)).toBe('First slot element');
158-
expect(await page.evaluate(() => root.firstChild.nextSibling.nextSibling.textContent)).toBe(
159-
' Default slot text node ',
160-
);
157+
expect(await page.evaluate(() => root.firstChild.textContent)).toBe('First slot element');
158+
expect(await page.evaluate(() => root.firstChild.nextSibling.textContent)).toBe(' Default slot text node ');
159+
expect(await page.evaluate(() => root.firstChild.nextSibling.nextSibling.textContent)).toBe('Second slot element');
161160
expect(await page.evaluate(() => root.firstChild.nextSibling.nextSibling.nextSibling.textContent)).toBe(
162-
'Second slot element',
163-
);
164-
expect(await page.evaluate(() => root.firstChild.nextSibling.nextSibling.nextSibling.nextSibling.textContent)).toBe(
165161
' Default slot comment node ',
166162
);
167163

168-
expect(await page.evaluate(() => root.lastChild.previousSibling.textContent)).toBe(' Default slot comment node ');
164+
expect(await page.evaluate(() => root.lastChild.textContent)).toBe(' Default slot comment node ');
165+
expect(await page.evaluate(() => root.lastChild.previousSibling.textContent)).toBe('Second slot element');
169166
expect(await page.evaluate(() => root.lastChild.previousSibling.previousSibling.textContent)).toBe(
170-
'Second slot element',
167+
' Default slot text node ',
171168
);
172169
expect(await page.evaluate(() => root.lastChild.previousSibling.previousSibling.previousSibling.textContent)).toBe(
173-
' Default slot text node ',
170+
'First slot element',
174171
);
175-
expect(
176-
await page.evaluate(
177-
() => root.lastChild.previousSibling.previousSibling.previousSibling.previousSibling.textContent,
178-
),
179-
).toBe('First slot element');
180172
});
181173

182174
it('Steps through only "lightDOM" elements', async () => {

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

+202-88
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,200 @@
1+
import { browser } from '@wdio/globals';
2+
13
import { renderToString } from '../hydrate/index.mjs';
4+
import { setupIFrameTest } from '../util.js';
25

3-
describe('ssr-shadow-cmp', () => {
4-
function getNodeNames(chidNodes: NodeListOf<ChildNode>) {
5-
return Array.from(chidNodes)
6-
.flatMap((node) => {
7-
if (node.nodeType === 3) {
8-
if (node.textContent?.trim()) {
9-
return 'text';
6+
describe('Sanity check SSR > Client hydration', () => {
7+
const testSuites = async (
8+
root: Document,
9+
method: 'scoped' | 'declarative-shadow-dom',
10+
renderType: 'dist' | 'custom-elements',
11+
) => {
12+
function getNodeNames(chidNodes: NodeListOf<ChildNode>) {
13+
return Array.from(chidNodes)
14+
.flatMap((node) => {
15+
if (node.nodeType === 3) {
16+
if (node.textContent?.trim()) {
17+
return 'text';
18+
} else {
19+
return [];
20+
}
21+
} else if (node.nodeType === 8) {
22+
return 'comment';
1023
} else {
11-
return [];
24+
return node.nodeName.toLowerCase();
1225
}
13-
} else if (node.nodeType === 8) {
14-
return 'comment';
15-
} else {
16-
return node.nodeName.toLowerCase();
17-
}
18-
})
19-
.join(' ');
20-
}
21-
22-
it('verifies all nodes are preserved during hydration', async () => {
23-
if (!document.querySelector('#stage')) {
24-
const { html } = await renderToString(
25-
`
26-
<ssr-shadow-cmp>
27-
A text node
28-
<!-- a comment -->
29-
<div>An element</div>
30-
<!-- another comment -->
31-
Another text node
32-
</ssr-shadow-cmp>
33-
`,
34-
{
35-
fullDocument: true,
36-
serializeShadowRoot: true,
37-
constrainTimeouts: false,
38-
},
39-
);
40-
const stage = document.createElement('div');
41-
stage.setAttribute('id', 'stage');
42-
stage.setHTMLUnsafe(html);
43-
document.body.appendChild(stage);
26+
})
27+
.join(' ');
4428
}
4529

46-
// @ts-expect-error resolved through WDIO
47-
const { defineCustomElements } = await import('/dist/loader/index.js');
48-
defineCustomElements().catch(console.error);
30+
return {
31+
sanityCheck: async () => {
32+
if (root.querySelector('#stage')) {
33+
root.querySelector('#stage')?.remove();
34+
await browser.waitUntil(async () => !root.querySelector('#stage'));
35+
}
36+
const { html } = await renderToString(
37+
`
38+
<ssr-shadow-cmp>
39+
A text node
40+
<!-- a comment -->
41+
<div>An element</div>
42+
<!-- another comment -->
43+
Another text node
44+
</ssr-shadow-cmp>
45+
`,
46+
{
47+
fullDocument: true,
48+
serializeShadowRoot: method,
49+
constrainTimeouts: false,
50+
prettyHTML: false,
51+
},
52+
);
53+
const stage = root.createElement('div');
54+
stage.setAttribute('id', 'stage');
55+
stage.setHTMLUnsafe(html);
56+
root.body.appendChild(stage);
57+
58+
if (renderType === 'dist') {
59+
// @ts-expect-error resolved through WDIO
60+
const { defineCustomElements } = await import('/dist/loader/index.js');
61+
defineCustomElements().catch(console.error);
4962

50-
// wait for Stencil to take over and reconcile
51-
await browser.waitUntil(async () => customElements.get('ssr-shadow-cmp'));
52-
expect(typeof customElements.get('ssr-shadow-cmp')).toBe('function');
63+
// wait for Stencil to take over and reconcile
64+
await browser.waitUntil(async () => customElements.get('ssr-shadow-cmp'));
65+
expect(typeof customElements.get('ssr-shadow-cmp')).toBe('function');
66+
}
5367

54-
await expect(getNodeNames(document.querySelector('ssr-shadow-cmp').childNodes)).toBe(
55-
`text comment div comment text`,
56-
);
68+
const ele = root.querySelector('ssr-shadow-cmp');
69+
await browser.waitUntil(async () => !!ele.childNodes);
70+
await browser.pause(100);
71+
72+
// Checking slotted content
73+
await expect(getNodeNames(ele.childNodes)).toBe(`text comment div comment text`);
74+
75+
// Checking shadow content
76+
const eles = method === 'scoped' ? 'div' : 'style div';
77+
await expect(getNodeNames(ele.shadowRoot.childNodes)).toBe(eles);
78+
79+
// Checking styling
80+
await expect(getComputedStyle(ele).color).toBe('rgb(255, 0, 0)');
81+
await expect(getComputedStyle(ele).backgroundColor).toBe('rgb(255, 255, 0)');
82+
},
83+
84+
slots: async () => {
85+
if (root.querySelector('#stage')) {
86+
root.querySelector('#stage')?.remove();
87+
await browser.waitUntil(async () => !root.querySelector('#stage'));
88+
}
89+
const { html } = await renderToString(
90+
`
91+
<ssr-shadow-cmp>
92+
<p>Default slot content</p>
93+
<p slot="client-only">Client-only slot content</p>
94+
</ssr-shadow-cmp>
95+
`,
96+
{
97+
fullDocument: true,
98+
serializeShadowRoot: method,
99+
constrainTimeouts: false,
100+
prettyHTML: false,
101+
},
102+
);
103+
const stage = root.createElement('div');
104+
stage.setAttribute('id', 'stage');
105+
stage.setHTMLUnsafe(html);
106+
root.body.appendChild(stage);
107+
108+
if (renderType === 'dist') {
109+
// @ts-expect-error resolved through WDIO
110+
const { defineCustomElements } = await import('/dist/loader/index.js');
111+
defineCustomElements().catch(console.error);
112+
113+
// wait for Stencil to take over and reconcile
114+
await browser.waitUntil(async () => customElements.get('ssr-shadow-cmp'));
115+
expect(typeof customElements.get('ssr-shadow-cmp')).toBe('function');
116+
}
117+
118+
await browser.waitUntil(async () => root.querySelector('ssr-shadow-cmp [slot="client-only"]'));
119+
await expect(root.querySelector('ssr-shadow-cmp').textContent).toBe(
120+
'Default slot contentClient-only slot content',
121+
);
122+
},
123+
};
124+
};
125+
126+
describe('dist / declarative-shadow-dom', () => {
127+
let testSuite;
128+
beforeEach(async () => {
129+
testSuite = await testSuites(document, 'declarative-shadow-dom', 'dist');
130+
});
131+
132+
it('verifies all nodes & styles are preserved during hydration', async () => {
133+
await testSuite.sanityCheck();
134+
});
135+
136+
it('resolves slots correctly during client-side hydration', async () => {
137+
await testSuite.slots();
138+
});
139+
});
140+
141+
describe('dist / scoped', () => {
142+
let testSuite;
143+
beforeEach(async () => {
144+
testSuite = await testSuites(document, 'scoped', 'dist');
145+
});
146+
147+
it('verifies all nodes & styles are preserved during hydration', async () => {
148+
await testSuite.sanityCheck();
149+
});
150+
151+
it('resolves slots correctly during client-side hydration', async () => {
152+
await testSuite.slots();
153+
});
154+
});
155+
156+
describe('custom-elements / declarative-shadow-dom', () => {
157+
let doc: Document;
158+
let testSuite;
159+
160+
beforeEach(async () => {
161+
await setupIFrameTest('/ssr-hydration/custom-element.html', 'dsd-custom-elements');
162+
const frameEle: HTMLIFrameElement = document.querySelector('iframe#dsd-custom-elements');
163+
doc = frameEle.contentDocument;
164+
testSuite = await testSuites(doc, 'declarative-shadow-dom', 'custom-elements');
165+
});
166+
167+
it('verifies all nodes & styles are preserved during hydration', async () => {
168+
await testSuite.sanityCheck();
169+
});
170+
171+
it('resolves slots correctly during client-side hydration', async () => {
172+
await testSuite.slots();
173+
});
174+
});
57175

58-
document.querySelector('#stage')?.remove();
59-
await browser.waitUntil(async () => !document.querySelector('#stage'));
176+
describe('custom-elements / scoped', () => {
177+
let doc: Document;
178+
let testSuite;
179+
180+
beforeEach(async () => {
181+
await setupIFrameTest('/ssr-hydration/custom-element.html', 'scoped-custom-elements');
182+
const frameEle: HTMLIFrameElement = document.querySelector('iframe#scoped-custom-elements');
183+
doc = frameEle.contentDocument;
184+
testSuite = await testSuites(doc, 'scoped', 'custom-elements');
185+
});
186+
187+
it('verifies all nodes & styles are preserved during hydration', async () => {
188+
await testSuite.sanityCheck();
189+
});
190+
191+
it('resolves slots correctly during client-side hydration', async () => {
192+
await testSuite.slots();
193+
});
60194
});
61195

62196
it('checks perf when loading lots of the same component', async () => {
63-
performance.mark('start');
197+
performance.mark('start-dsd');
64198

65199
await renderToString(
66200
Array(50)
@@ -69,49 +203,29 @@ describe('ssr-shadow-cmp', () => {
69203
.join(''),
70204
{
71205
fullDocument: true,
72-
serializeShadowRoot: true,
206+
serializeShadowRoot: 'declarative-shadow-dom',
73207
constrainTimeouts: false,
74208
},
75209
);
76-
performance.mark('end');
77-
const renderTime = performance.measure('render', 'start', 'end').duration;
210+
performance.mark('end-dsd');
211+
let renderTime = performance.measure('render', 'start-dsd', 'end-dsd').duration;
78212
await expect(renderTime).toBeLessThan(50);
79-
});
80-
81-
it('resolves slots correctly during client-side hydration', async () => {
82-
if (!document.querySelector('#stage')) {
83-
const { html } = await renderToString(
84-
`
85-
<ssr-shadow-cmp>
86-
<p>Default slot content</p>
87-
<p slot="client-only">Client-only slot content</p>
88-
</ssr-shadow-cmp>
89-
`,
90-
{
91-
fullDocument: true,
92-
serializeShadowRoot: true,
93-
constrainTimeouts: false,
94-
},
95-
);
96-
const stage = document.createElement('div');
97-
stage.setAttribute('id', 'stage');
98-
stage.setHTMLUnsafe(html);
99-
document.body.appendChild(stage);
100-
}
101213

102-
// @ts-expect-error resolved through WDIO
103-
const { defineCustomElements } = await import('/dist/loader/index.js');
104-
defineCustomElements().catch(console.error);
214+
performance.mark('start-scoped');
105215

106-
// wait for Stencil to take over and reconcile
107-
await browser.waitUntil(async () => customElements.get('ssr-shadow-cmp'));
108-
expect(typeof customElements.get('ssr-shadow-cmp')).toBe('function');
109-
110-
await browser.waitUntil(async () => document.querySelector('ssr-shadow-cmp [slot="client-only"]'));
111-
await expect(document.querySelector('ssr-shadow-cmp').textContent).toBe(
112-
' Default slot content Client-only slot content ',
216+
await renderToString(
217+
Array(50)
218+
.fill(0)
219+
.map((_, i) => `<ssr-shadow-cmp>Value ${i}</ssr-shadow-cmp>`)
220+
.join(''),
221+
{
222+
fullDocument: true,
223+
serializeShadowRoot: 'scoped',
224+
constrainTimeouts: false,
225+
},
113226
);
114-
115-
document.querySelector('#stage')?.remove();
227+
performance.mark('end-scoped');
228+
renderTime = performance.measure('render', 'start-scoped', 'end-scoped').duration;
229+
await expect(renderTime).toBeLessThan(50);
116230
});
117231
});

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

+10-7
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,24 @@ import { Build, Component, h, Prop } from '@stencil/core';
33
@Component({
44
tag: 'ssr-shadow-cmp',
55
shadow: true,
6+
styles: `
7+
:host {
8+
display: block;
9+
padding: 10px;
10+
border: 2px solid #000;
11+
background: yellow;
12+
color: red;
13+
}
14+
`,
615
})
716
export class SsrShadowCmp {
8-
@Prop() value: string;
9-
@Prop() label: string;
1017
@Prop() selected: boolean;
11-
@Prop() disabled: boolean;
1218

1319
render() {
1420
return (
1521
<div
1622
class={{
17-
option: true,
18-
'option--selected': this.selected,
19-
'option--disabled': this.disabled,
20-
'option--novalue': !this.value,
23+
selected: this.selected,
2124
}}
2225
>
2326
<slot name="top" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<html>
2+
<head>
3+
<title>SSR testing dist-custom-elements output</title>
4+
<script type="module">
5+
import { defineCustomElement } from '/test-components/ssr-shadow-cmp.js';
6+
defineCustomElement()
7+
</script>
8+
</head>
9+
<body>
10+
11+
</body>
12+
</html>
13+

‎test/wdio/stencil.config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const config: Config = {
1414
dir: 'test-components',
1515
customElementsExportBehavior: 'bundle',
1616
isPrimaryPackageOutputTarget: true,
17+
externalRuntime: false,
1718
},
1819
{
1920
type: 'dist-hydrate-script',

0 commit comments

Comments
 (0)
Please sign in to comment.