From 9053e05339848385685810f57d9a965b977ab08f Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Mon, 25 Nov 2024 15:15:29 +0000 Subject: [PATCH 1/5] chore: tidy --- src/runtime/client-hydrate.ts | 11 +++ src/runtime/dom-extras.ts | 155 ++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/src/runtime/client-hydrate.ts b/src/runtime/client-hydrate.ts index cb024bad05a..3ac54f6e36a 100644 --- a/src/runtime/client-hydrate.ts +++ b/src/runtime/client-hydrate.ts @@ -1,4 +1,5 @@ import { BUILD } from '@app-data'; +import { CMP_FLAGS } from '@utils'; import { doc, plt, supportsShadow } from '@platform'; import type * as d from '../declarations'; @@ -13,6 +14,7 @@ import { TEXT_NODE_ID, } from './runtime-constants'; import { newVNode } from './vdom/h'; +import { patchNextPrev } from './dom-extras'; /** * Entrypoint of the client-side hydration process. Facilitates calls to hydrate the @@ -68,6 +70,15 @@ export const initializeClientHydrate = ( } plt.$orgLocNodes$.delete(orgLocationId); + + if (BUILD.experimentalSlotFixes) { + if (BUILD.scoped && hostRef.$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. + // Patch this node's accessors like `nextSibling` (et al) + patchNextPrev(node); + } + } }); if (BUILD.shadowDom && shadowRoot) { diff --git a/src/runtime/dom-extras.ts b/src/runtime/dom-extras.ts index c6870a8f38c..fa7aa253c47 100644 --- a/src/runtime/dom-extras.ts +++ b/src/runtime/dom-extras.ts @@ -389,6 +389,18 @@ export const patchChildSlotNodes = (elm: HTMLElement, cmpMeta: d.ComponentRuntim return elm.children.length; }, }); + + Object.defineProperty(elm, 'firstChild', { + get() { + return this.childNodes[0]; + }, + }); + + Object.defineProperty(elm, 'lastChild', { + get() { + return this.childNodes[this.childNodes.length - 1]; + }, + }); Object.defineProperty(elm, 'childNodes', { get() { @@ -412,6 +424,149 @@ export const patchChildSlotNodes = (elm: HTMLElement, cmpMeta: d.ComponentRuntim } }; +/** + * Patches sibling accessors of a 'slotted' node within a non-shadow component. + * Meaning whilst stepping through a non-shadow element's nodes, only the mock 'lightDOM' nodes are returned. + * Especially relevant when rendering components via SSR... Frameworks will often try to reconcile their + * VDOM with the real DOM by stepping through nodes with 'nextSibling' et al. + * - `nextSibling` + * - `nextSiblingElement` + * - `previousSibling` + * - `previousSiblingElement` + * @param NodePrototype the slotted node to be patched + */ +export const patchNextPrev = (node: any) => { + if (!node || (node as any).__nextSibling || !globalThis.Node) return; + + patchNextSibling(node); + patchPreviousSibling(node); + patchNextElementSibling(node); + patchPreviousElementSibling(node); +}; + +/** + * Patches the `nextSibling` accessor of a non-shadow slotted node + * @param node the slotted node to be patched + * Required during during testing / mock environnement. + */ +const patchNextSibling = (node: Node) => { + // already been patched? return + if (!node || (node as any).__nextSibling) return; + + let descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'nextSibling'); + let toOverride = Node.prototype; + + if (!descriptor) { + // for mock-doc + descriptor = Object.getOwnPropertyDescriptor(node, 'nextSibling'); + toOverride = node; + } + if (descriptor) Object.defineProperty(toOverride, '__nextSibling', descriptor); + + Object.defineProperty(node, 'nextSibling', { + get: function () { + const parentNodes = this['s-ol']?.parentNode.childNodes; + const index = parentNodes?.indexOf(this); + if (parentNodes && index > -1) { + return parentNodes[index + 1]; + } + return this.__nextSibling; + }, + }); +}; + +/** + * Patches the `nextElementSibling` accessor of a non-shadow slotted node + * @param element the slotted element to be patched + * Required during during testing / mock environnement. + */ +const patchNextElementSibling = (element: HTMLElement) => { + if (!element || (element as any).__nextElementSibling || !element.nextElementSibling) return; + + let descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'nextElementSibling'); + let toOverride = Node.prototype; + + if (!descriptor) { + // for mock-doc + descriptor = Object.getOwnPropertyDescriptor(element, 'nextElementSibling'); + toOverride = element; + } + if (descriptor) Object.defineProperty(toOverride, '__nextElementSibling', descriptor); + + Object.defineProperty(element, 'nextElementSibling', { + get: function () { + const parentEles = this['s-ol']?.parentNode.children; + const index = parentEles?.indexOf(this); + if (parentEles && index > -1) { + return parentEles[index + 1]; + } + return this.__nextElementSibling; + }, + }); +}; + +/** + * Patches the `previousSibling` accessor of a non-shadow slotted node + * @param element the slotted node to be patched + * Required during during testing / mock environnement. + */ +const patchPreviousSibling = (node: Node) => { + if (!node || (node as any).__previousSibling) return; + + let descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'previousSibling'); + let toOverride = Node.prototype; + + if (!descriptor) { + // for mock-doc + descriptor = Object.getOwnPropertyDescriptor(node, 'previousSibling'); + toOverride = node; + } + if (descriptor) Object.defineProperty(toOverride, '__previousSibling', descriptor); + + Object.defineProperty(node, 'previousSibling', { + get: function () { + const parentNodes = this['s-ol']?.parentNode.childNodes; + const index = parentNodes?.indexOf(this); + if (parentNodes && index > -1) { + return parentNodes[index - 1]; + } + return this.__previousSibling; + }, + }); +}; + +/** + * Patches the `previousElementSibling` accessor of a non-shadow slotted node + * @param ElementPrototype the slotted node to be patched + * @param DescriptorNodePrototype optional. Where to find the OG descriptor for `previousElementSibling`. + * Required during during testing / mock environnement. + */ +const patchPreviousElementSibling = (element: HTMLElement) => { + if (!element || (element as any).__previousElementSibling || !element.previousElementSibling) + return; + + let descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'previousElementSibling'); + let toOverride = Node.prototype; + + if (!descriptor) { + // for mock-doc + descriptor = Object.getOwnPropertyDescriptor(element, 'previousElementSibling'); + toOverride = element; + } + if (descriptor) Object.defineProperty(toOverride, '__previousElementSibling', descriptor); + + Object.defineProperty(element, 'previousElementSibling', { + get: function () { + const parentNodes = this['s-ol']?.parentNode.children; + const index = parentNodes?.indexOf(this); + if (parentNodes && index > -1) { + return parentNodes[index - 1]; + } + return this.__previousElementSibling; + }, + }); +}; + /** * Recursively finds all slot reference nodes ('s-sr') in a series of child nodes. * From 056e98cb2104e6105a6101a7c9c02cea1334d3a7 Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Tue, 3 Dec 2024 13:31:32 +0000 Subject: [PATCH 2/5] chore: added more tests --- src/declarations/stencil-private.ts | 58 +++++++- src/runtime/client-hydrate.ts | 6 +- src/runtime/dom-extras.ts | 147 +++++++++----------- src/runtime/test/dom-extras.spec.tsx | 14 +- test/wdio/scoped-slot-children/cmp-root.tsx | 20 +++ test/wdio/scoped-slot-children/cmp.test.tsx | 128 +++++++++++++++++ 6 files changed, 284 insertions(+), 89 deletions(-) create mode 100644 test/wdio/scoped-slot-children/cmp-root.tsx create mode 100644 test/wdio/scoped-slot-children/cmp.test.tsx diff --git a/src/declarations/stencil-private.ts b/src/declarations/stencil-private.ts index 80b4665e48f..664699df677 100644 --- a/src/declarations/stencil-private.ts +++ b/src/declarations/stencil-private.ts @@ -1459,9 +1459,65 @@ export interface RenderNode extends HostElement { /** * On a `scoped: true` component * with `experimentalSlotFixes` flag enabled, - * returns the internal `childNodes` of the scoped element + * returns the internal `childNodes` of the component */ readonly __childNodes?: NodeListOf; + + /** + * On a `scoped: true` component + * with `experimentalSlotFixes` flag enabled, + * returns the internal `children` of the component + */ + readonly __children?: HTMLCollectionOf; + + /** + * On a `scoped: true` component + * with `experimentalSlotFixes` flag enabled, + * returns the internal `firstChild` of the component + */ + readonly __firstChild?: ChildNode; + + /** + * On a `scoped: true` component + * with `experimentalSlotFixes` flag enabled, + * returns the internal `lastChild` of the component + */ + readonly __lastChild?: ChildNode; + + /** + * On a `scoped: true` component + * with `experimentalSlotFixes` flag enabled, + * returns the internal `textContent` of the component + */ + __textContent?: string; + + /** + * On a `scoped: true` component + * with `experimentalSlotFixes` flag enabled, + * gives access to the original `append` method + */ + __append?: (...nodes: (Node | string)[]) => void; + + /** + * On a `scoped: true` component + * with `experimentalSlotFixes` flag enabled, + * gives access to the original `prepend` method + */ + __prepend?: (...nodes: (Node | string)[]) => void; + + /** + * On a `scoped: true` component + * with `experimentalSlotFixes` flag enabled, + * gives access to the original `appendChild` method + */ + __appendChild?: (newChild: T) => T; + + /** + * On a `scoped: true` component + * with `experimentalSlotFixes` flag enabled, + * gives access to the original `removeChild` method + */ + __removeChild?: (child: T) => T; } export type LazyBundlesRuntimeData = LazyBundleRuntimeData[]; diff --git a/src/runtime/client-hydrate.ts b/src/runtime/client-hydrate.ts index 3ac54f6e36a..85569831793 100644 --- a/src/runtime/client-hydrate.ts +++ b/src/runtime/client-hydrate.ts @@ -1,8 +1,9 @@ import { BUILD } from '@app-data'; -import { CMP_FLAGS } from '@utils'; import { doc, plt, supportsShadow } from '@platform'; +import { CMP_FLAGS } from '@utils'; import type * as d from '../declarations'; +import { patchNextPrev } from './dom-extras'; import { createTime } from './profile'; import { CONTENT_REF_ID, @@ -14,7 +15,6 @@ import { TEXT_NODE_ID, } from './runtime-constants'; import { newVNode } from './vdom/h'; -import { patchNextPrev } from './dom-extras'; /** * Entrypoint of the client-side hydration process. Facilitates calls to hydrate the @@ -70,7 +70,7 @@ export const initializeClientHydrate = ( } plt.$orgLocNodes$.delete(orgLocationId); - + if (BUILD.experimentalSlotFixes) { if (BUILD.scoped && hostRef.$cmpMeta$.$flags$ & CMP_FLAGS.scopedCssEncapsulation) { // This check is intentionally not combined with the surrounding `experimentalSlotFixes` check diff --git a/src/runtime/dom-extras.ts b/src/runtime/dom-extras.ts index 8dfdde872e0..a6b4b1bba8b 100644 --- a/src/runtime/dom-extras.ts +++ b/src/runtime/dom-extras.ts @@ -246,17 +246,11 @@ export const patchSlotInsertAdjacentElement = (HostElementPrototype: HTMLElement /** * Patches the text content of an unnamed slotted node inside a scoped component - * + * * @param hostElementPrototype the `Element` to be patched */ export const patchTextContent = (hostElementPrototype: HTMLElement): void => { - let descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent'); - - if (!descriptor) { - // for mock-doc - descriptor = Object.getOwnPropertyDescriptor(hostElementPrototype, 'textContent'); - } - if (descriptor) Object.defineProperty(hostElementPrototype, '__textContent', descriptor); + patchHostOriginalAccessor('textContent', hostElementPrototype); Object.defineProperty(hostElementPrototype, 'textContent', { get: function () { @@ -283,14 +277,7 @@ export const patchChildSlotNodes = (elm: HTMLElement) => { } } - let childNodesFn = Object.getOwnPropertyDescriptor(Node.prototype, 'childNodes'); - if (!childNodesFn) { - // for mock-doc - childNodesFn = Object.getOwnPropertyDescriptor(elm, 'childNodes'); - } - - if (childNodesFn) Object.defineProperty(elm, '__childNodes', childNodesFn); - + patchHostOriginalAccessor('children', elm); Object.defineProperty(elm, 'children', { get() { return this.childNodes.filter((n: any) => n.nodeType === 1); @@ -303,20 +290,21 @@ export const patchChildSlotNodes = (elm: HTMLElement) => { }, }); + patchHostOriginalAccessor('firstChild', elm); Object.defineProperty(elm, 'firstChild', { get() { return this.childNodes[0]; }, }); + patchHostOriginalAccessor('lastChild', elm); Object.defineProperty(elm, 'lastChild', { get() { return this.childNodes[this.childNodes.length - 1]; }, }); - if (!childNodesFn) return; - + patchHostOriginalAccessor('childNodes', elm); Object.defineProperty(elm, 'childNodes', { get() { if ( @@ -339,27 +327,30 @@ export const patchChildSlotNodes = (elm: HTMLElement) => { /** * Patches sibling accessors of a 'slotted' node within a non-shadow component. * Meaning whilst stepping through a non-shadow element's nodes, only the mock 'lightDOM' nodes are returned. - * Especially relevant when rendering components via SSR... Frameworks will often try to reconcile their + * Especially relevant when rendering components via SSR... Frameworks will often try to reconcile their * VDOM with the real DOM by stepping through nodes with 'nextSibling' et al. * - `nextSibling` * - `nextSiblingElement` * - `previousSibling` * - `previousSiblingElement` - * - * @param NodePrototype the slotted node to be patched + * + * @param node the slotted node to be patched */ -export const patchNextPrev = (node: any) => { +export const patchNextPrev = (node: Node) => { if (!node || (node as any).__nextSibling || !globalThis.Node) return; patchNextSibling(node); patchPreviousSibling(node); - patchNextElementSibling(node); - patchPreviousElementSibling(node); + + if (node instanceof Element) { + patchNextElementSibling(node); + patchPreviousElementSibling(node); + } }; /** * Patches the `nextSibling` accessor of a non-shadow slotted node - * + * * @param node the slotted node to be patched * Required during during testing / mock environnement. */ @@ -367,16 +358,7 @@ const patchNextSibling = (node: Node) => { // already been patched? return if (!node || (node as any).__nextSibling) return; - let descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'nextSibling'); - let toOverride = Node.prototype; - - if (!descriptor) { - // for mock-doc - descriptor = Object.getOwnPropertyDescriptor(node, 'nextSibling'); - toOverride = node; - } - if (descriptor) Object.defineProperty(toOverride, '__nextSibling', descriptor); - + patchHostOriginalAccessor('nextSibling', node); Object.defineProperty(node, 'nextSibling', { get: function () { const parentNodes = this['s-ol']?.parentNode.childNodes; @@ -391,23 +373,14 @@ const patchNextSibling = (node: Node) => { /** * Patches the `nextElementSibling` accessor of a non-shadow slotted node - * - * @param element the slotted element to be patched + * + * @param element the slotted element node to be patched * Required during during testing / mock environnement. */ -const patchNextElementSibling = (element: HTMLElement) => { +const patchNextElementSibling = (element: Element) => { if (!element || (element as any).__nextElementSibling || !element.nextElementSibling) return; - let descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'nextElementSibling'); - let toOverride = Node.prototype; - - if (!descriptor) { - // for mock-doc - descriptor = Object.getOwnPropertyDescriptor(element, 'nextElementSibling'); - toOverride = element; - } - if (descriptor) Object.defineProperty(toOverride, '__nextElementSibling', descriptor); - + patchHostOriginalAccessor('nextElementSibling', element); Object.defineProperty(element, 'nextElementSibling', { get: function () { const parentEles = this['s-ol']?.parentNode.children; @@ -422,23 +395,14 @@ const patchNextElementSibling = (element: HTMLElement) => { /** * Patches the `previousSibling` accessor of a non-shadow slotted node - * - * @param element the slotted node to be patched + * + * @param node the slotted node to be patched * Required during during testing / mock environnement. */ const patchPreviousSibling = (node: Node) => { if (!node || (node as any).__previousSibling) return; - let descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'previousSibling'); - let toOverride = Node.prototype; - - if (!descriptor) { - // for mock-doc - descriptor = Object.getOwnPropertyDescriptor(node, 'previousSibling'); - toOverride = node; - } - if (descriptor) Object.defineProperty(toOverride, '__previousSibling', descriptor); - + patchHostOriginalAccessor('previousSibling', node); Object.defineProperty(node, 'previousSibling', { get: function () { const parentNodes = this['s-ol']?.parentNode.childNodes; @@ -453,25 +417,14 @@ const patchPreviousSibling = (node: Node) => { /** * Patches the `previousElementSibling` accessor of a non-shadow slotted node - * - * @param ElementPrototype the slotted node to be patched - * @param DescriptorNodePrototype optional. Where to find the OG descriptor for `previousElementSibling`. + * + * @param element the slotted element node to be patched * Required during during testing / mock environnement. */ -const patchPreviousElementSibling = (element: HTMLElement) => { - if (!element || (element as any).__previousElementSibling || !element.previousElementSibling) - return; - - let descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'previousElementSibling'); - let toOverride = Node.prototype; - - if (!descriptor) { - // for mock-doc - descriptor = Object.getOwnPropertyDescriptor(element, 'previousElementSibling'); - toOverride = element; - } - if (descriptor) Object.defineProperty(toOverride, '__previousElementSibling', descriptor); +const patchPreviousElementSibling = (element: Element) => { + if (!element || (element as any).__previousElementSibling || !element.previousElementSibling) return; + patchHostOriginalAccessor('previousElementSibling', element); Object.defineProperty(element, 'previousElementSibling', { get: function () { const parentNodes = this['s-ol']?.parentNode.children; @@ -486,11 +439,45 @@ const patchPreviousElementSibling = (element: HTMLElement) => { /// UTILS /// +const validElementPatches = ['children', 'nextElementSibling', 'previousElementSibling'] as const; +const validNodesPatches = [ + 'childNodes', + 'firstChild', + 'lastChild', + 'nextSibling', + 'previousSibling', + 'textContent', +] as const; + +/** + * Patches a node or element; making it's original accessor method available under a new name. + * e.g. `nextSibling` -> `__nextSibling` + * + * @param accessorName - the name of the accessor to patch + * @param node - the node to patch + */ +function patchHostOriginalAccessor( + accessorName: (typeof validElementPatches)[number] | (typeof validNodesPatches)[number], + node: Node, +) { + let accessor; + if (validElementPatches.includes(accessorName as any)) { + accessor = Object.getOwnPropertyDescriptor(Element.prototype, accessorName); + } else if (validNodesPatches.includes(accessorName as any)) { + accessor = Object.getOwnPropertyDescriptor(Node.prototype, accessorName); + } + if (!accessor) { + // for mock-doc + accessor = Object.getOwnPropertyDescriptor(node, accessorName); + } + if (accessor) Object.defineProperty(node, '__' + accessorName, accessor); +} + /** * 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 @@ -515,13 +502,11 @@ export const addSlotRelocateNode = (newChild: d.RenderNode, slotNode: d.RenderNo appendMethod.call(parent, slottedNodeLocation); }; - - /** * 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. */ @@ -541,7 +526,7 @@ const getSlotName = (node: d.RenderNode) => /** * Recursively searches a series of child nodes for a slot with the provided name. - * + * * @param childNodes the nodes to search for a slot with a specific name. * @param slotName the name of the slot to match on. * @param hostName the host name of the slot to match on. diff --git a/src/runtime/test/dom-extras.spec.tsx b/src/runtime/test/dom-extras.spec.tsx index 5a139af2ec0..a372e060ce1 100644 --- a/src/runtime/test/dom-extras.spec.tsx +++ b/src/runtime/test/dom-extras.spec.tsx @@ -81,13 +81,19 @@ describe('dom-extras - patches for non-shadow dom methods and accessors', () => 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`, ); }); + + it('firstChild', async () => { + expect(nodeOrEleContent(specPage.root.firstChild)).toBe(`Some default slot, slotted text`); + }); + + it('lastChild', async () => { + expect(nodeOrEleContent(specPage.root.lastChild)).toBe( + `
a second slot, slotted element nested element in the second slot
`, + ); + }); }); diff --git a/test/wdio/scoped-slot-children/cmp-root.tsx b/test/wdio/scoped-slot-children/cmp-root.tsx new file mode 100644 index 00000000000..9301f9f8668 --- /dev/null +++ b/test/wdio/scoped-slot-children/cmp-root.tsx @@ -0,0 +1,20 @@ +import { Component, h, Host } from '@stencil/core'; + +@Component({ + tag: 'scoped-slot-children', + scoped: true, +}) +export class ScopedSlotChildren { + render() { + return ( + +

internal text 1

+ +
+ This is fallback text +
+

internal text 2

+
+ ); + } +} diff --git a/test/wdio/scoped-slot-children/cmp.test.tsx b/test/wdio/scoped-slot-children/cmp.test.tsx new file mode 100644 index 00000000000..2e17cc7f07e --- /dev/null +++ b/test/wdio/scoped-slot-children/cmp.test.tsx @@ -0,0 +1,128 @@ +import { h } from '@stencil/core'; +import { render } from '@wdio/browser-runner/stencil'; + +describe('scoped-slot-children', function () { + const nodeOrEleContent = (node: Node | Element) => { + return (node as Element)?.outerHTML || node?.nodeValue?.trim(); + }; + + beforeEach(async () => { + render({ + template: () => ( + + Some default slot, slotted text + a default slot, slotted element +
+ a second slot, slotted element + nested element in the second slot +
+
+ ), + }); + }); + + it('patches `childNodes` to return only nodes that have been slotted', async () => { + await $('scoped-slot-children').waitForStable(); + + const childNodes = () => document.querySelector('scoped-slot-children').childNodes; + const innerChildNodes = () => + (document.querySelector('scoped-slot-children') as any).__childNodes as NodeListOf; + + 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( + `
a second slot, slotted element nested element in the second slot
`, + ); + + expect(nodeOrEleContent(innerChildNodes()[4])).toBe(`

internal text 1

`); + + childNodes()[0].remove(); + expect(nodeOrEleContent(childNodes()[0])).toBe( + `a default slot, slotted element`, + ); + + expect(nodeOrEleContent(innerChildNodes()[4])).toBe(`

internal text 1

`); + + childNodes()[0].remove(); + expect(nodeOrEleContent(childNodes()[0])).toBe( + `
a second slot, slotted element nested element in the second slot
`, + ); + + expect(nodeOrEleContent(innerChildNodes()[4])).toBe(`

internal text 1

`); + + childNodes()[0].remove(); + expect(nodeOrEleContent(childNodes()[0])).toBe(undefined); + + expect(nodeOrEleContent(innerChildNodes()[4])).toBe(`

internal text 1

`); + }); + + it('patches `children` to return only elements that have been slotted', async () => { + await $('scoped-slot-children').waitForStable(); + + const children = () => document.querySelector('scoped-slot-children').children; + const innerChildren = () => + (document.querySelector('scoped-slot-children') as any).__children as NodeListOf; + + 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); + + expect(nodeOrEleContent(innerChildren()[0])).toBe(`

internal text 1

`); + + children()[0].remove(); + expect(nodeOrEleContent(children()[0])).toBe( + `
a second slot, slotted element nested element in the second slot
`, + ); + expect(nodeOrEleContent(innerChildren()[0])).toBe(`

internal text 1

`); + + children()[0].remove(); + expect(nodeOrEleContent(children()[0])).toBe(undefined); + expect(nodeOrEleContent(innerChildren()[0])).toBe(`

internal text 1

`); + }); + + it('patches `firstChild` to return only the first slotted node', async () => { + await $('scoped-slot-children').waitForStable(); + expect(nodeOrEleContent(document.querySelector('scoped-slot-children').firstChild)).toBe( + `Some default slot, slotted text`, + ); + + document.querySelector('scoped-slot-children').firstChild.remove(); + expect(nodeOrEleContent(document.querySelector('scoped-slot-children').firstChild)).toBe( + `a default slot, slotted element`, + ); + + document.querySelector('scoped-slot-children').firstChild.remove(); + expect(nodeOrEleContent(document.querySelector('scoped-slot-children').firstChild)).toBe( + `
a second slot, slotted element nested element in the second slot
`, + ); + + document.querySelector('scoped-slot-children').firstChild.remove(); + expect(nodeOrEleContent(document.querySelector('scoped-slot-children').firstChild)).toBe(undefined); + }); + + it('patches `lastChild` to return only the last slotted node', async () => { + await $('scoped-slot-children').waitForStable(); + expect(nodeOrEleContent(document.querySelector('scoped-slot-children').lastChild)).toBe( + `
a second slot, slotted element nested element in the second slot
`, + ); + + document.querySelector('scoped-slot-children').lastChild.remove(); + expect(nodeOrEleContent(document.querySelector('scoped-slot-children').lastChild)).toBe( + `a default slot, slotted element`, + ); + + document.querySelector('scoped-slot-children').lastChild.remove(); + expect(nodeOrEleContent(document.querySelector('scoped-slot-children').lastChild)).toBe( + `Some default slot, slotted text`, + ); + + document.querySelector('scoped-slot-children').lastChild.remove(); + expect(nodeOrEleContent(document.querySelector('scoped-slot-children').lastChild)).toBe(undefined); + }); +}); From 50f9b1ccd3283fd1028b80917f6526a2cd6fb8d1 Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Sat, 14 Dec 2024 16:30:15 +0000 Subject: [PATCH 3/5] chore: prettier --- src/runtime/client-hydrate.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/runtime/client-hydrate.ts b/src/runtime/client-hydrate.ts index 293af33ca33..ef230ce306e 100644 --- a/src/runtime/client-hydrate.ts +++ b/src/runtime/client-hydrate.ts @@ -1,5 +1,6 @@ import { BUILD } from '@app-data'; import { doc, plt } from '@platform'; + import type * as d from '../declarations'; import { addSlotRelocateNode, patchNextPrev } from './dom-extras'; import { createTime } from './profile'; From d72445a0cc704f1094526d7456736a8df8cc317d Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Mon, 16 Dec 2024 22:57:54 +0000 Subject: [PATCH 4/5] chore: tests --- src/runtime/client-hydrate.ts | 6 ++- src/runtime/dom-extras.ts | 15 +++--- src/runtime/test/dom-extras.spec.tsx | 36 ++++++++++++- .../cmp.test.tsx | 54 +++++++++++++++++++ .../hydrated-scoped-sibling-accessors/cmp.tsx | 18 +++++++ 5 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 test/wdio/hydrated-scoped-sibling-accessors/cmp.test.tsx create mode 100644 test/wdio/hydrated-scoped-sibling-accessors/cmp.tsx diff --git a/src/runtime/client-hydrate.ts b/src/runtime/client-hydrate.ts index ef230ce306e..304ee905982 100644 --- a/src/runtime/client-hydrate.ts +++ b/src/runtime/client-hydrate.ts @@ -163,8 +163,10 @@ export const initializeClientHydrate = ( // Create our 'Original Location' node addSlotRelocateNode(slottedItem.node, slottedItem.slot, false, slottedItem.node['s-oo']); - // patch this node for accessors like `nextSibling` (et al) - patchNextPrev(slottedItem.node); + if (BUILD.experimentalSlotFixes) { + // patch this node for accessors like `nextSibling` (et al) + patchNextPrev(slottedItem.node); + } } if (hostEle.shadowRoot && slottedItem.node.parentElement !== hostEle) { diff --git a/src/runtime/dom-extras.ts b/src/runtime/dom-extras.ts index 891317384af..9a4a2da907e 100644 --- a/src/runtime/dom-extras.ts +++ b/src/runtime/dom-extras.ts @@ -330,9 +330,9 @@ export const patchChildSlotNodes = (elm: HTMLElement) => { * Especially relevant when rendering components via SSR... Frameworks will often try to reconcile their * VDOM with the real DOM by stepping through nodes with 'nextSibling' et al. * - `nextSibling` - * - `nextSiblingElement` + * - `nextElementSibling` * - `previousSibling` - * - `previousSiblingElement` + * - `previousElementSibling` * * @param node the slotted node to be patched */ @@ -342,9 +342,9 @@ export const patchNextPrev = (node: Node) => { patchNextSibling(node); patchPreviousSibling(node); - if (node instanceof Element) { - patchNextElementSibling(node); - patchPreviousElementSibling(node); + if (node.nodeType === Node.ELEMENT_NODE) { + patchNextElementSibling(node as Element); + patchPreviousElementSibling(node as Element); } }; @@ -378,7 +378,7 @@ const patchNextSibling = (node: Node) => { * Required during during testing / mock environnement. */ const patchNextElementSibling = (element: Element) => { - if (!element || (element as any).__nextElementSibling || !element.nextElementSibling) return; + if (!element || (element as any).__nextElementSibling) return; patchHostOriginalAccessor('nextElementSibling', element); Object.defineProperty(element, 'nextElementSibling', { @@ -422,13 +422,14 @@ const patchPreviousSibling = (node: Node) => { * Required during during testing / mock environnement. */ const patchPreviousElementSibling = (element: Element) => { - if (!element || (element as any).__previousElementSibling || !element.previousElementSibling) return; + if (!element || (element as any).__previousElementSibling) return; patchHostOriginalAccessor('previousElementSibling', element); Object.defineProperty(element, 'previousElementSibling', { get: function () { const parentNodes = this['s-ol']?.parentNode.children; const index = parentNodes?.indexOf(this); + if (parentNodes && index > -1) { return parentNodes[index - 1]; } diff --git a/src/runtime/test/dom-extras.spec.tsx b/src/runtime/test/dom-extras.spec.tsx index a372e060ce1..8639a17c5dd 100644 --- a/src/runtime/test/dom-extras.spec.tsx +++ b/src/runtime/test/dom-extras.spec.tsx @@ -1,7 +1,7 @@ import { Component, h, Host } from '@stencil/core'; import { newSpecPage, SpecPage } from '@stencil/core/testing'; -import { patchPseudoShadowDom } from '../../runtime/dom-extras'; +import { patchNextPrev,patchPseudoShadowDom } from '../../runtime/dom-extras'; describe('dom-extras - patches for non-shadow dom methods and accessors', () => { let specPage: SpecPage; @@ -81,6 +81,10 @@ describe('dom-extras - patches for non-shadow dom methods and accessors', () => 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`, @@ -96,4 +100,34 @@ describe('dom-extras - patches for non-shadow dom methods and accessors', () => `
a second slot, slotted element nested element in the second slot
`, ); }); + + it('patches nextSibling / previousSibling accessors of slotted nodes', async () => { + specPage.root.childNodes.forEach((node: Node) => patchNextPrev(node)); + expect(nodeOrEleContent(specPage.root.firstChild)).toBe('Some default slot, slotted text'); + expect(nodeOrEleContent(specPage.root.firstChild.nextSibling)).toBe('a default slot, slotted element'); + expect(nodeOrEleContent(specPage.root.firstChild.nextSibling.nextSibling)).toBe(``); + expect(nodeOrEleContent(specPage.root.firstChild.nextSibling.nextSibling.nextSibling)).toBe( + `
a second slot, slotted element nested element in the second slot
`, + ); + // back we go! + expect(nodeOrEleContent(specPage.root.firstChild.nextSibling.nextSibling.nextSibling.previousSibling)).toBe(``); + expect( + nodeOrEleContent(specPage.root.firstChild.nextSibling.nextSibling.nextSibling.previousSibling.previousSibling), + ).toBe(`a default slot, slotted element`); + expect( + nodeOrEleContent( + specPage.root.firstChild.nextSibling.nextSibling.nextSibling.previousSibling.previousSibling.previousSibling, + ), + ).toBe(`Some default slot, slotted text`); + }); + + it('patches nextElementSibling / previousElementSibling accessors of slotted nodes', async () => { + specPage.root.childNodes.forEach((node: Node) => patchNextPrev(node)); + expect(nodeOrEleContent(specPage.root.children[0].nextElementSibling)).toBe( + '
a second slot, slotted element nested element in the second slot
', + ); + expect(nodeOrEleContent(specPage.root.children[0].nextElementSibling.previousElementSibling)).toBe( + 'a default slot, slotted element', + ); + }); }); diff --git a/test/wdio/hydrated-scoped-sibling-accessors/cmp.test.tsx b/test/wdio/hydrated-scoped-sibling-accessors/cmp.test.tsx new file mode 100644 index 00000000000..41f73715edc --- /dev/null +++ b/test/wdio/hydrated-scoped-sibling-accessors/cmp.test.tsx @@ -0,0 +1,54 @@ +import { render } from '@wdio/browser-runner/stencil'; + +import { renderToString } from '../hydrate/index.mjs'; + +describe('hydrated-sibling-accessors', () => { + beforeEach(async () => { + const { html } = await renderToString( + ` + +

First slot element

+ Default slot text node +

Second slot element

+ +
`, + { + fullDocument: true, + serializeShadowRoot: true, + constrainTimeouts: false, + }, + ); + render({ html }); + await $('hydrated-sibling-accessors').waitForStable(); + }); + + it('verifies slotted nodes next / previous sibling accessors are working', async () => { + const root = document.querySelector('hydrated-sibling-accessors'); + expect(root.firstChild.nextSibling.textContent).toBe('First slot element'); + expect(root.firstChild.nextSibling.nextSibling.textContent).toBe(' Default slot text node '); + expect(root.firstChild.nextSibling.nextSibling.nextSibling.textContent).toBe('Second slot element'); + expect(root.firstChild.nextSibling.nextSibling.nextSibling.nextSibling.textContent).toBe(' '); + expect(root.firstChild.nextSibling.nextSibling.nextSibling.nextSibling.nextSibling.nodeType).toBe( + Node.COMMENT_NODE, + ); + + expect(root.lastChild.previousSibling.nodeType).toBe(Node.COMMENT_NODE); + expect(root.lastChild.previousSibling.previousSibling.textContent).toBe(' '); + expect(root.lastChild.previousSibling.previousSibling.previousSibling.textContent).toBe('Second slot element'); + expect(root.lastChild.previousSibling.previousSibling.previousSibling.previousSibling.textContent).toBe( + ' Default slot text node ', + ); + expect( + root.lastChild.previousSibling.previousSibling.previousSibling.previousSibling.previousSibling.textContent, + ).toBe('First slot element'); + }); + + it('verifies slotted nodes next / previous sibling element accessors are working', async () => { + const root = document.querySelector('hydrated-sibling-accessors'); + expect(root.children[0].textContent).toBe('First slot element'); + expect(root.children[0].nextElementSibling.textContent).toBe('Second slot element'); + expect(root.children[0].nextElementSibling.nextElementSibling).toBeFalsy(); + + expect(root.children[0].nextElementSibling.previousElementSibling.textContent).toBe('First slot element'); + }); +}); diff --git a/test/wdio/hydrated-scoped-sibling-accessors/cmp.tsx b/test/wdio/hydrated-scoped-sibling-accessors/cmp.tsx new file mode 100644 index 00000000000..cf665fdd92b --- /dev/null +++ b/test/wdio/hydrated-scoped-sibling-accessors/cmp.tsx @@ -0,0 +1,18 @@ +import { Component, h } from '@stencil/core'; + +@Component({ + tag: 'hydrated-sibling-accessors', + scoped: true, +}) +export class HydratedSiblingAccessors { + render() { + return ( +
+ Hidden text Node + + Hidden span element + Second slot fallback text +
+ ); + } +} From 9239f8835be44accfc62dd1e4309a972193d1717 Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Tue, 17 Dec 2024 15:22:26 +0000 Subject: [PATCH 5/5] chore: change to end-to-end tests --- src/runtime/test/dom-extras.spec.tsx | 2 +- test/end-to-end/src/components.d.ts | 13 ++++ .../non-shadow-slotted-siblings.tsx} | 4 +- .../scoped-hydration/scoped-hydration.e2e.ts | 69 +++++++++++++++++++ .../cmp.test.tsx | 54 --------------- 5 files changed, 85 insertions(+), 57 deletions(-) rename test/{wdio/hydrated-scoped-sibling-accessors/cmp.tsx => end-to-end/src/scoped-hydration/non-shadow-slotted-siblings.tsx} (73%) delete mode 100644 test/wdio/hydrated-scoped-sibling-accessors/cmp.test.tsx diff --git a/src/runtime/test/dom-extras.spec.tsx b/src/runtime/test/dom-extras.spec.tsx index 8639a17c5dd..5d689d58c37 100644 --- a/src/runtime/test/dom-extras.spec.tsx +++ b/src/runtime/test/dom-extras.spec.tsx @@ -1,7 +1,7 @@ import { Component, h, Host } from '@stencil/core'; import { newSpecPage, SpecPage } from '@stencil/core/testing'; -import { patchNextPrev,patchPseudoShadowDom } from '../../runtime/dom-extras'; +import { patchNextPrev, patchPseudoShadowDom } from '../../runtime/dom-extras'; describe('dom-extras - patches for non-shadow dom methods and accessors', () => { let specPage: SpecPage; diff --git a/test/end-to-end/src/components.d.ts b/test/end-to-end/src/components.d.ts index 0dec2d90383..800def9c64e 100644 --- a/test/end-to-end/src/components.d.ts +++ b/test/end-to-end/src/components.d.ts @@ -80,6 +80,8 @@ export namespace Components { */ "methodThatFiresMyWindowEvent": (value: number) => Promise; } + interface HydratedSiblingAccessors { + } interface ImportAssets { } interface ListenCmp { @@ -337,6 +339,12 @@ declare global { prototype: HTMLEventCmpElement; new (): HTMLEventCmpElement; }; + interface HTMLHydratedSiblingAccessorsElement extends Components.HydratedSiblingAccessors, HTMLStencilElement { + } + var HTMLHydratedSiblingAccessorsElement: { + prototype: HTMLHydratedSiblingAccessorsElement; + new (): HTMLHydratedSiblingAccessorsElement; + }; interface HTMLImportAssetsElement extends Components.ImportAssets, HTMLStencilElement { } var HTMLImportAssetsElement: { @@ -493,6 +501,7 @@ declare global { "empty-cmp-shadow": HTMLEmptyCmpShadowElement; "env-data": HTMLEnvDataElement; "event-cmp": HTMLEventCmpElement; + "hydrated-sibling-accessors": HTMLHydratedSiblingAccessorsElement; "import-assets": HTMLImportAssetsElement; "listen-cmp": HTMLListenCmpElement; "method-cmp": HTMLMethodCmpElement; @@ -576,6 +585,8 @@ declare namespace LocalJSX { "onMyDocumentEvent"?: (event: EventCmpCustomEvent) => void; "onMyWindowEvent"?: (event: EventCmpCustomEvent) => void; } + interface HydratedSiblingAccessors { + } interface ImportAssets { } interface ListenCmp { @@ -659,6 +670,7 @@ declare namespace LocalJSX { "empty-cmp-shadow": EmptyCmpShadow; "env-data": EnvData; "event-cmp": EventCmp; + "hydrated-sibling-accessors": HydratedSiblingAccessors; "import-assets": ImportAssets; "listen-cmp": ListenCmp; "method-cmp": MethodCmp; @@ -712,6 +724,7 @@ declare module "@stencil/core" { "empty-cmp-shadow": LocalJSX.EmptyCmpShadow & JSXBase.HTMLAttributes; "env-data": LocalJSX.EnvData & JSXBase.HTMLAttributes; "event-cmp": LocalJSX.EventCmp & JSXBase.HTMLAttributes; + "hydrated-sibling-accessors": LocalJSX.HydratedSiblingAccessors & JSXBase.HTMLAttributes; "import-assets": LocalJSX.ImportAssets & JSXBase.HTMLAttributes; "listen-cmp": LocalJSX.ListenCmp & JSXBase.HTMLAttributes; "method-cmp": LocalJSX.MethodCmp & JSXBase.HTMLAttributes; diff --git a/test/wdio/hydrated-scoped-sibling-accessors/cmp.tsx b/test/end-to-end/src/scoped-hydration/non-shadow-slotted-siblings.tsx similarity index 73% rename from test/wdio/hydrated-scoped-sibling-accessors/cmp.tsx rename to test/end-to-end/src/scoped-hydration/non-shadow-slotted-siblings.tsx index cf665fdd92b..22daefed510 100644 --- a/test/wdio/hydrated-scoped-sibling-accessors/cmp.tsx +++ b/test/end-to-end/src/scoped-hydration/non-shadow-slotted-siblings.tsx @@ -8,9 +8,9 @@ export class HydratedSiblingAccessors { render() { return (
- Hidden text Node + Internal text node before slot - Hidden span element +
Internal element before second slot, after first slot
Second slot fallback text
); diff --git a/test/end-to-end/src/scoped-hydration/scoped-hydration.e2e.ts b/test/end-to-end/src/scoped-hydration/scoped-hydration.e2e.ts index bd74cf360a3..5a830656a67 100644 --- a/test/end-to-end/src/scoped-hydration/scoped-hydration.e2e.ts +++ b/test/end-to-end/src/scoped-hydration/scoped-hydration.e2e.ts @@ -106,4 +106,73 @@ describe('`scoped: true` hydration checks', () => { 'Text node 1 Comment 1 Slotted element 1 Slotted element 2 Comment 2 Text node 2', ); }); + + it('Steps through only "lightDOM" nodes', async () => { + const { html } = await renderToString( + ` +

First slot element

+ Default slot text node +

Second slot element

+ +
`, + { + serializeShadowRoot: true, + }, + ); + const page = await newE2EPage({ html, url: 'https://stencil.com' }); + + let root: HTMLElement; + await page.evaluate(() => { + (window as any).root = document.querySelector('hydrated-sibling-accessors'); + }); + expect(await page.evaluate(() => root.firstChild.nextSibling.textContent)).toBe('First slot element'); + expect(await page.evaluate(() => root.firstChild.nextSibling.nextSibling.textContent)).toBe( + ' Default slot text node ', + ); + expect(await page.evaluate(() => root.firstChild.nextSibling.nextSibling.nextSibling.textContent)).toBe( + 'Second slot element', + ); + expect(await page.evaluate(() => root.firstChild.nextSibling.nextSibling.nextSibling.nextSibling.textContent)).toBe( + ' Default slot comment node ', + ); + + expect(await page.evaluate(() => root.lastChild.previousSibling.textContent)).toBe(' Default slot comment node '); + expect(await page.evaluate(() => root.lastChild.previousSibling.previousSibling.textContent)).toBe( + 'Second slot element', + ); + expect(await page.evaluate(() => root.lastChild.previousSibling.previousSibling.previousSibling.textContent)).toBe( + ' Default slot text node ', + ); + expect( + await page.evaluate( + () => root.lastChild.previousSibling.previousSibling.previousSibling.previousSibling.textContent, + ), + ).toBe('First slot element'); + }); + + it('Steps through only "lightDOM" elements', async () => { + const { html } = await renderToString( + ` +

First slot element

+ Default slot text node +

Second slot element

+ +
`, + { + serializeShadowRoot: true, + }, + ); + const page = await newE2EPage({ html, url: 'https://stencil.com' }); + + let root: HTMLElement; + await page.evaluate(() => { + (window as any).root = document.querySelector('hydrated-sibling-accessors'); + }); + expect(await page.evaluate(() => root.children[0].textContent)).toBe('First slot element'); + expect(await page.evaluate(() => root.children[0].nextElementSibling.textContent)).toBe('Second slot element'); + expect(await page.evaluate(() => !root.children[0].nextElementSibling.nextElementSibling)).toBe(true); + expect(await page.evaluate(() => root.children[0].nextElementSibling.previousElementSibling.textContent)).toBe( + 'First slot element', + ); + }); }); diff --git a/test/wdio/hydrated-scoped-sibling-accessors/cmp.test.tsx b/test/wdio/hydrated-scoped-sibling-accessors/cmp.test.tsx deleted file mode 100644 index 41f73715edc..00000000000 --- a/test/wdio/hydrated-scoped-sibling-accessors/cmp.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { render } from '@wdio/browser-runner/stencil'; - -import { renderToString } from '../hydrate/index.mjs'; - -describe('hydrated-sibling-accessors', () => { - beforeEach(async () => { - const { html } = await renderToString( - ` - -

First slot element

- Default slot text node -

Second slot element

- -
`, - { - fullDocument: true, - serializeShadowRoot: true, - constrainTimeouts: false, - }, - ); - render({ html }); - await $('hydrated-sibling-accessors').waitForStable(); - }); - - it('verifies slotted nodes next / previous sibling accessors are working', async () => { - const root = document.querySelector('hydrated-sibling-accessors'); - expect(root.firstChild.nextSibling.textContent).toBe('First slot element'); - expect(root.firstChild.nextSibling.nextSibling.textContent).toBe(' Default slot text node '); - expect(root.firstChild.nextSibling.nextSibling.nextSibling.textContent).toBe('Second slot element'); - expect(root.firstChild.nextSibling.nextSibling.nextSibling.nextSibling.textContent).toBe(' '); - expect(root.firstChild.nextSibling.nextSibling.nextSibling.nextSibling.nextSibling.nodeType).toBe( - Node.COMMENT_NODE, - ); - - expect(root.lastChild.previousSibling.nodeType).toBe(Node.COMMENT_NODE); - expect(root.lastChild.previousSibling.previousSibling.textContent).toBe(' '); - expect(root.lastChild.previousSibling.previousSibling.previousSibling.textContent).toBe('Second slot element'); - expect(root.lastChild.previousSibling.previousSibling.previousSibling.previousSibling.textContent).toBe( - ' Default slot text node ', - ); - expect( - root.lastChild.previousSibling.previousSibling.previousSibling.previousSibling.previousSibling.textContent, - ).toBe('First slot element'); - }); - - it('verifies slotted nodes next / previous sibling element accessors are working', async () => { - const root = document.querySelector('hydrated-sibling-accessors'); - expect(root.children[0].textContent).toBe('First slot element'); - expect(root.children[0].nextElementSibling.textContent).toBe('Second slot element'); - expect(root.children[0].nextElementSibling.nextElementSibling).toBeFalsy(); - - expect(root.children[0].nextElementSibling.previousElementSibling.textContent).toBe('First slot element'); - }); -});