From af102ce8c7faaf1f999c09faefb4aeb55145dade Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Wed, 18 Dec 2024 01:50:34 +0000 Subject: [PATCH] fix(SSR): patch `scoped: true` SSR-ed, slotted nodes next/prev sibling accessors (#6057) * chore: tidy * chore: added more tests * chore: prettier * chore: tests * chore: change to end-to-end tests --------- Co-authored-by: John Jenkins --- src/declarations/stencil-private.ts | 58 +++++- src/runtime/client-hydrate.ts | 7 +- src/runtime/dom-extras.ts | 192 ++++++++++++++++-- src/runtime/test/dom-extras.spec.tsx | 42 +++- test/end-to-end/src/components.d.ts | 13 ++ .../non-shadow-slotted-siblings.tsx | 18 ++ .../scoped-hydration/scoped-hydration.e2e.ts | 69 +++++++ test/wdio/scoped-slot-children/cmp-root.tsx | 20 ++ test/wdio/scoped-slot-children/cmp.test.tsx | 128 ++++++++++++ 9 files changed, 522 insertions(+), 25 deletions(-) create mode 100644 test/end-to-end/src/scoped-hydration/non-shadow-slotted-siblings.tsx 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 1b962343227..2b2d47f2898 100644 --- a/src/declarations/stencil-private.ts +++ b/src/declarations/stencil-private.ts @@ -1465,9 +1465,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 5653916e531..304ee905982 100644 --- a/src/runtime/client-hydrate.ts +++ b/src/runtime/client-hydrate.ts @@ -2,7 +2,7 @@ import { BUILD } from '@app-data'; import { doc, plt } from '@platform'; import type * as d from '../declarations'; -import { addSlotRelocateNode } from './dom-extras'; +import { addSlotRelocateNode, patchNextPrev } from './dom-extras'; import { createTime } from './profile'; import { COMMENT_NODE_ID, @@ -162,6 +162,11 @@ export const initializeClientHydrate = ( } // Create our 'Original Location' node addSlotRelocateNode(slottedItem.node, slottedItem.slot, false, slottedItem.node['s-oo']); + + 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 674426e88ac..9a4a2da907e 100644 --- a/src/runtime/dom-extras.ts +++ b/src/runtime/dom-extras.ts @@ -246,16 +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 = globalThis.Node && 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 () { @@ -282,20 +277,7 @@ export const patchChildSlotNodes = (elm: HTMLElement) => { } } - let childNodesFn = globalThis.Node && Object.getOwnPropertyDescriptor(Node.prototype, 'childNodes'); - if (!childNodesFn) { - // 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); - + patchHostOriginalAccessor('children', elm); Object.defineProperty(elm, 'children', { get() { return this.childNodes.filter((n: any) => n.nodeType === 1); @@ -308,8 +290,21 @@ export const patchChildSlotNodes = (elm: HTMLElement) => { }, }); - if (!childNodesFn) return; + 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]; + }, + }); + patchHostOriginalAccessor('childNodes', elm); Object.defineProperty(elm, 'childNodes', { get() { if ( @@ -327,12 +322,163 @@ export const patchChildSlotNodes = (elm: HTMLElement) => { }); }; +/// SLOTTED NODES /// + +/** + * 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` + * - `nextElementSibling` + * - `previousSibling` + * - `previousElementSibling` + * + * @param node the slotted node to be patched + */ +export const patchNextPrev = (node: Node) => { + if (!node || (node as any).__nextSibling || !globalThis.Node) return; + + patchNextSibling(node); + patchPreviousSibling(node); + + if (node.nodeType === Node.ELEMENT_NODE) { + patchNextElementSibling(node as Element); + patchPreviousElementSibling(node as Element); + } +}; + +/** + * 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; + + patchHostOriginalAccessor('nextSibling', node); + 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 node to be patched + * Required during during testing / mock environnement. + */ +const patchNextElementSibling = (element: Element) => { + if (!element || (element as any).__nextElementSibling) return; + + patchHostOriginalAccessor('nextElementSibling', element); + 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 node the slotted node to be patched + * Required during during testing / mock environnement. + */ +const patchPreviousSibling = (node: Node) => { + if (!node || (node as any).__previousSibling) return; + + patchHostOriginalAccessor('previousSibling', node); + 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 element the slotted element node to be patched + * Required during during testing / mock environnement. + */ +const patchPreviousElementSibling = (element: Element) => { + 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]; + } + return this.__previousElementSibling; + }, + }); +}; + /// 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 @@ -387,6 +533,7 @@ export const addSlotRelocateNode = ( * 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. */ @@ -406,6 +553,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..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 { 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; @@ -90,4 +90,44 @@ describe('dom-extras - patches for non-shadow dom methods and accessors', () => `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
`, + ); + }); + + 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/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/end-to-end/src/scoped-hydration/non-shadow-slotted-siblings.tsx b/test/end-to-end/src/scoped-hydration/non-shadow-slotted-siblings.tsx new file mode 100644 index 00000000000..22daefed510 --- /dev/null +++ b/test/end-to-end/src/scoped-hydration/non-shadow-slotted-siblings.tsx @@ -0,0 +1,18 @@ +import { Component, h } from '@stencil/core'; + +@Component({ + tag: 'hydrated-sibling-accessors', + scoped: true, +}) +export class HydratedSiblingAccessors { + render() { + return ( +
+ Internal text node before slot + +
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/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); + }); +});