Skip to content

Commit

Permalink
fix(runtime): patch methods for scoped slot append, prepend, and …
Browse files Browse the repository at this point in the history
…`insertAdjacent` (#4719)

* cherry-pick test commit

* port over method patches

* add tests for additional `insertAdjacent` methods

* add e2e tests for patched append/prepend methods

* remove previous tests for `appendChild`

* patch shadowDOM for custom elements & mark future code deletions w/ todos

* update component typedefs from removing test suite

* remove some `any` types

* keep original methods in patch scope

* add co-author for squash

Co-authored-by: johnjenkins <[email protected]>

---------

Co-authored-by: johnjenkins <[email protected]>
  • Loading branch information
tanner-reits and johnjenkins authored Sep 5, 2023
1 parent 1567f86 commit 1d98462
Show file tree
Hide file tree
Showing 20 changed files with 610 additions and 153 deletions.
6 changes: 6 additions & 0 deletions src/app-data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,18 @@ export const BUILD: BuildConditionals = {
lazyLoad: false,
profile: false,
slotRelocation: true,
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
appendChildSlotFix: false,
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
cloneNodeFix: false,
hydratedAttribute: false,
hydratedClass: true,
scriptDataOpts: false,
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
scopedSlotTextContentFix: false,
// TODO(STENCIL-854): Remove code related to legacy shadowDomShim field
shadowDomShim: false,
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
slotChildNodesFix: false,
invisiblePrehydration: true,
propBoolean: true,
Expand All @@ -103,6 +107,8 @@ export const BUILD: BuildConditionals = {
asyncQueue: false,
transformTagName: false,
attachStyles: true,
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
patchPseudoShadowDom: false,
};

export const Env = {};
Expand Down
6 changes: 6 additions & 0 deletions src/compiler/app-core/app-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,16 @@ export const updateBuildConditionals = (config: ValidatedConfig, b: BuildConditi
b.constructableCSS = !b.hotModuleReplacement || !!config._isTesting;
b.asyncLoading = !!(b.asyncLoading || b.lazyLoad || b.taskQueue || b.initializeNextTick);
b.cssAnnotations = true;
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
b.appendChildSlotFix = config.extras.appendChildSlotFix;
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
b.slotChildNodesFix = config.extras.slotChildNodesFix;
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
b.patchPseudoShadowDom = config.extras.experimentalSlotFixes;
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
b.cloneNodeFix = config.extras.cloneNodeFix;
b.lifecycleDOMEvents = !!(b.isDebug || config._isTesting || config.extras.lifecycleDOMEvents);
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
b.scopedSlotTextContentFix = !!config.extras.scopedSlotTextContentFix;
b.scriptDataOpts = config.extras.scriptDataOpts;
b.attachStyles = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,13 @@ export const getHydrateBuildConditionals = (cmps: d.ComponentCompilerMeta[]) =>
build.member = true;
build.constructableCSS = false;
build.asyncLoading = true;
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
build.appendChildSlotFix = false;
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
build.slotChildNodesFix = false;
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
build.patchPseudoShadowDom = false;
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
build.cloneNodeFix = false;
build.cssAnnotations = true;
// TODO(STENCIL-854): Remove code related to legacy shadowDomShim field
Expand Down
7 changes: 7 additions & 0 deletions src/declarations/stencil-private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,13 @@ export interface BuildConditionals extends Partial<BuildFeatures> {
lazyLoad?: boolean;
profile?: boolean;
constructableCSS?: boolean;
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
appendChildSlotFix?: boolean;
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
slotChildNodesFix?: boolean;
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
scopedSlotTextContentFix?: boolean;
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
cloneNodeFix?: boolean;
hydratedAttribute?: boolean;
hydratedClass?: boolean;
Expand All @@ -179,6 +183,9 @@ export interface BuildConditionals extends Partial<BuildFeatures> {
asyncQueue?: boolean;
transformTagName?: boolean;
attachStyles?: boolean;

// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
patchPseudoShadowDom?: boolean;
}

export type ModuleFormat =
Expand Down
4 changes: 4 additions & 0 deletions src/declarations/stencil-public-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ export interface StencilConfig {
}

export interface ConfigExtras {
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
/**
* By default, the slot polyfill does not update `appendChild()` so that it appends
* new child nodes into the correct child slot like how shadow dom works. This is an opt-in
Expand All @@ -302,6 +303,7 @@ export interface ConfigExtras {
*/
appendChildSlotFix?: boolean;

// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
/**
* By default, the runtime does not polyfill `cloneNode()` when cloning a component
* that uses the slot polyfill. This is an opt-in polyfill for those who need it.
Expand Down Expand Up @@ -341,6 +343,7 @@ export interface ConfigExtras {
*/
scriptDataOpts?: boolean;

// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
/**
* Experimental flag to align the behavior of invoking `textContent` on a scoped component to act more like a
* component that uses the shadow DOM. Defaults to `false`
Expand All @@ -355,6 +358,7 @@ export interface ConfigExtras {
*/
initializeNextTick?: boolean;

// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
/**
* For browsers that do not support shadow dom (IE11 and Edge 18 and below), slot is polyfilled
* to simulate the same behavior. However, the host element's `childNodes` and `children`
Expand Down
26 changes: 26 additions & 0 deletions src/runtime/bootstrap-custom-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import { CMP_FLAGS } from '@utils';
import type * as d from '../declarations';
import { connectedCallback } from './connected-callback';
import { disconnectedCallback } from './disconnected-callback';
import {
patchChildSlotNodes,
patchCloneNode,
patchPseudoShadowDom,
patchSlotAppendChild,
patchTextContent,
} from './dom-extras';
import { computeMode } from './mode';
import { proxyComponent } from './proxy-component';
import { PROXY_FLAGS } from './runtime-constants';
Expand Down Expand Up @@ -36,6 +43,25 @@ export const proxyCustomElement = (Cstr: any, compactMeta: d.ComponentRuntimeMet
cmpMeta.$flags$ |= CMP_FLAGS.needsShadowDomShim;
}

// TODO(STENCIL-914): this check and `else` block can go away and be replaced by just `BUILD.scoped` once we
// default our pseudo-slot behavior
if (BUILD.patchPseudoShadowDom && BUILD.scoped) {
patchPseudoShadowDom(Cstr.prototype, cmpMeta);
} else {
if (BUILD.slotChildNodesFix) {
patchChildSlotNodes(Cstr.prototype, cmpMeta);
}
if (BUILD.cloneNodeFix) {
patchCloneNode(Cstr.prototype);
}
if (BUILD.appendChildSlotFix) {
patchSlotAppendChild(Cstr.prototype);
}
if (BUILD.scopedSlotTextContentFix) {
patchTextContent(Cstr.prototype, cmpMeta);
}
}

const originalConnectedCallback = Cstr.prototype.connectedCallback;
const originalDisconnectedCallback = Cstr.prototype.disconnectedCallback;
Object.assign(Cstr.prototype, {
Expand Down
38 changes: 24 additions & 14 deletions src/runtime/bootstrap-lazy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import { CMP_FLAGS, queryNonceMetaTagContent } from '@utils';
import type * as d from '../declarations';
import { connectedCallback } from './connected-callback';
import { disconnectedCallback } from './disconnected-callback';
import { patchChildSlotNodes, patchCloneNode, patchSlotAppendChild, patchTextContent } from './dom-extras';
import {
patchChildSlotNodes,
patchCloneNode,
patchPseudoShadowDom,
patchSlotAppendChild,
patchTextContent,
} from './dom-extras';
import { hmrStart } from './hmr-component';
import { createTime, installDevTools } from './profile';
import { proxyComponent } from './proxy-component';
Expand Down Expand Up @@ -108,9 +114,6 @@ export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d.
(self as any).shadowRoot = self;
}
}
if (BUILD.slotChildNodesFix) {
patchChildSlotNodes(self, cmpMeta);
}
}

connectedCallback() {
Expand All @@ -135,12 +138,23 @@ export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d.
}
};

if (BUILD.cloneNodeFix) {
patchCloneNode(HostElement.prototype);
}

if (BUILD.appendChildSlotFix) {
patchSlotAppendChild(HostElement.prototype);
// TODO(STENCIL-914): this check and `else` block can go away and be replaced by just `BUILD.scoped` once we
// default our pseudo-slot behavior
if (BUILD.patchPseudoShadowDom && BUILD.scoped) {
patchPseudoShadowDom(HostElement.prototype, cmpMeta);
} else {
if (BUILD.slotChildNodesFix) {
patchChildSlotNodes(HostElement.prototype, cmpMeta);
}
if (BUILD.cloneNodeFix) {
patchCloneNode(HostElement.prototype);
}
if (BUILD.appendChildSlotFix) {
patchSlotAppendChild(HostElement.prototype);
}
if (BUILD.scopedSlotTextContentFix) {
patchTextContent(HostElement.prototype, cmpMeta);
}
}

if (BUILD.hotModuleReplacement) {
Expand All @@ -149,10 +163,6 @@ export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d.
};
}

if (BUILD.scopedSlotTextContentFix) {
patchTextContent(HostElement.prototype, cmpMeta);
}

cmpMeta.$lazyBundleId$ = lazyBundle[0];

if (!exclude.includes(tagName) && !customElements.get(tagName)) {
Expand Down
144 changes: 140 additions & 4 deletions src/runtime/dom-extras.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,22 @@ import { CMP_FLAGS, HOST_FLAGS } from '@utils';
import type * as d from '../declarations';
import { PLATFORM_FLAGS } from './runtime-constants';

export const patchCloneNode = (HostElementPrototype: any) => {
export const patchPseudoShadowDom = (
hostElementPrototype: HTMLElement,
descriptorPrototype: d.ComponentRuntimeMeta,
) => {
patchCloneNode(hostElementPrototype);
patchSlotAppendChild(hostElementPrototype);
patchSlotAppend(hostElementPrototype);
patchSlotPrepend(hostElementPrototype);
patchSlotInsertAdjacentElement(hostElementPrototype);
patchSlotInsertAdjacentHTML(hostElementPrototype);
patchSlotInsertAdjacentText(hostElementPrototype);
patchTextContent(hostElementPrototype, descriptorPrototype);
patchChildSlotNodes(hostElementPrototype, descriptorPrototype);
};

export const patchCloneNode = (HostElementPrototype: HTMLElement) => {
const orgCloneNode = HostElementPrototype.cloneNode;

HostElementPrototype.cloneNode = function (deep?: boolean) {
Expand Down Expand Up @@ -65,6 +80,127 @@ export const patchSlotAppendChild = (HostElementPrototype: any) => {
};
};

/**
* Patches the `prepend` method for a slotted node inside a scoped component.
*
* @param HostElementPrototype the `Element` to be patched
*/
export const patchSlotPrepend = (HostElementPrototype: HTMLElement) => {
const originalPrepend = HostElementPrototype.prepend;

HostElementPrototype.prepend = function (this: d.HostElement, ...newChildren: (d.RenderNode | string)[]) {
newChildren.forEach((newChild: d.RenderNode | string) => {
if (typeof newChild === 'string') {
newChild = this.ownerDocument.createTextNode(newChild) as unknown as d.RenderNode;
}
const slotName = (newChild['s-sn'] = getSlotName(newChild));
const slotNode = getHostSlotNode(this.childNodes, slotName);
if (slotNode) {
const slotPlaceholder: d.RenderNode = document.createTextNode('') as any;
slotPlaceholder['s-nr'] = newChild;
(slotNode['s-cr'].parentNode as any).__appendChild(slotPlaceholder);
newChild['s-ol'] = slotPlaceholder;

const slotChildNodes = getHostSlotChildNodes(slotNode, slotName);
const appendAfter = slotChildNodes[0];
return appendAfter.parentNode.insertBefore(newChild, appendAfter.nextSibling);
}

if (newChild.nodeType === 1 && !!newChild.getAttribute('slot')) {
newChild.hidden = true;
}

return originalPrepend.call(this, newChild);
});
};
};

/**
* Patches the `append` method for a slotted node inside a scoped component. The patched method uses
* `appendChild` under-the-hood while creating text nodes for any new children that passed as bare strings.
*
* @param HostElementPrototype the `Element` to be patched
*/
export const patchSlotAppend = (HostElementPrototype: HTMLElement) => {
HostElementPrototype.append = function (this: d.HostElement, ...newChildren: (d.RenderNode | string)[]) {
newChildren.forEach((newChild: d.RenderNode | string) => {
if (typeof newChild === 'string') {
newChild = this.ownerDocument.createTextNode(newChild) as unknown as d.RenderNode;
}
this.appendChild(newChild);
});
};
};

/**
* Patches the `insertAdjacentHTML` method for a slotted node inside a scoped component. Specifically,
* we only need to patch the behavior for the specific `beforeend` and `afterbegin` positions so the element
* gets inserted into the DOM in the correct location.
*
* @param HostElementPrototype the `Element` to be patched
*/
export const patchSlotInsertAdjacentHTML = (HostElementPrototype: HTMLElement) => {
const originalInsertAdjacentHtml = HostElementPrototype.insertAdjacentHTML;

HostElementPrototype.insertAdjacentHTML = function (this: d.HostElement, position: InsertPosition, text: string) {
if (position !== 'afterbegin' && position !== 'beforeend') {
return originalInsertAdjacentHtml.call(this, position, text);
}
const container = this.ownerDocument.createElement('_');
let node: d.RenderNode;
container.innerHTML = text;

if (position === 'afterbegin') {
while ((node = container.firstChild as d.RenderNode)) {
this.prepend(node);
}
} else if (position === 'beforeend') {
while ((node = container.firstChild as d.RenderNode)) {
this.append(node);
}
}
};
};

/**
* Patches the `insertAdjacentText` method for a slotted node inside a scoped component. Specifically,
* we only need to patch the behavior for the specific `beforeend` and `afterbegin` positions so the text node
* gets inserted into the DOM in the correct location.
*
* @param HostElementPrototype the `Element` to be patched
*/
export const patchSlotInsertAdjacentText = (HostElementPrototype: HTMLElement) => {
HostElementPrototype.insertAdjacentText = function (this: d.HostElement, position: InsertPosition, text: string) {
this.insertAdjacentHTML(position, text);
};
};

/**
* Patches the `insertAdjacentElement` method for a slotted node inside a scoped component. Specifically,
* we only need to patch the behavior for the specific `beforeend` and `afterbegin` positions so the element
* gets inserted into the DOM in the correct location.
*
* @param HostElementPrototype the `Element` to be patched
*/
export const patchSlotInsertAdjacentElement = (HostElementPrototype: HTMLElement) => {
const originalInsertAdjacentElement = HostElementPrototype.insertAdjacentElement;

HostElementPrototype.insertAdjacentElement = function (
this: d.HostElement,
position: InsertPosition,
element: d.RenderNode,
) {
if (position !== 'afterbegin' && position !== 'beforeend') {
return originalInsertAdjacentElement.call(this, position, element);
}
if (position === 'afterbegin') {
this.prepend(element);
} else if (position === 'beforeend') {
this.append(element);
}
};
};

/**
* Patches the text content of an unnamed slotted node inside a scoped component
* @param hostElementPrototype the `Element` to be patched
Expand Down Expand Up @@ -119,15 +255,15 @@ export const patchTextContent = (hostElementPrototype: HTMLElement, cmpMeta: d.C
}
};

export const patchChildSlotNodes = (elm: any, cmpMeta: d.ComponentRuntimeMeta) => {
export const patchChildSlotNodes = (elm: HTMLElement, cmpMeta: d.ComponentRuntimeMeta) => {
class FakeNodeList extends Array {
item(n: number) {
return this[n];
}
}
// TODO(STENCIL-854): Remove code related to legacy shadowDomShim field
if (cmpMeta.$flags$ & CMP_FLAGS.needsShadowDomShim) {
const childNodesFn = elm.__lookupGetter__('childNodes');
const childNodesFn = (elm as any).__lookupGetter__('childNodes');

Object.defineProperty(elm, 'children', {
get() {
Expand All @@ -143,7 +279,7 @@ export const patchChildSlotNodes = (elm: any, cmpMeta: d.ComponentRuntimeMeta) =

Object.defineProperty(elm, 'childNodes', {
get() {
const childNodes = childNodesFn.call(this);
const childNodes = childNodesFn.call(this) as NodeListOf<d.RenderNode>;
if (
(plt.$flags$ & PLATFORM_FLAGS.isTmpDisconnected) === 0 &&
getHostRef(this).$flags$ & HOST_FLAGS.hasRendered
Expand Down
Loading

0 comments on commit 1d98462

Please sign in to comment.