Skip to content

Commit adb3ccf

Browse files
authored
fix(runtime): prevent additional attempted move of slot content (#4921)
* yo this might actually work * create single build flag for `experimentalSlotFixes` * gate new vDOM logic behind build flag * add e2e test for repro case * document new node property * PR feedback
1 parent eb3ae9a commit adb3ccf

File tree

14 files changed

+182
-9
lines changed

14 files changed

+182
-9
lines changed

src/app-data/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export const BUILD: BuildConditionals = {
109109
transformTagName: false,
110110
attachStyles: true,
111111
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
112-
patchPseudoShadowDom: false,
112+
experimentalSlotFixes: false,
113113
};
114114

115115
export const Env = {};

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ export const updateBuildConditionals = (config: ValidatedConfig, b: BuildConditi
168168
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
169169
b.slotChildNodesFix = config.extras.slotChildNodesFix;
170170
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
171-
b.patchPseudoShadowDom = config.extras.experimentalSlotFixes;
171+
b.experimentalSlotFixes = config.extras.experimentalSlotFixes;
172172
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
173173
b.cloneNodeFix = config.extras.cloneNodeFix;
174174
b.lifecycleDOMEvents = !!(b.isDebug || config._isTesting || config.extras.lifecycleDOMEvents);

src/compiler/output-targets/dist-hydrate-script/hydrate-build-conditionals.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export const getHydrateBuildConditionals = (cmps: d.ComponentCompilerMeta[]) =>
2424
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
2525
build.slotChildNodesFix = false;
2626
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
27-
build.patchPseudoShadowDom = false;
27+
build.experimentalSlotFixes = false;
2828
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
2929
build.cloneNodeFix = false;
3030
build.cssAnnotations = true;

src/declarations/stencil-private.ts

+14-1
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export interface BuildConditionals extends Partial<BuildFeatures> {
186186
attachStyles?: boolean;
187187

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

192192
export type ModuleFormat =
@@ -1364,6 +1364,19 @@ export interface RenderNode extends HostElement {
13641364
*/
13651365
['s-hn']?: string;
13661366

1367+
/**
1368+
* Slot host tag name:
1369+
* This is the tag name of the element where this node
1370+
* has been moved to during slot relocation.
1371+
*
1372+
* This allows us to check if the node has been moved and prevent
1373+
* us from thinking a node _should_ be moved when it may already be in
1374+
* its final destination.
1375+
*
1376+
* This value is set to `undefined` whenever the node is put back into its original location.
1377+
*/
1378+
['s-sh']?: string;
1379+
13671380
/**
13681381
* Original Location Reference:
13691382
* A reference pointing to the comment

src/runtime/bootstrap-custom-element.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export const proxyCustomElement = (Cstr: any, compactMeta: d.ComponentRuntimeMet
4545

4646
// TODO(STENCIL-914): this check and `else` block can go away and be replaced by just `BUILD.scoped` once we
4747
// default our pseudo-slot behavior
48-
if (BUILD.patchPseudoShadowDom && BUILD.scoped) {
48+
if (BUILD.experimentalSlotFixes && BUILD.scoped) {
4949
patchPseudoShadowDom(Cstr.prototype, cmpMeta);
5050
} else {
5151
if (BUILD.slotChildNodesFix) {

src/runtime/bootstrap-lazy.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d.
140140

141141
// TODO(STENCIL-914): this check and `else` block can go away and be replaced by just `BUILD.scoped` once we
142142
// default our pseudo-slot behavior
143-
if (BUILD.patchPseudoShadowDom && BUILD.scoped) {
143+
if (BUILD.experimentalSlotFixes && BUILD.scoped) {
144144
patchPseudoShadowDom(HostElement.prototype, cmpMeta);
145145
} else {
146146
if (BUILD.slotChildNodesFix) {

src/runtime/vdom/vdom-render.ts

+16-2
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ const putBackInOriginalLocation = (parentElm: Node, recursive: boolean) => {
181181
childNode['s-ol'].remove();
182182
childNode['s-ol'] = undefined;
183183

184+
// Reset so we can correctly move the node around again.
185+
childNode['s-sh'] = undefined;
186+
184187
checkSlotRelocate = true;
185188
}
186189

@@ -723,8 +726,17 @@ const markSlotContentForRelocation = (elm: d.RenderNode) => {
723726

724727
// check that the node is not a content reference node or a node
725728
// reference and then check that the host name does not match that of
726-
// childNode
727-
if (!node['s-cn'] && !node['s-nr'] && node['s-hn'] !== childNode['s-hn']) {
729+
// childNode.
730+
// In addition, check that the slot either has not already been relocated, or
731+
// that its current location's host is not childNode's host. This is essentially
732+
// a check so that we don't try to relocate (and then hide) a node that is already
733+
// where it should be.
734+
if (
735+
!node['s-cn'] &&
736+
!node['s-nr'] &&
737+
node['s-hn'] !== childNode['s-hn'] &&
738+
(!BUILD.experimentalSlotFixes || !node['s-sh'] || node['s-sh'] !== childNode['s-hn'])
739+
) {
728740
// if `node` is located in the slot that `childNode` refers to (via the
729741
// `'s-sn'` property) then we need to relocate it from it's current spot
730742
// (under the host element parent) to the right slot location
@@ -740,11 +752,13 @@ const markSlotContentForRelocation = (elm: d.RenderNode) => {
740752
node['s-sn'] = node['s-sn'] || slotName;
741753

742754
if (relocateNodeData) {
755+
relocateNodeData.$nodeToRelocate$['s-sh'] = childNode['s-hn'];
743756
// we marked this node for relocation previously but didn't find
744757
// out the slot reference node to which it needs to be relocated
745758
// so write it down now!
746759
relocateNodeData.$slotRefNode$ = childNode;
747760
} else {
761+
node['s-sh'] = childNode['s-hn'];
748762
// add to our list of nodes to relocate
749763
relocateNodes.push({
750764
$slotRefNode$: childNode,

src/testing/reset-build-conditionals.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,5 @@ export function resetBuildConditionals(b: d.BuildConditionals) {
5656
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
5757
b.slotChildNodesFix = false;
5858
// TODO(STENCIL-914): remove this option when `experimentalSlotFixes` is the default behavior
59-
b.patchPseudoShadowDom = false;
59+
b.experimentalSlotFixes = false;
6060
}

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

+39
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,12 @@ export namespace Components {
150150
interface InputBasicRoot {
151151
"value"?: string;
152152
}
153+
interface IonChild {
154+
}
155+
interface IonHost {
156+
}
157+
interface IonParent {
158+
}
153159
interface JsonBasic {
154160
}
155161
interface KeyReorder {
@@ -730,6 +736,24 @@ declare global {
730736
prototype: HTMLInputBasicRootElement;
731737
new (): HTMLInputBasicRootElement;
732738
};
739+
interface HTMLIonChildElement extends Components.IonChild, HTMLStencilElement {
740+
}
741+
var HTMLIonChildElement: {
742+
prototype: HTMLIonChildElement;
743+
new (): HTMLIonChildElement;
744+
};
745+
interface HTMLIonHostElement extends Components.IonHost, HTMLStencilElement {
746+
}
747+
var HTMLIonHostElement: {
748+
prototype: HTMLIonHostElement;
749+
new (): HTMLIonHostElement;
750+
};
751+
interface HTMLIonParentElement extends Components.IonParent, HTMLStencilElement {
752+
}
753+
var HTMLIonParentElement: {
754+
prototype: HTMLIonParentElement;
755+
new (): HTMLIonParentElement;
756+
};
733757
interface HTMLJsonBasicElement extends Components.JsonBasic, HTMLStencilElement {
734758
}
735759
var HTMLJsonBasicElement: {
@@ -1364,6 +1388,9 @@ declare global {
13641388
"image-import": HTMLImageImportElement;
13651389
"init-css-root": HTMLInitCssRootElement;
13661390
"input-basic-root": HTMLInputBasicRootElement;
1391+
"ion-child": HTMLIonChildElement;
1392+
"ion-host": HTMLIonHostElement;
1393+
"ion-parent": HTMLIonParentElement;
13671394
"json-basic": HTMLJsonBasicElement;
13681395
"key-reorder": HTMLKeyReorderElement;
13691396
"key-reorder-root": HTMLKeyReorderRootElement;
@@ -1594,6 +1621,12 @@ declare namespace LocalJSX {
15941621
interface InputBasicRoot {
15951622
"value"?: string;
15961623
}
1624+
interface IonChild {
1625+
}
1626+
interface IonHost {
1627+
}
1628+
interface IonParent {
1629+
}
15971630
interface JsonBasic {
15981631
}
15991632
interface KeyReorder {
@@ -1865,6 +1898,9 @@ declare namespace LocalJSX {
18651898
"image-import": ImageImport;
18661899
"init-css-root": InitCssRoot;
18671900
"input-basic-root": InputBasicRoot;
1901+
"ion-child": IonChild;
1902+
"ion-host": IonHost;
1903+
"ion-parent": IonParent;
18681904
"json-basic": JsonBasic;
18691905
"key-reorder": KeyReorder;
18701906
"key-reorder-root": KeyReorderRoot;
@@ -2011,6 +2047,9 @@ declare module "@stencil/core" {
20112047
"image-import": LocalJSX.ImageImport & JSXBase.HTMLAttributes<HTMLImageImportElement>;
20122048
"init-css-root": LocalJSX.InitCssRoot & JSXBase.HTMLAttributes<HTMLInitCssRootElement>;
20132049
"input-basic-root": LocalJSX.InputBasicRoot & JSXBase.HTMLAttributes<HTMLInputBasicRootElement>;
2050+
"ion-child": LocalJSX.IonChild & JSXBase.HTMLAttributes<HTMLIonChildElement>;
2051+
"ion-host": LocalJSX.IonHost & JSXBase.HTMLAttributes<HTMLIonHostElement>;
2052+
"ion-parent": LocalJSX.IonParent & JSXBase.HTMLAttributes<HTMLIonParentElement>;
20142053
"json-basic": LocalJSX.JsonBasic & JSXBase.HTMLAttributes<HTMLJsonBasicElement>;
20152054
"key-reorder": LocalJSX.KeyReorder & JSXBase.HTMLAttributes<HTMLKeyReorderElement>;
20162055
"key-reorder-root": LocalJSX.KeyReorderRoot & JSXBase.HTMLAttributes<HTMLKeyReorderRootElement>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Component, h } from '@stencil/core';
2+
3+
@Component({
4+
tag: 'ion-child',
5+
scoped: true,
6+
})
7+
export class Child {
8+
render() {
9+
return (
10+
<div style={{ display: 'flex', gap: '13px' }}>
11+
<slot name="suffix" />
12+
</div>
13+
);
14+
}
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Component, h } from '@stencil/core';
2+
3+
@Component({
4+
tag: 'ion-host',
5+
scoped: true,
6+
})
7+
export class Host {
8+
render() {
9+
return (
10+
<div>
11+
<ion-parent>
12+
<slot name="label" slot="label" />
13+
<slot name="suffix" slot="suffix" />
14+
<slot name="message" slot="message" />
15+
</ion-parent>
16+
</div>
17+
);
18+
}
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
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+
<ion-host>
7+
<span slot="label">Label text</span>
8+
<span slot="suffix">Suffix text</span>
9+
<span slot="message">Message text</span>
10+
</ion-host>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { setupDomTests } from '../util';
2+
3+
describe('scoped-slot-in-slot', () => {
4+
const { setupDom, tearDownDom } = setupDomTests(document);
5+
6+
let app: HTMLElement | undefined;
7+
let host: HTMLElement | undefined;
8+
9+
beforeEach(async () => {
10+
app = await setupDom('/scoped-slot-in-slot/index.html');
11+
host = app.querySelector('ion-host');
12+
});
13+
14+
afterEach(tearDownDom);
15+
16+
it('correctly renders content slotted through multiple levels of nested slots', async () => {
17+
expect(host).toBeDefined();
18+
19+
// Check the parent content
20+
const parent = host.querySelector('ion-parent');
21+
expect(parent).toBeDefined();
22+
expect(parent.firstElementChild.tagName).toBe('LABEL');
23+
24+
// Ensure the label slot content made it through
25+
const span = parent.firstElementChild.firstElementChild;
26+
expect(span).toBeDefined();
27+
expect(span.tagName).toBe('SPAN');
28+
expect(span.textContent).toBe('Label text');
29+
30+
// Ensure the message slot content made it through
31+
expect(parent.lastElementChild.tagName).toBe('SPAN');
32+
expect(parent.lastElementChild.textContent).toBe('Message text');
33+
34+
// Check the child content
35+
const child = parent.querySelector('ion-child');
36+
expect(child).toBeDefined();
37+
38+
// Ensure the suffix slot content made it through
39+
expect(child.firstElementChild.firstElementChild.tagName).toBe('SPAN');
40+
expect(child.firstElementChild.firstElementChild.textContent).toBe('Suffix text');
41+
});
42+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Component, Fragment, h } from '@stencil/core';
2+
3+
@Component({
4+
tag: 'ion-parent',
5+
scoped: true,
6+
})
7+
export class Parent {
8+
render() {
9+
return (
10+
<Fragment>
11+
<label>
12+
<slot name="label" />
13+
</label>
14+
<ion-child>
15+
<slot name="suffix" slot="suffix" />
16+
</ion-child>
17+
<slot name="message" />
18+
</Fragment>
19+
);
20+
}
21+
}

0 commit comments

Comments
 (0)