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(runtime): textContent for scoped components with slots #3047

Merged
merged 4 commits into from
Sep 9, 2021
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions src/app-data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const BUILD: BuildConditionals = {
hydratedClass: true,
safari10: false,
scriptDataOpts: false,
scopedSlotTextContentFix: false,
shadowDomShim: false,
slotChildNodesFix: false,
invisiblePrehydration: true,
Expand Down
1 change: 1 addition & 0 deletions src/compiler/app-core/app-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export const updateBuildConditionals = (config: Config, b: BuildConditionals) =>
b.dynamicImportShim = config.extras.dynamicImportShim;
b.lifecycleDOMEvents = !!(b.isDebug || config._isTesting || config.extras.lifecycleDOMEvents);
b.safari10 = config.extras.safari10;
b.scopedSlotTextContentFix = !!config.extras.scopedSlotTextContentFix;
b.scriptDataOpts = config.extras.scriptDataOpts;
b.shadowDomShim = config.extras.shadowDomShim;
b.attachStyles = true;
Expand Down
1 change: 0 additions & 1 deletion src/compiler/output-targets/dist-lazy/lazy-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ export const outputLazy = async (config: d.Config, compilerCtx: d.CompilerCtx, b
const timespan = buildCtx.createTimeSpan(`generate lazy started`);

try {
// const criticalBundles = getCriticalPath(buildCtx);
const bundleOpts: BundleOptions = {
id: 'lazy',
platform: 'client',
Expand Down
6 changes: 5 additions & 1 deletion src/declarations/stencil-private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ export interface BuildConditionals extends Partial<BuildFeatures> {
constructableCSS?: boolean;
appendChildSlotFix?: boolean;
slotChildNodesFix?: boolean;
scopedSlotTextContentFix?: boolean;
cloneNodeFix?: boolean;
dynamicImportShim?: boolean;
hydratedAttribute?: boolean;
Expand Down Expand Up @@ -1502,7 +1503,7 @@ export interface RenderNode extends HostElement {

/**
* Is a slot reference node:
* This is a node that represents where a slots
* This is a node that represents where a slot
* was originally located.
*/
['s-sr']?: boolean;
Expand Down Expand Up @@ -1629,6 +1630,9 @@ export interface ModeBundleIds {

export type RuntimeRef = HostElement | {};

/**
* Interface used to track an Element, it's virtual Node (`VNode`), and other data
*/
export interface HostRef {
$ancestorComponent$?: HostElement;
$flags$: number;
Expand Down
6 changes: 6 additions & 0 deletions src/declarations/stencil-public-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,12 @@ export interface ConfigExtras {
*/
scriptDataOpts?: boolean;

/**
* 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`
*/
scopedSlotTextContentFix?: boolean;

/**
* If enabled `true`, the runtime will check if the shadow dom shim is required. However,
* if it's determined that shadow dom is already natively supported by the browser then
Expand Down
6 changes: 5 additions & 1 deletion src/runtime/bootstrap-lazy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { disconnectedCallback } from './disconnected-callback';
import { doc, getHostRef, plt, registerHost, win, supportsShadow } from '@platform';
import { hmrStart } from './hmr-component';
import { HYDRATED_CSS, HYDRATED_STYLE_ID, PLATFORM_FLAGS, PROXY_FLAGS } from './runtime-constants';
import { patchCloneNode, patchSlotAppendChild, patchChildSlotNodes } from './dom-extras';
import { patchCloneNode, patchSlotAppendChild, patchChildSlotNodes, patchTextContent } from './dom-extras';
import { proxyComponent } from './proxy-component';

export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d.CustomElementsDefineOptions = {}) => {
Expand Down Expand Up @@ -146,6 +146,10 @@ 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
61 changes: 61 additions & 0 deletions src/runtime/dom-extras.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type * as d from '../declarations';
import { BUILD } from '@app-data';
import { NODE_TYPES } from '@stencil/core/mock-doc';
import { CMP_FLAGS, HOST_FLAGS } from '@utils';
import { PLATFORM_FLAGS } from './runtime-constants';
import { plt, supportsShadow, getHostRef } from '@platform';
Expand Down Expand Up @@ -63,6 +64,60 @@ export const patchSlotAppendChild = (HostElementPrototype: any) => {
};
};

/**
* Patches the text content of an unnamed slotted node inside a scoped component
* @param hostElementPrototype the `Element` to be patched
* @param cmpMeta component runtime metadata used to determine if the component should be patched or not
*/
export const patchTextContent = (hostElementPrototype: HTMLElement, cmpMeta: d.ComponentRuntimeMeta): void => {
if (BUILD.scoped && cmpMeta.$flags$ & CMP_FLAGS.scopedCssEncapsulation) {
const descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent');

Object.defineProperty(hostElementPrototype, '__textContent', descriptor);

Object.defineProperty(hostElementPrototype, 'textContent', {
get(): string | null {
// get the 'default slot', which would be the first slot in a shadow tree (if we were using one), whose name is
// the empty string
const slotNode = getHostSlotNode(this.childNodes, '');
// when a slot node is found, the textContent _may_ be found in the next sibling (text) node, depending on how
// nodes were reordered during the vdom render. first try to get the text content from the sibling.
if (slotNode?.nextSibling?.nodeType === NODE_TYPES.TEXT_NODE) {
return slotNode.nextSibling.textContent;
} else if (slotNode) {
return slotNode.textContent;
} else {
// fallback to the original implementation
return this.__textContent;
}
},

set(value: string | null) {
// get the 'default slot', which would be the first slot in a shadow tree (if we were using one), whose name is
// the empty string
const slotNode = getHostSlotNode(this.childNodes, '');
// when a slot node is found, the textContent _may_ need to be placed in the next sibling (text) node,
// depending on how nodes were reordered during the vdom render. first try to set the text content on the
// sibling.
if (slotNode?.nextSibling?.nodeType === NODE_TYPES.TEXT_NODE) {
slotNode.nextSibling.textContent = value;
} else if (slotNode) {
slotNode.textContent = value;
} else {
// we couldn't find a slot, but that doesn't mean that there isn't one. if this check ran before the DOM
// loaded, we could have missed it. check for a content reference element on the scoped component and insert
// it there
this.__textContent = value;
const contentRefElm = this['s-cr'];
if (contentRefElm) {
this.insertBefore(contentRefElm, this.firstChild);
}
}
},
});
}
};

export const patchChildSlotNodes = (elm: any, cmpMeta: d.ComponentRuntimeMeta) => {
class FakeNodeList extends Array {
item(n: number) {
Expand Down Expand Up @@ -109,6 +164,12 @@ export const patchChildSlotNodes = (elm: any, cmpMeta: d.ComponentRuntimeMeta) =
const getSlotName = (node: d.RenderNode) =>
node['s-sn'] || (node.nodeType === 1 && (node as Element).getAttribute('slot')) || '';

/**
* 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.
* @returns a reference to the slot node that matches the provided name, `null` otherwise
*/
const getHostSlotNode = (childNodes: NodeListOf<ChildNode>, slotName: string) => {
let i = 0;
let childNode: d.RenderNode;
Expand Down
7 changes: 7 additions & 0 deletions src/runtime/event-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ export const createEvent = (ref: d.RuntimeRef, name: string, flags: number) => {
};
};

/**
* Helper function to create & dispatch a custom Event on a provided target
* @param elm the target of the Event
* @param name the name to give the custom Event
* @param opts options for configuring a custom Event
* @returns the custom Event
*/
export const emitEvent = (elm: EventTarget, name: string, opts?: CustomEventInit) => {
const ev = plt.ce(name, opts);
elm.dispatchEvent(ev);
Expand Down
7 changes: 7 additions & 0 deletions src/runtime/runtime-constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
export const enum VNODE_FLAGS {
isSlotReference = 1 << 0,

// slot element has fallback content
// still create an element that "mocks" the slot element
isSlotFallback = 1 << 1,

// slot element does not have fallback content
// create an html comment we'll use to always reference
// where actual slot content should sit next to
isHost = 1 << 2,
}

Expand Down
1 change: 1 addition & 0 deletions src/testing/reset-build-conditionals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,6 @@ export function resetBuildConditionals(b: d.BuildConditionals) {
b.hotModuleReplacement = false;
b.safari10 = false;
b.scriptDataOpts = false;
b.scopedSlotTextContentFix = false;
b.slotChildNodesFix = false;
}
1 change: 1 addition & 0 deletions test/karma/stencil.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const config: Config = {
dynamicImportShim: true,
lifecycleDOMEvents: true,
safari10: true,
scopedSlotTextContentFix: true,
scriptDataOpts: true,
shadowDomShim: true,
slotChildNodesFix: true,
Expand Down
26 changes: 26 additions & 0 deletions test/karma/test-app/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ export namespace Components {
}
interface BuildData {
}
interface CmpLabel {
}
interface CmpLabelWithSlotSibling {
}
interface ConditionalBasic {
}
interface ConditionalRerender {
Expand Down Expand Up @@ -355,6 +359,18 @@ declare global {
prototype: HTMLBuildDataElement;
new (): HTMLBuildDataElement;
};
interface HTMLCmpLabelElement extends Components.CmpLabel, HTMLStencilElement {
}
var HTMLCmpLabelElement: {
prototype: HTMLCmpLabelElement;
new (): HTMLCmpLabelElement;
};
interface HTMLCmpLabelWithSlotSiblingElement extends Components.CmpLabelWithSlotSibling, HTMLStencilElement {
}
var HTMLCmpLabelWithSlotSiblingElement: {
prototype: HTMLCmpLabelWithSlotSiblingElement;
new (): HTMLCmpLabelWithSlotSiblingElement;
};
interface HTMLConditionalBasicElement extends Components.ConditionalBasic, HTMLStencilElement {
}
var HTMLConditionalBasicElement: {
Expand Down Expand Up @@ -990,6 +1006,8 @@ declare global {
"attribute-html-root": HTMLAttributeHtmlRootElement;
"bad-shared-jsx": HTMLBadSharedJsxElement;
"build-data": HTMLBuildDataElement;
"cmp-label": HTMLCmpLabelElement;
"cmp-label-with-slot-sibling": HTMLCmpLabelWithSlotSiblingElement;
"conditional-basic": HTMLConditionalBasicElement;
"conditional-rerender": HTMLConditionalRerenderElement;
"conditional-rerender-root": HTMLConditionalRerenderRootElement;
Expand Down Expand Up @@ -1135,6 +1153,10 @@ declare namespace LocalJSX {
}
interface BuildData {
}
interface CmpLabel {
}
interface CmpLabelWithSlotSibling {
}
interface ConditionalBasic {
}
interface ConditionalRerender {
Expand Down Expand Up @@ -1399,6 +1421,8 @@ declare namespace LocalJSX {
"attribute-html-root": AttributeHtmlRoot;
"bad-shared-jsx": BadSharedJsx;
"build-data": BuildData;
"cmp-label": CmpLabel;
"cmp-label-with-slot-sibling": CmpLabelWithSlotSibling;
"conditional-basic": ConditionalBasic;
"conditional-rerender": ConditionalRerender;
"conditional-rerender-root": ConditionalRerenderRoot;
Expand Down Expand Up @@ -1519,6 +1543,8 @@ declare module "@stencil/core" {
"attribute-html-root": LocalJSX.AttributeHtmlRoot & JSXBase.HTMLAttributes<HTMLAttributeHtmlRootElement>;
"bad-shared-jsx": LocalJSX.BadSharedJsx & JSXBase.HTMLAttributes<HTMLBadSharedJsxElement>;
"build-data": LocalJSX.BuildData & JSXBase.HTMLAttributes<HTMLBuildDataElement>;
"cmp-label": LocalJSX.CmpLabel & JSXBase.HTMLAttributes<HTMLCmpLabelElement>;
"cmp-label-with-slot-sibling": LocalJSX.CmpLabelWithSlotSibling & JSXBase.HTMLAttributes<HTMLCmpLabelWithSlotSiblingElement>;
"conditional-basic": LocalJSX.ConditionalBasic & JSXBase.HTMLAttributes<HTMLConditionalBasicElement>;
"conditional-rerender": LocalJSX.ConditionalRerender & JSXBase.HTMLAttributes<HTMLConditionalRerenderElement>;
"conditional-rerender-root": LocalJSX.ConditionalRerenderRoot & JSXBase.HTMLAttributes<HTMLConditionalRerenderRootElement>;
Expand Down
18 changes: 18 additions & 0 deletions test/karma/test-app/scoped-slot-text-with-sibling/cmp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Component, Host, h } from '@stencil/core';

@Component({
tag: 'cmp-label-with-slot-sibling',
scoped: true,
})
export class CmpLabelWithSlotSibling {
render() {
return (
<Host>
<label>
<slot />
<div>Non-slotted text</div>
</label>
</Host>
);
}
}
6 changes: 6 additions & 0 deletions test/karma/test-app/scoped-slot-text-with-sibling/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!DOCTYPE html>
<meta charset="utf8">
<script src="/build/testapp.esm.js" type="module"></script>
<script src="/build/testapp.js" nomodule></script>

<cmp-label-with-slot-sibling>This text should go in a slot</cmp-label-with-slot-sibling>
64 changes: 64 additions & 0 deletions test/karma/test-app/scoped-slot-text-with-sibling/karma.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { setupDomTests } from '../util';

describe('scoped-slot-text-with-sibling', () => {
const { setupDom, tearDownDom } = setupDomTests(document);
let app: HTMLElement | undefined;

beforeEach(async () => {
app = await setupDom('/scoped-slot-text-with-sibling/index.html');
});

afterEach(tearDownDom);

/**
* Helper function to retrieve custom element used by this test suite. If the element cannot be found, the test that
* invoked this function shall fail.
* @returns the custom element
*/
function getCmpLabel(): HTMLCmpLabelElement {
const customElementSelector = 'cmp-label-with-slot-sibling';
const cmpLabel: HTMLCmpLabelElement = app.querySelector(customElementSelector);
if (!cmpLabel) {
fail(`Unable to find element using query selector '${customElementSelector}'`);
}

return cmpLabel;
}

it('sets the textContent in the slot location', () => {
const cmpLabel: HTMLCmpLabelElement = getCmpLabel();

cmpLabel.textContent = 'New text to go in the slot';

expect(cmpLabel.textContent).toBe('New text to go in the slot');
});

it("doesn't override all children when assigning textContent", () => {
const cmpLabel: HTMLCmpLabelElement = getCmpLabel();

cmpLabel.textContent = "New text that we want to go in a slot, but don't care about for this test";

const divElement: HTMLDivElement = cmpLabel.querySelector('div');
expect(divElement?.textContent).toBe('Non-slotted text');
});

it('leaves the structure of the label intact', () => {
const cmpLabel: HTMLCmpLabelElement = getCmpLabel();

cmpLabel.textContent = 'New text for label structure testing';

const label: HTMLLabelElement = cmpLabel.querySelector('label');

/**
* Expect three child nodes in the label
* - a content reference text node
* - the slotted text node
* - the non-slotted text
*/
expect(label).toBeDefined();
expect(label.childNodes.length).toBe(3);
expect(label.childNodes[0]['s-cr']).toBeDefined();
expect(label.childNodes[1].textContent).toBe('New text for label structure testing');
expect(label.childNodes[2].textContent).toBe('Non-slotted text');
});
});
17 changes: 17 additions & 0 deletions test/karma/test-app/scoped-slot-text/cmp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Component, Host, h } from '@stencil/core';

@Component({
tag: 'cmp-label',
scoped: true,
})
export class CmpLabel {
render() {
return (
<Host>
<label>
<slot />
</label>
</Host>
);
}
}
6 changes: 6 additions & 0 deletions test/karma/test-app/scoped-slot-text/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!DOCTYPE html>
<meta charset="utf8">
<script src="/build/testapp.esm.js" type="module"></script>
<script src="/build/testapp.js" nomodule></script>

<cmp-label>This text should go in a slot</cmp-label>
Loading