Skip to content

Commit

Permalink
feat(ssr): shadow dom components can render as declarative-shadow-dom…
Browse files Browse the repository at this point in the history
… 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>
  • Loading branch information
3 people authored Feb 11, 2025
1 parent 3281159 commit 26e4aa3
Show file tree
Hide file tree
Showing 29 changed files with 592 additions and 162 deletions.
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

0 comments on commit 26e4aa3

Please sign in to comment.