Skip to content
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/compiler/build/compiler-ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export const getModuleLegacy = (compilerCtx: d.CompilerCtx, sourceFilePath: stri
cmps: [],
isExtended: false,
isMixin: false,
hasExportableMixins: false,
coreRuntimeApis: [],
outputTargetCoreRuntimeApis: {},
collectionName: null,
Expand Down
5 changes: 5 additions & 0 deletions src/compiler/output-targets/dist-collection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ const serializeCollectionManifest = (config: d.ValidatedConfig, compilerCtx: d.C
entries: buildCtx.moduleFiles
.filter((mod) => !mod.isCollectionDependency && mod.cmps.length > 0)
.map((mod) => relative(config.srcDir, mod.jsFilePath)),
// Include mixin/abstract class modules that can be extended by consuming projects
// These are modules with Stencil static members but no @Component decorator
mixins: buildCtx.moduleFiles
.filter((mod) => !mod.isCollectionDependency && mod.hasExportableMixins && mod.cmps.length === 0)
.map((mod) => relative(config.srcDir, mod.jsFilePath)),
compiler: {
name: '@stencil/core',
version,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ export const parseCollectionComponents = (
collectionManifest: d.CollectionManifest,
collection: d.CollectionCompilerMeta,
) => {
// Load mixin/abstract class entries (classes that can be extended by consuming projects)
if (collectionManifest.mixins) {
collectionManifest.mixins.forEach((mixinPath) => {
const fullPath = join(collectionDir, mixinPath);
transpileCollectionModule(config, compilerCtx, buildCtx, collection, fullPath);
});
}

// Load component entries
if (collectionManifest.entries) {
collectionManifest.entries.forEach((entryPath) => {
const componentPath = join(collectionDir, entryPath);
Expand Down
32 changes: 28 additions & 4 deletions src/compiler/transformers/static-to-meta/class-extension.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import ts from 'typescript';
import { augmentDiagnosticWithNode, buildWarn } from '@utils';
import { augmentDiagnosticWithNode, buildWarn, normalizePath } from '@utils';
import { tsResolveModuleName, tsGetSourceFile } from '../../sys/typescript/typescript-resolve-module';
import { isStaticGetter } from '../transform-utils';
import { parseStaticEvents } from './events';
Expand Down Expand Up @@ -215,6 +215,21 @@ function matchesNamedDeclaration(name: string) {
};
}

/**
* Helper function to convert a .d.ts declaration file path to its corresponding
* .js source file path and get the source file from the compiler context.
* This is needed because in external projects the extended class may only be found as a .d.ts declaration.
* *
* @param declarationSourceFile the path to the .d.ts declaration file
* @param compilerCtx the current compiler context
* @returns the corresponding .js source file
*/
function convertDtsToJs(declarationSourceFile: string, compilerCtx: d.CompilerCtx): ts.SourceFile {
const jsPath = normalizePath(declarationSourceFile.replace(/\.d\.ts$/, '.js').replace('/types/', '/collection/'));
const jsModule = compilerCtx.moduleMap.get(jsPath);
return jsModule?.staticSourceFile as ts.SourceFile;
}

/**
* A recursive function that builds a tree of classes that extend from each other.
*
Expand Down Expand Up @@ -264,14 +279,23 @@ function buildExtendsTree(
try {
// happy path (normally 1 file level removed): the extends type resolves to a class declaration in another file

const symbol = typeChecker.getSymbolAtLocation(extendee);
const symbol = typeChecker?.getSymbolAtLocation(extendee);
const aliasedSymbol = symbol ? typeChecker.getAliasedSymbol(symbol) : undefined;
foundClassDeclaration = aliasedSymbol?.declarations?.find(ts.isClassDeclaration);

let source = aliasedSymbol?.declarations?.[0].getSourceFile();
let declarations: ts.Declaration[] | ts.Statement[] = aliasedSymbol?.declarations;

if (source.fileName.endsWith('.d.ts')) {
source = convertDtsToJs(source.fileName, compilerCtx);
declarations = [...source.statements];
}

foundClassDeclaration = declarations?.find(ts.isClassDeclaration);

if (!foundClassDeclaration) {
// the found `extends` type does not resolve to a class declaration;
// if it's wrapped in a function - let's try and find it inside
const node = aliasedSymbol?.declarations?.[0];
const node = declarations?.[0];
foundClassDeclaration = findClassWalk(node);
if (!node) {
throw 'revert to sad path';
Expand Down
57 changes: 55 additions & 2 deletions src/compiler/transformers/static-to-meta/parse-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,53 @@ import ts from 'typescript';

import type * as d from '../../../declarations';
import { createModule, getModule } from '../../transpile/transpiled-module';
import { getComponentTagName, isStaticGetter } from '../transform-utils';
import { parseCallExpression } from './call-expression';
import { parseStaticComponentMeta } from './component';
import { parseModuleImport } from './import';
import { parseStringLiteral } from './string-literal';

/**
* Stencil static getter names that indicate a class has Stencil metadata
* and can be extended by other components (mixin/abstract class pattern).
*/
const STENCIL_MIXIN_STATIC_MEMBERS = ['properties', 'states', 'methods', 'events', 'listeners', 'watchers'];

/**
* Gets the name of a class member as a string, safely handling cases where
* getText() might not work (e.g., synthetic nodes without source file context).
*/
const getMemberName = (member: ts.ClassElement): string | undefined => {
if (!member.name) return undefined;
if (ts.isIdentifier(member.name)) {
return member.name.text ?? member.name.escapedText?.toString();
}
if (ts.isStringLiteral(member.name)) {
return member.name.text;
}
return undefined;
};

/**
* Checks if a class declaration is an exportable mixin - i.e., it has Stencil
* static getters (properties, states, etc.) but is NOT a component (no tag name).
* These are abstract/partial classes meant to be extended by actual components.
*/
const isExportableMixinClass = (classNode: ts.ClassDeclaration): boolean => {
const staticGetters = classNode.members.filter(isStaticGetter);
if (staticGetters.length === 0) return false;

// If it has a tag name, it's a component, not a mixin
const tagName = getComponentTagName(staticGetters);
if (tagName) return false;

// Check if it has any Stencil mixin static members
return staticGetters.some((getter) => {
const name = getMemberName(getter);
return name && STENCIL_MIXIN_STATIC_MEMBERS.includes(name);
});
};

export const updateModule = (
config: d.ValidatedConfig,
compilerCtx: d.CompilerCtx,
Expand Down Expand Up @@ -46,7 +88,13 @@ export const updateModule = (

const visitNode = (node: ts.Node) => {
if (ts.isClassDeclaration(node)) {
// First try to parse as a component
parseStaticComponentMeta(compilerCtx, typeChecker, node, moduleFile, buildCtx, undefined);

// Also check if this is an exportable mixin class (has Stencil static members but no tag)
if (isExportableMixinClass(node)) {
moduleFile.hasExportableMixins = true;
}
return;
} else if (ts.isImportDeclaration(node)) {
parseModuleImport(config, compilerCtx, buildCtx, moduleFile, srcDirPath, node, true);
Expand All @@ -64,8 +112,11 @@ export const updateModule = (
// Handle functions with block body: (Base) => { class MyMixin ... }
if (ts.isBlock(funcBody)) {
funcBody.statements.forEach((statement) => {
// Look for class declarations in the function body
// Look for class declarations in the function body (mixin factory pattern)
if (ts.isClassDeclaration(statement)) {
if (isExportableMixinClass(statement)) {
moduleFile.hasExportableMixins = true;
}
statement.members.forEach((member) => {
if (ts.isPropertyDeclaration(member) && member.initializer) {
// Traverse into the property initializer (e.g., arrow function)
Expand All @@ -91,7 +142,9 @@ export const updateModule = (

// TODO: workaround around const enums
// find better way
if (moduleFile.cmps.length > 0) {
// Create staticSourceFile for modules with components OR exportable mixins
// (needed for class-extension to process mixin metadata from external collections)
if (moduleFile.cmps.length > 0 || moduleFile.hasExportableMixins) {
moduleFile.staticSourceFile = ts.createSourceFile(
sourceFilePath,
sourceFileText,
Expand Down
Loading
Loading