Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(SSR): patch scoped: true SSR-ed, slotted nodes next/prev sibling accessors #6057

Merged
merged 8 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion src/declarations/stencil-private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChildNode>;

/**
* On a `scoped: true` component
* with `experimentalSlotFixes` flag enabled,
* returns the internal `children` of the component
*/
readonly __children?: HTMLCollectionOf<Element>;

/**
* 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?: <T extends Node>(newChild: T) => T;

/**
* On a `scoped: true` component
* with `experimentalSlotFixes` flag enabled,
* gives access to the original `removeChild` method
*/
__removeChild?: <T extends Node>(child: T) => T;
}

export type LazyBundlesRuntimeData = LazyBundleRuntimeData[];
Expand Down
7 changes: 6 additions & 1 deletion src/runtime/client-hydrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
192 changes: 170 additions & 22 deletions src/runtime/dom-extras.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand All @@ -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);
Expand All @@ -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 (
Expand All @@ -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
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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.
Expand Down
42 changes: 41 additions & 1 deletion src/runtime/test/dom-extras.spec.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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(
`<div slot=\"second-slot\"> a second slot, slotted element <span>nested element in the second slot<span></span></span></div>`,
);
});

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('<span>a default slot, slotted element</span>');
expect(nodeOrEleContent(specPage.root.firstChild.nextSibling.nextSibling)).toBe(``);
expect(nodeOrEleContent(specPage.root.firstChild.nextSibling.nextSibling.nextSibling)).toBe(
`<div slot=\"second-slot\"> a second slot, slotted element <span>nested element in the second slot<span></span></span></div>`,
);
// back we go!
expect(nodeOrEleContent(specPage.root.firstChild.nextSibling.nextSibling.nextSibling.previousSibling)).toBe(``);
expect(
nodeOrEleContent(specPage.root.firstChild.nextSibling.nextSibling.nextSibling.previousSibling.previousSibling),
).toBe(`<span>a default slot, slotted element</span>`);
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(
'<div slot="second-slot"> a second slot, slotted element <span>nested element in the second slot<span></span></span></div>',
);
expect(nodeOrEleContent(specPage.root.children[0].nextElementSibling.previousElementSibling)).toBe(
'<span>a default slot, slotted element</span>',
);
});
});
Loading
Loading