Skip to content

Commit 9fc7657

Browse files
authored
fix(runtime): textContent for scoped components with slots (#3047)
patch textContent for scoped components that use slots. because there is no shadow dom, calling the setter/getter on `textContent` would otherwise act on the bare HTML elements. use the stencil metadata to relocate the text to the proper location this functionality is hidden behind a feature flag. users may enable it by setting `scopedSlotTextContentFix` to `true` in the `extras` section of their Stencil configuration file. This field defaults to `false` (keeping the same behavior that exists today) while this flag is evaluated. this does _not_ bring this functionality over to the custom elements build
1 parent b45e96d commit 9fc7657

File tree

18 files changed

+285
-3
lines changed

18 files changed

+285
-3
lines changed

src/app-data/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const BUILD: BuildConditionals = {
6565
hydratedClass: true,
6666
safari10: false,
6767
scriptDataOpts: false,
68+
scopedSlotTextContentFix: false,
6869
shadowDomShim: false,
6970
slotChildNodesFix: false,
7071
invisiblePrehydration: true,

src/compiler/app-core/app-data.ts

+1
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ export const updateBuildConditionals = (config: Config, b: BuildConditionals) =>
152152
b.dynamicImportShim = config.extras.dynamicImportShim;
153153
b.lifecycleDOMEvents = !!(b.isDebug || config._isTesting || config.extras.lifecycleDOMEvents);
154154
b.safari10 = config.extras.safari10;
155+
b.scopedSlotTextContentFix = !!config.extras.scopedSlotTextContentFix;
155156
b.scriptDataOpts = config.extras.scriptDataOpts;
156157
b.shadowDomShim = config.extras.shadowDomShim;
157158
b.attachStyles = true;

src/compiler/output-targets/dist-lazy/lazy-output.ts

-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ export const outputLazy = async (config: d.Config, compilerCtx: d.CompilerCtx, b
3333
const timespan = buildCtx.createTimeSpan(`generate lazy started`);
3434

3535
try {
36-
// const criticalBundles = getCriticalPath(buildCtx);
3736
const bundleOpts: BundleOptions = {
3837
id: 'lazy',
3938
platform: 'client',

src/declarations/stencil-private.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ export interface BuildConditionals extends Partial<BuildFeatures> {
163163
constructableCSS?: boolean;
164164
appendChildSlotFix?: boolean;
165165
slotChildNodesFix?: boolean;
166+
scopedSlotTextContentFix?: boolean;
166167
cloneNodeFix?: boolean;
167168
dynamicImportShim?: boolean;
168169
hydratedAttribute?: boolean;
@@ -1502,7 +1503,7 @@ export interface RenderNode extends HostElement {
15021503

15031504
/**
15041505
* Is a slot reference node:
1505-
* This is a node that represents where a slots
1506+
* This is a node that represents where a slot
15061507
* was originally located.
15071508
*/
15081509
['s-sr']?: boolean;
@@ -1629,6 +1630,9 @@ export interface ModeBundleIds {
16291630

16301631
export type RuntimeRef = HostElement | {};
16311632

1633+
/**
1634+
* Interface used to track an Element, it's virtual Node (`VNode`), and other data
1635+
*/
16321636
export interface HostRef {
16331637
$ancestorComponent$?: HostElement;
16341638
$flags$: number;

src/declarations/stencil-public-compiler.ts

+6
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,12 @@ export interface ConfigExtras {
292292
*/
293293
scriptDataOpts?: boolean;
294294

295+
/**
296+
* Experimental flag to align the behavior of invoking `textContent` on a scoped component to act more like a
297+
* component that uses the shadow DOM. Defaults to `false`
298+
*/
299+
scopedSlotTextContentFix?: boolean;
300+
295301
/**
296302
* If enabled `true`, the runtime will check if the shadow dom shim is required. However,
297303
* if it's determined that shadow dom is already natively supported by the browser then

src/runtime/bootstrap-lazy.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { disconnectedCallback } from './disconnected-callback';
99
import { doc, getHostRef, plt, registerHost, win, supportsShadow } from '@platform';
1010
import { hmrStart } from './hmr-component';
1111
import { HYDRATED_CSS, HYDRATED_STYLE_ID, PLATFORM_FLAGS, PROXY_FLAGS } from './runtime-constants';
12-
import { patchCloneNode, patchSlotAppendChild, patchChildSlotNodes } from './dom-extras';
12+
import { patchCloneNode, patchSlotAppendChild, patchChildSlotNodes, patchTextContent } from './dom-extras';
1313
import { proxyComponent } from './proxy-component';
1414

1515
export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d.CustomElementsDefineOptions = {}) => {
@@ -146,6 +146,10 @@ export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d.
146146
};
147147
}
148148

149+
if (BUILD.scopedSlotTextContentFix) {
150+
patchTextContent(HostElement.prototype, cmpMeta);
151+
}
152+
149153
cmpMeta.$lazyBundleId$ = lazyBundle[0];
150154

151155
if (!exclude.includes(tagName) && !customElements.get(tagName)) {

src/runtime/dom-extras.ts

+61
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type * as d from '../declarations';
22
import { BUILD } from '@app-data';
3+
import { NODE_TYPES } from '@stencil/core/mock-doc';
34
import { CMP_FLAGS, HOST_FLAGS } from '@utils';
45
import { PLATFORM_FLAGS } from './runtime-constants';
56
import { plt, supportsShadow, getHostRef } from '@platform';
@@ -63,6 +64,60 @@ export const patchSlotAppendChild = (HostElementPrototype: any) => {
6364
};
6465
};
6566

67+
/**
68+
* Patches the text content of an unnamed slotted node inside a scoped component
69+
* @param hostElementPrototype the `Element` to be patched
70+
* @param cmpMeta component runtime metadata used to determine if the component should be patched or not
71+
*/
72+
export const patchTextContent = (hostElementPrototype: HTMLElement, cmpMeta: d.ComponentRuntimeMeta): void => {
73+
if (BUILD.scoped && cmpMeta.$flags$ & CMP_FLAGS.scopedCssEncapsulation) {
74+
const descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent');
75+
76+
Object.defineProperty(hostElementPrototype, '__textContent', descriptor);
77+
78+
Object.defineProperty(hostElementPrototype, 'textContent', {
79+
get(): string | null {
80+
// get the 'default slot', which would be the first slot in a shadow tree (if we were using one), whose name is
81+
// the empty string
82+
const slotNode = getHostSlotNode(this.childNodes, '');
83+
// when a slot node is found, the textContent _may_ be found in the next sibling (text) node, depending on how
84+
// nodes were reordered during the vdom render. first try to get the text content from the sibling.
85+
if (slotNode?.nextSibling?.nodeType === NODE_TYPES.TEXT_NODE) {
86+
return slotNode.nextSibling.textContent;
87+
} else if (slotNode) {
88+
return slotNode.textContent;
89+
} else {
90+
// fallback to the original implementation
91+
return this.__textContent;
92+
}
93+
},
94+
95+
set(value: string | null) {
96+
// get the 'default slot', which would be the first slot in a shadow tree (if we were using one), whose name is
97+
// the empty string
98+
const slotNode = getHostSlotNode(this.childNodes, '');
99+
// when a slot node is found, the textContent _may_ need to be placed in the next sibling (text) node,
100+
// depending on how nodes were reordered during the vdom render. first try to set the text content on the
101+
// sibling.
102+
if (slotNode?.nextSibling?.nodeType === NODE_TYPES.TEXT_NODE) {
103+
slotNode.nextSibling.textContent = value;
104+
} else if (slotNode) {
105+
slotNode.textContent = value;
106+
} else {
107+
// we couldn't find a slot, but that doesn't mean that there isn't one. if this check ran before the DOM
108+
// loaded, we could have missed it. check for a content reference element on the scoped component and insert
109+
// it there
110+
this.__textContent = value;
111+
const contentRefElm = this['s-cr'];
112+
if (contentRefElm) {
113+
this.insertBefore(contentRefElm, this.firstChild);
114+
}
115+
}
116+
},
117+
});
118+
}
119+
};
120+
66121
export const patchChildSlotNodes = (elm: any, cmpMeta: d.ComponentRuntimeMeta) => {
67122
class FakeNodeList extends Array {
68123
item(n: number) {
@@ -109,6 +164,12 @@ export const patchChildSlotNodes = (elm: any, cmpMeta: d.ComponentRuntimeMeta) =
109164
const getSlotName = (node: d.RenderNode) =>
110165
node['s-sn'] || (node.nodeType === 1 && (node as Element).getAttribute('slot')) || '';
111166

167+
/**
168+
* Recursively searches a series of child nodes for a slot with the provided name.
169+
* @param childNodes the nodes to search for a slot with a specific name.
170+
* @param slotName the name of the slot to match on.
171+
* @returns a reference to the slot node that matches the provided name, `null` otherwise
172+
*/
112173
const getHostSlotNode = (childNodes: NodeListOf<ChildNode>, slotName: string) => {
113174
let i = 0;
114175
let childNode: d.RenderNode;

src/runtime/event-emitter.ts

+7
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ export const createEvent = (ref: d.RuntimeRef, name: string, flags: number) => {
2121
};
2222
};
2323

24+
/**
25+
* Helper function to create & dispatch a custom Event on a provided target
26+
* @param elm the target of the Event
27+
* @param name the name to give the custom Event
28+
* @param opts options for configuring a custom Event
29+
* @returns the custom Event
30+
*/
2431
export const emitEvent = (elm: EventTarget, name: string, opts?: CustomEventInit) => {
2532
const ev = plt.ce(name, opts);
2633
elm.dispatchEvent(ev);

src/runtime/runtime-constants.ts

+7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
export const enum VNODE_FLAGS {
22
isSlotReference = 1 << 0,
3+
4+
// slot element has fallback content
5+
// still create an element that "mocks" the slot element
36
isSlotFallback = 1 << 1,
7+
8+
// slot element does not have fallback content
9+
// create an html comment we'll use to always reference
10+
// where actual slot content should sit next to
411
isHost = 1 << 2,
512
}
613

src/testing/reset-build-conditionals.ts

+1
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,6 @@ export function resetBuildConditionals(b: d.BuildConditionals) {
4242
b.hotModuleReplacement = false;
4343
b.safari10 = false;
4444
b.scriptDataOpts = false;
45+
b.scopedSlotTextContentFix = false;
4546
b.slotChildNodesFix = false;
4647
}

test/karma/stencil.config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const config: Config = {
3232
dynamicImportShim: true,
3333
lifecycleDOMEvents: true,
3434
safari10: true,
35+
scopedSlotTextContentFix: true,
3536
scriptDataOpts: true,
3637
shadowDomShim: true,
3738
slotChildNodesFix: true,

test/karma/test-app/components.d.ts

+26
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ export namespace Components {
4747
}
4848
interface BuildData {
4949
}
50+
interface CmpLabel {
51+
}
52+
interface CmpLabelWithSlotSibling {
53+
}
5054
interface ConditionalBasic {
5155
}
5256
interface ConditionalRerender {
@@ -355,6 +359,18 @@ declare global {
355359
prototype: HTMLBuildDataElement;
356360
new (): HTMLBuildDataElement;
357361
};
362+
interface HTMLCmpLabelElement extends Components.CmpLabel, HTMLStencilElement {
363+
}
364+
var HTMLCmpLabelElement: {
365+
prototype: HTMLCmpLabelElement;
366+
new (): HTMLCmpLabelElement;
367+
};
368+
interface HTMLCmpLabelWithSlotSiblingElement extends Components.CmpLabelWithSlotSibling, HTMLStencilElement {
369+
}
370+
var HTMLCmpLabelWithSlotSiblingElement: {
371+
prototype: HTMLCmpLabelWithSlotSiblingElement;
372+
new (): HTMLCmpLabelWithSlotSiblingElement;
373+
};
358374
interface HTMLConditionalBasicElement extends Components.ConditionalBasic, HTMLStencilElement {
359375
}
360376
var HTMLConditionalBasicElement: {
@@ -990,6 +1006,8 @@ declare global {
9901006
"attribute-html-root": HTMLAttributeHtmlRootElement;
9911007
"bad-shared-jsx": HTMLBadSharedJsxElement;
9921008
"build-data": HTMLBuildDataElement;
1009+
"cmp-label": HTMLCmpLabelElement;
1010+
"cmp-label-with-slot-sibling": HTMLCmpLabelWithSlotSiblingElement;
9931011
"conditional-basic": HTMLConditionalBasicElement;
9941012
"conditional-rerender": HTMLConditionalRerenderElement;
9951013
"conditional-rerender-root": HTMLConditionalRerenderRootElement;
@@ -1135,6 +1153,10 @@ declare namespace LocalJSX {
11351153
}
11361154
interface BuildData {
11371155
}
1156+
interface CmpLabel {
1157+
}
1158+
interface CmpLabelWithSlotSibling {
1159+
}
11381160
interface ConditionalBasic {
11391161
}
11401162
interface ConditionalRerender {
@@ -1399,6 +1421,8 @@ declare namespace LocalJSX {
13991421
"attribute-html-root": AttributeHtmlRoot;
14001422
"bad-shared-jsx": BadSharedJsx;
14011423
"build-data": BuildData;
1424+
"cmp-label": CmpLabel;
1425+
"cmp-label-with-slot-sibling": CmpLabelWithSlotSibling;
14021426
"conditional-basic": ConditionalBasic;
14031427
"conditional-rerender": ConditionalRerender;
14041428
"conditional-rerender-root": ConditionalRerenderRoot;
@@ -1519,6 +1543,8 @@ declare module "@stencil/core" {
15191543
"attribute-html-root": LocalJSX.AttributeHtmlRoot & JSXBase.HTMLAttributes<HTMLAttributeHtmlRootElement>;
15201544
"bad-shared-jsx": LocalJSX.BadSharedJsx & JSXBase.HTMLAttributes<HTMLBadSharedJsxElement>;
15211545
"build-data": LocalJSX.BuildData & JSXBase.HTMLAttributes<HTMLBuildDataElement>;
1546+
"cmp-label": LocalJSX.CmpLabel & JSXBase.HTMLAttributes<HTMLCmpLabelElement>;
1547+
"cmp-label-with-slot-sibling": LocalJSX.CmpLabelWithSlotSibling & JSXBase.HTMLAttributes<HTMLCmpLabelWithSlotSiblingElement>;
15221548
"conditional-basic": LocalJSX.ConditionalBasic & JSXBase.HTMLAttributes<HTMLConditionalBasicElement>;
15231549
"conditional-rerender": LocalJSX.ConditionalRerender & JSXBase.HTMLAttributes<HTMLConditionalRerenderElement>;
15241550
"conditional-rerender-root": LocalJSX.ConditionalRerenderRoot & JSXBase.HTMLAttributes<HTMLConditionalRerenderRootElement>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Component, Host, h } from '@stencil/core';
2+
3+
@Component({
4+
tag: 'cmp-label-with-slot-sibling',
5+
scoped: true,
6+
})
7+
export class CmpLabelWithSlotSibling {
8+
render() {
9+
return (
10+
<Host>
11+
<label>
12+
<slot />
13+
<div>Non-slotted text</div>
14+
</label>
15+
</Host>
16+
);
17+
}
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<!DOCTYPE html>
2+
<meta charset="utf8">
3+
<script src="/build/testapp.esm.js" type="module"></script>
4+
<script src="/build/testapp.js" nomodule></script>
5+
6+
<cmp-label-with-slot-sibling>This text should go in a slot</cmp-label-with-slot-sibling>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { setupDomTests } from '../util';
2+
3+
describe('scoped-slot-text-with-sibling', () => {
4+
const { setupDom, tearDownDom } = setupDomTests(document);
5+
let app: HTMLElement | undefined;
6+
7+
beforeEach(async () => {
8+
app = await setupDom('/scoped-slot-text-with-sibling/index.html');
9+
});
10+
11+
afterEach(tearDownDom);
12+
13+
/**
14+
* Helper function to retrieve custom element used by this test suite. If the element cannot be found, the test that
15+
* invoked this function shall fail.
16+
* @returns the custom element
17+
*/
18+
function getCmpLabel(): HTMLCmpLabelElement {
19+
const customElementSelector = 'cmp-label-with-slot-sibling';
20+
const cmpLabel: HTMLCmpLabelElement = app.querySelector(customElementSelector);
21+
if (!cmpLabel) {
22+
fail(`Unable to find element using query selector '${customElementSelector}'`);
23+
}
24+
25+
return cmpLabel;
26+
}
27+
28+
it('sets the textContent in the slot location', () => {
29+
const cmpLabel: HTMLCmpLabelElement = getCmpLabel();
30+
31+
cmpLabel.textContent = 'New text to go in the slot';
32+
33+
expect(cmpLabel.textContent).toBe('New text to go in the slot');
34+
});
35+
36+
it("doesn't override all children when assigning textContent", () => {
37+
const cmpLabel: HTMLCmpLabelElement = getCmpLabel();
38+
39+
cmpLabel.textContent = "New text that we want to go in a slot, but don't care about for this test";
40+
41+
const divElement: HTMLDivElement = cmpLabel.querySelector('div');
42+
expect(divElement?.textContent).toBe('Non-slotted text');
43+
});
44+
45+
it('leaves the structure of the label intact', () => {
46+
const cmpLabel: HTMLCmpLabelElement = getCmpLabel();
47+
48+
cmpLabel.textContent = 'New text for label structure testing';
49+
50+
const label: HTMLLabelElement = cmpLabel.querySelector('label');
51+
52+
/**
53+
* Expect three child nodes in the label
54+
* - a content reference text node
55+
* - the slotted text node
56+
* - the non-slotted text
57+
*/
58+
expect(label).toBeDefined();
59+
expect(label.childNodes.length).toBe(3);
60+
expect(label.childNodes[0]['s-cr']).toBeDefined();
61+
expect(label.childNodes[1].textContent).toBe('New text for label structure testing');
62+
expect(label.childNodes[2].textContent).toBe('Non-slotted text');
63+
});
64+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Component, Host, h } from '@stencil/core';
2+
3+
@Component({
4+
tag: 'cmp-label',
5+
scoped: true,
6+
})
7+
export class CmpLabel {
8+
render() {
9+
return (
10+
<Host>
11+
<label>
12+
<slot />
13+
</label>
14+
</Host>
15+
);
16+
}
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<!DOCTYPE html>
2+
<meta charset="utf8">
3+
<script src="/build/testapp.esm.js" type="module"></script>
4+
<script src="/build/testapp.js" nomodule></script>
5+
6+
<cmp-label>This text should go in a slot</cmp-label>

0 commit comments

Comments
 (0)