Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ssr): shadow dom components can render as declarative-shadow-dom or as 'scoped' #6147

Merged
merged 16 commits into from
Feb 11, 2025
Merged
2 changes: 1 addition & 1 deletion src/compiler/style/css-to-esm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ const transformCssToEsmModule = (input: d.TransformCssToEsmInput): d.TransformCs

if (isString(input.tag) && input.encapsulation === 'scoped') {
const scopeId = getScopeId(input.tag, input.mode);
results.styleText = scopeCss(results.styleText, scopeId);
results.styleText = scopeCss(results.styleText, scopeId, false);
}

const cssImports = getCssToEsmImports(varNames, results.styleText, input.file, input.mode);
Expand Down
2 changes: 1 addition & 1 deletion src/compiler/transformers/add-static-style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ const createStyleLiteral = (cmp: d.ComponentCompilerMeta, style: d.StyleCompiler
if (cmp.encapsulation === 'scoped') {
// scope the css first
const scopeId = getScopeId(cmp.tagName, style.modeName);
return ts.factory.createStringLiteral(scopeCss(style.styleStr, scopeId));
return ts.factory.createStringLiteral(scopeCss(style.styleStr, scopeId, false));
}

return ts.factory.createStringLiteral(style.styleStr);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ const createStyleLiteral = (cmp: d.ComponentCompilerMeta, style: d.StyleCompiler
if (cmp.encapsulation === 'scoped') {
// scope the css first
const scopeId = getScopeId(cmp.tagName, style.modeName);
return ts.factory.createStringLiteral(scopeCss(style.styleStr, scopeId));
return ts.factory.createStringLiteral(scopeCss(style.styleStr, scopeId, false));
}

return ts.factory.createStringLiteral(style.styleStr);
Expand Down
33 changes: 27 additions & 6 deletions src/declarations/stencil-public-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -946,12 +946,33 @@ export interface SerializeDocumentOptions extends HydrateDocumentOptions {
*/
removeHtmlComments?: boolean;
/**
* If set to `true` the component will be rendered within a Declarative Shadow DOM.
* If set to `false` Stencil will ignore the contents of the shadow root and render the
* element as given in provided template.
* @default true
*/
serializeShadowRoot?: boolean;
* Configure how Stencil serializes the components shadow root.
* - If set to `declarative-shadow-dom` the component will be rendered within a Declarative Shadow DOM.
* - If set to `scoped` Stencil will render the contents of the shadow root as a `scoped: true` component
* and the shadow DOM will be created during client-side hydration.
* - Alternatively you can mix and match the two by providing an object with `declarative-shadow-dom` and `scoped` keys,
* the value arrays containing the tag names of the components that should be rendered in that mode.
*
* Examples:
* - `{ 'declarative-shadow-dom': ['my-component-1', 'another-component'], default: 'scoped' }`
* Render all components as `scoped` apart from `my-component-1` and `another-component`
* - `{ 'scoped': ['an-option-component'], default: 'declarative-shadow-dom' }`
* Render all components within `declarative-shadow-dom` apart from `an-option-component`
* - `'scoped'` Render all components as `scoped`
* - `false` disables shadow root serialization
*
* *NOTE* `true` has been deprecated in favor of `declarative-shadow-dom` and `scoped`
* @default 'declarative-shadow-dom'
*/
serializeShadowRoot?:
| 'declarative-shadow-dom'
| 'scoped'
| {
'declarative-shadow-dom'?: string[];
scoped?: string[];
default: 'declarative-shadow-dom' | 'scoped';
}
| boolean;
/**
* The `fullDocument` flag determines the format of the rendered output. Set it to true to
* generate a complete HTML document, or false to render only the component.
Expand Down
57 changes: 57 additions & 0 deletions src/hydrate/platform/hydrate-app.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { globalScripts } from '@app-globals';
import { addHostEventListeners, doc, getHostRef, loadModule, plt, registerHost } from '@platform';
import { connectedCallback, insertVdomAnnotations } from '@runtime';
import { CMP_FLAGS } from '@utils';

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

if (Cstr != null && Cstr.cmpMeta != null) {
// we found valid component metadata

if (
opts.serializeShadowRoot !== false &&
!!(Cstr.cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation) &&
tagRequiresScoped(elm.tagName, opts.serializeShadowRoot)
) {
// this component requires scoped css encapsulation during SSR
const cmpMeta = Cstr.cmpMeta;
cmpMeta.$flags$ |= CMP_FLAGS.shadowNeedsScopedCss;

// 'cmpMeta' is a getter only, so needs redefining
Object.defineProperty(Cstr as any, 'cmpMeta', {
get: function (this: any) {
return cmpMeta;
},
});
}

createdElements.add(elm);
elm.connectedCallback = patchedConnectedCallback;

Expand Down Expand Up @@ -333,3 +352,41 @@ function waitingOnElementMsg(waitingElement: HTMLElement) {
function waitingOnElementsMsg(waitingElements: Set<HTMLElement>) {
return Array.from(waitingElements).map(waitingOnElementMsg);
}

/**
* Determines if the tag requires a declarative shadow dom
* or a scoped / light dom during SSR.
*
* @param tagName - component tag name
* @param opts - serializeShadowRoot options
* @returns `true` when the tag requires a scoped / light dom during SSR
*/
export function tagRequiresScoped(tagName: string, opts: d.HydrateFactoryOptions['serializeShadowRoot']) {
if (typeof opts === 'string') {
return opts === 'scoped';
}

if (typeof opts === 'boolean') {
return opts === true ? false : true;
}

if (typeof opts === 'object') {
tagName = tagName.toLowerCase();

if (Array.isArray(opts['declarative-shadow-dom']) && opts['declarative-shadow-dom'].includes(tagName)) {
// if the tag is in the dsd array, return dsd
return false;
} else if (
(!Array.isArray(opts.scoped) || !opts.scoped.includes(tagName)) &&
opts.default === 'declarative-shadow-dom'
) {
// if the tag is not in the scoped array and the default is dsd, return dsd
return false;
} else {
// otherwise, return scoped
return true;
}
}

return false;
}
9 changes: 7 additions & 2 deletions src/hydrate/platform/proxy-host-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,14 @@ export function proxyHostElement(elm: d.HostElement, cstr: d.ComponentConstructo
}

/**
* Only attach shadow root if there isn't one already
* Only attach shadow root if there isn't one already and
* the component is rendering DSD (not scoped) during SSR
*/
if (!elm.shadowRoot && !!(cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation)) {
if (
!elm.shadowRoot &&
!!(cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation) &&
!(cmpMeta.$flags$ & CMP_FLAGS.shadowNeedsScopedCss)
) {
if (BUILD.shadowDelegatesFocus) {
elm.attachShadow({
mode: 'open',
Expand Down
3 changes: 3 additions & 0 deletions src/hydrate/platform/test/__mocks__/@app-globals/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const globalScripts = /* default */ () => {
/**/
};
51 changes: 51 additions & 0 deletions src/hydrate/platform/test/serialize-shadow-root-opts.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { tagRequiresScoped as TypeTagRequiresScoped } from '../hydrate-app';

describe('tagRequiresScoped', () => {
let tagRequiresScoped: typeof TypeTagRequiresScoped;

beforeEach(async () => {
tagRequiresScoped = require('../hydrate-app').tagRequiresScoped;
});

afterEach(async () => {
jest.resetModules();
});

it('should return true for a component with serializeShadowRoot: true', () => {
expect(tagRequiresScoped('cmp-a', true)).toBe(false);
});

it('should return false for a component serializeShadowRoot: false', () => {
expect(tagRequiresScoped('cmp-b', false)).toBe(true);
});

it('should return false for a component with serializeShadowRoot: undefined', () => {
expect(tagRequiresScoped('cmp-c', undefined)).toBe(false);
});

it('should return true for a component with serializeShadowRoot: "scoped"', () => {
expect(tagRequiresScoped('cmp-d', 'scoped')).toBe(true);
});

it('should return false for a component with serializeShadowRoot: "declarative-shadow-dom"', () => {
expect(tagRequiresScoped('cmp-e', 'declarative-shadow-dom')).toBe(false);
});

it('should return true for a component when tag is in scoped list', () => {
expect(tagRequiresScoped('cmp-f', { scoped: ['cmp-f'], default: 'scoped' })).toBe(true);
});

it('should return false for a component when tag is not scoped list', () => {
expect(tagRequiresScoped('cmp-g', { scoped: ['cmp-f'], default: 'declarative-shadow-dom' })).toBe(false);
});

it('should return true for a component when default is scoped', () => {
expect(tagRequiresScoped('cmp-g', { 'declarative-shadow-dom': ['cmp-f'], default: 'scoped' })).toBe(true);
});

it('should return false for a component when default is declarative-shadow-dom', () => {
expect(tagRequiresScoped('cmp-g', { 'declarative-shadow-dom': ['cmp-f'], default: 'declarative-shadow-dom' })).toBe(
false,
);
});
});
3 changes: 2 additions & 1 deletion src/hydrate/runner/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ export function renderToString(
/**
* Defines whether we render the shadow root as a declarative shadow root or as scoped shadow root.
*/
opts.serializeShadowRoot = typeof opts.serializeShadowRoot === 'boolean' ? opts.serializeShadowRoot : true;
opts.serializeShadowRoot =
typeof opts.serializeShadowRoot === 'undefined' ? 'declarative-shadow-dom' : opts.serializeShadowRoot;
/**
* Make sure we wait for components to be hydrated.
*/
Expand Down
2 changes: 1 addition & 1 deletion src/mock-doc/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ export class MockElement extends MockNode {
*
* For example:
* calling `renderToString('<my-component></my-component>', {
* serializeShadowRoot: false
* serializeShadowRoot: 'scoped'
* })`
*/
delete this.__shadowRoot;
Expand Down
15 changes: 12 additions & 3 deletions src/mock-doc/serialize-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ function normalizeSerializationOptions(opts: Partial<SerializeNodeToHtmlOptions>
removeBooleanAttributeQuotes:
typeof opts.removeBooleanAttributeQuotes !== 'boolean' ? false : opts.removeBooleanAttributeQuotes,
removeHtmlComments: typeof opts.removeHtmlComments !== 'boolean' ? false : opts.removeHtmlComments,
serializeShadowRoot: typeof opts.serializeShadowRoot !== 'boolean' ? true : opts.serializeShadowRoot,
serializeShadowRoot:
typeof opts.serializeShadowRoot === 'undefined' ? 'declarative-shadow-dom' : opts.serializeShadowRoot,
fullDocument: typeof opts.fullDocument !== 'boolean' ? true : opts.fullDocument,
} as const;
}
Expand Down Expand Up @@ -243,7 +244,7 @@ function* streamToHtml(

if (EMPTY_ELEMENTS.has(tagName) === false) {
const shadowRoot = (node as HTMLElement).shadowRoot;
if (shadowRoot != null && opts.serializeShadowRoot) {
if (shadowRoot != null && opts.serializeShadowRoot !== false) {
output.indent = output.indent + (opts.indentSpaces ?? 0);

yield* streamToHtml(shadowRoot, opts, output);
Expand Down Expand Up @@ -681,6 +682,14 @@ export interface SerializeNodeToHtmlOptions {
removeBooleanAttributeQuotes?: boolean;
removeEmptyAttributes?: boolean;
removeHtmlComments?: boolean;
serializeShadowRoot?: boolean;
serializeShadowRoot?:
| 'declarative-shadow-dom'
| 'scoped'
| {
'declarative-shadow-dom'?: string[];
scoped?: string[];
default: 'declarative-shadow-dom' | 'scoped';
}
| boolean;
fullDocument?: boolean;
}
6 changes: 5 additions & 1 deletion src/runtime/bootstrap-custom-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
import { computeMode } from './mode';
import { proxyComponent } from './proxy-component';
import { PROXY_FLAGS } from './runtime-constants';
import { attachStyles, getScopeId, registerStyle } from './styles';
import { attachStyles, getScopeId, hydrateScopedToShadow, registerStyle } from './styles';

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

if (BUILD.hydrateClientSide && BUILD.shadowDom) {
hydrateScopedToShadow();
}

const originalConnectedCallback = Cstr.prototype.connectedCallback;
const originalDisconnectedCallback = Cstr.prototype.disconnectedCallback;
Object.assign(Cstr.prototype, {
Expand Down
5 changes: 5 additions & 0 deletions src/runtime/bootstrap-lazy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { hmrStart } from './hmr-component';
import { createTime, installDevTools } from './profile';
import { proxyComponent } from './proxy-component';
import { HYDRATED_CSS, PLATFORM_FLAGS, PROXY_FLAGS, SLOT_FB_CSS } from './runtime-constants';
import { hydrateScopedToShadow } from './styles';
import { appDidLoad } from './update-component';
export { setNonce } from '@platform';

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

if (BUILD.hydrateClientSide && BUILD.shadowDom) {
hydrateScopedToShadow();
}

let hasSlotRelocation = false;
lazyBundles.map((lazyBundle) => {
lazyBundle[1].map((compactMeta) => {
Expand Down
Loading