From ec243c250c6b8f6fc25835ec2db3c7f157c84947 Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Tue, 10 Dec 2024 18:45:20 +0000 Subject: [PATCH] fix: rewrite SSR client-side hydration (#6067) * chore: wip fix hydration errors * chore: fix spec tests * chore: added e2e tests * chore: fix analysis test * chore: try again * chore: fixup scoped classes --------- Co-authored-by: John Jenkins --- src/declarations/stencil-private.ts | 6 + src/mock-doc/serialize-node.ts | 31 +- src/runtime/client-hydrate.ts | 478 ++++++++++++---- src/runtime/connected-callback.ts | 7 +- src/runtime/dom-extras.ts | 42 +- src/runtime/runtime-constants.ts | 1 + .../test/hydrate-no-encapsulation.spec.tsx | 12 - src/runtime/test/hydrate-scoped.spec.tsx | 7 +- .../test/hydrate-shadow-child.spec.tsx | 29 +- .../test/hydrate-shadow-in-shadow.spec.tsx | 7 +- .../test/hydrate-shadow-parent.spec.tsx | 18 +- src/runtime/test/hydrate-shadow.spec.tsx | 4 +- .../test/hydrate-slot-fallback.spec.tsx | 435 ++++++++++++++ .../hydrate-slotted-content-order.spec.tsx | 541 ++++++++++++++++++ src/runtime/vdom/set-accessor.ts | 8 +- src/runtime/vdom/vdom-annotations.ts | 13 +- src/runtime/vdom/vdom-render.ts | 2 +- test/end-to-end/src/components.d.ts | 65 +++ .../src/declarative-shadow-dom/test.e2e.ts | 2 +- .../non-shadow-forwarded-slot.tsx | 32 ++ .../scoped-hydration/non-shadow-wrapper.tsx | 25 + .../src/scoped-hydration/non-shadow.tsx | 25 + .../end-to-end/src/scoped-hydration/readme.md | 10 + .../scoped-hydration/scoped-hydration.e2e.ts | 109 ++++ .../src/scoped-hydration/shadow-wrapper.tsx | 25 + .../src/scoped-hydration/shadow.tsx | 27 + test/end-to-end/stencil.config.ts | 3 + 27 files changed, 1791 insertions(+), 173 deletions(-) create mode 100644 src/runtime/test/hydrate-slot-fallback.spec.tsx create mode 100644 src/runtime/test/hydrate-slotted-content-order.spec.tsx create mode 100644 test/end-to-end/src/scoped-hydration/non-shadow-forwarded-slot.tsx create mode 100644 test/end-to-end/src/scoped-hydration/non-shadow-wrapper.tsx create mode 100644 test/end-to-end/src/scoped-hydration/non-shadow.tsx create mode 100644 test/end-to-end/src/scoped-hydration/readme.md create mode 100644 test/end-to-end/src/scoped-hydration/scoped-hydration.e2e.ts create mode 100644 test/end-to-end/src/scoped-hydration/shadow-wrapper.tsx create mode 100644 test/end-to-end/src/scoped-hydration/shadow.tsx diff --git a/src/declarations/stencil-private.ts b/src/declarations/stencil-private.ts index 80b4665e48f..1b962343227 100644 --- a/src/declarations/stencil-private.ts +++ b/src/declarations/stencil-private.ts @@ -1435,6 +1435,12 @@ export interface RenderNode extends HostElement { */ ['s-nr']?: RenderNode; + /** + * Original Order: + * During SSR; a number representing the order of a slotted node + */ + ['s-oo']?: number; + /** * Scope Id */ diff --git a/src/mock-doc/serialize-node.ts b/src/mock-doc/serialize-node.ts index c94707d5743..75e6d5726fa 100644 --- a/src/mock-doc/serialize-node.ts +++ b/src/mock-doc/serialize-node.ts @@ -67,7 +67,7 @@ export function serializeNodeToHtml(elm: Node | MockNode, serializationOptions: ? Array.from((elm as MockDocument).body.childNodes) : opts.outerHtml ? [elm] - : Array.from(elm.childNodes as NodeList); + : Array.from(getChildNodes(elm)); for (let i = 0, ii = children.length; i < ii; i++) { const child = children[i]; @@ -130,7 +130,10 @@ function* streamToHtml( * ToDo(https://github.com/ionic-team/stencil/issues/4111): the shadow root class is `#document-fragment` * and has no mode attribute. We should consider adding a mode attribute. */ - if (tag === 'template') { + if ( + tag === 'template' && + (!(node as Element).getAttribute || !(node as Element).getAttribute('shadowrootmode')) + ) { const mode = ` shadowrootmode="open"`; yield mode; output.currentLineWidth += mode.length; @@ -242,12 +245,13 @@ function* streamToHtml( yield* streamToHtml(shadowRoot, opts, output); output.indent = output.indent - (opts.indentSpaces ?? 0); + const childNodes = getChildNodes(node); if ( opts.newLines && - (node.childNodes.length === 0 || - (node.childNodes.length === 1 && - node.childNodes[0].nodeType === NODE_TYPES.TEXT_NODE && - node.childNodes[0].nodeValue?.trim() === '')) + (childNodes.length === 0 || + (childNodes.length === 1 && + childNodes[0].nodeType === NODE_TYPES.TEXT_NODE && + childNodes[0].nodeValue?.trim() === '')) ) { yield '\n'; output.currentLineWidth = 0; @@ -262,7 +266,9 @@ function* streamToHtml( if (opts.excludeTagContent == null || opts.excludeTagContent.includes(tagName) === false) { const tag = tagName === shadowRootTag ? 'template' : tagName; const childNodes = - tagName === 'template' ? ((node as any as HTMLTemplateElement).content.childNodes as any) : node.childNodes; + tagName === 'template' + ? ((node as any as HTMLTemplateElement).content.childNodes as any) + : getChildNodes(node); const childNodeLength = childNodes.length; if (childNodeLength > 0) { @@ -525,6 +531,17 @@ function isWithinWhitespaceSensitive(node: Node | MockNode) { return false; } +/** + * Normalizes the `childNodes` of a node due to if `experimentalSlotFixes` is enabled, ` + * childNodes` will only return 'slotted' / lightDOM nodes + * + * @param node to return `childNodes` from + * @returns a node list of child nodes + */ +function getChildNodes(node: Node | MockNode) { + return ((node as any).__childNodes || node.childNodes) as NodeList; +} + // TODO(STENCIL-1299): Audit this list, remove unsupported/deprecated elements /*@__PURE__*/ export const NON_ESCAPABLE_CONTENT = new Set([ 'STYLE', diff --git a/src/runtime/client-hydrate.ts b/src/runtime/client-hydrate.ts index cb024bad05a..5653916e531 100644 --- a/src/runtime/client-hydrate.ts +++ b/src/runtime/client-hydrate.ts @@ -1,9 +1,11 @@ import { BUILD } from '@app-data'; -import { doc, plt, supportsShadow } from '@platform'; +import { doc, plt } from '@platform'; import type * as d from '../declarations'; +import { addSlotRelocateNode } from './dom-extras'; import { createTime } from './profile'; import { + COMMENT_NODE_ID, CONTENT_REF_ID, HYDRATE_CHILD_ID, HYDRATE_ID, @@ -15,14 +17,16 @@ import { import { newVNode } from './vdom/h'; /** - * Entrypoint of the client-side hydration process. Facilitates calls to hydrate the - * document and all its nodes. + * Takes an SSR rendered document, as annotated by 'vdom-annotations.ts' and: * - * This process will also reconstruct the shadow root and slot DOM nodes for components using shadow DOM. + * 1) Recreate an accurate VDOM which is fed to 'vdom-render.ts'. A failure to do so can cause hydration errors; extra renders, duplicated nodes + * 2) Add shadowDOM trees to their respective #document-fragment + * 3) Move forwarded, slotted nodes out of shadowDOMs + * 4) Add meta nodes to non-shadow DOMs and their 'slotted' nodes * * @param hostElm The element to hydrate. * @param tagName The element's tag name. - * @param hostId The host ID assigned to the element by the server. + * @param hostId The host ID assigned to the element by the server. e.g. `s-id="1"` * @param hostRef The host reference for the element. */ export const initializeClientHydrate = ( @@ -33,68 +37,178 @@ export const initializeClientHydrate = ( ) => { const endHydrate = createTime('hydrateClient', tagName); const shadowRoot = hostElm.shadowRoot; + // children placed by SSR within this component but don't necessarily belong to it. + // We need to keep tabs on them so we can move them to the right place later const childRenderNodes: RenderNodeData[] = []; + // nodes representing a `` element const slotNodes: RenderNodeData[] = []; + // nodes that have been slotted from outside the component + const slottedNodes: SlottedNodes[] = []; + // nodes that make up this component's shadowDOM const shadowRootNodes: d.RenderNode[] = BUILD.shadowDom && shadowRoot ? [] : null; - const vnode: d.VNode = (hostRef.$vnode$ = newVNode(tagName, null)); + // The root VNode for this component + const vnode: d.VNode = newVNode(tagName, null); + vnode.$elm$ = hostElm; if (!plt.$orgLocNodes$) { + // 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())); } hostElm[HYDRATE_ID] = hostId; hostElm.removeAttribute(HYDRATE_ID); - clientHydrate(vnode, childRenderNodes, slotNodes, shadowRootNodes, hostElm, hostElm, hostId); - - childRenderNodes.map((c) => { - const orgLocationId = c.$hostId$ + '.' + c.$nodeId$; + hostRef.$vnode$ = clientHydrate( + vnode, + childRenderNodes, + slotNodes, + shadowRootNodes, + hostElm, + hostElm, + hostId, + slottedNodes, + ); + + let crIndex = 0; + const crLength = childRenderNodes.length; + let childRenderNode: RenderNodeData; + + // Steps through the child nodes we found. + // If moved from an original location (by nature of being rendered in SSR markup) we might be able to move it back there now, + // so slotted nodes don't get added to internal shadowDOMs + for (crIndex; crIndex < crLength; crIndex++) { + childRenderNode = childRenderNodes[crIndex]; + const orgLocationId = childRenderNode.$hostId$ + '.' + childRenderNode.$nodeId$; + // The original location of this node const orgLocationNode = plt.$orgLocNodes$.get(orgLocationId); - const node = c.$elm$ as d.RenderNode; - - // Put the node back in its original location since the native Shadow DOM - // can handle rendering it its correct location now - if (orgLocationNode && supportsShadow && orgLocationNode['s-en'] === '') { - orgLocationNode.parentNode.insertBefore(node, orgLocationNode.nextSibling); - } + const node = childRenderNode.$elm$ as d.RenderNode; if (!shadowRoot) { - node['s-hn'] = tagName; + node['s-hn'] = tagName.toUpperCase(); - if (orgLocationNode) { - node['s-ol'] = orgLocationNode; - node['s-ol']['s-nr'] = node; + if (childRenderNode.$tag$ === 'slot') { + // If this is a virtual 'slot', add it's Content-position Reference now. + // If we don't, `vdom-render.ts` will try to add nodes to it (and because it may be a comment node, it will error) + node['s-cr'] = hostElm['s-cr']; } } + if (orgLocationNode && orgLocationNode.isConnected) { + if (shadowRoot && orgLocationNode['s-en'] === '') { + // if this node is within a shadowDOM, with an original location home + // we're safe to move it now + orgLocationNode.parentNode.insertBefore(node, orgLocationNode.nextSibling); + } + // Remove original location / slot reference comment now regardless: + // 1) Stops SSR frameworks complaining about mismatches + // 2) is un-required for non-shadow, slotted nodes as we'll add all the meta nodes we need when we deal with *all* slotted nodes ↓↓↓ + orgLocationNode.parentNode.removeChild(orgLocationNode); + + if (!shadowRoot) { + // Add the Original Order of this node. + // We'll use it later to make sure slotted nodes get added in the correct order + node['s-oo'] = parseInt(childRenderNode.$nodeId$); + } + } + // Remove the original location from the map plt.$orgLocNodes$.delete(orgLocationId); - }); + } + + const hosts: d.HostElement[] = []; + let snIndex = 0; + const snLen = slottedNodes.length; + let slotGroup: SlottedNodes; + let snGroupIdx: number; + let snGroupLen: number; + let slottedItem: SlottedNodes[0]; + + // Loops through all the slotted nodes we found while stepping through this component + for (snIndex; snIndex < snLen; snIndex++) { + slotGroup = slottedNodes[snIndex]; + + if (!slotGroup || !slotGroup.length) continue; + + snGroupLen = slotGroup.length; + snGroupIdx = 0; + + for (snGroupIdx; snGroupIdx < snGroupLen; snGroupIdx++) { + slottedItem = slotGroup[snGroupIdx]; + + if (!hosts[slottedItem.hostId as any]) { + // Cache this host for other grouped slotted nodes + hosts[slottedItem.hostId as any] = plt.$orgLocNodes$.get(slottedItem.hostId); + } + // This *shouldn't* happen as we collect all the custom elements first in `initializeDocumentHydrate` + if (!hosts[slottedItem.hostId as any]) continue; + + const hostEle = hosts[slottedItem.hostId as any]; + + // This node is either slotted in a non-shadow host, OR *that* host is nested in a non-shadow host + if (!hostEle.shadowRoot || !shadowRoot) { + // Try to set an appropriate Content-position Reference (CR) node for this host element + + // Is a CR already set on the host? + slottedItem.slot['s-cr'] = hostEle['s-cr']; + + if (!slottedItem.slot['s-cr'] && hostEle.shadowRoot) { + // Host has shadowDOM - just use the host itself as the CR for native slotting + slottedItem.slot['s-cr'] = hostEle; + } else { + // If all else fails - just set the CR as the first child + // (9/10 if node['s-cr'] hasn't been set, the node will be at the element root) + const hostChildren = (hostEle as any).__childNodes || hostEle.childNodes; + slottedItem.slot['s-cr'] = hostChildren[0] as d.RenderNode; + } + // Create our 'Original Location' node + addSlotRelocateNode(slottedItem.node, slottedItem.slot, false, slottedItem.node['s-oo']); + } + + if (hostEle.shadowRoot && slottedItem.node.parentElement !== hostEle) { + // shadowDOM - move the item to the element root for native slotting + hostEle.appendChild(slottedItem.node); + } + } + } if (BUILD.shadowDom && shadowRoot) { - shadowRootNodes.map((shadowRootNode) => { - if (shadowRootNode) { - shadowRoot.appendChild(shadowRootNode as any); + // 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); + } + + // Tidy up left-over / unnecessary comments to stop frameworks complaining about DOM mismatches + Array.from(hostElm.childNodes).forEach((node) => { + if (node.nodeType === NODE_TYPE.CommentNode && typeof (node as d.RenderNode)['s-sn'] !== 'string') { + node.parentNode.removeChild(node); } }); } + + hostRef.$hostElement$ = hostElm; endHydrate(); }; /** * Recursively constructs the virtual node tree for a host element and its children. - * The tree is constructed by parsing the annotations set on the nodes by the server. + * The tree is constructed by parsing the annotations set on the nodes by the server (`vdom-annotations.ts`). * - * In addition to constructing the vNode tree, we also track information about the node's - * descendants like which are slots, which should exist in the shadow root, and which - * are nodes that should be rendered as children of the parent node. + * In addition to constructing the VNode tree, we also track information about the node's descendants: + * - which are slots + * - which should exist in the shadow root + * - which are nodes that should be rendered as children of the parent node * * @param parentVNode The vNode representing the parent node. * @param childRenderNodes An array of all child nodes in the parent's node tree. * @param slotNodes An array of all slot nodes in the parent's node tree. - * @param shadowRootNodes An array all nodes that should be rendered in the shadow root in the parent's node tree. + * @param shadowRootNodes An array of nodes that should be rendered in the shadowDOM of the parent. * @param hostElm The parent element. * @param node The node to construct the vNode tree for. * @param hostId The host ID assigned to the element by the server. + * @param slottedNodes - nodes that have been slotted + * @returns - the constructed VNode */ const clientHydrate = ( parentVNode: d.VNode, @@ -104,21 +218,23 @@ const clientHydrate = ( hostElm: d.HostElement, node: d.RenderNode, hostId: string, + slottedNodes: SlottedNodes[] = [], ) => { let childNodeType: string; let childIdSplt: string[]; let childVNode: RenderNodeData; let i: number; + const scopeId = hostElm['s-sc']; if (node.nodeType === NODE_TYPE.ElementNode) { childNodeType = (node as HTMLElement).getAttribute(HYDRATE_CHILD_ID); if (childNodeType) { - // got the node data from the element's attribute + // Node data from the element's attribute: // `${hostId}.${nodeId}.${depth}.${index}` childIdSplt = childNodeType.split('.'); if (childIdSplt[0] === hostId || childIdSplt[0] === '0') { - childVNode = { + childVNode = createSimpleVNode({ $flags$: 0, $hostId$: childIdSplt[0], $nodeId$: childIdSplt[1], @@ -126,26 +242,49 @@ const clientHydrate = ( $index$: childIdSplt[3], $tag$: node.tagName.toLowerCase(), $elm$: node, - $attrs$: null, - $children$: null, - $key$: null, - $name$: null, - $text$: null, - }; + // If we don't add the initial classes to the VNode, the first `vdom-render.ts` reconciliation will fail: + // client side changes before componentDidLoad will be ignored, `set-accessor.ts` will just take the element's initial classes + $attrs$: { class: node.className }, + }); childRenderNodes.push(childVNode); node.removeAttribute(HYDRATE_CHILD_ID); - // this is a new child vnode - // so ensure its parent vnode has the vchildren array + // This is a new child VNode so ensure its parent VNode has the VChildren array if (!parentVNode.$children$) { parentVNode.$children$ = []; } - // add our child vnode to a specific index of the vnode's children - parentVNode.$children$[childVNode.$index$ as any] = childVNode; + // Test if this element was 'slotted' or is a 'slot' (with fallback). Recreate node attributes + const slotName = childVNode.$elm$.getAttribute('s-sn'); + if (typeof slotName === 'string') { + if (childVNode.$tag$ === 'slot-fb') { + // This is a slot node. Set it up and find any assigned slotted nodes + addSlot( + slotName, + childIdSplt[2], + childVNode, + node, + parentVNode, + childRenderNodes, + slotNodes, + shadowRootNodes, + slottedNodes, + ); + } + childVNode.$elm$['s-sn'] = slotName; + childVNode.$elm$.removeAttribute('s-sn'); + } + if (childVNode.$index$ !== undefined) { + // add our child VNode to a specific index of the VNode's children + parentVNode.$children$[childVNode.$index$ as any] = childVNode; + } + + // Host is `scoped: true` - add that flag to the child. + // It's used in 'set-accessor.ts' to make sure our scoped class is present + if (scopeId) node['s-si'] = scopeId; - // this is now the new parent vnode for all the next child checks + // This is now the new parent VNode for all the next child checks parentVNode = childVNode; if (shadowRootNodes && childVNode.$depth$ === '0') { @@ -155,7 +294,7 @@ const clientHydrate = ( } if (node.shadowRoot) { - // keep drilling down through the shadow root nodes + // Keep drilling down through the shadow root nodes for (i = node.shadowRoot.childNodes.length - 1; i >= 0; i--) { clientHydrate( parentVNode, @@ -165,20 +304,23 @@ const clientHydrate = ( hostElm, node.shadowRoot.childNodes[i] as any, hostId, + slottedNodes, ); } } - // recursively drill down, end to start so we can remove nodes - for (i = node.childNodes.length - 1; i >= 0; i--) { + // Recursively drill down, end to start so we can remove nodes + const nonShadowNodes = node.__childNodes || node.childNodes; + for (i = nonShadowNodes.length - 1; i >= 0; i--) { clientHydrate( parentVNode, childRenderNodes, slotNodes, shadowRootNodes, hostElm, - node.childNodes[i] as any, + nonShadowNodes[i] as any, hostId, + slottedNodes, ); } } else if (node.nodeType === NODE_TYPE.CommentNode) { @@ -186,15 +328,14 @@ const clientHydrate = ( childIdSplt = node.nodeValue.split('.'); if (childIdSplt[1] === hostId || childIdSplt[1] === '0') { - // comment node for either the host id or a 0 host id + // A comment node for either this host OR (if 0) a root component childNodeType = childIdSplt[0]; - childVNode = { - $flags$: 0, + childVNode = createSimpleVNode({ $hostId$: childIdSplt[1], $nodeId$: childIdSplt[2], $depth$: childIdSplt[3], - $index$: childIdSplt[4], + $index$: childIdSplt[4] || '0', $elm$: node, $attrs$: null, $children$: null, @@ -202,71 +343,68 @@ const clientHydrate = ( $name$: null, $tag$: null, $text$: null, - }; + }); if (childNodeType === TEXT_NODE_ID) { childVNode.$elm$ = node.nextSibling as any; + if (childVNode.$elm$ && childVNode.$elm$.nodeType === NODE_TYPE.TextNode) { childVNode.$text$ = childVNode.$elm$.textContent; childRenderNodes.push(childVNode); - // remove the text comment since it's no longer needed + // Remove the text comment since it's no longer needed node.remove(); - if (!parentVNode.$children$) { - parentVNode.$children$ = []; + // Checks to make sure this node actually belongs to this host. + // If it was slotted from another component, we don't want to add it to this host's VDOM; it can be removed on render reconciliation. + // We *want* slotting logic to take care of it + if (hostId === childVNode.$hostId$) { + if (!parentVNode.$children$) { + parentVNode.$children$ = []; + } + parentVNode.$children$[childVNode.$index$ as any] = childVNode; } - parentVNode.$children$[childVNode.$index$ as any] = childVNode; if (shadowRootNodes && childVNode.$depth$ === '0') { shadowRootNodes[childVNode.$index$ as any] = childVNode.$elm$; } } + } else if (childNodeType === COMMENT_NODE_ID) { + childVNode.$elm$ = node.nextSibling as any; + + if (childVNode.$elm$ && childVNode.$elm$.nodeType === NODE_TYPE.CommentNode) { + // A non-Stencil comment node + childRenderNodes.push(childVNode); + + // Remove the comment comment since it's no longer needed + node.remove(); + } } else if (childVNode.$hostId$ === hostId) { - // this comment node is specifically for this host id + // This comment node is specifically for this host id if (childNodeType === SLOT_NODE_ID) { + // Comment refers to a slot node: // `${SLOT_NODE_ID}.${hostId}.${nodeId}.${depth}.${index}.${slotName}`; childVNode.$tag$ = 'slot'; - if (childIdSplt[5]) { - node['s-sn'] = childVNode.$name$ = childIdSplt[5]; - } else { - node['s-sn'] = ''; - } - node['s-sr'] = true; - - if (BUILD.shadowDom && shadowRootNodes) { - // browser support shadowRoot and this is a shadow dom component - // create an actual slot element - childVNode.$elm$ = doc.createElement(childVNode.$tag$); - - if (childVNode.$name$) { - // add the slot name attribute - childVNode.$elm$.setAttribute('name', childVNode.$name$); - } - - // insert the new slot element before the slot comment - node.parentNode.insertBefore(childVNode.$elm$, node); - - // remove the slot comment since it's not needed for shadow - node.remove(); - - if (childVNode.$depth$ === '0') { - shadowRootNodes[childVNode.$index$ as any] = childVNode.$elm$; - } - } - - slotNodes.push(childVNode); - - if (!parentVNode.$children$) { - parentVNode.$children$ = []; - } - parentVNode.$children$[childVNode.$index$ as any] = childVNode; + // Add the slot name + const slotName = (node['s-sn'] = childVNode.$name$ = childIdSplt[5] || ''); + // add the `` node to the VNode tree and prepare any slotted any child nodes + addSlot( + slotName, + childIdSplt[2], + childVNode, + node, + parentVNode, + childRenderNodes, + slotNodes, + shadowRootNodes, + slottedNodes, + ); } else if (childNodeType === CONTENT_REF_ID) { // `${CONTENT_REF_ID}.${hostId}`; if (BUILD.shadowDom && shadowRootNodes) { - // remove the content ref comment since it's not needed for shadow + // Remove the content ref comment since it's not needed for shadow node.remove(); } else if (BUILD.slotRelocation) { hostElm['s-cr'] = node; @@ -281,25 +419,35 @@ const clientHydrate = ( vnode.$index$ = '0'; parentVNode.$children$ = [vnode]; } + + return parentVNode; }; /** - * Recursively locate any comments representing an original location for a node in a node's - * children or shadowRoot children. + * 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 `
` * * @param node The node to search. - * @param orgLocNodes A map of the original location annotation and the current node being searched. + * @param orgLocNodes A map of the original location annotations and the current node being searched. */ export const initializeDocumentHydrate = (node: d.RenderNode, orgLocNodes: d.PlatformRuntime['$orgLocNodes$']) => { if (node.nodeType === NODE_TYPE.ElementNode) { + // Add all the loaded component IDs in this document; required to find nodes later when deciding where slotted nodes should live + const componentId = node[HYDRATE_ID] || node.getAttribute(HYDRATE_ID); + if (componentId) { + orgLocNodes.set(componentId, node); + } + let i = 0; if (node.shadowRoot) { for (; i < node.shadowRoot.childNodes.length; i++) { initializeDocumentHydrate(node.shadowRoot.childNodes[i] as d.RenderNode, orgLocNodes); } } - for (i = 0; i < node.childNodes.length; i++) { - initializeDocumentHydrate(node.childNodes[i] as d.RenderNode, orgLocNodes); + const nonShadowNodes = node.__childNodes || node.childNodes; + for (i = 0; i < nonShadowNodes.length; i++) { + initializeDocumentHydrate(nonShadowNodes[i] as d.RenderNode, orgLocNodes); } } else if (node.nodeType === NODE_TYPE.CommentNode) { const childIdSplt = node.nodeValue.split('.'); @@ -307,13 +455,145 @@ export const initializeDocumentHydrate = (node: d.RenderNode, orgLocNodes: d.Pla orgLocNodes.set(childIdSplt[1] + '.' + childIdSplt[2], node); node.nodeValue = ''; - // useful to know if the original location is - // the root light-dom of a shadow dom component + // Useful to know if the original location is The root light-dom of a shadow dom component node['s-en'] = childIdSplt[3] as any; } } }; +/** + * Creates a VNode to add to a hydrated component VDOM + * + * @param vnode - a vnode partial which will be augmented + * @returns an complete vnode + */ +const createSimpleVNode = (vnode: Partial): RenderNodeData => { + const defaultVNode: RenderNodeData = { + $flags$: 0, + $hostId$: null, + $nodeId$: null, + $depth$: null, + $index$: '0', + $elm$: null, + $attrs$: null, + $children$: null, + $key$: null, + $name$: null, + $tag$: null, + $text$: null, + }; + return { ...defaultVNode, ...vnode }; +}; + +function addSlot( + slotName: string, + slotId: string, + childVNode: RenderNodeData, + node: d.RenderNode, + parentVNode: d.VNode, + childRenderNodes: RenderNodeData[], + slotNodes: RenderNodeData[], + shadowRootNodes: d.RenderNode[], + slottedNodes: SlottedNodes[], +) { + node['s-sr'] = true; + + // Find this slots' current host parent (as dictated by the VDOM tree). + // Important because where it is now in the constructed SSR markup might be different to where to *should* be + const parentNodeId = parentVNode?.$elm$ ? parentVNode.$elm$['s-id'] || parentVNode.$elm$.getAttribute('s-id') : ''; + + if (BUILD.shadowDom && shadowRootNodes) { + /* SHADOW */ + + // Browser supports shadowRoot and this is a shadow dom component; create an actual slot element + const slot = (childVNode.$elm$ = doc.createElement(childVNode.$tag$ as string) as d.RenderNode); + + if (childVNode.$name$) { + // Add the slot name attribute + childVNode.$elm$.setAttribute('name', slotName); + } + + if (parentNodeId && parentNodeId !== childVNode.$hostId$) { + // Shadow component's slot is placed inside a nested component's shadowDOM; it doesn't belong to this host - it was forwarded by the SSR markup. + // Insert it in the root of this host; it's lightDOM. It doesn't really matter where in the host root; the component will take care of it. + parentVNode.$elm$.insertBefore(slot, parentVNode.$elm$.children[0]); + } else { + // Insert the new slot element before the slot comment + node.parentNode.insertBefore(childVNode.$elm$, node); + } + addSlottedNodes(slottedNodes, slotId, slotName, node, childVNode.$hostId$); + + // Remove the slot comment since it's not needed for shadow + node.remove(); + + if (childVNode.$depth$ === '0') { + shadowRootNodes[childVNode.$index$ as any] = childVNode.$elm$; + } + } else { + /* NON-SHADOW */ + const slot = childVNode.$elm$ as d.RenderNode; + + // Test to see if this non-shadow component's mock 'slot' is placed inside a nested component's shadowDOM. If so, it doesn't belong here; + // it was forwarded by the SSR markup. So we'll insert it into the root of this host; it's lightDOM with accompanying 'slotted' nodes + const shouldMove = parentNodeId && parentNodeId !== childVNode.$hostId$ && parentVNode.$elm$.shadowRoot; + + // attempt to find any mock slotted nodes which we'll move later + addSlottedNodes(slottedNodes, slotId, slotName, node, shouldMove ? parentNodeId : childVNode.$hostId$); + + if (shouldMove) { + // Move slot comment node (to after any other comment nodes) + parentVNode.$elm$.insertBefore(slot, parentVNode.$elm$.children[0]); + } + childRenderNodes.push(childVNode); + } + + slotNodes.push(childVNode); + + if (!parentVNode.$children$) { + parentVNode.$children$ = []; + } + parentVNode.$children$[childVNode.$index$ as any] = childVNode; +} + +/** + * Adds groups of slotted nodes (grouped by slot ID) to this host element's 'master' array. + * We'll use this after the host element's VDOM is completely constructed to finally position and add meta required by non-shadow slotted nodes + * + * @param slottedNodes - the main host element 'master' array to add to + * @param slotNodeId - the slot node unique ID + * @param slotName - the slot node name (can be '') + * @param slotNode - the slot node + * @param hostId - the host element id where this node should be slotted + */ +const addSlottedNodes = ( + slottedNodes: SlottedNodes[], + slotNodeId: string, + slotName: string, + slotNode: d.RenderNode, + hostId: string, +) => { + let slottedNode = slotNode.nextSibling as d.RenderNode; + slottedNodes[slotNodeId as any] = slottedNodes[slotNodeId as any] || []; + + // Looking for nodes that match this slot's name, + // OR are text / comment nodes and the slot is a default slot (no name) - text / comments cannot be direct descendants of *named* slots. + // Also ignore slot fallback nodes - they're not part of the lightDOM + while ( + slottedNode && + (((slottedNode['getAttribute'] && slottedNode.getAttribute('slot')) || slottedNode['s-sn']) === slotName || + (slotName === '' && + !slottedNode['s-sn'] && + ((slottedNode.nodeType === NODE_TYPE.CommentNode && slottedNode.nodeValue.indexOf('.') !== 1) || + slottedNode.nodeType === NODE_TYPE.TextNode))) + ) { + slottedNode['s-sn'] = slotName; + slottedNodes[slotNodeId as any].push({ slot: slotNode, node: slottedNode, hostId }); + slottedNode = slottedNode.nextSibling as d.RenderNode; + } +}; + +type SlottedNodes = Array<{ slot: d.RenderNode; node: d.RenderNode; hostId: string }>; + interface RenderNodeData extends d.VNode { $hostId$: string; $nodeId$: string; diff --git a/src/runtime/connected-callback.ts b/src/runtime/connected-callback.ts index e83db09d5f1..9ae5077d982 100644 --- a/src/runtime/connected-callback.ts +++ b/src/runtime/connected-callback.ts @@ -7,7 +7,7 @@ import { initializeClientHydrate } from './client-hydrate'; import { fireConnectedCallback, initializeComponent } from './initialize-component'; import { createTime } from './profile'; import { HYDRATE_ID, NODE_TYPE, PLATFORM_FLAGS } from './runtime-constants'; -import { addStyle } from './styles'; +import { addStyle, getScopeId } from './styles'; import { attachToAncestor } from './update-component'; import { insertBefore } from './vdom/vdom-render'; @@ -35,6 +35,11 @@ export const connectedCallback = (elm: d.HostElement) => { ? addStyle(elm.shadowRoot, cmpMeta, elm.getAttribute('s-mode')) : addStyle(elm.shadowRoot, cmpMeta); elm.classList.remove(scopeId + '-h', scopeId + '-s'); + } else if (BUILD.scoped && cmpMeta.$flags$ & CMP_FLAGS.scopedCssEncapsulation) { + // set the scope id on the element now. Useful when hydrating, + // to more quickly set the initial scoped classes for scoped css + const scopeId = getScopeId(cmpMeta, BUILD.mode ? elm.getAttribute('s-mode') : undefined); + elm['s-sc'] = scopeId; } initializeClientHydrate(elm, cmpMeta.$tagName$, hostId, hostRef); } diff --git a/src/runtime/dom-extras.ts b/src/runtime/dom-extras.ts index 76b7d975085..674426e88ac 100644 --- a/src/runtime/dom-extras.ts +++ b/src/runtime/dom-extras.ts @@ -287,9 +287,15 @@ export const patchChildSlotNodes = (elm: HTMLElement) => { // for mock-doc childNodesFn = Object.getOwnPropertyDescriptor(elm, 'childNodes'); } - if (childNodesFn) Object.defineProperty(elm, '__childNodes', childNodesFn); + let childrenFn = Object.getOwnPropertyDescriptor(Element.prototype, 'children'); + if (!childrenFn) { + // for mock-doc + childrenFn = Object.getOwnPropertyDescriptor(elm, 'children'); + } + if (childrenFn) Object.defineProperty(elm, '__children', childrenFn); + Object.defineProperty(elm, 'children', { get() { return this.childNodes.filter((n: any) => n.nodeType === 1); @@ -330,9 +336,15 @@ export const patchChildSlotNodes = (elm: HTMLElement) => { * @param newChild a node that's going to be added to the component * @param slotNode the slot node that the node will be added to * @param prepend move the slotted location node to the beginning of the host + * @param position an ordered position to add the ref node which mirrors the lightDom nodes' order. Used during SSR hydration * (the order of the slot location nodes determines the order of the slotted nodes in our patched accessors) */ -export const addSlotRelocateNode = (newChild: d.RenderNode, slotNode: d.RenderNode, prepend?: boolean) => { +export const addSlotRelocateNode = ( + newChild: d.RenderNode, + slotNode: d.RenderNode, + prepend?: boolean, + position?: number, +) => { let slottedNodeLocation: d.RenderNode; // does newChild already have a slot location node? if (newChild['s-ol'] && newChild['s-ol'].isConnected) { @@ -342,13 +354,33 @@ export const addSlotRelocateNode = (newChild: d.RenderNode, slotNode: d.RenderNo slottedNodeLocation['s-nr'] = newChild; } + if (!slotNode['s-cr'] || !slotNode['s-cr'].parentNode) return; + const parent = slotNode['s-cr'].parentNode as any; - const appendMethod = prepend ? parent.__prepend : parent.__appendChild; + const appendMethod = prepend ? parent.__prepend || parent.prepend : parent.__appendChild || parent.appendChild; + + if (typeof position !== 'undefined') { + if (BUILD.hydrateClientSide) { + slottedNodeLocation['s-oo'] = position; + const childNodes = (parent.__childNodes || parent.childNodes) as NodeListOf; + const slotRelocateNodes: d.RenderNode[] = [slottedNodeLocation]; + childNodes.forEach((n) => { + if (n['s-nr']) slotRelocateNodes.push(n); + }); + + slotRelocateNodes.sort((a, b) => { + if (!a['s-oo'] || a['s-oo'] < b['s-oo']) return -1; + else if (!b['s-oo'] || b['s-oo'] < a['s-oo']) return 1; + return 0; + }); + slotRelocateNodes.forEach((n) => appendMethod.call(parent, n)); + } + } else { + appendMethod.call(parent, slottedNodeLocation); + } newChild['s-ol'] = slottedNodeLocation; newChild['s-sh'] = slotNode['s-hn']; - - appendMethod.call(parent, slottedNodeLocation); }; /** diff --git a/src/runtime/runtime-constants.ts b/src/runtime/runtime-constants.ts index e0ba8964ca4..8af3d699237 100644 --- a/src/runtime/runtime-constants.ts +++ b/src/runtime/runtime-constants.ts @@ -54,6 +54,7 @@ export const CONTENT_REF_ID = 'r'; export const ORG_LOCATION_ID = 'o'; export const SLOT_NODE_ID = 's'; export const TEXT_NODE_ID = 't'; +export const COMMENT_NODE_ID = 'c'; export const HYDRATE_ID = 's-id'; export const HYDRATED_STYLE_ID = 'sty-id'; diff --git a/src/runtime/test/hydrate-no-encapsulation.spec.tsx b/src/runtime/test/hydrate-no-encapsulation.spec.tsx index 9733c08a796..c1e24987423 100644 --- a/src/runtime/test/hydrate-no-encapsulation.spec.tsx +++ b/src/runtime/test/hydrate-no-encapsulation.spec.tsx @@ -211,8 +211,6 @@ describe('hydrate no encapsulation', () => { - - light-dom
@@ -272,8 +270,6 @@ describe('hydrate no encapsulation', () => { - - light-dom
@@ -334,9 +330,7 @@ describe('hydrate no encapsulation', () => { -
- light-dom
@@ -397,9 +391,7 @@ describe('hydrate no encapsulation', () => { -
- light-dom
@@ -479,15 +471,11 @@ describe('hydrate no encapsulation', () => { - - -
top light-dom
- middle light-dom
diff --git a/src/runtime/test/hydrate-scoped.spec.tsx b/src/runtime/test/hydrate-scoped.spec.tsx index 233a1336f61..f8d23ed42fe 100644 --- a/src/runtime/test/hydrate-scoped.spec.tsx +++ b/src/runtime/test/hydrate-scoped.spec.tsx @@ -44,9 +44,7 @@ describe('hydrate scoped', () => { expect(clientHydrated.root).toEqualHtml(` -
- 88mph
@@ -97,9 +95,7 @@ describe('hydrate scoped', () => { expect(clientHydrated.root).toEqualHtml(` - -
- +
88mph
@@ -133,7 +129,6 @@ describe('hydrate scoped', () => { `); - // @ts-ignore const clientHydrated = await newSpecPage({ components: [CmpA], html: serverHydrated.root.outerHTML, diff --git a/src/runtime/test/hydrate-shadow-child.spec.tsx b/src/runtime/test/hydrate-shadow-child.spec.tsx index 8f6631e6c1c..b779f8db511 100644 --- a/src/runtime/test/hydrate-shadow-child.spec.tsx +++ b/src/runtime/test/hydrate-shadow-child.spec.tsx @@ -102,7 +102,6 @@ describe('hydrate, shadow child', () => { - light-dom @@ -285,7 +284,6 @@ describe('hydrate, shadow child', () => {
- light-dom @@ -417,7 +415,6 @@ describe('hydrate, shadow child', () => {
- light-dom @@ -428,7 +425,11 @@ describe('hydrate, shadow child', () => { @Component({ tag: 'cmp-a' }) class CmpA { render() { - return ; + return ( + + + + ); } } @Component({ tag: 'cmp-b', shadow: true }) @@ -469,15 +470,17 @@ describe('hydrate, shadow child', () => { expect(serverHydrated.root).toEqualHtml(` - + + + - - + +
- + cmp-b-top-text - +
@@ -497,18 +500,16 @@ describe('hydrate, shadow child', () => { }); expect(clientHydrated.root).toEqualHtml(` - + - +
- cmp-b-top-text - - +
cmp-c diff --git a/src/runtime/test/hydrate-shadow-in-shadow.spec.tsx b/src/runtime/test/hydrate-shadow-in-shadow.spec.tsx index f265814a7ae..1ff3c9c3576 100644 --- a/src/runtime/test/hydrate-shadow-in-shadow.spec.tsx +++ b/src/runtime/test/hydrate-shadow-in-shadow.spec.tsx @@ -60,17 +60,14 @@ describe('hydrate, shadow in shadow', () => { - - light-dom `); expect(clientHydrated.root).toEqualLightHtml(` - light-dom `); @@ -130,7 +127,6 @@ describe('hydrate, shadow in shadow', () => { - light-dom @@ -314,7 +310,6 @@ describe('hydrate, shadow in shadow', () => {
- light-dom @@ -443,7 +438,7 @@ describe('hydrate, shadow in shadow', () => {
- light-dom + light-dom `); diff --git a/src/runtime/test/hydrate-shadow-parent.spec.tsx b/src/runtime/test/hydrate-shadow-parent.spec.tsx index d9a7adc73c0..9c059237ebb 100644 --- a/src/runtime/test/hydrate-shadow-parent.spec.tsx +++ b/src/runtime/test/hydrate-shadow-parent.spec.tsx @@ -54,7 +54,6 @@ describe('hydrate, shadow parent', () => {
- middle `); @@ -114,7 +113,6 @@ describe('hydrate, shadow parent', () => { bottom - middle `); @@ -272,8 +270,6 @@ describe('hydrate, shadow parent', () => { - - cmp-a-light-dom @@ -351,13 +347,11 @@ describe('hydrate, shadow parent', () => { - Title `); expect(clientHydrated.root).toEqualLightHtml(` - Title `); @@ -409,7 +403,7 @@ describe('hydrate, shadow parent', () => { - + @@ -441,12 +435,10 @@ describe('hydrate, shadow parent', () => { - - root-text @@ -482,7 +474,7 @@ describe('hydrate, shadow parent', () => { - + @@ -504,11 +496,8 @@ describe('hydrate, shadow parent', () => { - - - cmp-a-light-dom @@ -516,11 +505,8 @@ describe('hydrate, shadow parent', () => { expect(clientHydrated.root).toEqualLightHtml(` - - - cmp-a-light-dom diff --git a/src/runtime/test/hydrate-shadow.spec.tsx b/src/runtime/test/hydrate-shadow.spec.tsx index 41fbabd7990..7af07c51d7f 100644 --- a/src/runtime/test/hydrate-shadow.spec.tsx +++ b/src/runtime/test/hydrate-shadow.spec.tsx @@ -61,7 +61,6 @@ describe('hydrate, shadow', () => { - CmpALightDom @@ -119,7 +118,7 @@ describe('hydrate, shadow', () => {
-
+

LightDom1 @@ -157,7 +156,6 @@ describe('hydrate, shadow', () => { -

diff --git a/src/runtime/test/hydrate-slot-fallback.spec.tsx b/src/runtime/test/hydrate-slot-fallback.spec.tsx new file mode 100644 index 00000000000..7a3afd8b68a --- /dev/null +++ b/src/runtime/test/hydrate-slot-fallback.spec.tsx @@ -0,0 +1,435 @@ +import { Component, h } from '@stencil/core'; +import { newSpecPage } from '@stencil/core/testing'; + +describe('hydrate, slot fallback', () => { + it('shows slot fallback content in a `scoped: true` parent', async () => { + @Component({ + tag: 'cmp-a', + scoped: true, + }) + class CmpA { + render() { + return ( +

+ + Fallback text - should not be hidden + Fallback element + +
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA], + html: ``, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + +
+ + + Fallback text - should not be hidden + + + Fallback element + + +
+
+ `); + + const clientHydrated = await newSpecPage({ + components: [CmpA], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + expect(clientHydrated.root).toEqualHtml(` + + +
+ + Fallback text - should not be hidden + + Fallback element + + +
+
+ `); + }); + + it('shows slot fallback content in a `shadow: true` component`', async () => { + @Component({ + tag: 'cmp-a', + shadow: true, + }) + class CmpA { + render() { + return ( +
+ + Fallback text - should not be hidden + Fallback element + +
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA], + html: ``, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + +
+ + + Fallback text - should not be hidden + + + Fallback element + + +
+
+ `); + + const clientHydrated = await newSpecPage({ + components: [CmpA], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + expect(clientHydrated.root).toEqualHtml(` + + +
+ + Fallback text - should not be hidden + + Fallback element + + +
+
+
+ `); + }); + + it('shows slot fallback text in a nested `scoped: true` component (hides the fallback in the `scoped: true` parent component)', async () => { + @Component({ + tag: 'cmp-a', + scoped: true, + }) + class CmpA { + render() { + return ( +
+ Fallback content parent - should be hidden +

Non slot based content

+
+ ); + } + } + + @Component({ + tag: 'cmp-b', + scoped: true, + }) + class CmpB { + render() { + return ( +
+ Fallback content child - should not be hidden +

Non slot based content

+
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: ``, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + +
+ + +
+ + + Fallback content child - should not be hidden + +

+ + Non slot based content +

+
+
+ +

+ + Non slot based content +

+
+
+ `); + + const clientHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + expect(clientHydrated.root.outerHTML).toEqualHtml(` + + +
+ + +
+ + Fallback content child - should not be hidden + +

+ Non slot based content +

+
+
+ +

+ Non slot based content +

+
+
+ `); + }); + + it('renders slot fallback text in a nested `shadow: true` component (`shadow: true` parent component)', async () => { + @Component({ + tag: 'cmp-a', + shadow: true, + }) + class CmpA { + render() { + return ( +
+ Fallback content parent - should be hidden +

Non slot based content

+
+ ); + } + } + + @Component({ + tag: 'cmp-b', + shadow: true, + }) + class CmpB { + render() { + return ( +
+ Fallback content child - should not be hidden +

Non slot based content

+
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: ``, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + +
+ + +
+ + + Fallback content child - should not be hidden + +

+ + Non slot based content +

+
+
+ +

+ + Non slot based content +

+
+
+ `); + + const clientHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + expect(clientHydrated.root).toEqualHtml(` + + +
+ + Fallback content parent - should be hidden + +

+ Non slot based content +

+
+
+ + +
+ + Fallback content child - should not be hidden + +

+ Non slot based content +

+
+
+
+
+ `); + }); + + it('does not show slot fallback text when a `scoped: true` component forwards the slot to nested `shadow: true`', async () => { + @Component({ + tag: 'cmp-a', + scoped: true, + }) + class CmpA { + render() { + return ( +
+ + Fallback content parent - should be hidden + +
+ ); + } + } + + @Component({ + tag: 'cmp-b', + shadow: true, + }) + class CmpB { + render() { + return ( +
+ Fallback content child - should be hidden +
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: ` + +

slotted item 1

+

slotted item 2

+

slotted item 3

+
+ `, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + + + +
+ + + +
+

+ slotted item 1 +

+

+ slotted item 2 +

+

+ slotted item 3 +

+ + +
+
+
+
+ `); + + const clientHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + expect(clientHydrated.root).toEqualHtml(` + + +
+ + +
+ + Fallback content child - should be hidden + +
+
+ +

+ slotted item 1 +

+

+ slotted item 2 +

+

+ slotted item 3 +

+
+
+
+ `); + }); +}); diff --git a/src/runtime/test/hydrate-slotted-content-order.spec.tsx b/src/runtime/test/hydrate-slotted-content-order.spec.tsx new file mode 100644 index 00000000000..77dfa36a39f --- /dev/null +++ b/src/runtime/test/hydrate-slotted-content-order.spec.tsx @@ -0,0 +1,541 @@ +import { Component, h } from '@stencil/core'; +import { newSpecPage } from '@stencil/core/testing'; + +import { patchPseudoShadowDom } from '../../runtime/dom-extras'; + +describe("hydrated components' slotted node order", () => { + const nodeOrEle = (node: Node | Element) => { + if (!node) return ''; + return (node as Element).outerHTML || node.nodeValue; + }; + + it('should retain original order of slotted nodes within a `shadow: true` component', async () => { + @Component({ + tag: 'cmp-a', + shadow: true, + }) + class CmpA { + render() { + return ( +
+ +
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA], + html: ` +

slotted item 1

slotted item 2

A text node

slotted item 3

+ `, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + + + + + + +
+ +

+ slotted item 1 +

+ + +

+ slotted item 2 +

+ + A text node +

+ slotted item 3 +

+ + +
+
`); + + const clientHydrated = await newSpecPage({ + components: [CmpA], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + expect(clientHydrated.root).toEqualHtml(` + + +
+ +
+
+

+ slotted item 1 +

+ +

+ slotted item 2 +

+ A text node +

+ slotted item 3 +

+ +
+ `); + + const childNodes = clientHydrated.root.childNodes; + + expect(nodeOrEle(childNodes[0])).toBe(`

slotted item 1

`); + expect(nodeOrEle(childNodes[1])).toBe(` a comment `); + expect(nodeOrEle(childNodes[2])).toBe(`

slotted item 2

`); + expect(nodeOrEle(childNodes[3])).toBe(`A text node`); + expect(nodeOrEle(childNodes[4])).toBe(`

slotted item 3

`); + expect(nodeOrEle(childNodes[5])).toBe(` another comment `); + }); + + it('should retain original order of slotted nodes within multiple slots of a `shadow: true` component', async () => { + @Component({ + tag: 'cmp-a', + shadow: true, + }) + class CmpA { + render() { + return ( +
+ +
+ +
+
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA], + html: ` + Default slot

second slot

+ `, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + + + + +
+ +
+ + + + + Default slot + + +
+
+
`); + + const clientHydrated = await newSpecPage({ + components: [CmpA], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + expect(clientHydrated.root.outerHTML).toEqualHtml(` + + + Default slot +

+ second slot +

+ +
+ `); + + const childNodes = clientHydrated.root.childNodes; + + expect(nodeOrEle(childNodes[0])).toBe(` comment node `); + expect(nodeOrEle(childNodes[1])).toBe(` Default slot `); + expect(nodeOrEle(childNodes[2])).toBe(`

second slot

`); + expect(nodeOrEle(childNodes[3])).toBe(` another comment node `); + }); + + it('should retain original order of slotted nodes within nested `shadow: true` components', async () => { + @Component({ + tag: 'cmp-a', + shadow: true, + }) + class CmpA { + render() { + return ( +
+ +
+ ); + } + } + + @Component({ + tag: 'cmp-b', + shadow: true, + }) + class CmpB { + render() { + return ( +
+ +
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: ` +

slotted item 1a

A text node

slotted item 1b

B text node
+ `, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + + + + + +
+ +

+ slotted item 1a +

+ + + + A text node + + + + + + + + +
+ +

+ slotted item 1b +

+ + + + B text node + + +
+
+
+
`); + + const clientHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + const childNodes = clientHydrated.root.childNodes; + + expect(nodeOrEle(childNodes[0])).toBe(`

slotted item 1a

`); + expect(nodeOrEle(childNodes[1])).toBe(` a comment `); + expect(nodeOrEle(childNodes[2])).toBe(`A text node`); + expect(nodeOrEle(childNodes[3])).toBe(` another comment a`); + expect(nodeOrEle(childNodes[4].childNodes[0])).toBe(`

slotted item 1b

`); + expect(nodeOrEle(childNodes[4].childNodes[1])).toBe(` b comment `); + expect(nodeOrEle(childNodes[4].childNodes[2])).toBe(`B text node`); + expect(nodeOrEle(childNodes[4].childNodes[3])).toBe(` another comment b`); + }); + + it('should retain original order of slotted nodes within a `scoped: true` component', async () => { + @Component({ + tag: 'cmp-a', + scoped: true, + }) + class CmpA { + render() { + return ( +
+ +
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA], + html: ` +

slotted item 1

slotted item 2

A text node

slotted item 3

+ `, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + + + + + + +
+ +

+ slotted item 1 +

+ + +

+ slotted item 2 +

+ + A text node +

+ slotted item 3 +

+ + +
+
`); + + const clientHydrated = await newSpecPage({ + components: [CmpA], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + // patches this element in the same way we patch all elements in the browser + patchPseudoShadowDom(clientHydrated.root); + + expect(clientHydrated.root.outerHTML).toEqualHtml(` + + +
+

+ slotted item 1 +

+ +

+ slotted item 2 +

+ A text node +

+ slotted item 3 +

+ +
+
+ `); + + const childNodes = clientHydrated.root.childNodes; + + expect(nodeOrEle(childNodes[0])).toBe(`

slotted item 1

`); + expect(nodeOrEle(childNodes[1])).toBe(` a comment `); + expect(nodeOrEle(childNodes[2])).toBe(`

slotted item 2

`); + expect(nodeOrEle(childNodes[3])).toBe(`A text node`); + expect(nodeOrEle(childNodes[4])).toBe(`

slotted item 3

`); + expect(nodeOrEle(childNodes[5])).toBe(` another comment `); + }); + + it('should retain original order of slotted nodes within multiple slots of a `scoped: true` component', async () => { + @Component({ + tag: 'cmp-a', + shadow: false, + }) + class CmpA { + render() { + return ( +
+ +
+ +
+
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA], + html: ` + Default slot

second slot

+ `, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + + + + +
+ +
+ + + + + Default slot + + +
+
+
`); + + const clientHydrated = await newSpecPage({ + components: [CmpA], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + // patches this element in the same way we patch all elements in the browser + patchPseudoShadowDom(clientHydrated.root); + + const childNodes = clientHydrated.root.childNodes; + + expect(nodeOrEle(childNodes[0])).toBe(` comment node `); + expect(nodeOrEle(childNodes[1])).toBe(` Default slot `); + expect(nodeOrEle(childNodes[2])).toBe(`

second slot

`); + expect(nodeOrEle(childNodes[3])).toBe(` another comment node `); + }); + + it('should retain original order of slotted nodes within nested `scoped: true` components', async () => { + @Component({ + tag: 'cmp-a', + shadow: false, + }) + class CmpA { + render() { + return ( +
+ +
+ ); + } + } + + @Component({ + tag: 'cmp-b', + shadow: false, + }) + class CmpB { + render() { + return ( +
+ +
+ ); + } + } + + const serverHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: ` +

slotted item 1a

A text node

slotted item 1b

B text node
+ `, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + + + + + +
+ +

+ slotted item 1a +

+ + + + A text node + + + + + + + + +
+ +

+ slotted item 1b +

+ + + + B text node + + +
+
+
+
`); + + const clientHydrated = await newSpecPage({ + components: [CmpA, CmpB], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + + // patches this element in the same way we patch all elements in the browser + patchPseudoShadowDom(clientHydrated.root); + + const childNodes = clientHydrated.root.childNodes; + + patchPseudoShadowDom(childNodes[4]); + + expect(nodeOrEle(childNodes[0])).toBe(`

slotted item 1a

`); + expect(nodeOrEle(childNodes[1])).toBe(` a comment `); + expect(nodeOrEle(childNodes[2])).toBe(`A text node`); + expect(nodeOrEle(childNodes[3])).toBe(` another comment a`); + expect(nodeOrEle(childNodes[4].childNodes[0])).toBe(`

slotted item 1b

`); + expect(nodeOrEle(childNodes[4].childNodes[1])).toBe(` b comment `); + expect(nodeOrEle(childNodes[4].childNodes[2])).toBe(`B text node`); + expect(nodeOrEle(childNodes[4].childNodes[3])).toBe(` another comment b`); + }); +}); diff --git a/src/runtime/vdom/set-accessor.ts b/src/runtime/vdom/set-accessor.ts index c4e0aa6f20c..f657ca793f1 100644 --- a/src/runtime/vdom/set-accessor.ts +++ b/src/runtime/vdom/set-accessor.ts @@ -11,6 +11,7 @@ import { BUILD } from '@app-data'; import { isMemberInElement, plt, win } from '@platform'; import { isComplexType } from '@utils'; +import type * as d from '../../declarations'; import { VNODE_FLAGS, XLINK_NS } from '../runtime-constants'; /** @@ -29,7 +30,7 @@ import { VNODE_FLAGS, XLINK_NS } from '../runtime-constants'; * @param flags bitflags for Vdom variables */ export const setAccessor = ( - elm: HTMLElement, + elm: d.RenderNode, memberName: string, oldValue: any, newValue: any, @@ -44,6 +45,11 @@ export const setAccessor = ( const classList = elm.classList; const oldClasses = parseClassList(oldValue); const newClasses = parseClassList(newValue); + // for `scoped: true` components, new nodes after initial hydration + // from SSR don't have the slotted class added. Let's add that now + if (elm['s-si'] && newClasses.indexOf(elm['s-si']) < 0) { + newClasses.push(elm['s-si']); + } classList.remove(...oldClasses.filter((c) => c && !newClasses.includes(c))); classList.add(...newClasses.filter((c) => c && !oldClasses.includes(c))); } else if (BUILD.vdomStyle && memberName === 'style') { diff --git a/src/runtime/vdom/vdom-annotations.ts b/src/runtime/vdom/vdom-annotations.ts index 4bc6ed6c449..4f69cf9bae8 100644 --- a/src/runtime/vdom/vdom-annotations.ts +++ b/src/runtime/vdom/vdom-annotations.ts @@ -2,6 +2,7 @@ import { getHostRef } from '@platform'; import type * as d from '../../declarations'; import { + COMMENT_NODE_ID, CONTENT_REF_ID, DEFAULT_DOC_DATA, HYDRATE_CHILD_ID, @@ -51,6 +52,9 @@ export const insertVdomAnnotations = (doc: Document, staticComponents: string[]) if (nodeRef.nodeType === NODE_TYPE.ElementNode) { nodeRef.setAttribute(HYDRATE_CHILD_ID, childId); + if (typeof nodeRef['s-sn'] === 'string' && !nodeRef.getAttribute('slot')) { + nodeRef.setAttribute('s-sn', nodeRef['s-sn']); + } } else if (nodeRef.nodeType === NODE_TYPE.TextNode) { if (hostId === 0) { const textContent = nodeRef.nodeValue?.trim(); @@ -63,6 +67,10 @@ export const insertVdomAnnotations = (doc: Document, staticComponents: string[]) const commentBeforeTextNode = doc.createComment(childId); commentBeforeTextNode.nodeValue = `${TEXT_NODE_ID}.${childId}`; insertBefore(nodeRef.parentNode, commentBeforeTextNode, nodeRef); + } else if (nodeRef.nodeType === NODE_TYPE.CommentNode) { + const commentBeforeTextNode = doc.createComment(childId); + commentBeforeTextNode.nodeValue = `${COMMENT_NODE_ID}.${childId}`; + nodeRef.parentNode.insertBefore(commentBeforeTextNode, nodeRef); } } @@ -73,7 +81,7 @@ export const insertVdomAnnotations = (doc: Document, staticComponents: string[]) if (orgLocationParentNode['s-en'] === '') { // ending with a "." means that the parent element // of this node's original location is a SHADOW dom element - // and this node is apart of the root level light dom + // and this node is a part of the root level light dom orgLocationNodeId += `.`; } else if (orgLocationParentNode['s-en'] === 'c') { // ending with a ".c" means that the parent element @@ -222,6 +230,9 @@ const insertChildVNodeAnnotations = ( if (childElm.nodeType === NODE_TYPE.ElementNode) { childElm.setAttribute(HYDRATE_CHILD_ID, childId); + if (typeof childElm['s-sn'] === 'string' && !childElm.getAttribute('slot')) { + childElm.setAttribute('s-sn', childElm['s-sn']); + } } else if (childElm.nodeType === NODE_TYPE.TextNode) { const parentNode = childElm.parentNode; const nodeName = parentNode?.nodeName; diff --git a/src/runtime/vdom/vdom-render.ts b/src/runtime/vdom/vdom-render.ts index 0a6b3e62ca5..5f637ec5ab2 100644 --- a/src/runtime/vdom/vdom-render.ts +++ b/src/runtime/vdom/vdom-render.ts @@ -775,7 +775,7 @@ export const updateFallbackSlotVisibility = (elm: d.RenderNode) => { childNode.hidden = true; break; } - } else { + } else if (slotName === siblingNode['s-sn']) { // this is a default fallback slot node // any element or text node (with content) // should hide the default fallback slot node diff --git a/test/end-to-end/src/components.d.ts b/test/end-to-end/src/components.d.ts index 3f4a2a67c67..0dec2d90383 100644 --- a/test/end-to-end/src/components.d.ts +++ b/test/end-to-end/src/components.d.ts @@ -106,6 +106,12 @@ export namespace Components { } interface NestedScopeCmp { } + interface NonShadowChild { + } + interface NonShadowForwardedSlot { + } + interface NonShadowWrapper { + } interface PathAliasCmp { } interface PrerenderCmp { @@ -133,6 +139,10 @@ export namespace Components { "cars": CarData[]; "selected": CarData; } + interface ShadowChild { + } + interface ShadowWrapper { + } interface SlotCmp { } interface SlotCmpContainer { @@ -363,6 +373,24 @@ declare global { prototype: HTMLNestedScopeCmpElement; new (): HTMLNestedScopeCmpElement; }; + interface HTMLNonShadowChildElement extends Components.NonShadowChild, HTMLStencilElement { + } + var HTMLNonShadowChildElement: { + prototype: HTMLNonShadowChildElement; + new (): HTMLNonShadowChildElement; + }; + interface HTMLNonShadowForwardedSlotElement extends Components.NonShadowForwardedSlot, HTMLStencilElement { + } + var HTMLNonShadowForwardedSlotElement: { + prototype: HTMLNonShadowForwardedSlotElement; + new (): HTMLNonShadowForwardedSlotElement; + }; + interface HTMLNonShadowWrapperElement extends Components.NonShadowWrapper, HTMLStencilElement { + } + var HTMLNonShadowWrapperElement: { + prototype: HTMLNonShadowWrapperElement; + new (): HTMLNonShadowWrapperElement; + }; interface HTMLPathAliasCmpElement extends Components.PathAliasCmp, HTMLStencilElement { } var HTMLPathAliasCmpElement: { @@ -407,6 +435,18 @@ declare global { prototype: HTMLScopedCarListElement; new (): HTMLScopedCarListElement; }; + interface HTMLShadowChildElement extends Components.ShadowChild, HTMLStencilElement { + } + var HTMLShadowChildElement: { + prototype: HTMLShadowChildElement; + new (): HTMLShadowChildElement; + }; + interface HTMLShadowWrapperElement extends Components.ShadowWrapper, HTMLStencilElement { + } + var HTMLShadowWrapperElement: { + prototype: HTMLShadowWrapperElement; + new (): HTMLShadowWrapperElement; + }; interface HTMLSlotCmpElement extends Components.SlotCmp, HTMLStencilElement { } var HTMLSlotCmpElement: { @@ -459,11 +499,16 @@ declare global { "nested-cmp-child": HTMLNestedCmpChildElement; "nested-cmp-parent": HTMLNestedCmpParentElement; "nested-scope-cmp": HTMLNestedScopeCmpElement; + "non-shadow-child": HTMLNonShadowChildElement; + "non-shadow-forwarded-slot": HTMLNonShadowForwardedSlotElement; + "non-shadow-wrapper": HTMLNonShadowWrapperElement; "path-alias-cmp": HTMLPathAliasCmpElement; "prerender-cmp": HTMLPrerenderCmpElement; "prop-cmp": HTMLPropCmpElement; "scoped-car-detail": HTMLScopedCarDetailElement; "scoped-car-list": HTMLScopedCarListElement; + "shadow-child": HTMLShadowChildElement; + "shadow-wrapper": HTMLShadowWrapperElement; "slot-cmp": HTMLSlotCmpElement; "slot-cmp-container": HTMLSlotCmpContainerElement; "slot-parent-cmp": HTMLSlotParentCmpElement; @@ -545,6 +590,12 @@ declare namespace LocalJSX { } interface NestedScopeCmp { } + interface NonShadowChild { + } + interface NonShadowForwardedSlot { + } + interface NonShadowWrapper { + } interface PathAliasCmp { } interface PrerenderCmp { @@ -573,6 +624,10 @@ declare namespace LocalJSX { "onCarSelected"?: (event: ScopedCarListCustomEvent) => void; "selected"?: CarData; } + interface ShadowChild { + } + interface ShadowWrapper { + } interface SlotCmp { } interface SlotCmpContainer { @@ -610,11 +665,16 @@ declare namespace LocalJSX { "nested-cmp-child": NestedCmpChild; "nested-cmp-parent": NestedCmpParent; "nested-scope-cmp": NestedScopeCmp; + "non-shadow-child": NonShadowChild; + "non-shadow-forwarded-slot": NonShadowForwardedSlot; + "non-shadow-wrapper": NonShadowWrapper; "path-alias-cmp": PathAliasCmp; "prerender-cmp": PrerenderCmp; "prop-cmp": PropCmp; "scoped-car-detail": ScopedCarDetail; "scoped-car-list": ScopedCarList; + "shadow-child": ShadowChild; + "shadow-wrapper": ShadowWrapper; "slot-cmp": SlotCmp; "slot-cmp-container": SlotCmpContainer; "slot-parent-cmp": SlotParentCmp; @@ -658,6 +718,9 @@ declare module "@stencil/core" { "nested-cmp-child": LocalJSX.NestedCmpChild & JSXBase.HTMLAttributes; "nested-cmp-parent": LocalJSX.NestedCmpParent & JSXBase.HTMLAttributes; "nested-scope-cmp": LocalJSX.NestedScopeCmp & JSXBase.HTMLAttributes; + "non-shadow-child": LocalJSX.NonShadowChild & JSXBase.HTMLAttributes; + "non-shadow-forwarded-slot": LocalJSX.NonShadowForwardedSlot & JSXBase.HTMLAttributes; + "non-shadow-wrapper": LocalJSX.NonShadowWrapper & JSXBase.HTMLAttributes; "path-alias-cmp": LocalJSX.PathAliasCmp & JSXBase.HTMLAttributes; "prerender-cmp": LocalJSX.PrerenderCmp & JSXBase.HTMLAttributes; "prop-cmp": LocalJSX.PropCmp & JSXBase.HTMLAttributes; @@ -666,6 +729,8 @@ declare module "@stencil/core" { * Component that helps display a list of cars */ "scoped-car-list": LocalJSX.ScopedCarList & JSXBase.HTMLAttributes; + "shadow-child": LocalJSX.ShadowChild & JSXBase.HTMLAttributes; + "shadow-wrapper": LocalJSX.ShadowWrapper & JSXBase.HTMLAttributes; "slot-cmp": LocalJSX.SlotCmp & JSXBase.HTMLAttributes; "slot-cmp-container": LocalJSX.SlotCmpContainer & JSXBase.HTMLAttributes; "slot-parent-cmp": LocalJSX.SlotParentCmp & JSXBase.HTMLAttributes; diff --git a/test/end-to-end/src/declarative-shadow-dom/test.e2e.ts b/test/end-to-end/src/declarative-shadow-dom/test.e2e.ts index 52b5979f2bc..70e02dc4733 100644 --- a/test/end-to-end/src/declarative-shadow-dom/test.e2e.ts +++ b/test/end-to-end/src/declarative-shadow-dom/test.e2e.ts @@ -355,7 +355,7 @@ describe('renderToString', () => {
- +
diff --git a/test/end-to-end/src/scoped-hydration/non-shadow-forwarded-slot.tsx b/test/end-to-end/src/scoped-hydration/non-shadow-forwarded-slot.tsx new file mode 100644 index 00000000000..66c7cdcac05 --- /dev/null +++ b/test/end-to-end/src/scoped-hydration/non-shadow-forwarded-slot.tsx @@ -0,0 +1,32 @@ +import { Component, Host, h } from '@stencil/core'; + +@Component({ + tag: 'non-shadow-forwarded-slot', + scoped: true, + styles: ` + :host { + display: block; + border: 3px solid red; + } + :host strong { + color: red; + } + `, +}) +export class Wrapper { + render() { + return ( + + Non shadow parent. Start. +
+ + + This is default content in the non-shadow parent slot + + +
+ Non shadow parent. End. +
+ ); + } +} diff --git a/test/end-to-end/src/scoped-hydration/non-shadow-wrapper.tsx b/test/end-to-end/src/scoped-hydration/non-shadow-wrapper.tsx new file mode 100644 index 00000000000..dc2f68ac19d --- /dev/null +++ b/test/end-to-end/src/scoped-hydration/non-shadow-wrapper.tsx @@ -0,0 +1,25 @@ +import { Component, Host, h } from '@stencil/core'; + +@Component({ + tag: 'non-shadow-wrapper', + scoped: true, + styles: ` + :host { + display: block; + border: 3px solid red; + } + `, +}) +export class Wrapper { + render() { + return ( + + Non-shadow Wrapper Start +

Wrapper Slot before

+ Wrapper Slot Fallback +

Wrapper Slot after

+ Non-shadow Wrapper End +
+ ); + } +} diff --git a/test/end-to-end/src/scoped-hydration/non-shadow.tsx b/test/end-to-end/src/scoped-hydration/non-shadow.tsx new file mode 100644 index 00000000000..a4cb459f6d5 --- /dev/null +++ b/test/end-to-end/src/scoped-hydration/non-shadow.tsx @@ -0,0 +1,25 @@ +import { Component, Host, h } from '@stencil/core'; + +@Component({ + tag: 'non-shadow-child', + scoped: true, + styles: ` + :host { + display: block; + border: 3px solid blue; + } + `, +}) +export class MyApp { + render() { + return ( + +
+ Nested Non-Shadow Component Start +
+ Slotted fallback content + Nested Non-Shadow Component End +
+ ); + } +} diff --git a/test/end-to-end/src/scoped-hydration/readme.md b/test/end-to-end/src/scoped-hydration/readme.md new file mode 100644 index 00000000000..732a84fe8a6 --- /dev/null +++ b/test/end-to-end/src/scoped-hydration/readme.md @@ -0,0 +1,10 @@ +# shadow-wrapper + + + + + + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* 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 new file mode 100644 index 00000000000..bd74cf360a3 --- /dev/null +++ b/test/end-to-end/src/scoped-hydration/scoped-hydration.e2e.ts @@ -0,0 +1,109 @@ +import { newE2EPage, E2EPage } from '@stencil/core/testing'; + +// @ts-ignore may not be existing when project hasn't been built +type HydrateModule = typeof import('../../hydrate'); +let renderToString: HydrateModule['renderToString']; + +async function getElementOrder(page: E2EPage, parent: string) { + return await page.evaluate((parent: string) => { + const external = Array.from(document.querySelector(parent).children).map((el) => el.tagName); + const internal = Array.from((document.querySelector(parent) as any).__children).map((el: Element) => el.tagName); + return { internal, external }; + }, parent); +} + +describe('`scoped: true` hydration checks', () => { + beforeAll(async () => { + // @ts-ignore may not be existing when project hasn't been built + const mod = await import('../../hydrate'); + renderToString = mod.renderToString; + }); + + it('shows fallback slot when no content is slotted', async () => { + const { html } = await renderToString( + ` + + test + `, + { + serializeShadowRoot: true, + }, + ); + expect(html).toContain('Slotted fallback content'); + const page = await newE2EPage({ html, url: 'https://stencil.com' }); + const slots = await page.findAll('slot-fb'); + expect(await slots[0].getAttribute('hidden')).toBeNull(); + expect(await slots[1].getAttribute('hidden')).not.toBeNull(); + }); + + it('keeps slotted elements in their assigned position and does not duplicate slotted children', async () => { + const { html } = await renderToString( + ` + + + + `, + { + serializeShadowRoot: true, + }, + ); + const page = await newE2EPage({ html, url: 'https://stencil.com' }); + + const { external, internal } = await getElementOrder(page, 'non-shadow-wrapper'); + expect(external.length).toBe(1); + expect(internal.length).toBe(6); + + expect(internal).toEqual(['STRONG', 'P', 'SLOT-FB', 'NON-SHADOW-CHILD', 'P', 'STRONG']); + expect(external).toEqual(['NON-SHADOW-CHILD']); + + const slots = await page.findAll('slot-fb'); + expect(await slots[0].getAttribute('hidden')).not.toBeNull(); + expect(await slots[1].getAttribute('hidden')).toBeNull(); + }); + + it('forwards slotted nodes into a nested shadow component whilst keeping those nodes in the light dom', async () => { + const { html } = await renderToString( + ` + +

slotted item 1

+

slotted item 2

+

slotted item 3

+
+ `, + { + serializeShadowRoot: true, + }, + ); + const page = await newE2EPage({ html, url: 'https://stencil.com' }); + + const { external, internal } = await getElementOrder(page, 'non-shadow-forwarded-slot'); + expect(external.length).toBe(3); + expect(internal.length).toBe(5); + + expect(internal).toEqual(['STRONG', 'BR', 'SHADOW-CHILD', 'BR', 'STRONG']); + expect(external).toEqual(['P', 'P', 'P']); + }); + + it('retains the correct order of different nodes', async () => { + const { html } = await renderToString( + ` + + Text node 1 + +

Slotted element 1

+

Slotted element 2

+ + Text node 2 +
+ `, + { + serializeShadowRoot: true, + }, + ); + const page = await newE2EPage({ html, url: 'https://stencil.com' }); + + expect(await page.evaluate(() => document.querySelector('non-shadow-forwarded-slot').textContent.trim())).toContain( + 'Text node 1 Comment 1 Slotted element 1 Slotted element 2 Comment 2 Text node 2', + ); + }); +}); diff --git a/test/end-to-end/src/scoped-hydration/shadow-wrapper.tsx b/test/end-to-end/src/scoped-hydration/shadow-wrapper.tsx new file mode 100644 index 00000000000..ccc3aa5472a --- /dev/null +++ b/test/end-to-end/src/scoped-hydration/shadow-wrapper.tsx @@ -0,0 +1,25 @@ +import { Component, Host, h } from '@stencil/core'; + +@Component({ + tag: 'shadow-wrapper', + shadow: true, + styles: ` + :host { + display: block; + border: 3px solid red; + } + `, +}) +export class Wrapper { + render() { + return ( + + Shadow Wrapper Start +

Shadow Slot before

+ Wrapper Slot Fallback +

Shadow Slot after

+ Shadow Wrapper End +
+ ); + } +} diff --git a/test/end-to-end/src/scoped-hydration/shadow.tsx b/test/end-to-end/src/scoped-hydration/shadow.tsx new file mode 100644 index 00000000000..1f102f5e7c7 --- /dev/null +++ b/test/end-to-end/src/scoped-hydration/shadow.tsx @@ -0,0 +1,27 @@ +import { Component, Host, h } from '@stencil/core'; + +@Component({ + tag: 'shadow-child', + shadow: true, + styles: ` + :host { + display: block; + border: 3px solid blue; + } + `, +}) +export class MyApp { + render() { + return ( + +
+ Nested Shadow Component Start +
+ +
Slotted fallback content
+
+ Nested Shadow Component End +
+ ); + } +} diff --git a/test/end-to-end/stencil.config.ts b/test/end-to-end/stencil.config.ts index e810f3266ea..8a0301f187d 100644 --- a/test/end-to-end/stencil.config.ts +++ b/test/end-to-end/stencil.config.ts @@ -61,4 +61,7 @@ export const config: Config = { hashFileNames: false, buildEs5: 'prod', sourceMap: true, + extras: { + experimentalSlotFixes: true, + }, };