From 26e4aa3221d293f294d56b050f29a244d3e44c8c Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Tue, 11 Feb 2025 16:39:26 +0000 Subject: [PATCH] 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 * Update src/declarations/stencil-public-compiler.ts Co-authored-by: Christian Bromann * Update src/declarations/stencil-public-compiler.ts Co-authored-by: Christian Bromann * 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 * Update src/runtime/client-hydrate.ts Co-authored-by: Christian Bromann * Update src/runtime/client-hydrate.ts Co-authored-by: Christian Bromann * chore: fixup tests and suggestions --------- Co-authored-by: John Jenkins Co-authored-by: Christian Bromann --- src/compiler/style/css-to-esm.ts | 2 +- src/compiler/transformers/add-static-style.ts | 2 +- .../component-native/native-static-style.ts | 2 +- src/declarations/stencil-public-compiler.ts | 33 +- src/hydrate/platform/hydrate-app.ts | 57 ++++ src/hydrate/platform/proxy-host-element.ts | 9 +- .../test/__mocks__/@app-globals/index.ts | 3 + .../test/serialize-shadow-root-opts.spec.ts | 51 +++ src/hydrate/runner/render.ts | 3 +- src/mock-doc/node.ts | 2 +- src/mock-doc/serialize-node.ts | 15 +- src/runtime/bootstrap-custom-element.ts | 6 +- src/runtime/bootstrap-lazy.ts | 5 + src/runtime/client-hydrate.ts | 59 +++- src/runtime/initialize-component.ts | 12 +- src/runtime/styles.ts | 48 ++- .../test/hydrate-shadow-in-shadow.spec.tsx | 1 - src/runtime/vdom/vdom-render.ts | 5 +- .../jest/jest-27-and-under/matchers/html.ts | 2 +- src/testing/jest/jest-28/matchers/html.ts | 2 +- src/testing/jest/jest-29/matchers/html.ts | 2 +- src/utils/constants.ts | 6 + src/utils/shadow-css.ts | 61 +++- src/utils/test/scope-css.spec.ts | 19 +- .../scoped-hydration/scoped-hydration.e2e.ts | 26 +- test/wdio/ssr-hydration/cmp.test.tsx | 290 ++++++++++++------ test/wdio/ssr-hydration/cmp.tsx | 17 +- test/wdio/ssr-hydration/custom-element.html | 13 + test/wdio/stencil.config.ts | 1 + 29 files changed, 592 insertions(+), 162 deletions(-) create mode 100644 src/hydrate/platform/test/__mocks__/@app-globals/index.ts create mode 100644 src/hydrate/platform/test/serialize-shadow-root-opts.spec.ts create mode 100644 test/wdio/ssr-hydration/custom-element.html diff --git a/src/compiler/style/css-to-esm.ts b/src/compiler/style/css-to-esm.ts index 67c7519f9d6..c259a076d0d 100644 --- a/src/compiler/style/css-to-esm.ts +++ b/src/compiler/style/css-to-esm.ts @@ -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); diff --git a/src/compiler/transformers/add-static-style.ts b/src/compiler/transformers/add-static-style.ts index 74efe1257de..6345aa41867 100644 --- a/src/compiler/transformers/add-static-style.ts +++ b/src/compiler/transformers/add-static-style.ts @@ -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); diff --git a/src/compiler/transformers/component-native/native-static-style.ts b/src/compiler/transformers/component-native/native-static-style.ts index 3accfc4b2cf..c5ea1f3eca1 100644 --- a/src/compiler/transformers/component-native/native-static-style.ts +++ b/src/compiler/transformers/component-native/native-static-style.ts @@ -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); diff --git a/src/declarations/stencil-public-compiler.ts b/src/declarations/stencil-public-compiler.ts index f93664052a8..36e5bf6ac6b 100644 --- a/src/declarations/stencil-public-compiler.ts +++ b/src/declarations/stencil-public-compiler.ts @@ -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. diff --git a/src/hydrate/platform/hydrate-app.ts b/src/hydrate/platform/hydrate-app.ts index 1925fae3b8f..6f58eb5a156 100644 --- a/src/hydrate/platform/hydrate-app.ts +++ b/src/hydrate/platform/hydrate-app.ts @@ -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'; @@ -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; @@ -333,3 +352,41 @@ function waitingOnElementMsg(waitingElement: HTMLElement) { function waitingOnElementsMsg(waitingElements: Set) { 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; +} diff --git a/src/hydrate/platform/proxy-host-element.ts b/src/hydrate/platform/proxy-host-element.ts index 01cc12dffba..7c13f5a6123 100644 --- a/src/hydrate/platform/proxy-host-element.ts +++ b/src/hydrate/platform/proxy-host-element.ts @@ -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', diff --git a/src/hydrate/platform/test/__mocks__/@app-globals/index.ts b/src/hydrate/platform/test/__mocks__/@app-globals/index.ts new file mode 100644 index 00000000000..9706bce00fb --- /dev/null +++ b/src/hydrate/platform/test/__mocks__/@app-globals/index.ts @@ -0,0 +1,3 @@ +export const globalScripts = /* default */ () => { + /**/ +}; diff --git a/src/hydrate/platform/test/serialize-shadow-root-opts.spec.ts b/src/hydrate/platform/test/serialize-shadow-root-opts.spec.ts new file mode 100644 index 00000000000..85f1b1c25e6 --- /dev/null +++ b/src/hydrate/platform/test/serialize-shadow-root-opts.spec.ts @@ -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, + ); + }); +}); diff --git a/src/hydrate/runner/render.ts b/src/hydrate/runner/render.ts index 920034388b6..d68c77c1782 100644 --- a/src/hydrate/runner/render.ts +++ b/src/hydrate/runner/render.ts @@ -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. */ diff --git a/src/mock-doc/node.ts b/src/mock-doc/node.ts index bbab00ed33d..f7597828539 100644 --- a/src/mock-doc/node.ts +++ b/src/mock-doc/node.ts @@ -308,7 +308,7 @@ export class MockElement extends MockNode { * * For example: * calling `renderToString('', { - * serializeShadowRoot: false + * serializeShadowRoot: 'scoped' * })` */ delete this.__shadowRoot; diff --git a/src/mock-doc/serialize-node.ts b/src/mock-doc/serialize-node.ts index 905fce9aa84..677c37948da 100644 --- a/src/mock-doc/serialize-node.ts +++ b/src/mock-doc/serialize-node.ts @@ -36,7 +36,8 @@ function normalizeSerializationOptions(opts: Partial 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; } @@ -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); @@ -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; } diff --git a/src/runtime/bootstrap-custom-element.ts b/src/runtime/bootstrap-custom-element.ts index 736f7de6eaf..8bd005657b5 100644 --- a/src/runtime/bootstrap-custom-element.ts +++ b/src/runtime/bootstrap-custom-element.ts @@ -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); @@ -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, { diff --git a/src/runtime/bootstrap-lazy.ts b/src/runtime/bootstrap-lazy.ts index e723323cc28..6e46f96f1bf 100644 --- a/src/runtime/bootstrap-lazy.ts +++ b/src/runtime/bootstrap-lazy.ts @@ -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'; @@ -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) => { diff --git a/src/runtime/client-hydrate.ts b/src/runtime/client-hydrate.ts index 3077044acda..0bc1360be66 100644 --- a/src/runtime/client-hydrate.ts +++ b/src/runtime/client-hydrate.ts @@ -64,7 +64,7 @@ export const initializeClientHydrate = ( } } - if (!plt.$orgLocNodes$) { + if (!plt.$orgLocNodes$ || !plt.$orgLocNodes$.size) { // This is the first pass over of this whole document; // does a scrape to construct a 'bare-bones' tree of what elements we have and where content has been moved from initializeDocumentHydrate(doc.body, (plt.$orgLocNodes$ = new Map())); @@ -214,15 +214,36 @@ export const initializeClientHydrate = ( }); } - if (BUILD.shadowDom && shadowRoot) { + if (BUILD.shadowDom && shadowRoot && !shadowRoot.childNodes.length) { + // For `scoped` shadowDOM rendering (not DSD); // Add all the root nodes in the shadowDOM (a root node can have a whole nested DOM tree) let rnIdex = 0; const rnLen = shadowRootNodes.length; - for (rnIdex; rnIdex < rnLen; rnIdex++) { - shadowRoot.appendChild(shadowRootNodes[rnIdex] as any); + if (rnLen) { + for (rnIdex; rnIdex < rnLen; rnIdex++) { + shadowRoot.appendChild(shadowRootNodes[rnIdex]); + } + + Array.from(hostElm.childNodes).forEach((node) => { + if (typeof (node as d.RenderNode)['s-sn'] !== 'string') { + if (node.nodeType === NODE_TYPE.ElementNode && (node as HTMLElement).slot && (node as HTMLElement).hidden) { + // this is a slotted node that doesn't have a home ... yet. + // we can safely leave it be, native behavior will mean it's hidden + (node as HTMLElement).removeAttribute('hidden'); + } else if ( + node.nodeType === NODE_TYPE.CommentNode || + (node.nodeType === NODE_TYPE.TextNode && !(node as Text).wholeText.trim()) + ) { + // During `scoped` shadowDOM rendering, there's a bunch of comment nodes used for positioning / empty text nodes. + // Let's tidy them up now to stop frameworks complaining about DOM mismatches. + node.parentNode.removeChild(node); + } + } + }); } } + plt.$orgLocNodes$.delete(hostElm['s-id']); hostRef.$hostElement$ = hostElm; endHydrate(); }; @@ -391,7 +412,7 @@ const clientHydrate = ( }); if (childNodeType === TEXT_NODE_ID) { - childVNode.$elm$ = node.nextSibling as any; + childVNode.$elm$ = findCorrespondingNode(node, NODE_TYPE.TextNode) as d.RenderNode; if (childVNode.$elm$ && childVNode.$elm$.nodeType === NODE_TYPE.TextNode) { childVNode.$text$ = childVNode.$elm$.textContent; @@ -415,7 +436,7 @@ const clientHydrate = ( } } } else if (childNodeType === COMMENT_NODE_ID) { - childVNode.$elm$ = node.nextSibling as any; + childVNode.$elm$ = findCorrespondingNode(node, NODE_TYPE.CommentNode) as d.RenderNode; if (childVNode.$elm$ && childVNode.$elm$.nodeType === NODE_TYPE.CommentNode) { // A non-Stencil comment node @@ -463,6 +484,14 @@ const clientHydrate = ( vnode.$elm$ = node; vnode.$index$ = '0'; parentVNode.$children$ = [vnode]; + } else { + if (node.nodeType === NODE_TYPE.TextNode && !(node as unknown as Text).wholeText.trim()) { + // empty white space is never accounted for from SSR so there's + // no corresponding comment node giving it a position in the DOM. + // It therefore gets slotted / clumped together at the end of the host. + // It's cleaner to remove. Ideally, SSR is rendered with `prettyHtml: false` + node.remove(); + } } return parentVNode; @@ -471,7 +500,7 @@ const clientHydrate = ( /** * Recursively locate any comments representing an 'original location' for a node; in a node's children or shadowRoot children. * Creates a map of component IDs and 'original location' ID's which are derived from comment nodes placed by 'vdom-annotations.ts'. - * Each 'original location' relates to lightDOM node that was moved deeper into the SSR markup. e.g. `` maps to `
` + * Each 'original location' relates to a lightDOM node that was moved deeper into the SSR markup. e.g. `` maps to `
` * * @param node The node to search. * @param orgLocNodes A map of the original location annotations and the current node being searched. @@ -639,6 +668,22 @@ const addSlottedNodes = ( } }; +/** + * Steps through the node's siblings to find the next node of a specific type, with a value. + * e.g. when we find a position comment ``, we need to find the next text node with a value. + * (it's a guard against whitespace which is never accounted for in the SSR output) + * @param node - the starting node + * @param type - the type of node to find + * @returns the first corresponding node of the type + */ +const findCorrespondingNode = (node: Node, type: NODE_TYPE.CommentNode | NODE_TYPE.TextNode) => { + let sibling = node; + do { + sibling = sibling.nextSibling; + } while (sibling && (sibling.nodeType !== type || !sibling.nodeValue)); + return sibling; +}; + type SlottedNodes = Array<{ slot: d.RenderNode; node: d.RenderNode; hostId: string }>; interface RenderNodeData extends d.VNode { diff --git a/src/runtime/initialize-component.ts b/src/runtime/initialize-component.ts index db155bd366e..16267122c3f 100644 --- a/src/runtime/initialize-component.ts +++ b/src/runtime/initialize-component.ts @@ -3,6 +3,7 @@ import { consoleError, loadModule, styles } from '@platform'; import { CMP_FLAGS, HOST_FLAGS } from '@utils'; import type * as d from '../declarations'; +import { scopeCss } from '../utils/shadow-css'; import { computeMode } from './mode'; import { createTime, uniqueTime } from './profile'; import { proxyComponent } from './proxy-component'; @@ -155,16 +156,9 @@ export const initializeComponent = async ( if (!styles.has(scopeId)) { const endRegisterStyles = createTime('registerStyles', cmpMeta.$tagName$); - if ( - !BUILD.hydrateServerSide && - BUILD.shadowDom && - // TODO(STENCIL-854): Remove code related to legacy shadowDomShim field - BUILD.shadowDomShim && - cmpMeta.$flags$ & CMP_FLAGS.needsShadowDomShim - ) { - style = await import('@utils/shadow-css').then((m) => m.scopeCss(style, scopeId)); + if (BUILD.hydrateServerSide && BUILD.shadowDom && cmpMeta.$flags$ & CMP_FLAGS.shadowNeedsScopedCss) { + style = scopeCss(style, scopeId, true); } - registerStyle(scopeId, style, !!(cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation)); endRegisterStyles(); } diff --git a/src/runtime/styles.ts b/src/runtime/styles.ts index 5599b9240e2..ae5b6ef834d 100644 --- a/src/runtime/styles.ts +++ b/src/runtime/styles.ts @@ -86,7 +86,7 @@ export const addStyle = (styleContainerNode: any, cmpMeta: d.ComponentRuntimeMet if ( (BUILD.hydrateServerSide || BUILD.hotModuleReplacement) && - cmpMeta.$flags$ & CMP_FLAGS.scopedCssEncapsulation + (cmpMeta.$flags$ & CMP_FLAGS.scopedCssEncapsulation || cmpMeta.$flags$ & CMP_FLAGS.shadowNeedsScopedCss) ) { styleElm.setAttribute(HYDRATED_STYLE_ID, scopeId); } @@ -147,7 +147,7 @@ export const addStyle = (styleContainerNode: any, cmpMeta: d.ComponentRuntimeMet /** * attach styles at the beginning of a shadow root node if we render shadow components */ - if (cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation && styleContainerNode.nodeName !== 'HEAD') { + if (cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation) { styleContainerNode.insertBefore(styleElm, null); } } @@ -188,8 +188,8 @@ export const attachStyles = (hostRef: d.HostRef) => { if ( (BUILD.shadowDom || BUILD.scoped) && BUILD.cssAnnotations && - flags & CMP_FLAGS.needsScopedEncapsulation && - flags & CMP_FLAGS.scopedCssEncapsulation + ((flags & CMP_FLAGS.needsScopedEncapsulation && flags & CMP_FLAGS.scopedCssEncapsulation) || + flags & CMP_FLAGS.shadowNeedsScopedCss) ) { // only required when we're NOT using native shadow dom (slot) // or this browser doesn't support native shadow dom @@ -214,6 +214,46 @@ export const attachStyles = (hostRef: d.HostRef) => { export const getScopeId = (cmp: d.ComponentRuntimeMeta, mode?: string) => 'sc-' + (BUILD.mode && mode && cmp.$flags$ & CMP_FLAGS.hasMode ? cmp.$tagName$ + '-' + mode : cmp.$tagName$); +/** + * Convert a 'scoped' CSS string to one appropriate for use in the shadow DOM. + * + * Given a 'scoped' CSS string that looks like this: + * + * ``` + * /*!@div*\/div.class-name { display: flex }; + * ``` + * + * Convert it to a 'shadow' appropriate string, like so: + * + * ``` + * /*!@div*\/div.class-name { display: flex } + * ─┬─ ────────┬──────── + * │ │ + * │ ┌─────────────────┘ + * ▼ ▼ + * div{ display: flex } + * ``` + * + * Note that forward-slashes in the above are escaped so they don't end the + * comment. + * + * @param css a CSS string to convert + * @returns the converted string + */ +export const convertScopedToShadow = (css: string) => css.replace(/\/\*!@([^\/]+)\*\/[^\{]+\{/g, '$1{'); + +/** + * Hydrate styles after SSR for components *not* using DSD. Convert 'scoped' styles to 'shadow' + * and add them to a constructable stylesheet. + */ +export const hydrateScopedToShadow = () => { + const styles = doc.querySelectorAll(`[${HYDRATED_STYLE_ID}]`); + let i = 0; + for (; i < styles.length; i++) { + registerStyle(styles[i].getAttribute(HYDRATED_STYLE_ID), convertScopedToShadow(styles[i].innerHTML), true); + } +}; + declare global { export interface CSSStyleSheet { replaceSync(cssText: string): void; diff --git a/src/runtime/test/hydrate-shadow-in-shadow.spec.tsx b/src/runtime/test/hydrate-shadow-in-shadow.spec.tsx index 7db0a01f1a9..1ff3c9c3576 100644 --- a/src/runtime/test/hydrate-shadow-in-shadow.spec.tsx +++ b/src/runtime/test/hydrate-shadow-in-shadow.spec.tsx @@ -60,7 +60,6 @@ describe('hydrate, shadow in shadow', () => { - diff --git a/src/runtime/vdom/vdom-render.ts b/src/runtime/vdom/vdom-render.ts index a21cae838d1..79cd7f16a2e 100644 --- a/src/runtime/vdom/vdom-render.ts +++ b/src/runtime/vdom/vdom-render.ts @@ -1021,7 +1021,10 @@ render() { scopeId = hostElm['s-sc']; } - useNativeShadowDom = supportsShadow && (cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation) !== 0; + useNativeShadowDom = + supportsShadow && + !!(cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation) && + !(cmpMeta.$flags$ & CMP_FLAGS.shadowNeedsScopedCss); if (BUILD.slotRelocation) { contentRef = hostElm['s-cr']; diff --git a/src/testing/jest/jest-27-and-under/matchers/html.ts b/src/testing/jest/jest-27-and-under/matchers/html.ts index d407a061bf8..46c4d9febe1 100644 --- a/src/testing/jest/jest-27-and-under/matchers/html.ts +++ b/src/testing/jest/jest-27-and-under/matchers/html.ts @@ -12,7 +12,7 @@ export function toEqualLightHtml(input: string | HTMLElement | ShadowRoot, shoul export function compareHtml( input: string | HTMLElement | ShadowRoot, shouldEqual: string, - serializeShadowRoot: boolean, + serializeShadowRoot: d.SerializeDocumentOptions['serializeShadowRoot'], ) { if (input == null) { throw new Error(`expect toEqualHtml() value is "${input}"`); diff --git a/src/testing/jest/jest-28/matchers/html.ts b/src/testing/jest/jest-28/matchers/html.ts index d407a061bf8..46c4d9febe1 100644 --- a/src/testing/jest/jest-28/matchers/html.ts +++ b/src/testing/jest/jest-28/matchers/html.ts @@ -12,7 +12,7 @@ export function toEqualLightHtml(input: string | HTMLElement | ShadowRoot, shoul export function compareHtml( input: string | HTMLElement | ShadowRoot, shouldEqual: string, - serializeShadowRoot: boolean, + serializeShadowRoot: d.SerializeDocumentOptions['serializeShadowRoot'], ) { if (input == null) { throw new Error(`expect toEqualHtml() value is "${input}"`); diff --git a/src/testing/jest/jest-29/matchers/html.ts b/src/testing/jest/jest-29/matchers/html.ts index d407a061bf8..46c4d9febe1 100644 --- a/src/testing/jest/jest-29/matchers/html.ts +++ b/src/testing/jest/jest-29/matchers/html.ts @@ -12,7 +12,7 @@ export function toEqualLightHtml(input: string | HTMLElement | ShadowRoot, shoul export function compareHtml( input: string | HTMLElement | ShadowRoot, shouldEqual: string, - serializeShadowRoot: boolean, + serializeShadowRoot: d.SerializeDocumentOptions['serializeShadowRoot'], ) { if (input == null) { throw new Error(`expect toEqualHtml() value is "${input}"`); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index b2bf0febaf9..5c547a953eb 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -113,6 +113,12 @@ export const enum CMP_FLAGS { * options passed to the `@Component` decorator. */ formAssociated = 1 << 6, + + /** + * Determines if a `shadow: true` component needs + * to have its styles scoped during SSR as opposed to using DSD. + */ + shadowNeedsScopedCss = 1 << 7, } /** diff --git a/src/utils/shadow-css.ts b/src/utils/shadow-css.ts index eaa31f7e694..f2a600bd12c 100644 --- a/src/utils/shadow-css.ts +++ b/src/utils/shadow-css.ts @@ -425,7 +425,13 @@ const scopeSelector = (selector: string, scopeSelectorText: string, hostSelector .join(', '); }; -const scopeSelectors = (cssText: string, scopeSelectorText: string, hostSelector: string, slotSelector: string) => { +const scopeSelectors = ( + cssText: string, + scopeSelectorText: string, + hostSelector: string, + slotSelector: string, + commentOriginalSelector: boolean, +) => { return processRules(cssText, (rule: CssRule) => { let selector = rule.selector; let content = rule.content; @@ -437,7 +443,7 @@ const scopeSelectors = (cssText: string, scopeSelectorText: string, hostSelector rule.selector.startsWith('@page') || rule.selector.startsWith('@document') ) { - content = scopeSelectors(rule.content, scopeSelectorText, hostSelector, slotSelector); + content = scopeSelectors(rule.content, scopeSelectorText, hostSelector, slotSelector, commentOriginalSelector); } const cssRule: CssRule = { @@ -448,7 +454,13 @@ const scopeSelectors = (cssText: string, scopeSelectorText: string, hostSelector }); }; -const scopeCssText = (cssText: string, scopeId: string, hostScopeId: string, slotScopeId: string) => { +const scopeCssText = ( + cssText: string, + scopeId: string, + hostScopeId: string, + slotScopeId: string, + commentOriginalSelector: boolean, +) => { cssText = insertPolyfillHostInCssText(cssText); cssText = convertColonHost(cssText); cssText = convertColonHostContext(cssText); @@ -458,7 +470,7 @@ const scopeCssText = (cssText: string, scopeId: string, hostScopeId: string, slo cssText = convertShadowDOMSelectors(cssText); if (scopeId) { - cssText = scopeSelectors(cssText, scopeId, hostScopeId, slotScopeId); + cssText = scopeSelectors(cssText, scopeId, hostScopeId, slotScopeId, commentOriginalSelector); } cssText = replaceShadowCssHost(cssText, hostScopeId); @@ -487,16 +499,53 @@ const replaceShadowCssHost = (cssText: string, hostScopeId: string) => { return cssText.replace(/-shadowcsshost-no-combinator/g, `.${hostScopeId}`); }; -export const scopeCss = (cssText: string, scopeId: string) => { +export const scopeCss = (cssText: string, scopeId: string, commentOriginalSelector: boolean) => { const hostScopeId = scopeId + '-h'; const slotScopeId = scopeId + '-s'; const commentsWithHash = extractCommentsWithHash(cssText); cssText = stripComments(cssText); - const scoped = scopeCssText(cssText, scopeId, hostScopeId, slotScopeId); + const orgSelectors: { + placeholder: string; + comment: string; + }[] = []; + + if (commentOriginalSelector) { + const processCommentedSelector = (rule: CssRule) => { + const placeholder = `/*!@___${orgSelectors.length}___*/`; + const comment = `/*!@${rule.selector}*/`; + + orgSelectors.push({ placeholder, comment }); + rule.selector = placeholder + rule.selector; + return rule; + }; + + cssText = processRules(cssText, (rule) => { + if (rule.selector[0] !== '@') { + return processCommentedSelector(rule); + } else if ( + rule.selector.startsWith('@media') || + rule.selector.startsWith('@supports') || + rule.selector.startsWith('@page') || + rule.selector.startsWith('@document') + ) { + rule.content = processRules(rule.content, processCommentedSelector); + return rule; + } + return rule; + }); + } + + const scoped = scopeCssText(cssText, scopeId, hostScopeId, slotScopeId, commentOriginalSelector); cssText = [scoped.cssText, ...commentsWithHash].join('\n'); + if (commentOriginalSelector) { + orgSelectors.forEach(({ placeholder, comment }) => { + cssText = cssText.replace(placeholder, comment); + }); + } + scoped.slottedSelectors.forEach((slottedSelector) => { const regex = new RegExp(escapeRegExpSpecialCharacters(slottedSelector.orgSelector), 'g'); cssText = cssText.replace(regex, slottedSelector.updatedSelector); diff --git a/src/utils/test/scope-css.spec.ts b/src/utils/test/scope-css.spec.ts index a6e9dff6dad..d2a981e6535 100644 --- a/src/utils/test/scope-css.spec.ts +++ b/src/utils/test/scope-css.spec.ts @@ -14,8 +14,8 @@ import { scopeCss } from '../shadow-css'; describe('ShadowCss', function () { - function s(cssText: string, scopeId: string) { - const shim = scopeCss(cssText, scopeId); + function s(cssText: string, scopeId: string, commentOriginalSelector = false) { + const shim = scopeCss(cssText, scopeId, commentOriginalSelector); const nlRegexp = /\n/g; return normalizeCSS(shim.replace(nlRegexp, '')); @@ -25,6 +25,21 @@ describe('ShadowCss', function () { expect(s('', 'a')).toEqual(''); }); + it('should handle empty string, commented org selector', () => { + expect(s('', 'a', true)).toEqual(''); + }); + + it('div', () => { + const r = s('div {}', 'sc-ion-tag', true); + expect(r).toEqual('/*!@div*/div.sc-ion-tag {}'); + }); + + it('should add an attribute to every rule, commented org selector', () => { + const css = 'one {color: red;}two {color: red;}'; + const expected = '/*!@one*/one.a {color:red;}/*!@two*/two.a {color:red;}'; + expect(s(css, 'a', true)).toEqual(expected); + }); + it('should add an attribute to every rule', () => { const css = 'one {color: red;}two {color: red;}'; const expected = 'one.a {color:red;}two.a {color:red;}'; diff --git a/test/end-to-end/src/scoped-hydration/scoped-hydration.e2e.ts b/test/end-to-end/src/scoped-hydration/scoped-hydration.e2e.ts index 5f26e7b5f6e..51eaccff64a 100644 --- a/test/end-to-end/src/scoped-hydration/scoped-hydration.e2e.ts +++ b/test/end-to-end/src/scoped-hydration/scoped-hydration.e2e.ts @@ -28,8 +28,8 @@ describe('`scoped: true` hydration checks', () => { const page = await newE2EPage({ html, url: 'https://stencil.com' }); const styles = await page.findAll('style'); expect(styles.length).toBe(3); - expect(styles[0].textContent).toContain(`.sc-non-shadow-child-h`); - expect(styles[1].textContent).not.toContain(`.sc-non-shadow-child-h`); + expect(styles[1].textContent).toContain(`.sc-non-shadow-child-h`); + expect(styles[0].textContent).not.toContain(`.sc-non-shadow-child-h`); expect(styles[2].textContent).not.toContain(`.sc-non-shadow-child-h`); }); @@ -154,29 +154,21 @@ describe('`scoped: true` hydration checks', () => { await page.evaluate(() => { (window as any).root = document.querySelector('hydrated-sibling-accessors'); }); - expect(await page.evaluate(() => root.firstChild.nextSibling.textContent)).toBe('First slot element'); - expect(await page.evaluate(() => root.firstChild.nextSibling.nextSibling.textContent)).toBe( - ' Default slot text node ', - ); + expect(await page.evaluate(() => root.firstChild.textContent)).toBe('First slot element'); + expect(await page.evaluate(() => root.firstChild.nextSibling.textContent)).toBe(' Default slot text node '); + expect(await page.evaluate(() => root.firstChild.nextSibling.nextSibling.textContent)).toBe('Second slot element'); expect(await page.evaluate(() => root.firstChild.nextSibling.nextSibling.nextSibling.textContent)).toBe( - 'Second slot element', - ); - expect(await page.evaluate(() => root.firstChild.nextSibling.nextSibling.nextSibling.nextSibling.textContent)).toBe( ' Default slot comment node ', ); - expect(await page.evaluate(() => root.lastChild.previousSibling.textContent)).toBe(' Default slot comment node '); + expect(await page.evaluate(() => root.lastChild.textContent)).toBe(' Default slot comment node '); + expect(await page.evaluate(() => root.lastChild.previousSibling.textContent)).toBe('Second slot element'); expect(await page.evaluate(() => root.lastChild.previousSibling.previousSibling.textContent)).toBe( - 'Second slot element', + ' Default slot text node ', ); expect(await page.evaluate(() => root.lastChild.previousSibling.previousSibling.previousSibling.textContent)).toBe( - ' Default slot text node ', + 'First slot element', ); - expect( - await page.evaluate( - () => root.lastChild.previousSibling.previousSibling.previousSibling.previousSibling.textContent, - ), - ).toBe('First slot element'); }); it('Steps through only "lightDOM" elements', async () => { diff --git a/test/wdio/ssr-hydration/cmp.test.tsx b/test/wdio/ssr-hydration/cmp.test.tsx index 6f8e10287cd..af5035062c8 100644 --- a/test/wdio/ssr-hydration/cmp.test.tsx +++ b/test/wdio/ssr-hydration/cmp.test.tsx @@ -1,66 +1,200 @@ +import { browser } from '@wdio/globals'; + import { renderToString } from '../hydrate/index.mjs'; +import { setupIFrameTest } from '../util.js'; -describe('ssr-shadow-cmp', () => { - function getNodeNames(chidNodes: NodeListOf) { - return Array.from(chidNodes) - .flatMap((node) => { - if (node.nodeType === 3) { - if (node.textContent?.trim()) { - return 'text'; +describe('Sanity check SSR > Client hydration', () => { + const testSuites = async ( + root: Document, + method: 'scoped' | 'declarative-shadow-dom', + renderType: 'dist' | 'custom-elements', + ) => { + function getNodeNames(chidNodes: NodeListOf) { + return Array.from(chidNodes) + .flatMap((node) => { + if (node.nodeType === 3) { + if (node.textContent?.trim()) { + return 'text'; + } else { + return []; + } + } else if (node.nodeType === 8) { + return 'comment'; } else { - return []; + return node.nodeName.toLowerCase(); } - } else if (node.nodeType === 8) { - return 'comment'; - } else { - return node.nodeName.toLowerCase(); - } - }) - .join(' '); - } - - it('verifies all nodes are preserved during hydration', async () => { - if (!document.querySelector('#stage')) { - const { html } = await renderToString( - ` - - A text node - -
An element
- - Another text node -
- `, - { - fullDocument: true, - serializeShadowRoot: true, - constrainTimeouts: false, - }, - ); - const stage = document.createElement('div'); - stage.setAttribute('id', 'stage'); - stage.setHTMLUnsafe(html); - document.body.appendChild(stage); + }) + .join(' '); } - // @ts-expect-error resolved through WDIO - const { defineCustomElements } = await import('/dist/loader/index.js'); - defineCustomElements().catch(console.error); + return { + sanityCheck: async () => { + if (root.querySelector('#stage')) { + root.querySelector('#stage')?.remove(); + await browser.waitUntil(async () => !root.querySelector('#stage')); + } + const { html } = await renderToString( + ` + + A text node + +
An element
+ + Another text node +
+ `, + { + fullDocument: true, + serializeShadowRoot: method, + constrainTimeouts: false, + prettyHTML: false, + }, + ); + const stage = root.createElement('div'); + stage.setAttribute('id', 'stage'); + stage.setHTMLUnsafe(html); + root.body.appendChild(stage); + + if (renderType === 'dist') { + // @ts-expect-error resolved through WDIO + const { defineCustomElements } = await import('/dist/loader/index.js'); + defineCustomElements().catch(console.error); - // wait for Stencil to take over and reconcile - await browser.waitUntil(async () => customElements.get('ssr-shadow-cmp')); - expect(typeof customElements.get('ssr-shadow-cmp')).toBe('function'); + // wait for Stencil to take over and reconcile + await browser.waitUntil(async () => customElements.get('ssr-shadow-cmp')); + expect(typeof customElements.get('ssr-shadow-cmp')).toBe('function'); + } - await expect(getNodeNames(document.querySelector('ssr-shadow-cmp').childNodes)).toBe( - `text comment div comment text`, - ); + const ele = root.querySelector('ssr-shadow-cmp'); + await browser.waitUntil(async () => !!ele.childNodes); + await browser.pause(100); + + // Checking slotted content + await expect(getNodeNames(ele.childNodes)).toBe(`text comment div comment text`); + + // Checking shadow content + const eles = method === 'scoped' ? 'div' : 'style div'; + await expect(getNodeNames(ele.shadowRoot.childNodes)).toBe(eles); + + // Checking styling + await expect(getComputedStyle(ele).color).toBe('rgb(255, 0, 0)'); + await expect(getComputedStyle(ele).backgroundColor).toBe('rgb(255, 255, 0)'); + }, + + slots: async () => { + if (root.querySelector('#stage')) { + root.querySelector('#stage')?.remove(); + await browser.waitUntil(async () => !root.querySelector('#stage')); + } + const { html } = await renderToString( + ` + +

Default slot content

+

Client-only slot content

+
+ `, + { + fullDocument: true, + serializeShadowRoot: method, + constrainTimeouts: false, + prettyHTML: false, + }, + ); + const stage = root.createElement('div'); + stage.setAttribute('id', 'stage'); + stage.setHTMLUnsafe(html); + root.body.appendChild(stage); + + if (renderType === 'dist') { + // @ts-expect-error resolved through WDIO + const { defineCustomElements } = await import('/dist/loader/index.js'); + defineCustomElements().catch(console.error); + + // wait for Stencil to take over and reconcile + await browser.waitUntil(async () => customElements.get('ssr-shadow-cmp')); + expect(typeof customElements.get('ssr-shadow-cmp')).toBe('function'); + } + + await browser.waitUntil(async () => root.querySelector('ssr-shadow-cmp [slot="client-only"]')); + await expect(root.querySelector('ssr-shadow-cmp').textContent).toBe( + 'Default slot contentClient-only slot content', + ); + }, + }; + }; + + describe('dist / declarative-shadow-dom', () => { + let testSuite; + beforeEach(async () => { + testSuite = await testSuites(document, 'declarative-shadow-dom', 'dist'); + }); + + it('verifies all nodes & styles are preserved during hydration', async () => { + await testSuite.sanityCheck(); + }); + + it('resolves slots correctly during client-side hydration', async () => { + await testSuite.slots(); + }); + }); + + describe('dist / scoped', () => { + let testSuite; + beforeEach(async () => { + testSuite = await testSuites(document, 'scoped', 'dist'); + }); + + it('verifies all nodes & styles are preserved during hydration', async () => { + await testSuite.sanityCheck(); + }); + + it('resolves slots correctly during client-side hydration', async () => { + await testSuite.slots(); + }); + }); + + describe('custom-elements / declarative-shadow-dom', () => { + let doc: Document; + let testSuite; + + beforeEach(async () => { + await setupIFrameTest('/ssr-hydration/custom-element.html', 'dsd-custom-elements'); + const frameEle: HTMLIFrameElement = document.querySelector('iframe#dsd-custom-elements'); + doc = frameEle.contentDocument; + testSuite = await testSuites(doc, 'declarative-shadow-dom', 'custom-elements'); + }); + + it('verifies all nodes & styles are preserved during hydration', async () => { + await testSuite.sanityCheck(); + }); + + it('resolves slots correctly during client-side hydration', async () => { + await testSuite.slots(); + }); + }); - document.querySelector('#stage')?.remove(); - await browser.waitUntil(async () => !document.querySelector('#stage')); + describe('custom-elements / scoped', () => { + let doc: Document; + let testSuite; + + beforeEach(async () => { + await setupIFrameTest('/ssr-hydration/custom-element.html', 'scoped-custom-elements'); + const frameEle: HTMLIFrameElement = document.querySelector('iframe#scoped-custom-elements'); + doc = frameEle.contentDocument; + testSuite = await testSuites(doc, 'scoped', 'custom-elements'); + }); + + it('verifies all nodes & styles are preserved during hydration', async () => { + await testSuite.sanityCheck(); + }); + + it('resolves slots correctly during client-side hydration', async () => { + await testSuite.slots(); + }); }); it('checks perf when loading lots of the same component', async () => { - performance.mark('start'); + performance.mark('start-dsd'); await renderToString( Array(50) @@ -69,49 +203,29 @@ describe('ssr-shadow-cmp', () => { .join(''), { fullDocument: true, - serializeShadowRoot: true, + serializeShadowRoot: 'declarative-shadow-dom', constrainTimeouts: false, }, ); - performance.mark('end'); - const renderTime = performance.measure('render', 'start', 'end').duration; + performance.mark('end-dsd'); + let renderTime = performance.measure('render', 'start-dsd', 'end-dsd').duration; await expect(renderTime).toBeLessThan(50); - }); - - it('resolves slots correctly during client-side hydration', async () => { - if (!document.querySelector('#stage')) { - const { html } = await renderToString( - ` - -

Default slot content

-

Client-only slot content

-
- `, - { - fullDocument: true, - serializeShadowRoot: true, - constrainTimeouts: false, - }, - ); - const stage = document.createElement('div'); - stage.setAttribute('id', 'stage'); - stage.setHTMLUnsafe(html); - document.body.appendChild(stage); - } - // @ts-expect-error resolved through WDIO - const { defineCustomElements } = await import('/dist/loader/index.js'); - defineCustomElements().catch(console.error); + performance.mark('start-scoped'); - // wait for Stencil to take over and reconcile - await browser.waitUntil(async () => customElements.get('ssr-shadow-cmp')); - expect(typeof customElements.get('ssr-shadow-cmp')).toBe('function'); - - await browser.waitUntil(async () => document.querySelector('ssr-shadow-cmp [slot="client-only"]')); - await expect(document.querySelector('ssr-shadow-cmp').textContent).toBe( - ' Default slot content Client-only slot content ', + await renderToString( + Array(50) + .fill(0) + .map((_, i) => `Value ${i}`) + .join(''), + { + fullDocument: true, + serializeShadowRoot: 'scoped', + constrainTimeouts: false, + }, ); - - document.querySelector('#stage')?.remove(); + performance.mark('end-scoped'); + renderTime = performance.measure('render', 'start-scoped', 'end-scoped').duration; + await expect(renderTime).toBeLessThan(50); }); }); diff --git a/test/wdio/ssr-hydration/cmp.tsx b/test/wdio/ssr-hydration/cmp.tsx index f84f52d7b5c..33d4f28b4b5 100644 --- a/test/wdio/ssr-hydration/cmp.tsx +++ b/test/wdio/ssr-hydration/cmp.tsx @@ -3,21 +3,24 @@ import { Build, Component, h, Prop } from '@stencil/core'; @Component({ tag: 'ssr-shadow-cmp', shadow: true, + styles: ` + :host { + display: block; + padding: 10px; + border: 2px solid #000; + background: yellow; + color: red; + } + `, }) export class SsrShadowCmp { - @Prop() value: string; - @Prop() label: string; @Prop() selected: boolean; - @Prop() disabled: boolean; render() { return (
diff --git a/test/wdio/ssr-hydration/custom-element.html b/test/wdio/ssr-hydration/custom-element.html new file mode 100644 index 00000000000..3f437e4caf4 --- /dev/null +++ b/test/wdio/ssr-hydration/custom-element.html @@ -0,0 +1,13 @@ + + + SSR testing dist-custom-elements output + + + + + + + diff --git a/test/wdio/stencil.config.ts b/test/wdio/stencil.config.ts index d4454c70fd2..6ab25271790 100644 --- a/test/wdio/stencil.config.ts +++ b/test/wdio/stencil.config.ts @@ -14,6 +14,7 @@ export const config: Config = { dir: 'test-components', customElementsExportBehavior: 'bundle', isPrimaryPackageOutputTarget: true, + externalRuntime: false, }, { type: 'dist-hydrate-script',