Skip to content

Commit

Permalink
feat(runtime): add slotchange event and assignedNodes / `assigned…
Browse files Browse the repository at this point in the history
…Elements` methods for `scoped: true` slots (#6151)

* chore: wip.. pretty much there?

* feat(runtime): add `assignedNodes()` and `assignedElements()` to polyfilled slot elements

* chore: tidy

* chore: add to client-hydration

* chore: pretty much there

* chore: more tests

* chore: formatting / linting

* chore: update tests

* chore: fixup tests

* chore: remove `{deepn: true}`

---------

Co-authored-by: John Jenkins <john.jenkins@nanoporetech.com>
  • Loading branch information
johnjenkins and John Jenkins authored Feb 19, 2025
1 parent eafe1f9 commit 2a1038e
Show file tree
Hide file tree
Showing 13 changed files with 453 additions and 134 deletions.
2 changes: 1 addition & 1 deletion src/declarations/stencil-private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1440,7 +1440,7 @@ export interface RenderNode extends HostElement {

/**
* Node reference:
* This is a reference for a original location node
* This is a reference from an original location node
* back to the node that's been moved around.
*/
['s-nr']?: PatchedSlotNode | RenderNode;
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/client-hydrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
TEXT_NODE_ID,
VNODE_FLAGS,
} from './runtime-constants';
import { addSlotRelocateNode } from './slot-polyfill-utils';
import { addSlotRelocateNode, patchSlotNode } from './slot-polyfill-utils';
import { newVNode } from './vdom/h';

/**
Expand Down Expand Up @@ -615,6 +615,7 @@ function addSlot(

// attempt to find any mock slotted nodes which we'll move later
addSlottedNodes(slottedNodes, slotId, slotName, node, shouldMove ? parentNodeId : childVNode.$hostId$);
patchSlotNode(node);

if (shouldMove) {
// Move slot comment node (to after any other comment nodes)
Expand Down
61 changes: 28 additions & 33 deletions src/runtime/dom-extras.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { supportsShadow } from '@platform';
import type * as d from '../declarations';
import {
addSlotRelocateNode,
getHostSlotChildNodes,
dispatchSlotChangeEvent,
findSlotFromSlottedNode,
getHostSlotNodes,
getSlotChildSiblings,
getSlotName,
getSlottedChildNodes,
updateFallbackSlotVisibility,
Expand Down Expand Up @@ -90,22 +92,18 @@ export const patchCloneNode = (HostElementPrototype: HTMLElement) => {
*/
export const patchSlotAppendChild = (HostElementPrototype: any) => {
HostElementPrototype.__appendChild = HostElementPrototype.appendChild;

HostElementPrototype.appendChild = function (this: d.RenderNode, newChild: d.RenderNode) {
const slotName = (newChild['s-sn'] = getSlotName(newChild));
const slotNode = getHostSlotNodes((this as any).__childNodes || this.childNodes, this.tagName, slotName)[0];
const { slotName, slotNode } = findSlotFromSlottedNode(newChild, this);
if (slotNode) {
addSlotRelocateNode(newChild, slotNode);

const slotChildNodes = getHostSlotChildNodes(slotNode, slotName);
const slotChildNodes = getSlotChildSiblings(slotNode, slotName);
const appendAfter = slotChildNodes[slotChildNodes.length - 1];

const parent = intrnlCall(appendAfter, 'parentNode') as d.RenderNode;
let insertedNode: d.RenderNode;
if (parent.__insertBefore) {
insertedNode = parent.__insertBefore(newChild, appendAfter.nextSibling);
} else {
insertedNode = parent.insertBefore(newChild, appendAfter.nextSibling);
}
const parent = internalCall(appendAfter, 'parentNode') as d.RenderNode;
const insertedNode: d.RenderNode = internalCall(parent, 'insertBefore')(newChild, appendAfter.nextSibling);
dispatchSlotChangeEvent(slotNode);

// Check if there is fallback content that should be hidden
updateFallbackSlotVisibility(this);
Expand Down Expand Up @@ -155,20 +153,18 @@ export const patchSlotPrepend = (HostElementPrototype: HTMLElement) => {
if (typeof newChild === 'string') {
newChild = this.ownerDocument.createTextNode(newChild) as unknown as d.RenderNode;
}
const slotName = (newChild['s-sn'] = getSlotName(newChild));
const childNodes = (this as any).__childNodes || this.childNodes;
const slotName = (newChild['s-sn'] = getSlotName(newChild)) || '';
const childNodes = internalCall(this, 'childNodes');
const slotNode = getHostSlotNodes(childNodes, this.tagName, slotName)[0];
if (slotNode) {
addSlotRelocateNode(newChild, slotNode, true);
const slotChildNodes = getHostSlotChildNodes(slotNode, slotName);
const slotChildNodes = getSlotChildSiblings(slotNode, slotName);
const appendAfter = slotChildNodes[0];
const parent = intrnlCall(appendAfter, 'parentNode') as d.RenderNode;

if (parent.__insertBefore) {
return parent.__insertBefore(newChild, intrnlCall(appendAfter, 'nextSibling'));
} else {
return parent.insertBefore(newChild, intrnlCall(appendAfter, 'nextSibling'));
}
const parent = internalCall(appendAfter, 'parentNode') as d.RenderNode;
const toReturn = internalCall(parent, 'insertBefore')(newChild, internalCall(appendAfter, 'nextSibling'));
dispatchSlotChangeEvent(slotNode);
return toReturn;
}

if (newChild.nodeType === 1 && !!newChild.getAttribute('slot')) {
Expand Down Expand Up @@ -263,8 +259,7 @@ const patchInsertBefore = (HostElementPrototype: HTMLElement) => {
newChild: T,
currentChild: d.RenderNode | null,
) {
const slotName = (newChild['s-sn'] = getSlotName(newChild));
const slotNode = getHostSlotNodes(this.__childNodes, this.tagName, slotName)[0];
const { slotName, slotNode } = findSlotFromSlottedNode(newChild, this);
const slottedNodes = this.__childNodes ? this.childNodes : getSlottedChildNodes(this.childNodes);

if (slotNode) {
Expand All @@ -286,13 +281,10 @@ const patchInsertBefore = (HostElementPrototype: HTMLElement) => {
// current child ('slot before' node) is 'in' the same slot
addSlotRelocateNode(newChild, slotNode);

const parent = intrnlCall(currentChild, 'parentNode') as d.RenderNode;
if (parent.__insertBefore) {
// the parent is a patched component, so we need to use the internal method
parent.__insertBefore(newChild, currentChild);
} else {
parent.insertBefore(newChild, currentChild);
}
const parent = internalCall(currentChild, 'parentNode') as d.RenderNode;
internalCall(parent, 'insertBefore')(newChild, currentChild);

dispatchSlotChangeEvent(slotNode);
}
return;
}
Expand Down Expand Up @@ -432,7 +424,7 @@ export const patchChildSlotNodes = (elm: HTMLElement) => {
* @param node the slotted node to be patched
*/
export const patchSlottedNode = (node: Node) => {
if (!node || (node as any).__nextSibling || !globalThis.Node) return;
if (!node || (node as any).__nextSibling !== undefined || !globalThis.Node) return;

patchNextSibling(node);
patchPreviousSibling(node);
Expand Down Expand Up @@ -595,10 +587,13 @@ function patchHostOriginalAccessor(
*
* @returns the original accessor or method of the node
*/
function intrnlCall<T extends d.RenderNode, P extends keyof d.RenderNode>(node: T, method: P): T[P] {
export function internalCall<T extends d.RenderNode, P extends keyof d.RenderNode>(node: T, method: P): T[P] {
if ('__' + method in node) {
return node[('__' + method) as keyof d.RenderNode] as T[P];
const toReturn = node[('__' + method) as keyof d.RenderNode] as T[P];
if (typeof toReturn !== 'function') return toReturn;
return toReturn.bind(node) as T[P];
} else {
return node[method];
if (typeof node[method] !== 'function') return node[method];
return node[method].bind(node) as T[P];
}
}
155 changes: 114 additions & 41 deletions src/runtime/slot-polyfill-utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { BUILD } from '@app-data';

import type * as d from '../declarations';
import { internalCall } from './dom-extras';
import { NODE_TYPE } from './runtime-constants';

/**
* Adjust the `.hidden` property as-needed on any nodes in a DOM subtree which
* are slot fallbacks nodes - `<slot-fb>...</slot-fb>`
* are slot fallback nodes - `<slot-fb>...</slot-fb>`
*
* A slot fallback node should be visible by default. Then, it should be
* conditionally hidden if:
Expand All @@ -17,15 +18,15 @@ import { NODE_TYPE } from './runtime-constants';
* @param elm the element of interest
*/
export const updateFallbackSlotVisibility = (elm: d.RenderNode) => {
const childNodes: d.RenderNode[] = elm.__childNodes || (elm.childNodes as any);
const childNodes = internalCall(elm, 'childNodes');

// is this is a stencil component?
if (elm.tagName && elm.tagName.includes('-') && elm['s-cr'] && elm.tagName !== 'SLOT-FB') {
// stencil component - try to find any slot fallback nodes
getHostSlotNodes(childNodes as any, (elm as HTMLElement).tagName).forEach((slotNode) => {
if (slotNode.nodeType === NODE_TYPE.ElementNode && slotNode.tagName === 'SLOT-FB') {
// this is a slot fallback node
if (getHostSlotChildNodes(slotNode, slotNode['s-sn'], false)?.length) {
if (getSlotChildSiblings(slotNode, getSlotName(slotNode), false)?.length) {
// has slotted nodes, hide fallback
slotNode.hidden = true;
} else {
Expand All @@ -35,8 +36,11 @@ export const updateFallbackSlotVisibility = (elm: d.RenderNode) => {
}
});
}
for (const childNode of childNodes) {
if (childNode.nodeType === NODE_TYPE.ElementNode && (childNode.__childNodes || childNode.childNodes).length) {

let i = 0;
for (i = 0; i < childNodes.length; i++) {
const childNode = childNodes[i] as d.RenderNode;
if (childNode.nodeType === NODE_TYPE.ElementNode && internalCall(childNode, 'childNodes').length) {
// keep drilling down
updateFallbackSlotVisibility(childNode);
}
Expand All @@ -54,7 +58,7 @@ export const updateFallbackSlotVisibility = (elm: d.RenderNode) => {
* @returns An array of slotted reference nodes.
*/
export const getSlottedChildNodes = (childNodes: NodeListOf<ChildNode>): d.PatchedSlotNode[] => {
const result = [];
const result: d.PatchedSlotNode[] = [];
for (let i = 0; i < childNodes.length; i++) {
const slottedNode = ((childNodes[i] as d.RenderNode)['s-nr'] as d.PatchedSlotNode) || undefined;
if (slottedNode && slottedNode.isConnected) {
Expand All @@ -71,7 +75,7 @@ export const getSlottedChildNodes = (childNodes: NodeListOf<ChildNode>): d.Patch
* @param slotName the name of the slot to match on.
* @returns a reference to the slot node that matches the provided name, `null` otherwise
*/
export function getHostSlotNodes(childNodes: NodeListOf<ChildNode>, hostName: string, slotName?: string) {
export function getHostSlotNodes(childNodes: NodeListOf<ChildNode>, hostName?: string, slotName?: string) {
let i = 0;
let slottedNodes: d.RenderNode[] = [];
let childNode: d.RenderNode;
Expand All @@ -80,8 +84,8 @@ export function getHostSlotNodes(childNodes: NodeListOf<ChildNode>, hostName: st
childNode = childNodes[i] as any;
if (
childNode['s-sr'] &&
childNode['s-hn'] === hostName &&
(slotName === undefined || childNode['s-sn'] === slotName)
(!hostName || childNode['s-hn'] === hostName) &&
(slotName === undefined || getSlotName(childNode) === slotName)
) {
slottedNodes.push(childNode);
if (typeof slotName !== 'undefined') return slottedNodes;
Expand All @@ -92,18 +96,19 @@ export function getHostSlotNodes(childNodes: NodeListOf<ChildNode>, hostName: st
}

/**
* Get slotted child nodes of a slot node
* @param node - the slot node to get the child nodes from
* Get all 'child' sibling nodes of a slot node
* @param slot - the slot node to get the child nodes from
* @param slotName - the name of the slot to match on
* @param includeSlot - whether to include the slot node in the result
* @returns slotted child nodes of the slot node
* @returns child nodes of the slot node
*/
export const getHostSlotChildNodes = (node: d.RenderNode, slotName: string, includeSlot = true) => {
export const getSlotChildSiblings = (slot: d.RenderNode, slotName: string, includeSlot = true) => {
const childNodes: d.RenderNode[] = [];
if ((includeSlot && node['s-sr']) || !node['s-sr']) childNodes.push(node as any);
if ((includeSlot && slot['s-sr']) || !slot['s-sr']) childNodes.push(slot as any);
let node = slot;

while ((node = node.nextSibling as any) && (node as d.RenderNode)['s-sn'] === slotName) {
childNodes.push(node as any);
while ((node = node.nextSibling as any)) {
if (getSlotName(node) === slotName) childNodes.push(node as any);
}
return childNodes;
};
Expand Down Expand Up @@ -150,37 +155,34 @@ export const addSlotRelocateNode = (
prepend?: boolean,
position?: number,
) => {
let slottedNodeLocation: d.RenderNode;

// does newChild already have a slot location node?
if (newChild['s-ol'] && newChild['s-ol'].isConnected) {
slottedNodeLocation = newChild['s-ol'];
} else {
slottedNodeLocation = document.createTextNode('') as any;
slottedNodeLocation['s-nr'] = newChild;
// newChild already has a slot location node
return;
}

const slottedNodeLocation = document.createTextNode('') as any;
slottedNodeLocation['s-nr'] = newChild;

// if there's no content reference node, or parentNode we can't do anything
if (!slotNode['s-cr'] || !slotNode['s-cr'].parentNode) return;

const parent = slotNode['s-cr'].parentNode as any;
const appendMethod = prepend ? parent.__prepend || parent.prepend : parent.__appendChild || parent.appendChild;

if (typeof position !== 'undefined') {
if (BUILD.hydrateClientSide) {
slottedNodeLocation['s-oo'] = position;
const childNodes = (parent.__childNodes || parent.childNodes) as NodeListOf<d.RenderNode>;
const slotRelocateNodes: d.RenderNode[] = [slottedNodeLocation];
childNodes.forEach((n) => {
if (n['s-nr']) slotRelocateNodes.push(n);
});
const appendMethod = prepend ? internalCall(parent, 'prepend') : internalCall(parent, 'appendChild');

slotRelocateNodes.sort((a, b) => {
if (!a['s-oo'] || a['s-oo'] < b['s-oo']) return -1;
else if (!b['s-oo'] || b['s-oo'] < a['s-oo']) return 1;
return 0;
});
slotRelocateNodes.forEach((n) => appendMethod.call(parent, n));
}
if (BUILD.hydrateClientSide && typeof position !== 'undefined') {
slottedNodeLocation['s-oo'] = position;
const childNodes = internalCall(parent, 'childNodes') as NodeListOf<d.RenderNode>;
const slotRelocateNodes: d.RenderNode[] = [slottedNodeLocation];
childNodes.forEach((n) => {
if (n['s-nr']) slotRelocateNodes.push(n);
});

slotRelocateNodes.sort((a, b) => {
if (!a['s-oo'] || a['s-oo'] < (b['s-oo'] || 0)) return -1;
else if (!b['s-oo'] || b['s-oo'] < a['s-oo']) return 1;
return 0;
});
slotRelocateNodes.forEach((n) => appendMethod.call(parent, n));
} else {
appendMethod.call(parent, slottedNodeLocation);
}
Expand All @@ -190,4 +192,75 @@ export const addSlotRelocateNode = (
};

export const getSlotName = (node: d.PatchedSlotNode) =>
node['s-sn'] || (node.nodeType === 1 && (node as Element).getAttribute('slot')) || '';
typeof node['s-sn'] === 'string'
? node['s-sn']
: (node.nodeType === 1 && (node as Element).getAttribute('slot')) || undefined;

/**
* Add `assignedElements` and `assignedNodes` methods on a fake slot node
*
* @param node - slot node to patch
*/
export function patchSlotNode(node: d.RenderNode) {
if ((node as any).assignedElements || (node as any).assignedNodes || !node['s-sr']) return;

const assignedFactory = (elementsOnly: boolean) =>
function (opts?: { flatten: boolean }) {
const toReturn: d.RenderNode[] = [];
const slotName = this['s-sn'];

if (opts?.flatten) {
console.error(`
Flattening is not supported for Stencil non-shadow slots.
You can use \`.childNodes\` to nested slot fallback content.
If you have a particular use case, please open an issue on the Stencil repo.
`);
}

const parent = this['s-cr'].parentElement as d.RenderNode;
// get all light dom nodes
const slottedNodes = parent.__childNodes ? parent.childNodes : getSlottedChildNodes(parent.childNodes);

(slottedNodes as d.RenderNode[]).forEach((n) => {
// find all the nodes assigned to slots we care about
if (slotName === getSlotName(n)) {
toReturn.push(n);
}
});

if (elementsOnly) {
return toReturn.filter((n) => n.nodeType === NODE_TYPE.ElementNode);
}
return toReturn;
}.bind(node);

(node as any).assignedElements = assignedFactory(true);
(node as any).assignedNodes = assignedFactory(false);
}

/**
* Dispatches a `slotchange` event on a fake `<slot />` node.
*
* @param elm the slot node to dispatch the event from
*/
export function dispatchSlotChangeEvent(elm: d.RenderNode) {
elm.dispatchEvent(new CustomEvent('slotchange', { bubbles: false, cancelable: false, composed: false }));
}

/**
* Find the slot node that a slotted node belongs to
*
* @param slottedNode - the slotted node to find the slot for
* @param parentHost - the parent host element of the slotted node
* @returns the slot node and slot name
*/
export function findSlotFromSlottedNode(slottedNode: d.PatchedSlotNode, parentHost?: HTMLElement) {
parentHost = parentHost || slottedNode['s-ol']?.parentElement;

if (!parentHost) return { slotNode: null, slotName: '' };

const slotName = (slottedNode['s-sn'] = getSlotName(slottedNode) || '');
const childNodes = internalCall(parentHost, 'childNodes');
const slotNode = getHostSlotNodes(childNodes, parentHost.tagName, slotName)[0];
return { slotNode, slotName };
}
Loading

0 comments on commit 2a1038e

Please sign in to comment.