Skip to content

Commit 0ee951e

Browse files
authored
fix(compiler): proper discovery and processing of external mixins / classes (#6620)
* fix(compiler): proper discovery and processing of external mixin / classes * chore: * chore: add tests
1 parent d1e11dc commit 0ee951e

File tree

23 files changed

+1139
-6
lines changed

23 files changed

+1139
-6
lines changed

src/compiler/build/compiler-ctx.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export const getModuleLegacy = (compilerCtx: d.CompilerCtx, sourceFilePath: stri
9090
cmps: [],
9191
isExtended: false,
9292
isMixin: false,
93+
hasExportableMixins: false,
9394
coreRuntimeApis: [],
9495
outputTargetCoreRuntimeApis: {},
9596
collectionName: null,

src/compiler/output-targets/dist-collection/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@ const serializeCollectionManifest = (config: d.ValidatedConfig, compilerCtx: d.C
124124
entries: buildCtx.moduleFiles
125125
.filter((mod) => !mod.isCollectionDependency && mod.cmps.length > 0)
126126
.map((mod) => relative(config.srcDir, mod.jsFilePath)),
127+
// Include mixin/abstract class modules that can be extended by consuming projects
128+
// These are modules with Stencil static members but no @Component decorator
129+
mixins: buildCtx.moduleFiles
130+
.filter((mod) => !mod.isCollectionDependency && mod.hasExportableMixins && mod.cmps.length === 0)
131+
.map((mod) => relative(config.srcDir, mod.jsFilePath)),
127132
compiler: {
128133
name: '@stencil/core',
129134
version,

src/compiler/transformers/collections/parse-collection-components.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ export const parseCollectionComponents = (
1212
collectionManifest: d.CollectionManifest,
1313
collection: d.CollectionCompilerMeta,
1414
) => {
15+
// Load mixin/abstract class entries (classes that can be extended by consuming projects)
16+
if (collectionManifest.mixins) {
17+
collectionManifest.mixins.forEach((mixinPath) => {
18+
const fullPath = join(collectionDir, mixinPath);
19+
transpileCollectionModule(config, compilerCtx, buildCtx, collection, fullPath);
20+
});
21+
}
22+
23+
// Load component entries
1524
if (collectionManifest.entries) {
1625
collectionManifest.entries.forEach((entryPath) => {
1726
const componentPath = join(collectionDir, entryPath);

src/compiler/transformers/static-to-meta/class-extension.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import ts from 'typescript';
2-
import { augmentDiagnosticWithNode, buildWarn } from '@utils';
2+
import { augmentDiagnosticWithNode, buildWarn, normalizePath } from '@utils';
33
import { tsResolveModuleName, tsGetSourceFile } from '../../sys/typescript/typescript-resolve-module';
44
import { isStaticGetter } from '../transform-utils';
55
import { parseStaticEvents } from './events';
@@ -215,6 +215,21 @@ function matchesNamedDeclaration(name: string) {
215215
};
216216
}
217217

218+
/**
219+
* Helper function to convert a .d.ts declaration file path to its corresponding
220+
* .js source file path and get the source file from the compiler context.
221+
* This is needed because in external projects the extended class may only be found as a .d.ts declaration.
222+
* *
223+
* @param declarationSourceFile the path to the .d.ts declaration file
224+
* @param compilerCtx the current compiler context
225+
* @returns the corresponding .js source file
226+
*/
227+
function convertDtsToJs(declarationSourceFile: string, compilerCtx: d.CompilerCtx): ts.SourceFile {
228+
const jsPath = normalizePath(declarationSourceFile.replace(/\.d\.ts$/, '.js').replace('/types/', '/collection/'));
229+
const jsModule = compilerCtx.moduleMap.get(jsPath);
230+
return jsModule?.staticSourceFile as ts.SourceFile;
231+
}
232+
218233
/**
219234
* A recursive function that builds a tree of classes that extend from each other.
220235
*
@@ -264,14 +279,23 @@ function buildExtendsTree(
264279
try {
265280
// happy path (normally 1 file level removed): the extends type resolves to a class declaration in another file
266281

267-
const symbol = typeChecker.getSymbolAtLocation(extendee);
282+
const symbol = typeChecker?.getSymbolAtLocation(extendee);
268283
const aliasedSymbol = symbol ? typeChecker.getAliasedSymbol(symbol) : undefined;
269-
foundClassDeclaration = aliasedSymbol?.declarations?.find(ts.isClassDeclaration);
284+
285+
let source = aliasedSymbol?.declarations?.[0].getSourceFile();
286+
let declarations: ts.Declaration[] | ts.Statement[] = aliasedSymbol?.declarations;
287+
288+
if (source.fileName.endsWith('.d.ts')) {
289+
source = convertDtsToJs(source.fileName, compilerCtx);
290+
declarations = [...source.statements];
291+
}
292+
293+
foundClassDeclaration = declarations?.find(ts.isClassDeclaration);
270294

271295
if (!foundClassDeclaration) {
272296
// the found `extends` type does not resolve to a class declaration;
273297
// if it's wrapped in a function - let's try and find it inside
274-
const node = aliasedSymbol?.declarations?.[0];
298+
const node = declarations?.[0];
275299
foundClassDeclaration = findClassWalk(node);
276300
if (!node) {
277301
throw 'revert to sad path';

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

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,53 @@ import ts from 'typescript';
44

55
import type * as d from '../../../declarations';
66
import { createModule, getModule } from '../../transpile/transpiled-module';
7+
import { getComponentTagName, isStaticGetter } from '../transform-utils';
78
import { parseCallExpression } from './call-expression';
89
import { parseStaticComponentMeta } from './component';
910
import { parseModuleImport } from './import';
1011
import { parseStringLiteral } from './string-literal';
1112

13+
/**
14+
* Stencil static getter names that indicate a class has Stencil metadata
15+
* and can be extended by other components (mixin/abstract class pattern).
16+
*/
17+
const STENCIL_MIXIN_STATIC_MEMBERS = ['properties', 'states', 'methods', 'events', 'listeners', 'watchers'];
18+
19+
/**
20+
* Gets the name of a class member as a string, safely handling cases where
21+
* getText() might not work (e.g., synthetic nodes without source file context).
22+
*/
23+
const getMemberName = (member: ts.ClassElement): string | undefined => {
24+
if (!member.name) return undefined;
25+
if (ts.isIdentifier(member.name)) {
26+
return member.name.text ?? member.name.escapedText?.toString();
27+
}
28+
if (ts.isStringLiteral(member.name)) {
29+
return member.name.text;
30+
}
31+
return undefined;
32+
};
33+
34+
/**
35+
* Checks if a class declaration is an exportable mixin - i.e., it has Stencil
36+
* static getters (properties, states, etc.) but is NOT a component (no tag name).
37+
* These are abstract/partial classes meant to be extended by actual components.
38+
*/
39+
const isExportableMixinClass = (classNode: ts.ClassDeclaration): boolean => {
40+
const staticGetters = classNode.members.filter(isStaticGetter);
41+
if (staticGetters.length === 0) return false;
42+
43+
// If it has a tag name, it's a component, not a mixin
44+
const tagName = getComponentTagName(staticGetters);
45+
if (tagName) return false;
46+
47+
// Check if it has any Stencil mixin static members
48+
return staticGetters.some((getter) => {
49+
const name = getMemberName(getter);
50+
return name && STENCIL_MIXIN_STATIC_MEMBERS.includes(name);
51+
});
52+
};
53+
1254
export const updateModule = (
1355
config: d.ValidatedConfig,
1456
compilerCtx: d.CompilerCtx,
@@ -46,7 +88,13 @@ export const updateModule = (
4688

4789
const visitNode = (node: ts.Node) => {
4890
if (ts.isClassDeclaration(node)) {
91+
// First try to parse as a component
4992
parseStaticComponentMeta(compilerCtx, typeChecker, node, moduleFile, buildCtx, undefined);
93+
94+
// Also check if this is an exportable mixin class (has Stencil static members but no tag)
95+
if (isExportableMixinClass(node)) {
96+
moduleFile.hasExportableMixins = true;
97+
}
5098
return;
5199
} else if (ts.isImportDeclaration(node)) {
52100
parseModuleImport(config, compilerCtx, buildCtx, moduleFile, srcDirPath, node, true);
@@ -64,8 +112,11 @@ export const updateModule = (
64112
// Handle functions with block body: (Base) => { class MyMixin ... }
65113
if (ts.isBlock(funcBody)) {
66114
funcBody.statements.forEach((statement) => {
67-
// Look for class declarations in the function body
115+
// Look for class declarations in the function body (mixin factory pattern)
68116
if (ts.isClassDeclaration(statement)) {
117+
if (isExportableMixinClass(statement)) {
118+
moduleFile.hasExportableMixins = true;
119+
}
69120
statement.members.forEach((member) => {
70121
if (ts.isPropertyDeclaration(member) && member.initializer) {
71122
// Traverse into the property initializer (e.g., arrow function)
@@ -91,7 +142,9 @@ export const updateModule = (
91142

92143
// TODO: workaround around const enums
93144
// find better way
94-
if (moduleFile.cmps.length > 0) {
145+
// Create staticSourceFile for modules with components OR exportable mixins
146+
// (needed for class-extension to process mixin metadata from external collections)
147+
if (moduleFile.cmps.length > 0 || moduleFile.hasExportableMixins) {
95148
moduleFile.staticSourceFile = ts.createSourceFile(
96149
sourceFilePath,
97150
sourceFileText,

0 commit comments

Comments
 (0)