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) => {

0 commit comments

Comments
 (0)
Please sign in to comment.