Skip to content

Commit

Permalink
fix(SSR): patch scoped: true SSR-ed, slotted nodes next/prev siblin…
Browse files Browse the repository at this point in the history
…g accessors (#6057)

* chore: tidy

* chore: added more tests

* chore: prettier

* chore: tests

* chore: change to end-to-end tests

---------

Co-authored-by: John Jenkins <john.jenkins@nanoporetech.com>
  • Loading branch information
johnjenkins and John Jenkins authored Dec 18, 2024
1 parent 8592315 commit af102ce
Show file tree
Hide file tree
Showing 9 changed files with 522 additions and 25 deletions.
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

0 comments on commit af102ce

Please sign in to comment.