Skip to content

Commit af102ce

Browse files
johnjenkinsJohn Jenkins
and
John Jenkins
authored
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 <john.jenkins@nanoporetech.com>
1 parent 8592315 commit af102ce

File tree

9 files changed

+522
-25
lines changed

9 files changed

+522
-25
lines changed

src/declarations/stencil-private.ts

+57-1
Original file line numberDiff line numberDiff line change
@@ -1465,9 +1465,65 @@ export interface RenderNode extends HostElement {
14651465
/**
14661466
* On a `scoped: true` component
14671467
* with `experimentalSlotFixes` flag enabled,
1468-
* returns the internal `childNodes` of the scoped element
1468+
* returns the internal `childNodes` of the component
14691469
*/
14701470
readonly __childNodes?: NodeListOf<ChildNode>;
1471+
1472+
/**
1473+
* On a `scoped: true` component
1474+
* with `experimentalSlotFixes` flag enabled,
1475+
* returns the internal `children` of the component
1476+
*/
1477+
readonly __children?: HTMLCollectionOf<Element>;
1478+
1479+
/**
1480+
* On a `scoped: true` component
1481+
* with `experimentalSlotFixes` flag enabled,
1482+
* returns the internal `firstChild` of the component
1483+
*/
1484+
readonly __firstChild?: ChildNode;
1485+
1486+
/**
1487+
* On a `scoped: true` component
1488+
* with `experimentalSlotFixes` flag enabled,
1489+
* returns the internal `lastChild` of the component
1490+
*/
1491+
readonly __lastChild?: ChildNode;
1492+
1493+
/**
1494+
* On a `scoped: true` component
1495+
* with `experimentalSlotFixes` flag enabled,
1496+
* returns the internal `textContent` of the component
1497+
*/
1498+
__textContent?: string;
1499+
1500+
/**
1501+
* On a `scoped: true` component
1502+
* with `experimentalSlotFixes` flag enabled,
1503+
* gives access to the original `append` method
1504+
*/
1505+
__append?: (...nodes: (Node | string)[]) => void;
1506+
1507+
/**
1508+
* On a `scoped: true` component
1509+
* with `experimentalSlotFixes` flag enabled,
1510+
* gives access to the original `prepend` method
1511+
*/
1512+
__prepend?: (...nodes: (Node | string)[]) => void;
1513+
1514+
/**
1515+
* On a `scoped: true` component
1516+
* with `experimentalSlotFixes` flag enabled,
1517+
* gives access to the original `appendChild` method
1518+
*/
1519+
__appendChild?: <T extends Node>(newChild: T) => T;
1520+
1521+
/**
1522+
* On a `scoped: true` component
1523+
* with `experimentalSlotFixes` flag enabled,
1524+
* gives access to the original `removeChild` method
1525+
*/
1526+
__removeChild?: <T extends Node>(child: T) => T;
14711527
}
14721528

14731529
export type LazyBundlesRuntimeData = LazyBundleRuntimeData[];

src/runtime/client-hydrate.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { BUILD } from '@app-data';
22
import { doc, plt } from '@platform';
33

44
import type * as d from '../declarations';
5-
import { addSlotRelocateNode } from './dom-extras';
5+
import { addSlotRelocateNode, patchNextPrev } from './dom-extras';
66
import { createTime } from './profile';
77
import {
88
COMMENT_NODE_ID,
@@ -162,6 +162,11 @@ export const initializeClientHydrate = (
162162
}
163163
// Create our 'Original Location' node
164164
addSlotRelocateNode(slottedItem.node, slottedItem.slot, false, slottedItem.node['s-oo']);
165+
166+
if (BUILD.experimentalSlotFixes) {
167+
// patch this node for accessors like `nextSibling` (et al)
168+
patchNextPrev(slottedItem.node);
169+
}
165170
}
166171

167172
if (hostEle.shadowRoot && slottedItem.node.parentElement !== hostEle) {

src/runtime/dom-extras.ts

+170-22
Original file line numberDiff line numberDiff line change
@@ -246,16 +246,11 @@ export const patchSlotInsertAdjacentElement = (HostElementPrototype: HTMLElement
246246

247247
/**
248248
* Patches the text content of an unnamed slotted node inside a scoped component
249+
*
249250
* @param hostElementPrototype the `Element` to be patched
250251
*/
251252
export const patchTextContent = (hostElementPrototype: HTMLElement): void => {
252-
let descriptor = globalThis.Node && Object.getOwnPropertyDescriptor(Node.prototype, 'textContent');
253-
254-
if (!descriptor) {
255-
// for mock-doc
256-
descriptor = Object.getOwnPropertyDescriptor(hostElementPrototype, 'textContent');
257-
}
258-
if (descriptor) Object.defineProperty(hostElementPrototype, '__textContent', descriptor);
253+
patchHostOriginalAccessor('textContent', hostElementPrototype);
259254

260255
Object.defineProperty(hostElementPrototype, 'textContent', {
261256
get: function () {
@@ -282,20 +277,7 @@ export const patchChildSlotNodes = (elm: HTMLElement) => {
282277
}
283278
}
284279

285-
let childNodesFn = globalThis.Node && Object.getOwnPropertyDescriptor(Node.prototype, 'childNodes');
286-
if (!childNodesFn) {
287-
// for mock-doc
288-
childNodesFn = Object.getOwnPropertyDescriptor(elm, 'childNodes');
289-
}
290-
if (childNodesFn) Object.defineProperty(elm, '__childNodes', childNodesFn);
291-
292-
let childrenFn = Object.getOwnPropertyDescriptor(Element.prototype, 'children');
293-
if (!childrenFn) {
294-
// for mock-doc
295-
childrenFn = Object.getOwnPropertyDescriptor(elm, 'children');
296-
}
297-
if (childrenFn) Object.defineProperty(elm, '__children', childrenFn);
298-
280+
patchHostOriginalAccessor('children', elm);
299281
Object.defineProperty(elm, 'children', {
300282
get() {
301283
return this.childNodes.filter((n: any) => n.nodeType === 1);
@@ -308,8 +290,21 @@ export const patchChildSlotNodes = (elm: HTMLElement) => {
308290
},
309291
});
310292

311-
if (!childNodesFn) return;
293+
patchHostOriginalAccessor('firstChild', elm);
294+
Object.defineProperty(elm, 'firstChild', {
295+
get() {
296+
return this.childNodes[0];
297+
},
298+
});
299+
300+
patchHostOriginalAccessor('lastChild', elm);
301+
Object.defineProperty(elm, 'lastChild', {
302+
get() {
303+
return this.childNodes[this.childNodes.length - 1];
304+
},
305+
});
312306

307+
patchHostOriginalAccessor('childNodes', elm);
313308
Object.defineProperty(elm, 'childNodes', {
314309
get() {
315310
if (
@@ -327,12 +322,163 @@ export const patchChildSlotNodes = (elm: HTMLElement) => {
327322
});
328323
};
329324

325+
/// SLOTTED NODES ///
326+
327+
/**
328+
* Patches sibling accessors of a 'slotted' node within a non-shadow component.
329+
* Meaning whilst stepping through a non-shadow element's nodes, only the mock 'lightDOM' nodes are returned.
330+
* Especially relevant when rendering components via SSR... Frameworks will often try to reconcile their
331+
* VDOM with the real DOM by stepping through nodes with 'nextSibling' et al.
332+
* - `nextSibling`
333+
* - `nextElementSibling`
334+
* - `previousSibling`
335+
* - `previousElementSibling`
336+
*
337+
* @param node the slotted node to be patched
338+
*/
339+
export const patchNextPrev = (node: Node) => {
340+
if (!node || (node as any).__nextSibling || !globalThis.Node) return;
341+
342+
patchNextSibling(node);
343+
patchPreviousSibling(node);
344+
345+
if (node.nodeType === Node.ELEMENT_NODE) {
346+
patchNextElementSibling(node as Element);
347+
patchPreviousElementSibling(node as Element);
348+
}
349+
};
350+
351+
/**
352+
* Patches the `nextSibling` accessor of a non-shadow slotted node
353+
*
354+
* @param node the slotted node to be patched
355+
* Required during during testing / mock environnement.
356+
*/
357+
const patchNextSibling = (node: Node) => {
358+
// already been patched? return
359+
if (!node || (node as any).__nextSibling) return;
360+
361+
patchHostOriginalAccessor('nextSibling', node);
362+
Object.defineProperty(node, 'nextSibling', {
363+
get: function () {
364+
const parentNodes = this['s-ol']?.parentNode.childNodes;
365+
const index = parentNodes?.indexOf(this);
366+
if (parentNodes && index > -1) {
367+
return parentNodes[index + 1];
368+
}
369+
return this.__nextSibling;
370+
},
371+
});
372+
};
373+
374+
/**
375+
* Patches the `nextElementSibling` accessor of a non-shadow slotted node
376+
*
377+
* @param element the slotted element node to be patched
378+
* Required during during testing / mock environnement.
379+
*/
380+
const patchNextElementSibling = (element: Element) => {
381+
if (!element || (element as any).__nextElementSibling) return;
382+
383+
patchHostOriginalAccessor('nextElementSibling', element);
384+
Object.defineProperty(element, 'nextElementSibling', {
385+
get: function () {
386+
const parentEles = this['s-ol']?.parentNode.children;
387+
const index = parentEles?.indexOf(this);
388+
if (parentEles && index > -1) {
389+
return parentEles[index + 1];
390+
}
391+
return this.__nextElementSibling;
392+
},
393+
});
394+
};
395+
396+
/**
397+
* Patches the `previousSibling` accessor of a non-shadow slotted node
398+
*
399+
* @param node the slotted node to be patched
400+
* Required during during testing / mock environnement.
401+
*/
402+
const patchPreviousSibling = (node: Node) => {
403+
if (!node || (node as any).__previousSibling) return;
404+
405+
patchHostOriginalAccessor('previousSibling', node);
406+
Object.defineProperty(node, 'previousSibling', {
407+
get: function () {
408+
const parentNodes = this['s-ol']?.parentNode.childNodes;
409+
const index = parentNodes?.indexOf(this);
410+
if (parentNodes && index > -1) {
411+
return parentNodes[index - 1];
412+
}
413+
return this.__previousSibling;
414+
},
415+
});
416+
};
417+
418+
/**
419+
* Patches the `previousElementSibling` accessor of a non-shadow slotted node
420+
*
421+
* @param element the slotted element node to be patched
422+
* Required during during testing / mock environnement.
423+
*/
424+
const patchPreviousElementSibling = (element: Element) => {
425+
if (!element || (element as any).__previousElementSibling) return;
426+
427+
patchHostOriginalAccessor('previousElementSibling', element);
428+
Object.defineProperty(element, 'previousElementSibling', {
429+
get: function () {
430+
const parentNodes = this['s-ol']?.parentNode.children;
431+
const index = parentNodes?.indexOf(this);
432+
433+
if (parentNodes && index > -1) {
434+
return parentNodes[index - 1];
435+
}
436+
return this.__previousElementSibling;
437+
},
438+
});
439+
};
440+
330441
/// UTILS ///
331442

443+
const validElementPatches = ['children', 'nextElementSibling', 'previousElementSibling'] as const;
444+
const validNodesPatches = [
445+
'childNodes',
446+
'firstChild',
447+
'lastChild',
448+
'nextSibling',
449+
'previousSibling',
450+
'textContent',
451+
] as const;
452+
453+
/**
454+
* Patches a node or element; making it's original accessor method available under a new name.
455+
* e.g. `nextSibling` -> `__nextSibling`
456+
*
457+
* @param accessorName - the name of the accessor to patch
458+
* @param node - the node to patch
459+
*/
460+
function patchHostOriginalAccessor(
461+
accessorName: (typeof validElementPatches)[number] | (typeof validNodesPatches)[number],
462+
node: Node,
463+
) {
464+
let accessor;
465+
if (validElementPatches.includes(accessorName as any)) {
466+
accessor = Object.getOwnPropertyDescriptor(Element.prototype, accessorName);
467+
} else if (validNodesPatches.includes(accessorName as any)) {
468+
accessor = Object.getOwnPropertyDescriptor(Node.prototype, accessorName);
469+
}
470+
if (!accessor) {
471+
// for mock-doc
472+
accessor = Object.getOwnPropertyDescriptor(node, accessorName);
473+
}
474+
if (accessor) Object.defineProperty(node, '__' + accessorName, accessor);
475+
}
476+
332477
/**
333478
* Creates an empty text node to act as a forwarding address to a slotted node:
334479
* 1) When non-shadow components re-render, they need a place to temporarily put 'lightDOM' elements.
335480
* 2) Patched dom methods and accessors use this node to calculate what 'lightDOM' nodes are in the host.
481+
*
336482
* @param newChild a node that's going to be added to the component
337483
* @param slotNode the slot node that the node will be added to
338484
* @param prepend move the slotted location node to the beginning of the host
@@ -387,6 +533,7 @@ export const addSlotRelocateNode = (
387533
* Get's the child nodes of a component that are actually slotted.
388534
* This is only required until all patches are unified
389535
* either under 'experimentalSlotFixes' or on by default
536+
*
390537
* @param childNodes all 'internal' child nodes of the component
391538
* @returns An array of slotted reference nodes.
392539
*/
@@ -406,6 +553,7 @@ const getSlotName = (node: d.RenderNode) =>
406553

407554
/**
408555
* Recursively searches a series of child nodes for a slot with the provided name.
556+
*
409557
* @param childNodes the nodes to search for a slot with a specific name.
410558
* @param slotName the name of the slot to match on.
411559
* @param hostName the host name of the slot to match on.

src/runtime/test/dom-extras.spec.tsx

+41-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Component, h, Host } from '@stencil/core';
22
import { newSpecPage, SpecPage } from '@stencil/core/testing';
33

4-
import { patchPseudoShadowDom } from '../../runtime/dom-extras';
4+
import { patchNextPrev, patchPseudoShadowDom } from '../../runtime/dom-extras';
55

66
describe('dom-extras - patches for non-shadow dom methods and accessors', () => {
77
let specPage: SpecPage;
@@ -90,4 +90,44 @@ describe('dom-extras - patches for non-shadow dom methods and accessors', () =>
9090
`Some default slot, slotted text a default slot, slotted element a second slot, slotted element nested element in the second slot`,
9191
);
9292
});
93+
94+
it('firstChild', async () => {
95+
expect(nodeOrEleContent(specPage.root.firstChild)).toBe(`Some default slot, slotted text`);
96+
});
97+
98+
it('lastChild', async () => {
99+
expect(nodeOrEleContent(specPage.root.lastChild)).toBe(
100+
`<div slot=\"second-slot\"> a second slot, slotted element <span>nested element in the second slot<span></span></span></div>`,
101+
);
102+
});
103+
104+
it('patches nextSibling / previousSibling accessors of slotted nodes', async () => {
105+
specPage.root.childNodes.forEach((node: Node) => patchNextPrev(node));
106+
expect(nodeOrEleContent(specPage.root.firstChild)).toBe('Some default slot, slotted text');
107+
expect(nodeOrEleContent(specPage.root.firstChild.nextSibling)).toBe('<span>a default slot, slotted element</span>');
108+
expect(nodeOrEleContent(specPage.root.firstChild.nextSibling.nextSibling)).toBe(``);
109+
expect(nodeOrEleContent(specPage.root.firstChild.nextSibling.nextSibling.nextSibling)).toBe(
110+
`<div slot=\"second-slot\"> a second slot, slotted element <span>nested element in the second slot<span></span></span></div>`,
111+
);
112+
// back we go!
113+
expect(nodeOrEleContent(specPage.root.firstChild.nextSibling.nextSibling.nextSibling.previousSibling)).toBe(``);
114+
expect(
115+
nodeOrEleContent(specPage.root.firstChild.nextSibling.nextSibling.nextSibling.previousSibling.previousSibling),
116+
).toBe(`<span>a default slot, slotted element</span>`);
117+
expect(
118+
nodeOrEleContent(
119+
specPage.root.firstChild.nextSibling.nextSibling.nextSibling.previousSibling.previousSibling.previousSibling,
120+
),
121+
).toBe(`Some default slot, slotted text`);
122+
});
123+
124+
it('patches nextElementSibling / previousElementSibling accessors of slotted nodes', async () => {
125+
specPage.root.childNodes.forEach((node: Node) => patchNextPrev(node));
126+
expect(nodeOrEleContent(specPage.root.children[0].nextElementSibling)).toBe(
127+
'<div slot="second-slot"> a second slot, slotted element <span>nested element in the second slot<span></span></span></div>',
128+
);
129+
expect(nodeOrEleContent(specPage.root.children[0].nextElementSibling.previousElementSibling)).toBe(
130+
'<span>a default slot, slotted element</span>',
131+
);
132+
});
93133
});

0 commit comments

Comments
 (0)