From 6b867f27d626be1303ac0b18b9da7a7ae21d5f65 Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Wed, 5 Feb 2025 00:32:23 +0000 Subject: [PATCH 01/17] feat(ssr): `shadow: true` can now render as dsd or 'scoped' --- 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 | 18 ++++-- src/hydrate/platform/hydrate-app.ts | 53 ++++++++++++++++ src/hydrate/platform/proxy-host-element.ts | 9 ++- src/hydrate/runner/render.ts | 2 +- src/mock-doc/node.ts | 2 +- src/mock-doc/serialize-node.ts | 14 ++++- src/runtime/bootstrap-custom-element.ts | 13 +++- src/runtime/bootstrap-lazy.ts | 10 ++- src/runtime/client-hydrate.ts | 44 +++++++++++-- src/runtime/initialize-component.ts | 12 +--- src/runtime/styles.ts | 36 +++++++++-- src/runtime/vdom/vdom-render.ts | 5 +- src/testing/jest/jest-29/matchers/html.ts | 10 ++- src/utils/constants.ts | 6 ++ src/utils/shadow-css.ts | 61 +++++++++++++++++-- src/utils/test/scope-css.spec.ts | 19 +++++- 19 files changed, 275 insertions(+), 45 deletions(-) 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..9e06b042bc9 100644 --- a/src/declarations/stencil-public-compiler.ts +++ b/src/declarations/stencil-public-compiler.ts @@ -949,15 +949,25 @@ export interface SerializeDocumentOptions extends HydrateDocumentOptions { * 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; + * + * *NOTE* `true | false` values have been deprecated in favor of `dsd` and `scoped` + * @default 'dsd' + */ + serializeShadowRoot?: + | 'dsd' + | 'scoped' + | { + dsd: string[]; + scoped: string[]; + default: 'dsd' | '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. * @default true */ - fullDocument?: boolean; + fullDocument?: true; /** * Style modes to render the component in. * @see https://stenciljs.com/docs/styling#style-modes diff --git a/src/hydrate/platform/hydrate-app.ts b/src/hydrate/platform/hydrate-app.ts index 1925fae3b8f..14bfd34a219 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,23 @@ export function hydrateApp( if (Cstr != null && Cstr.cmpMeta != null) { // we found valid component metadata + + if ( + !!(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 +351,38 @@ 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 + */ +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.dsd) && opts.dsd.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 === 'dsd') { + // 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..e9ebae6766d 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 this 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/runner/render.ts b/src/hydrate/runner/render.ts index 920034388b6..9572bcac2b5 100644 --- a/src/hydrate/runner/render.ts +++ b/src/hydrate/runner/render.ts @@ -48,7 +48,7 @@ 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' ? 'dsd' : 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..6b87c498f3d 100644 --- a/src/mock-doc/serialize-node.ts +++ b/src/mock-doc/serialize-node.ts @@ -36,7 +36,7 @@ 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' ? 'dsd' : opts.serializeShadowRoot, fullDocument: typeof opts.fullDocument !== 'boolean' ? true : opts.fullDocument, } as const; } @@ -243,7 +243,7 @@ function* streamToHtml( if (EMPTY_ELEMENTS.has(tagName) === false) { const shadowRoot = (node as HTMLElement).shadowRoot; - if (shadowRoot != null && opts.serializeShadowRoot) { + if (shadowRoot != null) { output.indent = output.indent + (opts.indentSpaces ?? 0); yield* streamToHtml(shadowRoot, opts, output); @@ -681,6 +681,14 @@ export interface SerializeNodeToHtmlOptions { removeBooleanAttributeQuotes?: boolean; removeEmptyAttributes?: boolean; removeHtmlComments?: boolean; - serializeShadowRoot?: boolean; + serializeShadowRoot?: + | 'dsd' + | 'scoped' + | { + dsd?: string[]; + scoped?: string[]; + default: 'dsd' | 'scoped'; + } + | boolean; fullDocument?: boolean; } diff --git a/src/runtime/bootstrap-custom-element.ts b/src/runtime/bootstrap-custom-element.ts index 736f7de6eaf..e895ecd946b 100644 --- a/src/runtime/bootstrap-custom-element.ts +++ b/src/runtime/bootstrap-custom-element.ts @@ -2,6 +2,7 @@ import { BUILD } from '@app-data'; import { addHostEventListeners, deleteHostRef, + doc, forceUpdate, getHostRef, plt, @@ -23,8 +24,8 @@ import { } from './dom-extras'; import { computeMode } from './mode'; import { proxyComponent } from './proxy-component'; -import { PROXY_FLAGS } from './runtime-constants'; -import { attachStyles, getScopeId, registerStyle } from './styles'; +import { HYDRATED_STYLE_ID, PROXY_FLAGS } from './runtime-constants'; +import { attachStyles, getScopeId, registerStyle, convertScopedToShadow } from './styles'; export const defineCustomElement = (Cstr: any, compactMeta: d.ComponentRuntimeMetaCompact) => { customElements.define(compactMeta[1], proxyCustomElement(Cstr, compactMeta) as CustomElementConstructor); @@ -74,6 +75,14 @@ export const proxyCustomElement = (Cstr: any, compactMeta: d.ComponentRuntimeMet } } + if (BUILD.hydrateClientSide && BUILD.shadowDom) { + 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); + } + } + 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 2706d8519ed..813353884ae 100644 --- a/src/runtime/bootstrap-lazy.ts +++ b/src/runtime/bootstrap-lazy.ts @@ -16,7 +16,8 @@ import { 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 { HYDRATED_CSS, HYDRATED_STYLE_ID, PLATFORM_FLAGS, PROXY_FLAGS, SLOT_FB_CSS } from './runtime-constants'; +import { convertScopedToShadow, registerStyle } from './styles'; import { appDidLoad } from './update-component'; export { setNonce } from '@platform'; @@ -49,6 +50,13 @@ export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d. // async queue. This will improve the first input delay plt.$flags$ |= PLATFORM_FLAGS.appLoaded; } + if (BUILD.hydrateClientSide && BUILD.shadowDom) { + 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); + } + } let hasSlotRelocation = false; lazyBundles.map((lazyBundle) => { diff --git a/src/runtime/client-hydrate.ts b/src/runtime/client-hydrate.ts index 45498e8c990..f35cbe2a8f6 100644 --- a/src/runtime/client-hydrate.ts +++ b/src/runtime/client-hydrate.ts @@ -213,12 +213,22 @@ 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] as any); + } + // During `scoped` shadowDOM rendering, there's a bunch of comment nodes used for positioning. + // Let's tidy them up now to stop frameworks complaining about DOM mismatches. + Array.from(hostElm.childNodes).forEach((node) => { + if (typeof (node as d.RenderNode)['s-sn'] !== 'string') { + node.parentNode.removeChild(node); + } + }); } } @@ -390,7 +400,7 @@ const clientHydrate = ( }); if (childNodeType === TEXT_NODE_ID) { - childVNode.$elm$ = node.nextSibling as any; + childVNode.$elm$ = findCorrespondingNode(node, NODE_TYPE.TextNode) as any; if (childVNode.$elm$ && childVNode.$elm$.nodeType === NODE_TYPE.TextNode) { childVNode.$text$ = childVNode.$elm$.textContent; @@ -414,7 +424,7 @@ const clientHydrate = ( } } } else if (childNodeType === COMMENT_NODE_ID) { - childVNode.$elm$ = node.nextSibling as any; + childVNode.$elm$ = findCorrespondingNode(node, NODE_TYPE.CommentNode) as any; if (childVNode.$elm$ && childVNode.$elm$.nodeType === NODE_TYPE.CommentNode) { // A non-Stencil comment node @@ -462,6 +472,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; @@ -638,6 +656,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 06b0924614d..9ca122b5c96 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'; @@ -154,16 +155,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..ce1feded72d 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,34 @@ 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{'); + declare global { export interface CSSStyleSheet { replaceSync(cssText: string): void; diff --git a/src/runtime/vdom/vdom-render.ts b/src/runtime/vdom/vdom-render.ts index c39f0f58516..04db02e10fe 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-29/matchers/html.ts b/src/testing/jest/jest-29/matchers/html.ts index d407a061bf8..7e566598f57 100644 --- a/src/testing/jest/jest-29/matchers/html.ts +++ b/src/testing/jest/jest-29/matchers/html.ts @@ -12,7 +12,15 @@ export function toEqualLightHtml(input: string | HTMLElement | ShadowRoot, shoul export function compareHtml( input: string | HTMLElement | ShadowRoot, shouldEqual: string, - serializeShadowRoot: boolean, + serializeShadowRoot: + | boolean + | 'dsd' + | 'scoped' + | { + dsd?: string[]; + scoped?: string[]; + default: 'dsd' | 'scoped'; + }, ) { 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;}'; From aec15c02ddbd85c7a1aa381de5e317859da091cf Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Wed, 5 Feb 2025 00:48:55 +0000 Subject: [PATCH 02/17] chore: doc --- src/declarations/stencil-public-compiler.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/declarations/stencil-public-compiler.ts b/src/declarations/stencil-public-compiler.ts index 9e06b042bc9..f778ac18522 100644 --- a/src/declarations/stencil-public-compiler.ts +++ b/src/declarations/stencil-public-compiler.ts @@ -946,9 +946,11 @@ 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. + * - If set to `dsd` 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 shadowDOM will be created during client-side hydration. + * - Alternatively you can mix and match the two by providing an object with `dsd` and `scoped` keys, + * the value arrays containing tag names of the components that should be rendered in that mode. * * *NOTE* `true | false` values have been deprecated in favor of `dsd` and `scoped` * @default 'dsd' From da1fede448d4855de7114920e29113cefa4a0d93 Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Wed, 5 Feb 2025 00:49:47 +0000 Subject: [PATCH 03/17] chore: typo --- src/hydrate/platform/proxy-host-element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hydrate/platform/proxy-host-element.ts b/src/hydrate/platform/proxy-host-element.ts index e9ebae6766d..7c13f5a6123 100644 --- a/src/hydrate/platform/proxy-host-element.ts +++ b/src/hydrate/platform/proxy-host-element.ts @@ -17,7 +17,7 @@ export function proxyHostElement(elm: d.HostElement, cstr: d.ComponentConstructo /** * Only attach shadow root if there isn't one already and - * the this component is rendering DSD (not scoped) during SSR + * the component is rendering DSD (not scoped) during SSR */ if ( !elm.shadowRoot && From d034b40c8d28b6d74bce3317289ea8eb483d01dd Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Wed, 5 Feb 2025 07:06:00 +0000 Subject: [PATCH 04/17] Update src/declarations/stencil-public-compiler.ts Co-authored-by: Christian Bromann --- src/declarations/stencil-public-compiler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/declarations/stencil-public-compiler.ts b/src/declarations/stencil-public-compiler.ts index f778ac18522..c71d2cfdf9c 100644 --- a/src/declarations/stencil-public-compiler.ts +++ b/src/declarations/stencil-public-compiler.ts @@ -948,7 +948,7 @@ export interface SerializeDocumentOptions extends HydrateDocumentOptions { /** * - If set to `dsd` 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 shadowDOM will be created during client-side hydration. + * and the shadow DOM will be created during client-side hydration. * - Alternatively you can mix and match the two by providing an object with `dsd` and `scoped` keys, * the value arrays containing tag names of the components that should be rendered in that mode. * From 6dc61c3609e3637dd3e598d218e130a729514883 Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Wed, 5 Feb 2025 07:06:11 +0000 Subject: [PATCH 05/17] Update src/declarations/stencil-public-compiler.ts Co-authored-by: Christian Bromann --- src/declarations/stencil-public-compiler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/declarations/stencil-public-compiler.ts b/src/declarations/stencil-public-compiler.ts index c71d2cfdf9c..57bc658d591 100644 --- a/src/declarations/stencil-public-compiler.ts +++ b/src/declarations/stencil-public-compiler.ts @@ -956,7 +956,7 @@ export interface SerializeDocumentOptions extends HydrateDocumentOptions { * @default 'dsd' */ serializeShadowRoot?: - | 'dsd' + | 'declarative-shadow-dom' | 'scoped' | { dsd: string[]; From 684a3a1bf54ba559a7363066b54312eb5af8257c Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Wed, 5 Feb 2025 07:06:47 +0000 Subject: [PATCH 06/17] Update src/declarations/stencil-public-compiler.ts Co-authored-by: Christian Bromann --- src/declarations/stencil-public-compiler.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/declarations/stencil-public-compiler.ts b/src/declarations/stencil-public-compiler.ts index 57bc658d591..2cfd55571ee 100644 --- a/src/declarations/stencil-public-compiler.ts +++ b/src/declarations/stencil-public-compiler.ts @@ -946,6 +946,7 @@ export interface SerializeDocumentOptions extends HydrateDocumentOptions { */ removeHtmlComments?: boolean; /** + * Configure how Stencil serializes the components shadow root. * - If set to `dsd` 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. From e0050e0b88f45e9696a21a511fcda1c6bf2e0446 Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Wed, 5 Feb 2025 22:47:59 +0000 Subject: [PATCH 07/17] chore: tidy up docs --- src/declarations/stencil-public-compiler.ts | 25 ++++++++++++------- src/mock-doc/serialize-node.ts | 6 ++--- .../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 | 10 +------- 5 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/declarations/stencil-public-compiler.ts b/src/declarations/stencil-public-compiler.ts index 2cfd55571ee..8ff6442c48f 100644 --- a/src/declarations/stencil-public-compiler.ts +++ b/src/declarations/stencil-public-compiler.ts @@ -947,22 +947,29 @@ export interface SerializeDocumentOptions extends HydrateDocumentOptions { removeHtmlComments?: boolean; /** * Configure how Stencil serializes the components shadow root. - * - If set to `dsd` the component will be rendered within a Declarative Shadow DOM. + * - 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 `dsd` and `scoped` keys, - * the value arrays containing tag names of the components that should be rendered in that mode. + * - 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. * - * *NOTE* `true | false` values have been deprecated in favor of `dsd` and `scoped` - * @default 'dsd' + * 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` + * + * *NOTE* `true | false` values have been deprecated in favor of `declarative-shadow-dom` and `scoped` + * @default 'declarative-shadow-dom' */ serializeShadowRoot?: | 'declarative-shadow-dom' | 'scoped' | { - dsd: string[]; - scoped: string[]; - default: 'dsd' | 'scoped'; + 'declarative-shadow-dom'?: string[]; + scoped?: string[]; + default: 'declarative-shadow-dom' | 'scoped'; } | boolean; /** @@ -970,7 +977,7 @@ export interface SerializeDocumentOptions extends HydrateDocumentOptions { * generate a complete HTML document, or false to render only the component. * @default true */ - fullDocument?: true; + fullDocument?: boolean; /** * Style modes to render the component in. * @see https://stenciljs.com/docs/styling#style-modes diff --git a/src/mock-doc/serialize-node.ts b/src/mock-doc/serialize-node.ts index 6b87c498f3d..c82b968740e 100644 --- a/src/mock-doc/serialize-node.ts +++ b/src/mock-doc/serialize-node.ts @@ -682,12 +682,12 @@ export interface SerializeNodeToHtmlOptions { removeEmptyAttributes?: boolean; removeHtmlComments?: boolean; serializeShadowRoot?: - | 'dsd' + | 'declarative-shadow-dom' | 'scoped' | { - dsd?: string[]; + 'declarative-shadow-dom'?: string[]; scoped?: string[]; - default: 'dsd' | 'scoped'; + default: 'declarative-shadow-dom' | 'scoped'; } | boolean; fullDocument?: boolean; 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 7e566598f57..46c4d9febe1 100644 --- a/src/testing/jest/jest-29/matchers/html.ts +++ b/src/testing/jest/jest-29/matchers/html.ts @@ -12,15 +12,7 @@ export function toEqualLightHtml(input: string | HTMLElement | ShadowRoot, shoul export function compareHtml( input: string | HTMLElement | ShadowRoot, shouldEqual: string, - serializeShadowRoot: - | boolean - | 'dsd' - | 'scoped' - | { - dsd?: string[]; - scoped?: string[]; - default: 'dsd' | 'scoped'; - }, + serializeShadowRoot: d.SerializeDocumentOptions['serializeShadowRoot'], ) { if (input == null) { throw new Error(`expect toEqualHtml() value is "${input}"`); From b585dd2d90a6b5a9c5e6760260377eb321a431cf Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Wed, 5 Feb 2025 22:55:47 +0000 Subject: [PATCH 08/17] chore: update defaults --- src/hydrate/platform/hydrate-app.ts | 7 +++++-- src/hydrate/runner/render.ts | 3 ++- src/mock-doc/serialize-node.ts | 3 ++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/hydrate/platform/hydrate-app.ts b/src/hydrate/platform/hydrate-app.ts index 14bfd34a219..03241358935 100644 --- a/src/hydrate/platform/hydrate-app.ts +++ b/src/hydrate/platform/hydrate-app.ts @@ -372,10 +372,13 @@ function tagRequiresScoped(tagName: string, opts: d.HydrateFactoryOptions['seria if (typeof opts === 'object') { tagName = tagName.toLowerCase(); - if (Array.isArray(opts.dsd) && opts.dsd.includes(tagName)) { + 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 === 'dsd') { + } 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 { diff --git a/src/hydrate/runner/render.ts b/src/hydrate/runner/render.ts index 9572bcac2b5..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 === 'undefined' ? 'dsd' : opts.serializeShadowRoot; + 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/serialize-node.ts b/src/mock-doc/serialize-node.ts index c82b968740e..e8cec60d65d 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 === 'undefined' ? 'dsd' : opts.serializeShadowRoot, + serializeShadowRoot: + typeof opts.serializeShadowRoot === 'undefined' ? 'declarative-shadow-dom' : opts.serializeShadowRoot, fullDocument: typeof opts.fullDocument !== 'boolean' ? true : opts.fullDocument, } as const; } From 83e8f293dc8b17f5c19992f22a941b2332b7353a Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Thu, 6 Feb 2025 22:03:26 +0000 Subject: [PATCH 09/17] chore: wip tests --- src/runtime/client-hydrate.ts | 11 +- test/wdio/ssr-hydration/cmp.test.tsx | 304 +++++++++++++------- test/wdio/ssr-hydration/cmp.tsx | 16 +- test/wdio/ssr-hydration/custom-element.html | 13 + 4 files changed, 228 insertions(+), 116 deletions(-) create mode 100644 test/wdio/ssr-hydration/custom-element.html diff --git a/src/runtime/client-hydrate.ts b/src/runtime/client-hydrate.ts index 28a824a31c1..561cc9cc366 100644 --- a/src/runtime/client-hydrate.ts +++ b/src/runtime/client-hydrate.ts @@ -233,6 +233,7 @@ export const initializeClientHydrate = ( } } + plt.$orgLocNodes$.delete(hostElm['s-id']); hostRef.$hostElement$ = hostElm; endHydrate(); }; @@ -473,14 +474,6 @@ 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; @@ -489,7 +482,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. diff --git a/test/wdio/ssr-hydration/cmp.test.tsx b/test/wdio/ssr-hydration/cmp.test.tsx index 6f8e10287cd..641ab1f325f 100644 --- a/test/wdio/ssr-hydration/cmp.test.tsx +++ b/test/wdio/ssr-hydration/cmp.test.tsx @@ -1,117 +1,221 @@ +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(' '); + } + + async function getTxt(selector: string) { + await browser.waitUntil(() => !!root.querySelector(selector), { timeout: 3000 }); + return root.querySelector(selector).textContent.trim(); } - // @ts-expect-error resolved through WDIO - const { defineCustomElements } = await import('/dist/loader/index.js'); - defineCustomElements().catch(console.error); + function getTxtHtml(html: string, className: string) { + const match = html.match(new RegExp(`
(.*?)
`, 'g')); + if (match && match[0]) { + const textMatch = match[0].match(new RegExp(`
(.*?)
`)); + return textMatch ? textMatch[1].replace(//g, '').trim() : null; + } + return null; + } + + return { + preservesNodes: 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); - // 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'); + 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 expect(getNodeNames(root.querySelector('ssr-shadow-cmp').childNodes)).toBe( + `text comment div comment text`, + ); - await expect(getNodeNames(document.querySelector('ssr-shadow-cmp').childNodes)).toBe( - `text comment div comment text`, - ); + const eles = method === 'scoped' ? 'div' : 'style div'; + await expect(getNodeNames(root.querySelector('ssr-shadow-cmp').shadowRoot.childNodes)).toBe( + eles, + ); + }, + // viaAttributes: async () => { + // root.setAttribute('decorated-prop', '200'); + // root.setAttribute('decorated-getter-setter-prop', '-5'); + // root.setAttribute('basic-prop', 'basicProp via attribute'); + // root.setAttribute('basic-state', 'basicState via attribute'); + // root.setAttribute('decorated-state', 'decoratedState via attribute'); + + // await browser.pause(100); + + // expect(await getTxt('.basicProp')).toBe('basicProp via attribute'); + // expect(await getTxt('.decoratedProp')).toBe('25'); + // expect(await getTxt('.decoratedGetterSetterProp')).toBe('0'); + // expect(await getTxt('.basicState')).toBe('basicState'); + // expect(await getTxt('.decoratedState')).toBe('10'); + // }, + }; + }; - document.querySelector('#stage')?.remove(); - await browser.waitUntil(async () => !document.querySelector('#stage')); - }); + // describe('dist / declarative-shadow-dom', () => { + // let testSuite; + // beforeEach(async () => { + // testSuite = await testSuites(document, 'declarative-shadow-dom', 'dist'); + // }); + + // it('verifies all nodes are preserved during hydration', async () => { + // await testSuite.preservesNodes(); + // }); + // }) - it('checks perf when loading lots of the same component', async () => { - performance.mark('start'); - - await renderToString( - Array(50) - .fill(0) - .map((_, i) => `Value ${i}`) - .join(''), - { - fullDocument: true, - serializeShadowRoot: true, - constrainTimeouts: false, - }, - ); - performance.mark('end'); - const renderTime = performance.measure('render', 'start', 'end').duration; - await expect(renderTime).toBeLessThan(50); + // describe('dist / scoped', () => { + // let testSuite; + // beforeEach(async () => { + // testSuite = await testSuites(document, 'scoped', 'dist'); + // }); + + // it('verifies all nodes are preserved during hydration', async () => { + // await testSuite.preservesNodes(); + // }); + // }); + + 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 are preserved during hydration', async () => { + await testSuite.preservesNodes(); + }); }); - 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); + - // 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'); + // it('checks perf when loading lots of the same component', async () => { + // performance.mark('start-dsd'); - 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: 'declarative-shadow-dom', + // constrainTimeouts: false, + // }, + // ); + // performance.mark('end-dsd'); + // let renderTime = performance.measure('render', 'start-dsd', 'end-dsd').duration; + // await expect(renderTime).toBeLessThan(50); - document.querySelector('#stage')?.remove(); - }); + // performance.mark('start-scoped'); + + // await renderToString( + // Array(50) + // .fill(0) + // .map((_, i) => `Value ${i}`) + // .join(''), + // { + // fullDocument: true, + // serializeShadowRoot: 'scoped', + // constrainTimeouts: false, + // }, + // ); + // performance.mark('end-scoped'); + // renderTime = performance.measure('render', 'start-scoped', 'end-scoped').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, + // prettyHTML: 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); + + // // 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 ', + // ); + + // document.querySelector('#stage')?.remove(); + // }); }); diff --git a/test/wdio/ssr-hydration/cmp.tsx b/test/wdio/ssr-hydration/cmp.tsx index f84f52d7b5c..ea1a6bcf791 100644 --- a/test/wdio/ssr-hydration/cmp.tsx +++ b/test/wdio/ssr-hydration/cmp.tsx @@ -3,21 +3,23 @@ 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 + + + + + + + From 1bf2c339a7d5357d6aba2885b2465d429fbddbd9 Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Thu, 6 Feb 2025 23:38:31 +0000 Subject: [PATCH 10/17] chore: test and lint --- src/runtime/bootstrap-custom-element.ts | 2 +- src/runtime/client-hydrate.ts | 20 +- test/wdio/ssr-hydration/cmp.test.tsx | 296 ++++++++++++------------ test/wdio/ssr-hydration/cmp.tsx | 19 +- test/wdio/stencil.config.ts | 1 + 5 files changed, 182 insertions(+), 156 deletions(-) diff --git a/src/runtime/bootstrap-custom-element.ts b/src/runtime/bootstrap-custom-element.ts index e895ecd946b..4d41b5a5a73 100644 --- a/src/runtime/bootstrap-custom-element.ts +++ b/src/runtime/bootstrap-custom-element.ts @@ -25,7 +25,7 @@ import { import { computeMode } from './mode'; import { proxyComponent } from './proxy-component'; import { HYDRATED_STYLE_ID, PROXY_FLAGS } from './runtime-constants'; -import { attachStyles, getScopeId, registerStyle, convertScopedToShadow } from './styles'; +import { attachStyles, convertScopedToShadow,getScopeId, registerStyle } from './styles'; export const defineCustomElement = (Cstr: any, compactMeta: d.ComponentRuntimeMetaCompact) => { customElements.define(compactMeta[1], proxyCustomElement(Cstr, compactMeta) as CustomElementConstructor); diff --git a/src/runtime/client-hydrate.ts b/src/runtime/client-hydrate.ts index 561cc9cc366..f6ff74e2945 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())); @@ -223,11 +223,17 @@ export const initializeClientHydrate = ( for (rnIdex; rnIdex < rnLen; rnIdex++) { shadowRoot.appendChild(shadowRootNodes[rnIdex] as any); } - // During `scoped` shadowDOM rendering, there's a bunch of comment nodes used for positioning. + // 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. Array.from(hostElm.childNodes).forEach((node) => { if (typeof (node as d.RenderNode)['s-sn'] !== 'string') { - node.parentNode.removeChild(node); + 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 { + node.parentNode.removeChild(node); + } } }); } @@ -474,6 +480,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; diff --git a/test/wdio/ssr-hydration/cmp.test.tsx b/test/wdio/ssr-hydration/cmp.test.tsx index 641ab1f325f..af5035062c8 100644 --- a/test/wdio/ssr-hydration/cmp.test.tsx +++ b/test/wdio/ssr-hydration/cmp.test.tsx @@ -1,11 +1,14 @@ import { browser } from '@wdio/globals'; + import { renderToString } from '../hydrate/index.mjs'; import { setupIFrameTest } from '../util.js'; describe('Sanity check SSR > Client hydration', () => { - - - const testSuites = async (root: Document, method: 'scoped' | 'declarative-shadow-dom', renderType: 'dist' | 'custom-elements') => { + const testSuites = async ( + root: Document, + method: 'scoped' | 'declarative-shadow-dom', + renderType: 'dist' | 'custom-elements', + ) => { function getNodeNames(chidNodes: NodeListOf) { return Array.from(chidNodes) .flatMap((node) => { @@ -23,23 +26,9 @@ describe('Sanity check SSR > Client hydration', () => { }) .join(' '); } - - async function getTxt(selector: string) { - await browser.waitUntil(() => !!root.querySelector(selector), { timeout: 3000 }); - return root.querySelector(selector).textContent.trim(); - } - function getTxtHtml(html: string, className: string) { - const match = html.match(new RegExp(`
(.*?)
`, 'g')); - if (match && match[0]) { - const textMatch = match[0].match(new RegExp(`
(.*?)
`)); - return textMatch ? textMatch[1].replace(//g, '').trim() : null; - } - return null; - } - return { - preservesNodes: async () => { + sanityCheck: async () => { if (root.querySelector('#stage')) { root.querySelector('#stage')?.remove(); await browser.waitUntil(async () => !root.querySelector('#stage')); @@ -70,60 +59,99 @@ describe('Sanity check SSR > Client hydration', () => { // @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(root.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(root.querySelector('ssr-shadow-cmp').shadowRoot.childNodes)).toBe( - eles, + 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', ); }, - // viaAttributes: async () => { - // root.setAttribute('decorated-prop', '200'); - // root.setAttribute('decorated-getter-setter-prop', '-5'); - // root.setAttribute('basic-prop', 'basicProp via attribute'); - // root.setAttribute('basic-state', 'basicState via attribute'); - // root.setAttribute('decorated-state', 'decoratedState via attribute'); - - // await browser.pause(100); - - // expect(await getTxt('.basicProp')).toBe('basicProp via attribute'); - // expect(await getTxt('.decoratedProp')).toBe('25'); - // expect(await getTxt('.decoratedGetterSetterProp')).toBe('0'); - // expect(await getTxt('.basicState')).toBe('basicState'); - // expect(await getTxt('.decoratedState')).toBe('10'); - // }, }; }; - // describe('dist / declarative-shadow-dom', () => { - // let testSuite; - // beforeEach(async () => { - // testSuite = await testSuites(document, 'declarative-shadow-dom', 'dist'); - // }); - - // it('verifies all nodes are preserved during hydration', async () => { - // await testSuite.preservesNodes(); - // }); - // }) - - // describe('dist / scoped', () => { - // let testSuite; - // beforeEach(async () => { - // testSuite = await testSuites(document, 'scoped', 'dist'); - // }); - - // it('verifies all nodes are preserved during hydration', async () => { - // await testSuite.preservesNodes(); - // }); - // }); + 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; @@ -136,86 +164,68 @@ describe('Sanity check SSR > Client hydration', () => { testSuite = await testSuites(doc, 'declarative-shadow-dom', 'custom-elements'); }); - it('verifies all nodes are preserved during hydration', async () => { - await testSuite.preservesNodes(); + 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 / 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-dsd'); - - // await renderToString( - // Array(50) - // .fill(0) - // .map((_, i) => `Value ${i}`) - // .join(''), - // { - // fullDocument: true, - // serializeShadowRoot: 'declarative-shadow-dom', - // constrainTimeouts: false, - // }, - // ); - // performance.mark('end-dsd'); - // let renderTime = performance.measure('render', 'start-dsd', 'end-dsd').duration; - // await expect(renderTime).toBeLessThan(50); - - // performance.mark('start-scoped'); - - // await renderToString( - // Array(50) - // .fill(0) - // .map((_, i) => `Value ${i}`) - // .join(''), - // { - // fullDocument: true, - // serializeShadowRoot: 'scoped', - // constrainTimeouts: false, - // }, - // ); - // performance.mark('end-scoped'); - // renderTime = performance.measure('render', 'start-scoped', 'end-scoped').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, - // prettyHTML: 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); - - // // 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 ', - // ); - - // document.querySelector('#stage')?.remove(); - // }); + it('checks perf when loading lots of the same component', async () => { + performance.mark('start-dsd'); + + await renderToString( + Array(50) + .fill(0) + .map((_, i) => `Value ${i}`) + .join(''), + { + fullDocument: true, + serializeShadowRoot: 'declarative-shadow-dom', + constrainTimeouts: false, + }, + ); + performance.mark('end-dsd'); + let renderTime = performance.measure('render', 'start-dsd', 'end-dsd').duration; + await expect(renderTime).toBeLessThan(50); + + performance.mark('start-scoped'); + + await renderToString( + Array(50) + .fill(0) + .map((_, i) => `Value ${i}`) + .join(''), + { + fullDocument: true, + serializeShadowRoot: 'scoped', + constrainTimeouts: false, + }, + ); + 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 ea1a6bcf791..33d4f28b4b5 100644 --- a/test/wdio/ssr-hydration/cmp.tsx +++ b/test/wdio/ssr-hydration/cmp.tsx @@ -3,14 +3,15 @@ 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; - } - `, + styles: ` + :host { + display: block; + padding: 10px; + border: 2px solid #000; + background: yellow; + color: red; + } + `, }) export class SsrShadowCmp { @Prop() selected: boolean; @@ -19,7 +20,7 @@ export class SsrShadowCmp { return (
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', From 4f2c4db2c08802443f2b75ef03c4ec951eee1ac1 Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Fri, 7 Feb 2025 00:31:54 +0000 Subject: [PATCH 11/17] chore: unit test --- src/hydrate/platform/hydrate-app.ts | 2 +- .../test/__mocks__/@app-globals/index.ts | 3 ++ .../test/serialize-shadow-root-opts.spec.ts | 51 +++++++++++++++++++ src/runtime/bootstrap-custom-element.ts | 2 +- 4 files changed, 56 insertions(+), 2 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 diff --git a/src/hydrate/platform/hydrate-app.ts b/src/hydrate/platform/hydrate-app.ts index 03241358935..48ca64d5631 100644 --- a/src/hydrate/platform/hydrate-app.ts +++ b/src/hydrate/platform/hydrate-app.ts @@ -360,7 +360,7 @@ function waitingOnElementsMsg(waitingElements: Set) { * @param opts - serializeShadowRoot options * @returns `true` when the tag requires a scoped / light dom during SSR */ -function tagRequiresScoped(tagName: string, opts: d.HydrateFactoryOptions['serializeShadowRoot']) { +export function tagRequiresScoped(tagName: string, opts: d.HydrateFactoryOptions['serializeShadowRoot']) { if (typeof opts === 'string') { return opts === 'scoped'; } 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/runtime/bootstrap-custom-element.ts b/src/runtime/bootstrap-custom-element.ts index 4d41b5a5a73..dc520bfa5d2 100644 --- a/src/runtime/bootstrap-custom-element.ts +++ b/src/runtime/bootstrap-custom-element.ts @@ -25,7 +25,7 @@ import { import { computeMode } from './mode'; import { proxyComponent } from './proxy-component'; import { HYDRATED_STYLE_ID, PROXY_FLAGS } from './runtime-constants'; -import { attachStyles, convertScopedToShadow,getScopeId, registerStyle } from './styles'; +import { attachStyles, convertScopedToShadow, getScopeId, registerStyle } from './styles'; export const defineCustomElement = (Cstr: any, compactMeta: d.ComponentRuntimeMetaCompact) => { customElements.define(compactMeta[1], proxyCustomElement(Cstr, compactMeta) as CustomElementConstructor); From c8fe52c0e61d90ba18ee8739f127d78c0c0da426 Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Fri, 7 Feb 2025 19:29:42 +0000 Subject: [PATCH 12/17] Update src/runtime/client-hydrate.ts Co-authored-by: Christian Bromann --- src/runtime/client-hydrate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/client-hydrate.ts b/src/runtime/client-hydrate.ts index f6ff74e2945..cfbf1744394 100644 --- a/src/runtime/client-hydrate.ts +++ b/src/runtime/client-hydrate.ts @@ -432,7 +432,7 @@ const clientHydrate = ( } } } else if (childNodeType === COMMENT_NODE_ID) { - childVNode.$elm$ = findCorrespondingNode(node, NODE_TYPE.CommentNode) 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 From b48a2ff2ec9deaa567bbfb6d06ef44c64a41e55e Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Fri, 7 Feb 2025 19:30:55 +0000 Subject: [PATCH 13/17] Update src/runtime/client-hydrate.ts Co-authored-by: Christian Bromann --- src/runtime/client-hydrate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/client-hydrate.ts b/src/runtime/client-hydrate.ts index cfbf1744394..a93b5898c4d 100644 --- a/src/runtime/client-hydrate.ts +++ b/src/runtime/client-hydrate.ts @@ -221,7 +221,7 @@ export const initializeClientHydrate = ( const rnLen = shadowRootNodes.length; if (rnLen) { for (rnIdex; rnIdex < rnLen; rnIdex++) { - shadowRoot.appendChild(shadowRootNodes[rnIdex] as any); + shadowRoot.appendChild(shadowRootNodes[rnIdex]); } // 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. From dc4ac3cc7b2027e4b9e5c8ceea07cc6d01a6f8e0 Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Fri, 7 Feb 2025 19:31:06 +0000 Subject: [PATCH 14/17] Update src/runtime/client-hydrate.ts Co-authored-by: Christian Bromann --- src/runtime/client-hydrate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/client-hydrate.ts b/src/runtime/client-hydrate.ts index a93b5898c4d..5f56aeba0ff 100644 --- a/src/runtime/client-hydrate.ts +++ b/src/runtime/client-hydrate.ts @@ -408,7 +408,7 @@ const clientHydrate = ( }); if (childNodeType === TEXT_NODE_ID) { - childVNode.$elm$ = findCorrespondingNode(node, NODE_TYPE.TextNode) 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; From 76026699b3b5a318359062e5959674e0756da50b Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Fri, 7 Feb 2025 20:39:47 +0000 Subject: [PATCH 15/17] chore: fixup tests and suggestions --- src/declarations/stencil-public-compiler.ts | 5 ++-- src/hydrate/platform/hydrate-app.ts | 1 + src/mock-doc/serialize-node.ts | 2 +- src/runtime/bootstrap-custom-element.ts | 11 +++----- src/runtime/bootstrap-lazy.ts | 11 +++----- src/runtime/client-hydrate.ts | 10 ++++--- src/runtime/styles.ts | 12 +++++++++ .../test/hydrate-shadow-in-shadow.spec.tsx | 1 - .../scoped-hydration/scoped-hydration.e2e.ts | 26 +++++++------------ 9 files changed, 40 insertions(+), 39 deletions(-) diff --git a/src/declarations/stencil-public-compiler.ts b/src/declarations/stencil-public-compiler.ts index 8ff6442c48f..36e5bf6ac6b 100644 --- a/src/declarations/stencil-public-compiler.ts +++ b/src/declarations/stencil-public-compiler.ts @@ -958,9 +958,10 @@ export interface SerializeDocumentOptions extends HydrateDocumentOptions { * 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` + * - `'scoped'` Render all components as `scoped` + * - `false` disables shadow root serialization * - * *NOTE* `true | false` values have been deprecated in favor of `declarative-shadow-dom` and `scoped` + * *NOTE* `true` has been deprecated in favor of `declarative-shadow-dom` and `scoped` * @default 'declarative-shadow-dom' */ serializeShadowRoot?: diff --git a/src/hydrate/platform/hydrate-app.ts b/src/hydrate/platform/hydrate-app.ts index 48ca64d5631..6f58eb5a156 100644 --- a/src/hydrate/platform/hydrate-app.ts +++ b/src/hydrate/platform/hydrate-app.ts @@ -87,6 +87,7 @@ export function hydrateApp( // we found valid component metadata if ( + opts.serializeShadowRoot !== false && !!(Cstr.cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation) && tagRequiresScoped(elm.tagName, opts.serializeShadowRoot) ) { diff --git a/src/mock-doc/serialize-node.ts b/src/mock-doc/serialize-node.ts index e8cec60d65d..677c37948da 100644 --- a/src/mock-doc/serialize-node.ts +++ b/src/mock-doc/serialize-node.ts @@ -244,7 +244,7 @@ function* streamToHtml( if (EMPTY_ELEMENTS.has(tagName) === false) { const shadowRoot = (node as HTMLElement).shadowRoot; - if (shadowRoot != null) { + if (shadowRoot != null && opts.serializeShadowRoot !== false) { output.indent = output.indent + (opts.indentSpaces ?? 0); yield* streamToHtml(shadowRoot, opts, output); diff --git a/src/runtime/bootstrap-custom-element.ts b/src/runtime/bootstrap-custom-element.ts index dc520bfa5d2..8bd005657b5 100644 --- a/src/runtime/bootstrap-custom-element.ts +++ b/src/runtime/bootstrap-custom-element.ts @@ -2,7 +2,6 @@ import { BUILD } from '@app-data'; import { addHostEventListeners, deleteHostRef, - doc, forceUpdate, getHostRef, plt, @@ -24,8 +23,8 @@ import { } from './dom-extras'; import { computeMode } from './mode'; import { proxyComponent } from './proxy-component'; -import { HYDRATED_STYLE_ID, PROXY_FLAGS } from './runtime-constants'; -import { attachStyles, convertScopedToShadow, getScopeId, registerStyle } from './styles'; +import { PROXY_FLAGS } from './runtime-constants'; +import { attachStyles, getScopeId, hydrateScopedToShadow, registerStyle } from './styles'; export const defineCustomElement = (Cstr: any, compactMeta: d.ComponentRuntimeMetaCompact) => { customElements.define(compactMeta[1], proxyCustomElement(Cstr, compactMeta) as CustomElementConstructor); @@ -76,11 +75,7 @@ export const proxyCustomElement = (Cstr: any, compactMeta: d.ComponentRuntimeMet } if (BUILD.hydrateClientSide && BUILD.shadowDom) { - 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); - } + hydrateScopedToShadow(); } const originalConnectedCallback = Cstr.prototype.connectedCallback; diff --git a/src/runtime/bootstrap-lazy.ts b/src/runtime/bootstrap-lazy.ts index 92b4eb2ff3f..6e46f96f1bf 100644 --- a/src/runtime/bootstrap-lazy.ts +++ b/src/runtime/bootstrap-lazy.ts @@ -16,8 +16,8 @@ import { import { hmrStart } from './hmr-component'; import { createTime, installDevTools } from './profile'; import { proxyComponent } from './proxy-component'; -import { HYDRATED_CSS, HYDRATED_STYLE_ID, PLATFORM_FLAGS, PROXY_FLAGS, SLOT_FB_CSS } from './runtime-constants'; -import { convertScopedToShadow, registerStyle } from './styles'; +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,12 +50,9 @@ export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d. // async queue. This will improve the first input delay plt.$flags$ |= PLATFORM_FLAGS.appLoaded; } + if (BUILD.hydrateClientSide && BUILD.shadowDom) { - 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); - } + hydrateScopedToShadow(); } let hasSlotRelocation = false; diff --git a/src/runtime/client-hydrate.ts b/src/runtime/client-hydrate.ts index 5f56aeba0ff..0bc1360be66 100644 --- a/src/runtime/client-hydrate.ts +++ b/src/runtime/client-hydrate.ts @@ -223,15 +223,19 @@ export const initializeClientHydrate = ( for (rnIdex; rnIdex < rnLen; rnIdex++) { shadowRoot.appendChild(shadowRootNodes[rnIdex]); } - // 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. + 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 { + } 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); } } diff --git a/src/runtime/styles.ts b/src/runtime/styles.ts index ce1feded72d..ae5b6ef834d 100644 --- a/src/runtime/styles.ts +++ b/src/runtime/styles.ts @@ -242,6 +242,18 @@ export const getScopeId = (cmp: d.ComponentRuntimeMeta, mode?: 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/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 () => { From eaef56c2c123167a37b742045b3adfdf14e9f366 Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Tue, 11 Feb 2025 17:09:26 +0000 Subject: [PATCH 16/17] fix(ssr): include `