From a15bc5da60fb579aa80a38369d5464db17c40c38 Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Tue, 26 Nov 2024 19:22:25 +0000 Subject: [PATCH] fix: `patchChildSlotNodes` & `scopedSlotTextContentFix` not being applied (#6055) * fix: `patchChildSlotNodes` not being applied properly * chore: well that escalated quickly * chore: don't patch global prototypes * chore: fix node-22 / windows tests --------- Co-authored-by: John Jenkins --- src/compiler/config/validate-config.ts | 6 +- src/declarations/stencil-private.ts | 7 + src/runtime/bootstrap-custom-element.ts | 4 +- src/runtime/bootstrap-lazy.ts | 4 +- src/runtime/dom-extras.ts | 312 +++++++++------------- src/runtime/test/dom-extras.spec.tsx | 93 +++++++ src/runtime/vdom/vdom-render.ts | 22 +- test/wdio/attribute-basic/cmp.test.tsx | 1 + test/wdio/attribute-boolean/cmp.test.tsx | 1 + test/wdio/delegates-focus/cmp.test.tsx | 4 +- test/wdio/text-content-patch/cmp.test.tsx | 23 +- 11 files changed, 255 insertions(+), 222 deletions(-) create mode 100644 src/runtime/test/dom-extras.spec.tsx diff --git a/src/compiler/config/validate-config.ts b/src/compiler/config/validate-config.ts index 04645c9d326..364811a9427 100644 --- a/src/compiler/config/validate-config.ts +++ b/src/compiler/config/validate-config.ts @@ -150,6 +150,8 @@ export const validateConfig = ( validatedConfig.extras.scriptDataOpts = !!validatedConfig.extras.scriptDataOpts; validatedConfig.extras.initializeNextTick = !!validatedConfig.extras.initializeNextTick; validatedConfig.extras.tagNameTransform = !!validatedConfig.extras.tagNameTransform; + // TODO(STENCIL-1086): remove this option when it's the default behavior + validatedConfig.extras.experimentalScopedSlotChanges = !!validatedConfig.extras.experimentalScopedSlotChanges; // TODO(STENCIL-914): remove when `experimentalSlotFixes` is the default behavior // If the user set `experimentalSlotFixes` and any individual slot fix flags to `false`, we need to log a warning @@ -160,6 +162,7 @@ export const validateConfig = ( 'slotChildNodesFix', 'cloneNodeFix', 'scopedSlotTextContentFix', + 'experimentalScopedSlotChanges', ]; const conflictingFlags = possibleFlags.filter((flag) => validatedConfig.extras[flag] === false); if (conflictingFlags.length > 0) { @@ -185,9 +188,6 @@ export const validateConfig = ( validatedConfig.extras.scopedSlotTextContentFix = !!validatedConfig.extras.scopedSlotTextContentFix; } - // TODO(STENCIL-1086): remove this option when it's the default behavior - validatedConfig.extras.experimentalScopedSlotChanges = !!validatedConfig.extras.experimentalScopedSlotChanges; - setBooleanConfig( validatedConfig, 'sourceMap', diff --git a/src/declarations/stencil-private.ts b/src/declarations/stencil-private.ts index 9f023a5f6c6..80b4665e48f 100644 --- a/src/declarations/stencil-private.ts +++ b/src/declarations/stencil-private.ts @@ -1455,6 +1455,13 @@ export interface RenderNode extends HostElement { * empty "" for shadow, "c" from scoped */ ['s-en']?: '' | /*shadow*/ 'c' /*scoped*/; + + /** + * On a `scoped: true` component + * with `experimentalSlotFixes` flag enabled, + * returns the internal `childNodes` of the scoped element + */ + readonly __childNodes?: NodeListOf; } export type LazyBundlesRuntimeData = LazyBundleRuntimeData[]; diff --git a/src/runtime/bootstrap-custom-element.ts b/src/runtime/bootstrap-custom-element.ts index 4db002b21a2..70296d5978e 100644 --- a/src/runtime/bootstrap-custom-element.ts +++ b/src/runtime/bootstrap-custom-element.ts @@ -48,11 +48,11 @@ export const proxyCustomElement = (Cstr: any, compactMeta: d.ComponentRuntimeMet if (BUILD.scoped && cmpMeta.$flags$ & CMP_FLAGS.scopedCssEncapsulation) { // This check is intentionally not combined with the surrounding `experimentalSlotFixes` check // since, moving forward, we only want to patch the pseudo shadow DOM when the component is scoped - patchPseudoShadowDom(Cstr.prototype, cmpMeta); + patchPseudoShadowDom(Cstr.prototype); } } else { if (BUILD.slotChildNodesFix) { - patchChildSlotNodes(Cstr.prototype, cmpMeta); + patchChildSlotNodes(Cstr.prototype); } if (BUILD.cloneNodeFix) { patchCloneNode(Cstr.prototype); diff --git a/src/runtime/bootstrap-lazy.ts b/src/runtime/bootstrap-lazy.ts index af49b5387d1..7bc14684981 100644 --- a/src/runtime/bootstrap-lazy.ts +++ b/src/runtime/bootstrap-lazy.ts @@ -171,11 +171,11 @@ export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d. // This check is intentionally not combined with the surrounding `experimentalSlotFixes` check // since, moving forward, we only want to patch the pseudo shadow DOM when the component is scoped if (BUILD.scoped && cmpMeta.$flags$ & CMP_FLAGS.scopedCssEncapsulation) { - patchPseudoShadowDom(HostElement.prototype, cmpMeta); + patchPseudoShadowDom(HostElement.prototype); } } else { if (BUILD.slotChildNodesFix) { - patchChildSlotNodes(HostElement.prototype, cmpMeta); + patchChildSlotNodes(HostElement.prototype); } if (BUILD.cloneNodeFix) { patchCloneNode(HostElement.prototype); diff --git a/src/runtime/dom-extras.ts b/src/runtime/dom-extras.ts index c6870a8f38c..c3c9f410cfc 100644 --- a/src/runtime/dom-extras.ts +++ b/src/runtime/dom-extras.ts @@ -1,15 +1,12 @@ import { BUILD } from '@app-data'; import { getHostRef, plt, supportsShadow } from '@platform'; -import { CMP_FLAGS, HOST_FLAGS, NODE_TYPES } from '@utils/constants'; +import { HOST_FLAGS } from '@utils/constants'; import type * as d from '../declarations'; import { PLATFORM_FLAGS } from './runtime-constants'; import { insertBefore, updateFallbackSlotVisibility } from './vdom/vdom-render'; -export const patchPseudoShadowDom = ( - hostElementPrototype: HTMLElement, - descriptorPrototype: d.ComponentRuntimeMeta, -) => { +export const patchPseudoShadowDom = (hostElementPrototype: HTMLElement) => { patchCloneNode(hostElementPrototype); patchSlotAppendChild(hostElementPrototype); patchSlotAppend(hostElementPrototype); @@ -18,7 +15,7 @@ export const patchPseudoShadowDom = ( patchSlotInsertAdjacentHTML(hostElementPrototype); patchSlotInsertAdjacentText(hostElementPrototype); patchTextContent(hostElementPrototype); - patchChildSlotNodes(hostElementPrototype, descriptorPrototype); + patchChildSlotNodes(hostElementPrototype); patchSlotRemoveChild(hostElementPrototype); }; @@ -49,10 +46,11 @@ export const patchCloneNode = (HostElementPrototype: HTMLElement) => { 's-rf', 's-scs', ]; + const childNodes = (this as any).__childNodes || this.childNodes; - for (; i < srcNode.childNodes.length; i++) { - slotted = (srcNode.childNodes[i] as any)['s-nr']; - nonStencilNode = stencilPrivates.every((privateField) => !(srcNode.childNodes[i] as any)[privateField]); + for (; i < childNodes.length; i++) { + slotted = (childNodes[i] as any)['s-nr']; + nonStencilNode = stencilPrivates.every((privateField) => !(childNodes[i] as any)[privateField]); if (slotted) { if (BUILD.appendChildSlotFix && (clonedNode as any).__appendChild) { (clonedNode as any).__appendChild(slotted.cloneNode(true)); @@ -61,7 +59,7 @@ export const patchCloneNode = (HostElementPrototype: HTMLElement) => { } } if (nonStencilNode) { - clonedNode.appendChild((srcNode.childNodes[i] as any).cloneNode(true)); + clonedNode.appendChild((childNodes[i] as any).cloneNode(true)); } } } @@ -81,13 +79,9 @@ export const patchSlotAppendChild = (HostElementPrototype: any) => { HostElementPrototype.__appendChild = HostElementPrototype.appendChild; HostElementPrototype.appendChild = function (this: d.RenderNode, newChild: d.RenderNode) { const slotName = (newChild['s-sn'] = getSlotName(newChild)); - const slotNode = getHostSlotNode(this.childNodes, slotName, this.tagName); + const slotNode = getHostSlotNode((this as any).__childNodes || this.childNodes, slotName, this.tagName); if (slotNode) { - const slotPlaceholder: d.RenderNode = document.createTextNode('') as any; - slotPlaceholder['s-nr'] = newChild; - (slotNode['s-cr'].parentNode as any).__appendChild(slotPlaceholder); - newChild['s-ol'] = slotPlaceholder; - newChild['s-sh'] = slotNode['s-hn']; + addSlotRelocateNode(newChild, slotNode); const slotChildNodes = getHostSlotChildNodes(slotNode, slotName); const appendAfter = slotChildNodes[slotChildNodes.length - 1]; @@ -111,22 +105,17 @@ export const patchSlotAppendChild = (HostElementPrototype: any) => { */ const patchSlotRemoveChild = (ElementPrototype: any) => { ElementPrototype.__removeChild = ElementPrototype.removeChild; + ElementPrototype.removeChild = function (this: d.RenderNode, toRemove: d.RenderNode) { if (toRemove && typeof toRemove['s-sn'] !== 'undefined') { - const slotNode = getHostSlotNode(this.childNodes, toRemove['s-sn'], this.tagName); - if (slotNode) { - // Get all slot content - const slotChildNodes = getHostSlotChildNodes(slotNode, toRemove['s-sn']); - // See if any of the slotted content matches the node to remove - const existingNode = slotChildNodes.find((n) => n === toRemove); - - if (existingNode) { - existingNode.remove(); - // Check if there is fallback content that should be displayed if that - // was the last node in the slot - updateFallbackSlotVisibility(this); - return; - } + const childNodes = (this as any).__childNodes || this.childNodes; + const slotNode = getHostSlotNode(childNodes, toRemove['s-sn'], this.tagName); + if (slotNode && toRemove.isConnected) { + toRemove.remove(); + // Check if there is fallback content that should be displayed if that + // was the last node in the slot + updateFallbackSlotVisibility(this); + return; } } return (this as any).__removeChild(toRemove); @@ -139,7 +128,7 @@ const patchSlotRemoveChild = (ElementPrototype: any) => { * @param HostElementPrototype the `Element` to be patched */ export const patchSlotPrepend = (HostElementPrototype: HTMLElement) => { - const originalPrepend = HostElementPrototype.prepend; + (HostElementPrototype as any).__prepend = HostElementPrototype.prepend; HostElementPrototype.prepend = function (this: d.HostElement, ...newChildren: (d.RenderNode | string)[]) { newChildren.forEach((newChild: d.RenderNode | string) => { @@ -147,14 +136,10 @@ export const patchSlotPrepend = (HostElementPrototype: HTMLElement) => { newChild = this.ownerDocument.createTextNode(newChild) as unknown as d.RenderNode; } const slotName = (newChild['s-sn'] = getSlotName(newChild)); - const slotNode = getHostSlotNode(this.childNodes, slotName, this.tagName); + const childNodes = (this as any).__childNodes || this.childNodes; + const slotNode = getHostSlotNode(childNodes, slotName, this.tagName); if (slotNode) { - const slotPlaceholder: d.RenderNode = document.createTextNode('') as any; - slotPlaceholder['s-nr'] = newChild; - (slotNode['s-cr'].parentNode as any).__appendChild(slotPlaceholder); - newChild['s-ol'] = slotPlaceholder; - newChild['s-sh'] = slotNode['s-hn']; - + addSlotRelocateNode(newChild, slotNode, true); const slotChildNodes = getHostSlotChildNodes(slotNode, slotName); const appendAfter = slotChildNodes[0]; return insertBefore(appendAfter.parentNode, newChild, appendAfter.nextSibling); @@ -164,7 +149,7 @@ export const patchSlotPrepend = (HostElementPrototype: HTMLElement) => { newChild.hidden = true; } - return originalPrepend.call(this, newChild); + return (HostElementPrototype as any).__prepend(newChild); }); }; }; @@ -176,6 +161,7 @@ export const patchSlotPrepend = (HostElementPrototype: HTMLElement) => { * @param HostElementPrototype the `Element` to be patched */ export const patchSlotAppend = (HostElementPrototype: HTMLElement) => { + (HostElementPrototype as any).__append = HostElementPrototype.append; HostElementPrototype.append = function (this: d.HostElement, ...newChildren: (d.RenderNode | string)[]) { newChildren.forEach((newChild: d.RenderNode | string) => { if (typeof newChild === 'string') { @@ -263,172 +249,124 @@ export const patchSlotInsertAdjacentElement = (HostElementPrototype: HTMLElement * @param hostElementPrototype the `Element` to be patched */ export const patchTextContent = (hostElementPrototype: HTMLElement): void => { - const descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent'); - - Object.defineProperty(hostElementPrototype, '__textContent', descriptor); - - if (BUILD.experimentalScopedSlotChanges) { - // Patch `textContent` to mimic shadow root behavior - Object.defineProperty(hostElementPrototype, 'textContent', { - // To mimic shadow root behavior, we need to return the text content of all - // nodes in a slot reference node - get(): string | null { - const slotRefNodes = getAllChildSlotNodes(this.childNodes); - - const textContent = slotRefNodes - .map((node) => { - const text = []; - - // Need to get the text content of all nodes in the slot reference node - let slotContent = node.nextSibling as d.RenderNode | null; - while (slotContent && slotContent['s-sn'] === node['s-sn']) { - if (slotContent.nodeType === NODE_TYPES.TEXT_NODE || slotContent.nodeType === NODE_TYPES.ELEMENT_NODE) { - text.push(slotContent.textContent?.trim() ?? ''); - } - slotContent = slotContent.nextSibling as d.RenderNode | null; - } - - return text.filter((ref) => ref !== '').join(' '); - }) - .filter((text) => text !== '') - .join(' '); - - // Pad the string to return - return ' ' + textContent + ' '; - }, - - // To mimic shadow root behavior, we need to overwrite all nodes in a slot - // reference node. If a default slot reference node exists, the text content will be - // placed there. Otherwise, the new text node will be hidden - set(value: string | null) { - const slotRefNodes = getAllChildSlotNodes(this.childNodes); - - slotRefNodes.forEach((node) => { - // Remove the existing content of the slot - let slotContent = node.nextSibling as d.RenderNode | null; - while (slotContent && slotContent['s-sn'] === node['s-sn']) { - const tmp = slotContent; - slotContent = slotContent.nextSibling as d.RenderNode | null; - tmp.remove(); - } + let descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent'); - // If this is a default slot, add the text node in the slot location. - // Otherwise, destroy the slot reference node - if (node['s-sn'] === '') { - const textNode = this.ownerDocument.createTextNode(value); - textNode['s-sn'] = ''; - insertBefore(node.parentElement, textNode, node.nextSibling); - } else { - node.remove(); - } - }); - }, - }); - } else { - Object.defineProperty(hostElementPrototype, 'textContent', { - get(): string | null { - // get the 'default slot', which would be the first slot in a shadow tree (if we were using one), whose name is - // the empty string - const slotNode = getHostSlotNode(this.childNodes, '', this.tagName); - // when a slot node is found, the textContent _may_ be found in the next sibling (text) node, depending on how - // nodes were reordered during the vdom render. first try to get the text content from the sibling. - if (slotNode?.nextSibling?.nodeType === NODE_TYPES.TEXT_NODE) { - return slotNode.nextSibling.textContent; - } else if (slotNode) { - return slotNode.textContent; - } else { - // fallback to the original implementation - return this.__textContent; - } - }, - - set(value: string | null) { - // get the 'default slot', which would be the first slot in a shadow tree (if we were using one), whose name is - // the empty string - const slotNode = getHostSlotNode(this.childNodes, '', this.tagName); - // when a slot node is found, the textContent _may_ need to be placed in the next sibling (text) node, - // depending on how nodes were reordered during the vdom render. first try to set the text content on the - // sibling. - if (slotNode?.nextSibling?.nodeType === NODE_TYPES.TEXT_NODE) { - slotNode.nextSibling.textContent = value; - } else if (slotNode) { - slotNode.textContent = value; - } else { - // we couldn't find a slot, but that doesn't mean that there isn't one. if this check ran before the DOM - // loaded, we could have missed it. check for a content reference element on the scoped component and insert - // it there - this.__textContent = value; - const contentRefElm = this['s-cr']; - if (contentRefElm) { - insertBefore(this, contentRefElm, this.firstChild); - } - } - }, - }); + if (!descriptor) { + // for mock-doc + descriptor = Object.getOwnPropertyDescriptor(hostElementPrototype, 'textContent'); } + if (descriptor) Object.defineProperty(hostElementPrototype, '__textContent', descriptor); + + Object.defineProperty(hostElementPrototype, 'textContent', { + get: function () { + let text = ''; + const childNodes = this.__childNodes ? this.childNodes : getSlottedChildNodes(this.childNodes); + childNodes.forEach((node: d.RenderNode) => (text += node.textContent || '')); + return text; + }, + set: function (value) { + const childNodes = this.__childNodes ? this.childNodes : getSlottedChildNodes(this.childNodes); + childNodes.forEach((node: d.RenderNode) => { + if (node['s-ol']) node['s-ol'].remove(); + node.remove(); + }); + this.insertAdjacentHTML('beforeend', value); + }, + }); }; -export const patchChildSlotNodes = (elm: HTMLElement, cmpMeta: d.ComponentRuntimeMeta) => { +export const patchChildSlotNodes = (elm: HTMLElement) => { class FakeNodeList extends Array { item(n: number) { return this[n]; } } - // TODO(STENCIL-854): Remove code related to legacy shadowDomShim field - if (cmpMeta.$flags$ & CMP_FLAGS.needsShadowDomShim) { - const childNodesFn = (elm as any).__lookupGetter__('childNodes'); - - Object.defineProperty(elm, 'children', { - get() { - return this.childNodes.map((n: any) => n.nodeType === 1); - }, - }); - - Object.defineProperty(elm, 'childElementCount', { - get() { - return elm.children.length; - }, - }); - Object.defineProperty(elm, 'childNodes', { - get() { - const childNodes = childNodesFn.call(this) as NodeListOf; - if ( - (plt.$flags$ & PLATFORM_FLAGS.isTmpDisconnected) === 0 && - getHostRef(this).$flags$ & HOST_FLAGS.hasRendered - ) { - const result = new FakeNodeList(); - for (let i = 0; i < childNodes.length; i++) { - const slot = childNodes[i]['s-nr']; - if (slot) { - result.push(slot); - } - } - return result; - } - return FakeNodeList.from(childNodes); - }, - }); + let childNodesFn = Object.getOwnPropertyDescriptor(Node.prototype, 'childNodes'); + if (!childNodesFn) { + // for mock-doc + childNodesFn = Object.getOwnPropertyDescriptor(elm, 'childNodes'); } + + if (childNodesFn) Object.defineProperty(elm, '__childNodes', childNodesFn); + + Object.defineProperty(elm, 'children', { + get() { + return this.childNodes.filter((n: any) => n.nodeType === 1); + }, + }); + + Object.defineProperty(elm, 'childElementCount', { + get() { + return this.children.length; + }, + }); + + if (!childNodesFn) return; + + Object.defineProperty(elm, 'childNodes', { + get() { + if ( + !plt.$flags$ || + !getHostRef(this)?.$flags$ || + ((plt.$flags$ & PLATFORM_FLAGS.isTmpDisconnected) === 0 && getHostRef(this)?.$flags$ & HOST_FLAGS.hasRendered) + ) { + const result = new FakeNodeList(); + const nodes = getSlottedChildNodes(this.__childNodes); + result.push(...nodes); + return result; + } + return FakeNodeList.from(this.__childNodes); + }, + }); }; +/// UTILS /// + /** - * Recursively finds all slot reference nodes ('s-sr') in a series of child nodes. - * - * @param childNodes The set of child nodes to search for slot reference nodes. - * @returns An array of slot reference nodes. + * Creates an empty text node to act as a forwarding address to a slotted node: + * 1) When non-shadow components re-render, they need a place to temporarily put 'lightDOM' elements. + * 2) Patched dom methods and accessors use this node to calculate what 'lightDOM' nodes are in the host. + * @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 + * (the order of the slot location nodes determines the order of the slotted nodes in our patched accessors) */ -const getAllChildSlotNodes = (childNodes: NodeListOf): d.RenderNode[] => { - const slotRefNodes = []; +export const addSlotRelocateNode = (newChild: d.RenderNode, slotNode: d.RenderNode, prepend?: boolean) => { + let slottedNodeLocation: d.RenderNode; + // does newChild already have a slot location node? + if (newChild['s-ol'] && newChild['s-ol'].isConnected) { + slottedNodeLocation = newChild['s-ol']; + } else { + slottedNodeLocation = document.createTextNode('') as any; + slottedNodeLocation['s-nr'] = newChild; + } + + const parent = slotNode['s-cr'].parentNode as any; + const appendMethod = prepend ? parent.__prepend : parent.__appendChild; + + newChild['s-ol'] = slottedNodeLocation; + newChild['s-sh'] = slotNode['s-hn']; + + appendMethod.call(parent, slottedNodeLocation); +}; - for (const childNode of Array.from(childNodes) as d.RenderNode[]) { - if (childNode['s-sr']) { - slotRefNodes.push(childNode); +/** + * Get's the child nodes of a component that are actually slotted. + * This is only required until all patches are unified + * either under 'experimentalSlotFixes' or on by default + * @param childNodes all 'internal' child nodes of the component + * @returns An array of slotted reference nodes. + */ +const getSlottedChildNodes = (childNodes: NodeListOf) => { + const result = []; + for (let i = 0; i < childNodes.length; i++) { + const slottedNode = childNodes[i]['s-nr']; + if (slottedNode && slottedNode.isConnected) { + result.push(slottedNode); } - slotRefNodes.push(...getAllChildSlotNodes(childNode.childNodes)); } - - return slotRefNodes; + return result; }; const getSlotName = (node: d.RenderNode) => diff --git a/src/runtime/test/dom-extras.spec.tsx b/src/runtime/test/dom-extras.spec.tsx new file mode 100644 index 00000000000..5a139af2ec0 --- /dev/null +++ b/src/runtime/test/dom-extras.spec.tsx @@ -0,0 +1,93 @@ +import { Component, h, Host } from '@stencil/core'; +import { newSpecPage, SpecPage } from '@stencil/core/testing'; + +import { patchPseudoShadowDom } from '../../runtime/dom-extras'; + +describe('dom-extras - patches for non-shadow dom methods and accessors', () => { + let specPage: SpecPage; + + const nodeOrEleContent = (node: Node | Element) => { + return (node as Element)?.outerHTML || node?.nodeValue.trim(); + }; + + beforeEach(async () => { + @Component({ + tag: 'cmp-a', + scoped: true, + }) + class CmpA { + render() { + return ( + + 'Shadow' first text node +
+
+ Second slot fallback text +
+
+ Default slot fallback text +
+
+ 'Shadow' last text node +
+ ); + } + } + + specPage = await newSpecPage({ + components: [CmpA], + html: ` + + Some default slot, slotted text + a default slot, slotted element +
+ a second slot, slotted element + nested element in the second slot +
+ `, + hydrateClientSide: true, + }); + + patchPseudoShadowDom(specPage.root); + }); + + it('patches `childNodes` to return only nodes that have been slotted', async () => { + const childNodes = specPage.root.childNodes; + + expect(nodeOrEleContent(childNodes[0])).toBe(`Some default slot, slotted text`); + expect(nodeOrEleContent(childNodes[1])).toBe(`a default slot, slotted element`); + expect(nodeOrEleContent(childNodes[2])).toBe(``); + expect(nodeOrEleContent(childNodes[3])).toBe( + `
a second slot, slotted element nested element in the second slot
`, + ); + + const innerChildNodes = specPage.root.__childNodes; + + expect(nodeOrEleContent(innerChildNodes[0])).toBe(``); + expect(nodeOrEleContent(innerChildNodes[1])).toBe(``); + expect(nodeOrEleContent(innerChildNodes[2])).toBe(``); + expect(nodeOrEleContent(innerChildNodes[3])).toBe(``); + expect(nodeOrEleContent(innerChildNodes[4])).toBe(``); + expect(nodeOrEleContent(innerChildNodes[5])).toBe(`'Shadow' first text node`); + }); + + it('patches `children` to return only elements that have been slotted', async () => { + const children = specPage.root.children; + + expect(nodeOrEleContent(children[0])).toBe(`a default slot, slotted element`); + expect(nodeOrEleContent(children[1])).toBe( + `
a second slot, slotted element nested element in the second slot
`, + ); + expect(nodeOrEleContent(children[2])).toBe(undefined); + }); + + it('patches `childElementCount` to only count elements that have been slotted', async () => { + expect(specPage.root.childElementCount).toBe(2); + }); + + it('patches `textContent` to only return slotted node text', async () => { + expect(specPage.root.textContent.replace(/\s+/g, ' ').trim()).toBe( + `Some default slot, slotted text a default slot, slotted element a second slot, slotted element nested element in the second slot`, + ); + }); +}); diff --git a/src/runtime/vdom/vdom-render.ts b/src/runtime/vdom/vdom-render.ts index 27b42c2c3c0..0a6b3e62ca5 100644 --- a/src/runtime/vdom/vdom-render.ts +++ b/src/runtime/vdom/vdom-render.ts @@ -195,8 +195,12 @@ const relocateToHostRoot = (parentElm: Element) => { const host = parentElm.closest(hostTagName.toLowerCase()); if (host != null) { - const contentRefNode = (Array.from(host.childNodes) as d.RenderNode[]).find((ref) => ref['s-cr']); - const childNodeArray = Array.from(parentElm.childNodes) as d.RenderNode[]; + const contentRefNode = (Array.from((host as d.RenderNode).__childNodes || host.childNodes) as d.RenderNode[]).find( + (ref) => ref['s-cr'], + ); + const childNodeArray = Array.from( + (parentElm as d.RenderNode).__childNodes || parentElm.childNodes, + ) as d.RenderNode[]; // If we have a content ref, we need to invert the order of the nodes we're relocating // to preserve the correct order of elements in the DOM on future relocations @@ -219,7 +223,7 @@ const relocateToHostRoot = (parentElm: Element) => { const putBackInOriginalLocation = (parentElm: d.RenderNode, recursive: boolean) => { plt.$flags$ |= PLATFORM_FLAGS.isTmpDisconnected; - const oldSlotChildNodes: ChildNode[] = Array.from(parentElm.childNodes); + const oldSlotChildNodes: ChildNode[] = Array.from(parentElm.__childNodes || parentElm.childNodes); if (parentElm['s-sr'] && BUILD.experimentalSlotFixes) { let node = parentElm; @@ -741,7 +745,7 @@ export const patch = (oldVNode: d.VNode, newVNode: d.VNode, isInitialRender = fa * @param elm the element of interest */ export const updateFallbackSlotVisibility = (elm: d.RenderNode) => { - const childNodes: d.RenderNode[] = elm.childNodes as any; + const childNodes: d.RenderNode[] = elm.__childNodes || (elm.childNodes as any); for (const childNode of childNodes) { if (childNode.nodeType === NODE_TYPE.ElementNode) { @@ -812,13 +816,14 @@ const markSlotContentForRelocation = (elm: d.RenderNode) => { let hostContentNodes: NodeList; let j; - for (const childNode of elm.childNodes as unknown as d.RenderNode[]) { + const children = elm.__childNodes || elm.childNodes; + for (const childNode of children as unknown as d.RenderNode[]) { // we need to find child nodes which are slot references so we can then try // to match them up with nodes that need to be relocated if (childNode['s-sr'] && (node = childNode['s-cr']) && node.parentNode) { // first get the content reference comment node ('s-cr'), then we get // its parent, which is where all the host content is now - hostContentNodes = node.parentNode.childNodes; + hostContentNodes = (node.parentNode as d.RenderNode).__childNodes || node.parentNode.childNodes; const slotName = childNode['s-sn']; // iterate through all the nodes under the location where the host was @@ -992,7 +997,7 @@ const updateElementScopeIds = (element: d.RenderNode, parent: d.RenderNode, iter * So, we need to notify the child nodes to update their new scope ids since * the DOM structure is changed. */ - for (const childNode of Array.from(element.childNodes)) { + for (const childNode of Array.from(element.__childNodes || element.childNodes)) { updateElementScopeIds(childNode as d.RenderNode, element, true); } } @@ -1235,7 +1240,8 @@ render() { // Only an issue if there were no "slots" rendered. Otherwise, nodes are hidden correctly. // This _only_ happens for `scoped` components! if (BUILD.experimentalScopedSlotChanges && cmpMeta.$flags$ & CMP_FLAGS.scopedCssEncapsulation) { - for (const childNode of rootVnode.$elm$.childNodes) { + const children = rootVnode.$elm$.__childNodes || rootVnode.$elm$.childNodes; + for (const childNode of children) { if (childNode['s-hn'] !== hostTagName && !childNode['s-sh']) { // Store the initial value of `hidden` so we can reset it later when // moving nodes around. diff --git a/test/wdio/attribute-basic/cmp.test.tsx b/test/wdio/attribute-basic/cmp.test.tsx index 1ea1eb16858..97d65847dea 100644 --- a/test/wdio/attribute-basic/cmp.test.tsx +++ b/test/wdio/attribute-basic/cmp.test.tsx @@ -10,6 +10,7 @@ describe('attribute-basic', () => { }); it('button click rerenders', async () => { + await $('attribute-basic.hydrated').waitForExist(); await expect($('.single')).toHaveText('single'); await expect($('.multiWord')).toHaveText('multiWord'); await expect($('.customAttr')).toHaveText('my-custom-attr'); diff --git a/test/wdio/attribute-boolean/cmp.test.tsx b/test/wdio/attribute-boolean/cmp.test.tsx index ffcd8def2e3..db5bc3aa970 100644 --- a/test/wdio/attribute-boolean/cmp.test.tsx +++ b/test/wdio/attribute-boolean/cmp.test.tsx @@ -10,6 +10,7 @@ describe('attribute-boolean', () => { }); it('button click rerenders', async () => { + await $('attribute-boolean-root.hydrated').waitForExist(); const root: any = document.body.querySelector('attribute-boolean-root')!; await expect($(root)).toHaveAttribute('aria-hidden', 'false'); await expect(root).toHaveAttribute('aria-hidden', 'false'); diff --git a/test/wdio/delegates-focus/cmp.test.tsx b/test/wdio/delegates-focus/cmp.test.tsx index b5b66602ed0..9b65359bf0a 100644 --- a/test/wdio/delegates-focus/cmp.test.tsx +++ b/test/wdio/delegates-focus/cmp.test.tsx @@ -25,8 +25,8 @@ describe('delegates-focus', function () { }); it('should delegate focus', async () => { - await $('delegates-focus').waitForExist(); - await $('no-delegates-focus').waitForExist(); + await $('delegates-focus.hydrated').waitForExist(); + await $('no-delegates-focus.hydrated').waitForExist(); const delegatesFocus = document.querySelector('delegates-focus'); const noDelegatesFocus = document.querySelector('no-delegates-focus'); diff --git a/test/wdio/text-content-patch/cmp.test.tsx b/test/wdio/text-content-patch/cmp.test.tsx index ddf839fea78..4b9a72b37ad 100644 --- a/test/wdio/text-content-patch/cmp.test.tsx +++ b/test/wdio/text-content-patch/cmp.test.tsx @@ -23,19 +23,13 @@ describe('textContent patch', () => { it('should return the content of all slots', async () => { const elm = $('text-content-patch-scoped-with-slot'); await expect(elm.getText()).toMatchInlineSnapshot(` - "Top content - Slot content - Bottom content - Suffix content" - `); + "Slot content +Suffix content"`); }); it('should return an empty string if there is no slotted content', async () => { const elm = $('text-content-patch-scoped'); - await expect(elm.getText()).toMatchInlineSnapshot(` - "Top content - Bottom content" - `); + await expect(await elm.getText()).toBe(``); }); it('should overwrite the default slot content', async () => { @@ -47,11 +41,7 @@ describe('textContent patch', () => { elm as any as HTMLElement, ); - await expect(elm.getText()).toMatchInlineSnapshot(` - "Top content - New slot content - Bottom content" - `); + await expect(elm.getText()).toMatchInlineSnapshot(`"New slot content"`); }); it('should not insert the text node if there is no default slot', async () => { @@ -63,10 +53,7 @@ describe('textContent patch', () => { elm as any as HTMLElement, ); - await expect(elm.getText()).toMatchInlineSnapshot(` - "Top content - Bottom content" - `); + await expect(await elm.getText()).toBe(``); }); }); });