Skip to content

Commit ccda746

Browse files
authored
fix(compiler): mixin jsx processing (#6615)
* fix(compiler): mixin jsx processing * chore: * chore: add test
1 parent ad6a344 commit ccda746

8 files changed

Lines changed: 210 additions & 0 deletions

File tree

src/compiler/transformers/static-to-meta/call-expression.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,27 @@ export const parseCallExpression = (
2121
visitCallExpressionArgs(m, n, node.arguments, typeChecker);
2222
}
2323
}
24+
} else if (ts.isPropertyAccessExpression(node.expression)) {
25+
if (ts.isIdentifier(node.expression.name)) {
26+
// potential function calls that return jsx e.g. `renderContent()`
27+
const symbol = typeChecker?.getSymbolAtLocation(node.expression);
28+
if (!symbol) return;
29+
30+
const declarations = symbol.getDeclarations();
31+
if (!declarations || declarations.length === 0) return;
32+
33+
const declaration = declarations[0];
34+
const sourceFile = declaration.getSourceFile();
35+
if (!sourceFile) return;
36+
37+
const sourceFilePath = normalizePath(sourceFile.fileName);
38+
const moduleFile = m as d.Module;
39+
if (moduleFile.functionalComponentDeps) {
40+
if (!moduleFile.functionalComponentDeps.includes(sourceFilePath)) {
41+
moduleFile.functionalComponentDeps.push(sourceFilePath);
42+
}
43+
}
44+
}
2445
}
2546
};
2647

src/compiler/transformers/static-to-meta/parse-static.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,29 @@ export const updateModule = (
5555
parseCallExpression(moduleFile, node, typeChecker);
5656
} else if (ts.isStringLiteral(node)) {
5757
parseStringLiteral(moduleFile, node);
58+
} else if (ts.isVariableStatement(node)) {
59+
// Look for mixin patterns like `const MyMixin = (Base) => class MyMixin extends Base { ... }`
60+
node.declarationList.declarations.forEach((declaration) => {
61+
if (declaration.initializer) {
62+
if (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer)) {
63+
const funcBody = declaration.initializer.body;
64+
// Handle functions with block body: (Base) => { class MyMixin ... }
65+
if (ts.isBlock(funcBody)) {
66+
funcBody.statements.forEach((statement) => {
67+
// Look for class declarations in the function body
68+
if (ts.isClassDeclaration(statement)) {
69+
statement.members.forEach((member) => {
70+
if (ts.isPropertyDeclaration(member) && member.initializer) {
71+
// Traverse into the property initializer (e.g., arrow function)
72+
ts.forEachChild(member.initializer, visitNode);
73+
}
74+
});
75+
}
76+
});
77+
}
78+
}
79+
}
80+
});
5881
}
5982
node.forEachChild(visitNode);
6083
};

test/wdio/ts-target/components.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@ export namespace Components {
267267
*/
268268
"prop3": string;
269269
}
270+
interface ExtendsMixinSlotCmp {
271+
}
270272
/**
271273
* Test Case #3: Property & State Inheritance Basics
272274
* This component extends PropsStateBase to test:
@@ -574,6 +576,12 @@ declare global {
574576
prototype: HTMLExtendsMixinCmpElement;
575577
new (): HTMLExtendsMixinCmpElement;
576578
};
579+
interface HTMLExtendsMixinSlotCmpElement extends Components.ExtendsMixinSlotCmp, HTMLStencilElement {
580+
}
581+
var HTMLExtendsMixinSlotCmpElement: {
582+
prototype: HTMLExtendsMixinSlotCmpElement;
583+
new (): HTMLExtendsMixinSlotCmpElement;
584+
};
577585
/**
578586
* Test Case #3: Property & State Inheritance Basics
579587
* This component extends PropsStateBase to test:
@@ -706,6 +714,7 @@ declare global {
706714
"extends-methods": HTMLExtendsMethodsElement;
707715
"extends-mixed-decorators": HTMLExtendsMixedDecoratorsElement;
708716
"extends-mixin-cmp": HTMLExtendsMixinCmpElement;
717+
"extends-mixin-slot-cmp": HTMLExtendsMixinSlotCmpElement;
709718
"extends-props-state": HTMLExtendsPropsStateElement;
710719
"extends-render": HTMLExtendsRenderElement;
711720
"extends-via-host-cmp": HTMLExtendsViaHostCmpElement;
@@ -870,6 +879,8 @@ declare namespace LocalJSX {
870879
*/
871880
"prop3"?: string;
872881
}
882+
interface ExtendsMixinSlotCmp {
883+
}
873884
/**
874885
* Test Case #3: Property & State Inheritance Basics
875886
* This component extends PropsStateBase to test:
@@ -1040,6 +1051,7 @@ declare namespace LocalJSX {
10401051
"extends-methods": ExtendsMethods;
10411052
"extends-mixed-decorators": Omit<ExtendsMixedDecorators, keyof ExtendsMixedDecoratorsAttributes> & { [K in keyof ExtendsMixedDecorators & keyof ExtendsMixedDecoratorsAttributes]?: ExtendsMixedDecorators[K] } & { [K in keyof ExtendsMixedDecorators & keyof ExtendsMixedDecoratorsAttributes as `attr:${K}`]?: ExtendsMixedDecoratorsAttributes[K] } & { [K in keyof ExtendsMixedDecorators & keyof ExtendsMixedDecoratorsAttributes as `prop:${K}`]?: ExtendsMixedDecorators[K] };
10421053
"extends-mixin-cmp": Omit<ExtendsMixinCmp, keyof ExtendsMixinCmpAttributes> & { [K in keyof ExtendsMixinCmp & keyof ExtendsMixinCmpAttributes]?: ExtendsMixinCmp[K] } & { [K in keyof ExtendsMixinCmp & keyof ExtendsMixinCmpAttributes as `attr:${K}`]?: ExtendsMixinCmpAttributes[K] } & { [K in keyof ExtendsMixinCmp & keyof ExtendsMixinCmpAttributes as `prop:${K}`]?: ExtendsMixinCmp[K] };
1054+
"extends-mixin-slot-cmp": ExtendsMixinSlotCmp;
10431055
"extends-props-state": Omit<ExtendsPropsState, keyof ExtendsPropsStateAttributes> & { [K in keyof ExtendsPropsState & keyof ExtendsPropsStateAttributes]?: ExtendsPropsState[K] } & { [K in keyof ExtendsPropsState & keyof ExtendsPropsStateAttributes as `attr:${K}`]?: ExtendsPropsStateAttributes[K] } & { [K in keyof ExtendsPropsState & keyof ExtendsPropsStateAttributes as `prop:${K}`]?: ExtendsPropsState[K] };
10441056
"extends-render": ExtendsRender;
10451057
"extends-via-host-cmp": ExtendsViaHostCmp;
@@ -1105,6 +1117,7 @@ declare module "@stencil/core" {
11051117
*/
11061118
"extends-mixed-decorators": LocalJSX.IntrinsicElements["extends-mixed-decorators"] & JSXBase.HTMLAttributes<HTMLExtendsMixedDecoratorsElement>;
11071119
"extends-mixin-cmp": LocalJSX.IntrinsicElements["extends-mixin-cmp"] & JSXBase.HTMLAttributes<HTMLExtendsMixinCmpElement>;
1120+
"extends-mixin-slot-cmp": LocalJSX.IntrinsicElements["extends-mixin-slot-cmp"] & JSXBase.HTMLAttributes<HTMLExtendsMixinSlotCmpElement>;
11081121
/**
11091122
* Test Case #3: Property & State Inheritance Basics
11101123
* This component extends PropsStateBase to test:
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { browser, expect } from '@wdio/globals';
2+
import { setupIFrameTest } from '../../util.js';
3+
4+
/**
5+
* Tests for mixin slot detection. Verifies that Stencil properly walks into
6+
* mixin factory functions to detect slot elements for build conditionals.
7+
*
8+
* When the fix is working correctly, the slotted content should appear
9+
* inside the mixin-wrapper (between mixin-header and mixin-footer).
10+
*
11+
* When the fix is NOT working, the slotted content would be dumped outside
12+
* the component structure because Stencil wouldn't know there's a slot.
13+
*/
14+
describe('Mixin slot detection', () => {
15+
describe('es2022 dist output', () => {
16+
let frameContent: HTMLElement;
17+
18+
beforeEach(async () => {
19+
frameContent = await setupIFrameTest('/extends-mixin-slot/es2022.dist.html', 'es2022-dist');
20+
const frameEle = await browser.$('#es2022-dist');
21+
frameEle.waitUntil(async () => !!frameContent.querySelector('.component-root'), { timeout: 5000 });
22+
});
23+
24+
it('renders slotted content inside the mixin wrapper', async () => {
25+
const cmp = frameContent.querySelector('extends-mixin-slot-cmp');
26+
expect(cmp).toBeDefined();
27+
28+
// The slotted content should be within the component
29+
const slottedContent = cmp!.querySelector('.slotted-content');
30+
expect(slottedContent).toBeDefined();
31+
expect(slottedContent!.textContent).toBe('I am slotted content');
32+
33+
// Verify the mixin structure is present
34+
const mixinWrapper = cmp!.querySelector('.mixin-wrapper');
35+
expect(mixinWrapper).toBeDefined();
36+
37+
const header = cmp!.querySelector('.mixin-header');
38+
expect(header).toBeDefined();
39+
expect(header!.textContent).toBe('Mixin Content Header');
40+
41+
const footer = cmp!.querySelector('.mixin-footer');
42+
expect(footer).toBeDefined();
43+
expect(footer!.textContent).toBe('Mixin Content Footer');
44+
});
45+
46+
it('slotted content should not be outside the component', async () => {
47+
// If slot detection failed, the slotted content would be dumped at body level
48+
const bodySlottedContent = frameContent.querySelector(':scope > .slotted-content');
49+
expect(bodySlottedContent).toBeNull();
50+
});
51+
});
52+
53+
describe('es2022 dist-custom-elements output', () => {
54+
let frameContent: HTMLElement;
55+
56+
beforeEach(async () => {
57+
await browser.switchToParentFrame();
58+
frameContent = await setupIFrameTest('/extends-mixin-slot/es2022.custom-element.html', 'es2022-custom-elements');
59+
const frameEle = await browser.$('iframe#es2022-custom-elements');
60+
frameEle.waitUntil(async () => !!frameContent.querySelector('.component-root'), { timeout: 5000 });
61+
});
62+
63+
it('renders slotted content inside the mixin wrapper', async () => {
64+
const cmp = frameContent.querySelector('extends-mixin-slot-cmp');
65+
expect(cmp).toBeDefined();
66+
67+
// The slotted content should be within the component
68+
const slottedContent = cmp!.querySelector('.slotted-content');
69+
expect(slottedContent).toBeDefined();
70+
expect(slottedContent!.textContent).toBe('I am slotted content');
71+
72+
// Verify the mixin structure is present
73+
const mixinWrapper = cmp!.querySelector('.mixin-wrapper');
74+
expect(mixinWrapper).toBeDefined();
75+
76+
const header = cmp!.querySelector('.mixin-header');
77+
expect(header).toBeDefined();
78+
expect(header!.textContent).toBe('Mixin Content Header');
79+
80+
const footer = cmp!.querySelector('.mixin-footer');
81+
expect(footer).toBeDefined();
82+
expect(footer!.textContent).toBe('Mixin Content Footer');
83+
});
84+
85+
it('slotted content should not be outside the component', async () => {
86+
// If slot detection failed, the slotted content would be dumped at body level
87+
const bodySlottedContent = frameContent.querySelector(':scope > .slotted-content');
88+
expect(bodySlottedContent).toBeNull();
89+
});
90+
});
91+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Component, h, Mixin } from '@stencil/core';
2+
import { SlotMixinFactory } from './slot-mixin.js';
3+
4+
@Component({
5+
tag: 'extends-mixin-slot-cmp',
6+
})
7+
export class MixinSlotCmp extends Mixin(SlotMixinFactory) {
8+
render() {
9+
return (
10+
<div class="component-root">
11+
<h2 class="component-title">Mixin Slot Test Component</h2>
12+
{this.renderContent()}
13+
</div>
14+
);
15+
}
16+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<html>
2+
<head>
3+
<title>ES2022 dist-custom-elements output - Mixin Slot Test</title>
4+
<script type="module">
5+
import { defineCustomElement as defineMixinSlotCmp } from '/test-ts-target-output/custom-elements/extends-mixin-slot-cmp.js';
6+
defineMixinSlotCmp();
7+
</script>
8+
</head>
9+
<body>
10+
<h1>ES2022 dist-custom-elements output - Mixin Slot Test</h1>
11+
<extends-mixin-slot-cmp>
12+
<span slot="content" class="slotted-content">I am slotted content</span>
13+
</extends-mixin-slot-cmp>
14+
</body>
15+
</html>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<html>
2+
<head>
3+
<title>ES2022 dist output - Mixin Slot Test</title>
4+
<script src="/test-ts-target-output/dist/testtstarget/testtstarget.esm.js" type="module"></script>
5+
</head>
6+
<body>
7+
<h1>ES2022 dist output - Mixin Slot Test</h1>
8+
<extends-mixin-slot-cmp>
9+
<span slot="content" class="slotted-content">I am slotted content</span>
10+
</extends-mixin-slot-cmp>
11+
</body>
12+
</html>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { h } from '@stencil/core';
2+
3+
/**
4+
* A mixin that provides a renderContent method containing a slot.
5+
* This tests that Stencil properly walks into mixin factory functions
6+
* to detect slot elements for build conditionals.
7+
*/
8+
export const SlotMixinFactory = <T extends new (...args: any[]) => {}>(Base: T) => {
9+
class SlotMixin extends Base {
10+
renderContent = () => (
11+
<div class="mixin-wrapper">
12+
<div class="mixin-header">Mixin Content Header</div>
13+
<slot name="content" />
14+
<div class="mixin-footer">Mixin Content Footer</div>
15+
</div>
16+
);
17+
}
18+
return SlotMixin;
19+
};

0 commit comments

Comments
 (0)