diff --git a/src/compiler/build/compiler-ctx.ts b/src/compiler/build/compiler-ctx.ts index c5d9206b513..8e35b1c6c1e 100644 --- a/src/compiler/build/compiler-ctx.ts +++ b/src/compiler/build/compiler-ctx.ts @@ -90,6 +90,7 @@ export const getModuleLegacy = (compilerCtx: d.CompilerCtx, sourceFilePath: stri cmps: [], isExtended: false, isMixin: false, + hasExportableMixins: false, coreRuntimeApis: [], outputTargetCoreRuntimeApis: {}, collectionName: null, diff --git a/src/compiler/output-targets/dist-collection/index.ts b/src/compiler/output-targets/dist-collection/index.ts index 38898d66aa4..80061defb4b 100644 --- a/src/compiler/output-targets/dist-collection/index.ts +++ b/src/compiler/output-targets/dist-collection/index.ts @@ -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, diff --git a/src/compiler/transformers/collections/parse-collection-components.ts b/src/compiler/transformers/collections/parse-collection-components.ts index d7eeb96c1c6..0d43f83bba1 100644 --- a/src/compiler/transformers/collections/parse-collection-components.ts +++ b/src/compiler/transformers/collections/parse-collection-components.ts @@ -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); diff --git a/src/compiler/transformers/static-to-meta/class-extension.ts b/src/compiler/transformers/static-to-meta/class-extension.ts index 0d121d4b7e0..c608f9ba3ad 100644 --- a/src/compiler/transformers/static-to-meta/class-extension.ts +++ b/src/compiler/transformers/static-to-meta/class-extension.ts @@ -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'; @@ -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. * @@ -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'; diff --git a/src/compiler/transformers/static-to-meta/parse-static.ts b/src/compiler/transformers/static-to-meta/parse-static.ts index 840f91979a3..295e49d42e1 100644 --- a/src/compiler/transformers/static-to-meta/parse-static.ts +++ b/src/compiler/transformers/static-to-meta/parse-static.ts @@ -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, @@ -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); @@ -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) @@ -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, diff --git a/src/compiler/transformers/test/parse-exportable-mixin.spec.ts b/src/compiler/transformers/test/parse-exportable-mixin.spec.ts new file mode 100644 index 00000000000..d899401c9fb --- /dev/null +++ b/src/compiler/transformers/test/parse-exportable-mixin.spec.ts @@ -0,0 +1,275 @@ +import * as ts from 'typescript'; + +import { transpileModule } from './transpile'; + +describe('parse exportable mixin', () => { + describe('hasExportableMixins detection', () => { + it('detects abstract class with Stencil decorators as exportable mixin', () => { + const t = transpileModule( + ` + // An abstract class with Stencil decorators but NO @Component + class AbstractMixin { + @Prop() mixinProp: string = 'default'; + @State() mixinState: string = 'state'; + @Method() + async mixinMethod() { + return 'method'; + } + @Watch('mixinProp') + mixinPropChanged() {} + } + + @Component({tag: 'cmp-a'}) + class CmpA extends AbstractMixin { + @Prop() cmpProp: string = 'cmp'; + } + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ESNext }, + ); + + expect(t.moduleFile.hasExportableMixins).toBe(true); + }); + + it('detects mixin factory pattern as exportable mixin', () => { + const t = transpileModule( + ` + // A mixin factory function + const MixinFactory = (Base) => { + class Mixin extends Base { + @Prop() factoryProp: string = 'factory'; + @State() factoryState: string = 'state'; + } + return Mixin; + } + + @Component({tag: 'cmp-a'}) + class CmpA extends Mixin(MixinFactory) { + @Prop() cmpProp: string = 'cmp'; + } + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ESNext }, + ); + + expect(t.moduleFile.hasExportableMixins).toBe(true); + }); + + it('does NOT set hasExportableMixins for regular components', () => { + const t = transpileModule( + ` + @Component({tag: 'cmp-a'}) + class CmpA { + @Prop() prop1: string = 'default'; + @State() state1: string = 'state'; + } + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ESNext }, + ); + + expect(t.moduleFile.hasExportableMixins).toBeFalsy(); + }); + + it('does NOT set hasExportableMixins for classes without Stencil decorators', () => { + const t = transpileModule( + ` + // A plain class without Stencil decorators + class PlainClass { + plainProp: string = 'default'; + plainMethod() { + return 'method'; + } + } + + @Component({tag: 'cmp-a'}) + class CmpA extends PlainClass { + @Prop() cmpProp: string = 'cmp'; + } + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ESNext }, + ); + + expect(t.moduleFile.hasExportableMixins).toBeFalsy(); + }); + + it('detects mixin with only @Prop as exportable', () => { + const t = transpileModule( + ` + class PropOnlyMixin { + @Prop() mixinProp: string = 'default'; + } + + @Component({tag: 'cmp-a'}) + class CmpA extends PropOnlyMixin {} + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ESNext }, + ); + + expect(t.moduleFile.hasExportableMixins).toBe(true); + }); + + it('detects mixin with only @State as exportable', () => { + const t = transpileModule( + ` + class StateOnlyMixin { + @State() mixinState: string = 'default'; + } + + @Component({tag: 'cmp-a'}) + class CmpA extends StateOnlyMixin {} + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ESNext }, + ); + + expect(t.moduleFile.hasExportableMixins).toBe(true); + }); + + it('detects mixin with only @Method as exportable', () => { + const t = transpileModule( + ` + class MethodOnlyMixin { + @Method() + async mixinMethod() { + return 'method'; + } + } + + @Component({tag: 'cmp-a'}) + class CmpA extends MethodOnlyMixin {} + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ESNext }, + ); + + expect(t.moduleFile.hasExportableMixins).toBe(true); + }); + + it('detects mixin with only @Event as exportable', () => { + const t = transpileModule( + ` + class EventOnlyMixin { + @Event() mixinEvent: EventEmitter; + } + + @Component({tag: 'cmp-a'}) + class CmpA extends EventOnlyMixin {} + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ESNext }, + ); + + expect(t.moduleFile.hasExportableMixins).toBe(true); + }); + + it('detects mixin with only @Watch as exportable', () => { + const t = transpileModule( + ` + class WatchOnlyMixin { + someProp: string = 'default'; + @Watch('someProp') + somePropChanged() {} + } + + @Component({tag: 'cmp-a'}) + class CmpA extends WatchOnlyMixin { + @Prop() someProp: string = 'cmp'; + } + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ESNext }, + ); + + expect(t.moduleFile.hasExportableMixins).toBe(true); + }); + + it('creates staticSourceFile for modules with exportable mixins', () => { + const t = transpileModule( + ` + class AbstractMixin { + @Prop() mixinProp: string = 'default'; + } + + @Component({tag: 'cmp-a'}) + class CmpA extends AbstractMixin {} + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ESNext }, + ); + + expect(t.moduleFile.staticSourceFile).toBeDefined(); + }); + + it('detects multi-level inheritance with abstract mixin', () => { + const t = transpileModule( + ` + // Grandparent mixin + class GrandparentMixin { + @Prop() grandparentProp: string = 'grandparent'; + } + + // Parent mixin extends grandparent + class ParentMixin extends GrandparentMixin { + @Prop() parentProp: string = 'parent'; + } + + @Component({tag: 'cmp-a'}) + class CmpA extends ParentMixin { + @Prop() cmpProp: string = 'cmp'; + } + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ESNext }, + ); + + expect(t.moduleFile.hasExportableMixins).toBe(true); + }); + }); +}); diff --git a/src/compiler/transpile/transpiled-module.ts b/src/compiler/transpile/transpiled-module.ts index fae3a3140fa..691448aa6b0 100644 --- a/src/compiler/transpile/transpiled-module.ts +++ b/src/compiler/transpile/transpiled-module.ts @@ -33,6 +33,7 @@ export const createModule = ( cmps: [], isExtended: false, isMixin: false, + hasExportableMixins: false, coreRuntimeApis: [], outputTargetCoreRuntimeApis: {}, collectionName: null, diff --git a/src/declarations/stencil-private.ts b/src/declarations/stencil-private.ts index e7cfce9323d..f6b2941f50e 100644 --- a/src/declarations/stencil-private.ts +++ b/src/declarations/stencil-private.ts @@ -465,6 +465,12 @@ export interface CollectionCompilerVersion { export interface CollectionManifest { entries?: CollectionComponentEntryPath[]; + /** + * Paths to mixin/abstract class modules that can be extended by consuming projects. + * These are modules that contain classes with Stencil static members (properties, states, etc.) + * but are not components themselves (no @Component decorator / tag name). + */ + mixins?: CollectionComponentEntryPath[]; collections?: CollectionDependencyManifest[]; global?: string; compiler?: CollectionCompilerVersion; @@ -1311,6 +1317,11 @@ export interface Module { cmps: ComponentCompilerMeta[]; isMixin: boolean; isExtended: boolean; + /** + * Indicates this module contains mixin/abstract classes that can be extended by other projects. + * These are classes with Stencil static members (properties, states, etc.) but no @Component decorator. + */ + hasExportableMixins: boolean; /** * A collection of modules that a component will need. The modules in this list must have import statements generated * in order for the component to function. diff --git a/src/testing/mocks.ts b/src/testing/mocks.ts index 3fa0de4c75f..fac8bc2d46e 100644 --- a/src/testing/mocks.ts +++ b/src/testing/mocks.ts @@ -249,6 +249,7 @@ export const mockModule = (mod: Partial = {}): d.Module => ({ cmps: [], isExtended: false, isMixin: false, + hasExportableMixins: false, coreRuntimeApis: [], outputTargetCoreRuntimeApis: {}, collectionName: '', diff --git a/test/wdio/test-sibling/package.json b/test/wdio/test-sibling/package.json index a65c8fafb9b..6f2a49d7f2f 100644 --- a/test/wdio/test-sibling/package.json +++ b/test/wdio/test-sibling/package.json @@ -8,6 +8,14 @@ "./dist/collection/sibling-extended/sibling-extended": { "import": "./dist/collection/sibling-extended/sibling-extended.js", "types": "./dist/types/sibling-extended/sibling-extended.d.ts" + }, + "./dist/collection/sibling-abstract-mixin/sibling-abstract-mixin": { + "import": "./dist/collection/sibling-abstract-mixin/sibling-abstract-mixin.js", + "types": "./dist/types/sibling-abstract-mixin/sibling-abstract-mixin.d.ts" + }, + "./dist/collection/sibling-with-mixin/sibling-with-mixin": { + "import": "./dist/collection/sibling-with-mixin/sibling-with-mixin.js", + "types": "./dist/types/sibling-with-mixin/sibling-with-mixin.d.ts" } }, "volta": { diff --git a/test/wdio/test-sibling/src/components.d.ts b/test/wdio/test-sibling/src/components.d.ts index 9ee0152267c..d396d9a48d3 100644 --- a/test/wdio/test-sibling/src/components.d.ts +++ b/test/wdio/test-sibling/src/components.d.ts @@ -40,6 +40,29 @@ export namespace Components { } interface SiblingRoot { } + /** + * A component that uses a mixin factory pattern internally. + * This tests the scenario where a consumer project imports and renders a component + * from an external library, and that component internally uses a mixin pattern. + * The mixin's decorated members should be properly merged and reactive. + * Used as the extendedTag in tests - renders `.extended-*` elements with mixin defaults. + */ + interface SiblingWithMixin { + /** + * @default 'getter default value' + */ + "getterProp": string; + "method1": () => Promise; + "method2": () => Promise; + /** + * @default 'ExtendedCmp text' + */ + "prop1": string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2": string; + } } declare global { interface HTMLSiblingExtendedElement extends Components.SiblingExtended, HTMLStencilElement { @@ -60,10 +83,24 @@ declare global { prototype: HTMLSiblingRootElement; new (): HTMLSiblingRootElement; }; + /** + * A component that uses a mixin factory pattern internally. + * This tests the scenario where a consumer project imports and renders a component + * from an external library, and that component internally uses a mixin pattern. + * The mixin's decorated members should be properly merged and reactive. + * Used as the extendedTag in tests - renders `.extended-*` elements with mixin defaults. + */ + interface HTMLSiblingWithMixinElement extends Components.SiblingWithMixin, HTMLStencilElement { + } + var HTMLSiblingWithMixinElement: { + prototype: HTMLSiblingWithMixinElement; + new (): HTMLSiblingWithMixinElement; + }; interface HTMLElementTagNameMap { "sibling-extended": HTMLSiblingExtendedElement; "sibling-extended-base": HTMLSiblingExtendedBaseElement; "sibling-root": HTMLSiblingRootElement; + "sibling-with-mixin": HTMLSiblingWithMixinElement; } } declare namespace LocalJSX { @@ -97,6 +134,27 @@ declare namespace LocalJSX { } interface SiblingRoot { } + /** + * A component that uses a mixin factory pattern internally. + * This tests the scenario where a consumer project imports and renders a component + * from an external library, and that component internally uses a mixin pattern. + * The mixin's decorated members should be properly merged and reactive. + * Used as the extendedTag in tests - renders `.extended-*` elements with mixin defaults. + */ + interface SiblingWithMixin { + /** + * @default 'getter default value' + */ + "getterProp"?: string; + /** + * @default 'ExtendedCmp text' + */ + "prop1"?: string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2"?: string; + } interface SiblingExtendedAttributes { "getterProp": string; @@ -108,11 +166,17 @@ declare namespace LocalJSX { "prop1": string; "prop2": string; } + interface SiblingWithMixinAttributes { + "getterProp": string; + "prop1": string; + "prop2": string; + } interface IntrinsicElements { "sibling-extended": Omit & { [K in keyof SiblingExtended & keyof SiblingExtendedAttributes]?: SiblingExtended[K] } & { [K in keyof SiblingExtended & keyof SiblingExtendedAttributes as `attr:${K}`]?: SiblingExtendedAttributes[K] } & { [K in keyof SiblingExtended & keyof SiblingExtendedAttributes as `prop:${K}`]?: SiblingExtended[K] }; "sibling-extended-base": Omit & { [K in keyof SiblingExtendedBase & keyof SiblingExtendedBaseAttributes]?: SiblingExtendedBase[K] } & { [K in keyof SiblingExtendedBase & keyof SiblingExtendedBaseAttributes as `attr:${K}`]?: SiblingExtendedBaseAttributes[K] } & { [K in keyof SiblingExtendedBase & keyof SiblingExtendedBaseAttributes as `prop:${K}`]?: SiblingExtendedBase[K] }; "sibling-root": SiblingRoot; + "sibling-with-mixin": Omit & { [K in keyof SiblingWithMixin & keyof SiblingWithMixinAttributes]?: SiblingWithMixin[K] } & { [K in keyof SiblingWithMixin & keyof SiblingWithMixinAttributes as `attr:${K}`]?: SiblingWithMixinAttributes[K] } & { [K in keyof SiblingWithMixin & keyof SiblingWithMixinAttributes as `prop:${K}`]?: SiblingWithMixin[K] }; } } export { LocalJSX as JSX }; @@ -122,6 +186,14 @@ declare module "@stencil/core" { "sibling-extended": LocalJSX.IntrinsicElements["sibling-extended"] & JSXBase.HTMLAttributes; "sibling-extended-base": LocalJSX.IntrinsicElements["sibling-extended-base"] & JSXBase.HTMLAttributes; "sibling-root": LocalJSX.IntrinsicElements["sibling-root"] & JSXBase.HTMLAttributes; + /** + * A component that uses a mixin factory pattern internally. + * This tests the scenario where a consumer project imports and renders a component + * from an external library, and that component internally uses a mixin pattern. + * The mixin's decorated members should be properly merged and reactive. + * Used as the extendedTag in tests - renders `.extended-*` elements with mixin defaults. + */ + "sibling-with-mixin": LocalJSX.IntrinsicElements["sibling-with-mixin"] & JSXBase.HTMLAttributes; } } } diff --git a/test/wdio/test-sibling/src/sibling-abstract-mixin/sibling-abstract-mixin.ts b/test/wdio/test-sibling/src/sibling-abstract-mixin/sibling-abstract-mixin.ts new file mode 100644 index 00000000000..8c684b7961f --- /dev/null +++ b/test/wdio/test-sibling/src/sibling-abstract-mixin/sibling-abstract-mixin.ts @@ -0,0 +1,53 @@ +import { Prop, State, Method, Watch } from '@stencil/core'; + +/** + * An abstract mixin class with Stencil decorators but NO @Component decorator. + * This tests the scenario where a project imports and extends from an abstract + * mixin class in an external library. + */ +export class SiblingAbstractMixin { + /** + * Test getter/setter pattern - ensures default value is preserved + * and not overwritten with undefined during component initialization. + */ + private _getterProp: string = 'getter default value'; + @Prop() + get getterProp(): string { + return this._getterProp; + } + set getterProp(newValue: string) { + this._getterProp = newValue; + } + + @Prop() prop1: string = 'ExtendedCmp text'; + @Watch('prop1') + prop1Changed(newValue: string) { + console.info('extended class handler prop1:', newValue); + } + @Prop() prop2: string = 'ExtendedCmp prop2 text'; + @Watch('prop2') + prop2Changed(newValue: string) { + console.info('extended class handler prop2:', newValue); + } + + @State() state1: string = 'ExtendedCmp state text'; + @Watch('state1') + state1Changed(newValue: string) { + console.info('extended class handler state1:', newValue); + } + @State() state2: string = 'ExtendedCmp state2 text'; + @Watch('state2') + state2Changed(newValue: string) { + console.info('extended class handler state2:', newValue); + } + + @Method() + async method1() { + this.prop1 = 'ExtendedCmp method1 called'; + } + + @Method() + async method2() { + this.prop1 = 'ExtendedCmp method2 called'; + } +} diff --git a/test/wdio/test-sibling/src/sibling-with-mixin/mixin-factory.ts b/test/wdio/test-sibling/src/sibling-with-mixin/mixin-factory.ts new file mode 100644 index 00000000000..cf18c6dca38 --- /dev/null +++ b/test/wdio/test-sibling/src/sibling-with-mixin/mixin-factory.ts @@ -0,0 +1,57 @@ +import { Prop, State, Method, Watch } from '@stencil/core'; + +/** + * A mixin factory function that returns a class with Stencil decorators. + * This tests the scenario where a component in an external library uses + * a mixin pattern internally. + */ +export const SiblingMixinFactory = any>(Base: B) => { + class SiblingMixin extends Base { + /** + * Test getter/setter pattern - ensures default value is preserved + * and not overwritten with undefined during component initialization. + * Using JS private field (#) instead of TS private to avoid declaration emit issues. + */ + #_getterProp: string = 'getter default value'; + @Prop() + get getterProp(): string { + return this.#_getterProp; + } + set getterProp(newValue: string) { + this.#_getterProp = newValue; + } + + @Prop() prop1: string = 'ExtendedCmp text'; + @Watch('prop1') + prop1Changed(newValue: string) { + console.info('extended class handler prop1:', newValue); + } + @Prop() prop2: string = 'ExtendedCmp prop2 text'; + @Watch('prop2') + prop2Changed(newValue: string) { + console.info('extended class handler prop2:', newValue); + } + + @State() state1: string = 'ExtendedCmp state text'; + @Watch('state1') + state1Changed(newValue: string) { + console.info('extended class handler state1:', newValue); + } + @State() state2: string = 'ExtendedCmp state2 text'; + @Watch('state2') + state2Changed(newValue: string) { + console.info('extended class handler state2:', newValue); + } + + @Method() + async method1() { + this.prop1 = 'ExtendedCmp method1 called'; + } + + @Method() + async method2() { + this.prop1 = 'ExtendedCmp method2 called'; + } + } + return SiblingMixin; +}; diff --git a/test/wdio/test-sibling/src/sibling-with-mixin/sibling-with-mixin.tsx b/test/wdio/test-sibling/src/sibling-with-mixin/sibling-with-mixin.tsx new file mode 100644 index 00000000000..8437fbd6a73 --- /dev/null +++ b/test/wdio/test-sibling/src/sibling-with-mixin/sibling-with-mixin.tsx @@ -0,0 +1,26 @@ +import { Component, h, Mixin } from '@stencil/core'; +import { SiblingMixinFactory } from './mixin-factory.js'; + +/** + * A component that uses a mixin factory pattern internally. + * This tests the scenario where a consumer project imports and renders a component + * from an external library, and that component internally uses a mixin pattern. + * The mixin's decorated members should be properly merged and reactive. + * + * Used as the extendedTag in tests - renders `.extended-*` elements with mixin defaults. + */ +@Component({ + tag: 'sibling-with-mixin', +}) +export class SiblingWithMixin extends Mixin(SiblingMixinFactory) { + render() { + return ( +
+

Extended class prop 1: {this.prop1}

+

Extended class prop 2: {this.prop2}

+

Extended class state 1: {this.state1}

+

Extended class state 2: {this.state2}

+
+ ); + } +} diff --git a/test/wdio/ts-target/components.d.ts b/test/wdio/ts-target/components.d.ts index 9267554bd1a..774b60ee72d 100644 --- a/test/wdio/ts-target/components.d.ts +++ b/test/wdio/ts-target/components.d.ts @@ -173,6 +173,48 @@ export namespace Components { */ "prop2": string; } + /** + * A component that extends from an external library's abstract mixin class. + * This tests Bug B: importing abstract mixin classes from a lib - those classes' + * members should be properly merged in and have reactivity. + */ + interface ExtendsExternalAbstract { + /** + * @default 'getter default value' + */ + "getterProp": string; + "method1": () => Promise; + "method2": () => Promise; + /** + * @default 'default text' + */ + "prop1": string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2": string; + } + /** + * A component that extends from an external library's component which itself uses a mixin pattern. + * This tests Bug A: a project importing/rendering from a lib whose component utilises a mixin/abstract + * class pattern - the decorated class members should be properly merged and have reactivity. + */ + interface ExtendsExternalWithMixin { + /** + * @default 'getter default value' + */ + "getterProp": string; + "method1": () => Promise; + "method2": () => Promise; + /** + * @default 'default text' + */ + "prop1": string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2": string; + } interface ExtendsLifecycleBasic { } interface ExtendsLifecycleMultilevel { @@ -392,6 +434,29 @@ export namespace Components { } interface InheritanceTextInput { } + /** + * A component that uses a mixin factory pattern internally. + * This tests the scenario where a consumer project imports and renders a component + * from an external library, and that component internally uses a mixin pattern. + * The mixin's decorated members should be properly merged and reactive. + * Used as the extendedTag in tests - renders `.extended-*` elements with mixin defaults. + */ + interface SiblingWithMixin { + /** + * @default 'getter default value' + */ + "getterProp": string; + "method1": () => Promise; + "method2": () => Promise; + /** + * @default 'ExtendedCmp text' + */ + "prop1": string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2": string; + } interface TsTargetProps { /** * @default 'basicProp' @@ -549,6 +614,28 @@ declare global { prototype: HTMLExtendsExternalElement; new (): HTMLExtendsExternalElement; }; + /** + * A component that extends from an external library's abstract mixin class. + * This tests Bug B: importing abstract mixin classes from a lib - those classes' + * members should be properly merged in and have reactivity. + */ + interface HTMLExtendsExternalAbstractElement extends Components.ExtendsExternalAbstract, HTMLStencilElement { + } + var HTMLExtendsExternalAbstractElement: { + prototype: HTMLExtendsExternalAbstractElement; + new (): HTMLExtendsExternalAbstractElement; + }; + /** + * A component that extends from an external library's component which itself uses a mixin pattern. + * This tests Bug A: a project importing/rendering from a lib whose component utilises a mixin/abstract + * class pattern - the decorated class members should be properly merged and have reactivity. + */ + interface HTMLExtendsExternalWithMixinElement extends Components.ExtendsExternalWithMixin, HTMLStencilElement { + } + var HTMLExtendsExternalWithMixinElement: { + prototype: HTMLExtendsExternalWithMixinElement; + new (): HTMLExtendsExternalWithMixinElement; + }; interface HTMLExtendsLifecycleBasicElement extends Components.ExtendsLifecycleBasic, HTMLStencilElement { } var HTMLExtendsLifecycleBasicElement: { @@ -716,6 +803,19 @@ declare global { prototype: HTMLInheritanceTextInputElement; new (): HTMLInheritanceTextInputElement; }; + /** + * A component that uses a mixin factory pattern internally. + * This tests the scenario where a consumer project imports and renders a component + * from an external library, and that component internally uses a mixin pattern. + * The mixin's decorated members should be properly merged and reactive. + * Used as the extendedTag in tests - renders `.extended-*` elements with mixin defaults. + */ + interface HTMLSiblingWithMixinElement extends Components.SiblingWithMixin, HTMLStencilElement { + } + var HTMLSiblingWithMixinElement: { + prototype: HTMLSiblingWithMixinElement; + new (): HTMLSiblingWithMixinElement; + }; interface HTMLTsTargetPropsElement extends Components.TsTargetProps, HTMLStencilElement { } var HTMLTsTargetPropsElement: { @@ -736,6 +836,8 @@ declare global { "extends-direct-state": HTMLExtendsDirectStateElement; "extends-events": HTMLExtendsEventsElement; "extends-external": HTMLExtendsExternalElement; + "extends-external-abstract": HTMLExtendsExternalAbstractElement; + "extends-external-with-mixin": HTMLExtendsExternalWithMixinElement; "extends-lifecycle-basic": HTMLExtendsLifecycleBasicElement; "extends-lifecycle-multilevel": HTMLExtendsLifecycleMultilevelElement; "extends-local": HTMLExtendsLocalElement; @@ -751,6 +853,7 @@ declare global { "inheritance-radio-group": HTMLInheritanceRadioGroupElement; "inheritance-scaling-demo": HTMLInheritanceScalingDemoElement; "inheritance-text-input": HTMLInheritanceTextInputElement; + "sibling-with-mixin": HTMLSiblingWithMixinElement; "ts-target-props": HTMLTsTargetPropsElement; } } @@ -874,6 +977,44 @@ declare namespace LocalJSX { */ "prop2"?: string; } + /** + * A component that extends from an external library's abstract mixin class. + * This tests Bug B: importing abstract mixin classes from a lib - those classes' + * members should be properly merged in and have reactivity. + */ + interface ExtendsExternalAbstract { + /** + * @default 'getter default value' + */ + "getterProp"?: string; + /** + * @default 'default text' + */ + "prop1"?: string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2"?: string; + } + /** + * A component that extends from an external library's component which itself uses a mixin pattern. + * This tests Bug A: a project importing/rendering from a lib whose component utilises a mixin/abstract + * class pattern - the decorated class members should be properly merged and have reactivity. + */ + interface ExtendsExternalWithMixin { + /** + * @default 'getter default value' + */ + "getterProp"?: string; + /** + * @default 'default text' + */ + "prop1"?: string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2"?: string; + } interface ExtendsLifecycleBasic { } interface ExtendsLifecycleMultilevel { @@ -1019,6 +1160,27 @@ declare namespace LocalJSX { } interface InheritanceTextInput { } + /** + * A component that uses a mixin factory pattern internally. + * This tests the scenario where a consumer project imports and renders a component + * from an external library, and that component internally uses a mixin pattern. + * The mixin's decorated members should be properly merged and reactive. + * Used as the extendedTag in tests - renders `.extended-*` elements with mixin defaults. + */ + interface SiblingWithMixin { + /** + * @default 'getter default value' + */ + "getterProp"?: string; + /** + * @default 'ExtendedCmp text' + */ + "prop1"?: string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2"?: string; + } interface TsTargetProps { /** * @default 'basicProp' @@ -1061,6 +1223,16 @@ declare namespace LocalJSX { "prop2": string; "prop1": string; } + interface ExtendsExternalAbstractAttributes { + "getterProp": string; + "prop2": string; + "prop1": string; + } + interface ExtendsExternalWithMixinAttributes { + "getterProp": string; + "prop2": string; + "prop1": string; + } interface ExtendsLocalAttributes { "getterProp": string; "prop2": string; @@ -1088,6 +1260,11 @@ declare namespace LocalJSX { "overrideProp": string; "childProp": string; } + interface SiblingWithMixinAttributes { + "getterProp": string; + "prop1": string; + "prop2": string; + } interface TsTargetPropsAttributes { "basicProp": string; "decoratedProp": number; @@ -1108,6 +1285,8 @@ declare namespace LocalJSX { "extends-direct-state": ExtendsDirectState; "extends-events": ExtendsEvents; "extends-external": Omit & { [K in keyof ExtendsExternal & keyof ExtendsExternalAttributes]?: ExtendsExternal[K] } & { [K in keyof ExtendsExternal & keyof ExtendsExternalAttributes as `attr:${K}`]?: ExtendsExternalAttributes[K] } & { [K in keyof ExtendsExternal & keyof ExtendsExternalAttributes as `prop:${K}`]?: ExtendsExternal[K] }; + "extends-external-abstract": Omit & { [K in keyof ExtendsExternalAbstract & keyof ExtendsExternalAbstractAttributes]?: ExtendsExternalAbstract[K] } & { [K in keyof ExtendsExternalAbstract & keyof ExtendsExternalAbstractAttributes as `attr:${K}`]?: ExtendsExternalAbstractAttributes[K] } & { [K in keyof ExtendsExternalAbstract & keyof ExtendsExternalAbstractAttributes as `prop:${K}`]?: ExtendsExternalAbstract[K] }; + "extends-external-with-mixin": Omit & { [K in keyof ExtendsExternalWithMixin & keyof ExtendsExternalWithMixinAttributes]?: ExtendsExternalWithMixin[K] } & { [K in keyof ExtendsExternalWithMixin & keyof ExtendsExternalWithMixinAttributes as `attr:${K}`]?: ExtendsExternalWithMixinAttributes[K] } & { [K in keyof ExtendsExternalWithMixin & keyof ExtendsExternalWithMixinAttributes as `prop:${K}`]?: ExtendsExternalWithMixin[K] }; "extends-lifecycle-basic": ExtendsLifecycleBasic; "extends-lifecycle-multilevel": ExtendsLifecycleMultilevel; "extends-local": Omit & { [K in keyof ExtendsLocal & keyof ExtendsLocalAttributes]?: ExtendsLocal[K] } & { [K in keyof ExtendsLocal & keyof ExtendsLocalAttributes as `attr:${K}`]?: ExtendsLocalAttributes[K] } & { [K in keyof ExtendsLocal & keyof ExtendsLocalAttributes as `prop:${K}`]?: ExtendsLocal[K] }; @@ -1123,6 +1302,7 @@ declare namespace LocalJSX { "inheritance-radio-group": InheritanceRadioGroup; "inheritance-scaling-demo": InheritanceScalingDemo; "inheritance-text-input": InheritanceTextInput; + "sibling-with-mixin": Omit & { [K in keyof SiblingWithMixin & keyof SiblingWithMixinAttributes]?: SiblingWithMixin[K] } & { [K in keyof SiblingWithMixin & keyof SiblingWithMixinAttributes as `attr:${K}`]?: SiblingWithMixinAttributes[K] } & { [K in keyof SiblingWithMixin & keyof SiblingWithMixinAttributes as `prop:${K}`]?: SiblingWithMixin[K] }; "ts-target-props": Omit & { [K in keyof TsTargetProps & keyof TsTargetPropsAttributes]?: TsTargetProps[K] } & { [K in keyof TsTargetProps & keyof TsTargetPropsAttributes as `attr:${K}`]?: TsTargetPropsAttributes[K] } & { [K in keyof TsTargetProps & keyof TsTargetPropsAttributes as `prop:${K}`]?: TsTargetProps[K] }; } } @@ -1166,6 +1346,18 @@ declare module "@stencil/core" { */ "extends-events": LocalJSX.IntrinsicElements["extends-events"] & JSXBase.HTMLAttributes; "extends-external": LocalJSX.IntrinsicElements["extends-external"] & JSXBase.HTMLAttributes; + /** + * A component that extends from an external library's abstract mixin class. + * This tests Bug B: importing abstract mixin classes from a lib - those classes' + * members should be properly merged in and have reactivity. + */ + "extends-external-abstract": LocalJSX.IntrinsicElements["extends-external-abstract"] & JSXBase.HTMLAttributes; + /** + * A component that extends from an external library's component which itself uses a mixin pattern. + * This tests Bug A: a project importing/rendering from a lib whose component utilises a mixin/abstract + * class pattern - the decorated class members should be properly merged and have reactivity. + */ + "extends-external-with-mixin": LocalJSX.IntrinsicElements["extends-external-with-mixin"] & JSXBase.HTMLAttributes; "extends-lifecycle-basic": LocalJSX.IntrinsicElements["extends-lifecycle-basic"] & JSXBase.HTMLAttributes; "extends-lifecycle-multilevel": LocalJSX.IntrinsicElements["extends-lifecycle-multilevel"] & JSXBase.HTMLAttributes; "extends-local": LocalJSX.IntrinsicElements["extends-local"] & JSXBase.HTMLAttributes; @@ -1225,6 +1417,14 @@ declare module "@stencil/core" { */ "inheritance-scaling-demo": LocalJSX.IntrinsicElements["inheritance-scaling-demo"] & JSXBase.HTMLAttributes; "inheritance-text-input": LocalJSX.IntrinsicElements["inheritance-text-input"] & JSXBase.HTMLAttributes; + /** + * A component that uses a mixin factory pattern internally. + * This tests the scenario where a consumer project imports and renders a component + * from an external library, and that component internally uses a mixin pattern. + * The mixin's decorated members should be properly merged and reactive. + * Used as the extendedTag in tests - renders `.extended-*` elements with mixin defaults. + */ + "sibling-with-mixin": LocalJSX.IntrinsicElements["sibling-with-mixin"] & JSXBase.HTMLAttributes; "ts-target-props": LocalJSX.IntrinsicElements["ts-target-props"] & JSXBase.HTMLAttributes; } } diff --git a/test/wdio/ts-target/extends-external-abstract/cmp.test.ts b/test/wdio/ts-target/extends-external-abstract/cmp.test.ts new file mode 100644 index 00000000000..e88410a4435 --- /dev/null +++ b/test/wdio/ts-target/extends-external-abstract/cmp.test.ts @@ -0,0 +1,103 @@ +import { browser } from '@wdio/globals'; +import { setupIFrameTest } from '../../util.js'; +import { testSuites } from '../extends-test-suite.test.js'; + +/** + * Smoke tests for extending from external library abstract mixin classes (no @Component decorator). + * This tests Bug B: a project importing abstract mixin classes from a lib - those classes' + * members should be properly merged in and have reactivity. + * + * The external library (test-sibling) exports an abstract mixin class with Stencil decorators + * (@Prop, @State, @Method, @Watch) but no @Component decorator. The consuming project's + * component extends from this abstract class. + */ + +describe('Checks component classes can extend from external library abstract mixin classes', () => { + describe('es2022 dist output', () => { + let frameContent: HTMLElement; + + beforeEach(async () => { + frameContent = await setupIFrameTest('/extends-external-abstract/es2022.dist.html', 'es2022-dist'); + const frameEle = await browser.$('#es2022-dist'); + await frameEle.waitUntil(async () => !!frameContent.querySelector('.main-prop-1'), { timeout: 5000 }); + }); + + it('renders default values', async () => { + const { defaultValue } = await testSuites(frameContent, 'extends-external-abstract'); + await defaultValue(); + }); + + it('re-renders values via attributes', async () => { + const { viaAttributes } = await testSuites(frameContent, 'extends-external-abstract'); + await viaAttributes(); + }); + + it('re-renders values via props', async () => { + const { viaProps } = await testSuites(frameContent, 'extends-external-abstract'); + await viaProps(); + }); + + it('calls watch handlers', async () => { + const { watchHandlers } = await testSuites(frameContent, 'extends-external-abstract'); + await watchHandlers(); + }); + + it('calls methods', async () => { + const { methods } = await testSuites(frameContent, 'extends-external-abstract'); + await methods(); + }); + }); + + describe('es2022 dist-custom-elements output', () => { + let frameContent: HTMLElement; + + beforeEach(async () => { + await browser.switchToParentFrame(); + frameContent = await setupIFrameTest( + '/extends-external-abstract/es2022.custom-element.html', + 'es2022-custom-elements', + ); + const frameEle = await browser.$('iframe#es2022-custom-elements'); + await frameEle.waitUntil(async () => !!frameContent.querySelector('.main-prop-1'), { timeout: 5000 }); + }); + + it('renders default values', async () => { + const { defaultValue } = await testSuites(frameContent, 'extends-external-abstract'); + await defaultValue(); + }); + + it('re-renders values via attributes', async () => { + const { viaAttributes } = await testSuites(frameContent, 'extends-external-abstract'); + await viaAttributes(); + }); + + it('re-renders values via props', async () => { + const { viaProps } = await testSuites(frameContent, 'extends-external-abstract'); + await viaProps(); + }); + + it('calls watch handlers', async () => { + const { watchHandlers } = await testSuites(frameContent, 'extends-external-abstract'); + await watchHandlers(); + }); + + it('calls methods', async () => { + const { methods } = await testSuites(frameContent, 'extends-external-abstract'); + await methods(); + }); + }); + + describe('hydrate output', () => { + it('renders component during SSR hydration via attributes', async () => { + // @ts-ignore may not be existing when project hasn't been built + const mod = await import('/test-ts-target-output/hydrate/index.mjs'); + await (await testSuites(document.body, 'extends-external-abstract')).ssrViaAttrs(mod); + }); + + it('renders component during SSR hydration via props', async () => { + // @ts-ignore may not be existing when project hasn't been built + const mod = await import('/test-ts-target-output/hydrate/index.mjs'); + await (await testSuites(document.body, 'extends-external-abstract')).ssrViaProps(mod); + }); + }); +}); diff --git a/test/wdio/ts-target/extends-external-abstract/cmp.tsx b/test/wdio/ts-target/extends-external-abstract/cmp.tsx new file mode 100644 index 00000000000..8f26280eadc --- /dev/null +++ b/test/wdio/ts-target/extends-external-abstract/cmp.tsx @@ -0,0 +1,41 @@ +import { Component, h, Prop, State, Method, Watch } from '@stencil/core'; +import { SiblingAbstractMixin } from 'test-sibling/dist/collection/sibling-abstract-mixin/sibling-abstract-mixin'; + +/** + * A component that extends from an external library's abstract mixin class. + * This tests Bug B: importing abstract mixin classes from a lib - those classes' + * members should be properly merged in and have reactivity. + */ +@Component({ + tag: 'extends-external-abstract', +}) +export class ExtendsExternalAbstract extends SiblingAbstractMixin { + @Prop() prop1: string = 'default text'; + @Watch('prop1') + prop1Changed(newValue: string) { + console.info('main class handler prop1:', newValue); + } + + @State() state1: string = 'default state text'; + @Watch('state1') + state1Changed(newValue: string) { + console.info('main class handler state1:', newValue); + } + + @Method() + async method1() { + this.prop1 = 'main class method1 called'; + } + + render() { + return ( +
+

Main class prop1: {this.prop1}

+

Main class prop2: {this.prop2}

+

Main class getterProp: {this.getterProp}

+

Main class state1: {this.state1}

+

Main class state2: {this.state2}

+
+ ); + } +} diff --git a/test/wdio/ts-target/extends-external-abstract/es2022.custom-element.html b/test/wdio/ts-target/extends-external-abstract/es2022.custom-element.html new file mode 100644 index 00000000000..f1465362eea --- /dev/null +++ b/test/wdio/ts-target/extends-external-abstract/es2022.custom-element.html @@ -0,0 +1,13 @@ + + + ES2022 dist-custom-elements output + + + +

ES2022 dist-custom-elements output

+ + + diff --git a/test/wdio/ts-target/extends-external-abstract/es2022.dist.html b/test/wdio/ts-target/extends-external-abstract/es2022.dist.html new file mode 100644 index 00000000000..2b7000a6d67 --- /dev/null +++ b/test/wdio/ts-target/extends-external-abstract/es2022.dist.html @@ -0,0 +1,10 @@ + + + ES2022 dist output + + + +

ES2022 dist output

+ + + diff --git a/test/wdio/ts-target/extends-external-with-mixin/cmp.test.ts b/test/wdio/ts-target/extends-external-with-mixin/cmp.test.ts new file mode 100644 index 00000000000..832fee8892c --- /dev/null +++ b/test/wdio/ts-target/extends-external-with-mixin/cmp.test.ts @@ -0,0 +1,102 @@ +import { browser } from '@wdio/globals'; +import { setupIFrameTest } from '../../util.js'; +import { testSuites } from '../extends-test-suite.test.js'; + +/** + * Smoke tests for extending from external library component classes that internally use mixin patterns. + * This tests Bug A: a project importing/rendering from a lib whose component utilises a mixin/abstract + * class pattern - the decorated class members should be properly merged and have reactivity. + * + * The external library (test-sibling) has a component that uses `Mixin()` with a factory function. + * This test extends from that component and verifies all decorated members from the mixin chain work. + */ + +describe('Checks component classes can extend from external library components that use mixin patterns', () => { + describe('es2022 dist output', () => { + let frameContent: HTMLElement; + + beforeEach(async () => { + frameContent = await setupIFrameTest('/extends-external-with-mixin/es2022.dist.html', 'es2022-dist'); + const frameEle = await browser.$('#es2022-dist'); + await frameEle.waitUntil(async () => !!frameContent.querySelector('.main-prop-1'), { timeout: 5000 }); + }); + + it('renders default values', async () => { + const { defaultValue } = await testSuites(frameContent, 'extends-external-with-mixin', 'sibling-with-mixin'); + await defaultValue(); + }); + + it('re-renders values via attributes', async () => { + const { viaAttributes } = await testSuites(frameContent, 'extends-external-with-mixin', 'sibling-with-mixin'); + await viaAttributes(); + }); + + it('re-renders values via props', async () => { + const { viaProps } = await testSuites(frameContent, 'extends-external-with-mixin', 'sibling-with-mixin'); + await viaProps(); + }); + + it('calls watch handlers', async () => { + const { watchHandlers } = await testSuites(frameContent, 'extends-external-with-mixin', 'sibling-with-mixin'); + await watchHandlers(); + }); + + it('calls methods', async () => { + const { methods } = await testSuites(frameContent, 'extends-external-with-mixin', 'sibling-with-mixin'); + await methods(); + }); + }); + + describe('es2022 dist-custom-elements output', () => { + let frameContent: HTMLElement; + + beforeEach(async () => { + await browser.switchToParentFrame(); + frameContent = await setupIFrameTest( + '/extends-external-with-mixin/es2022.custom-element.html', + 'es2022-custom-elements', + ); + const frameEle = await browser.$('iframe#es2022-custom-elements'); + await frameEle.waitUntil(async () => !!frameContent.querySelector('.main-prop-1'), { timeout: 5000 }); + }); + + it('renders default values', async () => { + const { defaultValue } = await testSuites(frameContent, 'extends-external-with-mixin', 'sibling-with-mixin'); + await defaultValue(); + }); + + it('re-renders values via attributes', async () => { + const { viaAttributes } = await testSuites(frameContent, 'extends-external-with-mixin', 'sibling-with-mixin'); + await viaAttributes(); + }); + + it('re-renders values via props', async () => { + const { viaProps } = await testSuites(frameContent, 'extends-external-with-mixin', 'sibling-with-mixin'); + await viaProps(); + }); + + it('calls watch handlers', async () => { + const { watchHandlers } = await testSuites(frameContent, 'extends-external-with-mixin', 'sibling-with-mixin'); + await watchHandlers(); + }); + + it('calls methods', async () => { + const { methods } = await testSuites(frameContent, 'extends-external-with-mixin', 'sibling-with-mixin'); + await methods(); + }); + }); + + describe('hydrate output', () => { + it('renders component during SSR hydration via attributes', async () => { + // @ts-ignore may not be existing when project hasn't been built + const mod = await import('/test-ts-target-output/hydrate/index.mjs'); + await (await testSuites(document.body, 'extends-external-with-mixin', 'sibling-with-mixin')).ssrViaAttrs(mod); + }); + + it('renders component during SSR hydration via props', async () => { + // @ts-ignore may not be existing when project hasn't been built + const mod = await import('/test-ts-target-output/hydrate/index.mjs'); + await (await testSuites(document.body, 'extends-external-with-mixin', 'sibling-with-mixin')).ssrViaProps(mod); + }); + }); +}); diff --git a/test/wdio/ts-target/extends-external-with-mixin/cmp.tsx b/test/wdio/ts-target/extends-external-with-mixin/cmp.tsx new file mode 100644 index 00000000000..e43635bb6bc --- /dev/null +++ b/test/wdio/ts-target/extends-external-with-mixin/cmp.tsx @@ -0,0 +1,41 @@ +import { Component, h, Prop, State, Method, Watch } from '@stencil/core'; +import { SiblingWithMixin } from 'test-sibling/dist/collection/sibling-with-mixin/sibling-with-mixin'; + +/** + * A component that extends from an external library's component which itself uses a mixin pattern. + * This tests Bug A: a project importing/rendering from a lib whose component utilises a mixin/abstract + * class pattern - the decorated class members should be properly merged and have reactivity. + */ +@Component({ + tag: 'extends-external-with-mixin', +}) +export class ExtendsExternalWithMixin extends SiblingWithMixin { + @Prop() prop1: string = 'default text'; + @Watch('prop1') + prop1Changed(newValue: string) { + console.info('main class handler prop1:', newValue); + } + + @State() state1: string = 'default state text'; + @Watch('state1') + state1Changed(newValue: string) { + console.info('main class handler state1:', newValue); + } + + @Method() + async method1() { + this.prop1 = 'main class method1 called'; + } + + render() { + return ( +
+

Main class prop1: {this.prop1}

+

Main class prop2: {this.prop2}

+

Main class getterProp: {this.getterProp}

+

Main class state1: {this.state1}

+

Main class state2: {this.state2}

+
+ ); + } +} diff --git a/test/wdio/ts-target/extends-external-with-mixin/es2022.custom-element.html b/test/wdio/ts-target/extends-external-with-mixin/es2022.custom-element.html new file mode 100644 index 00000000000..3297954b6f7 --- /dev/null +++ b/test/wdio/ts-target/extends-external-with-mixin/es2022.custom-element.html @@ -0,0 +1,16 @@ + + + ES2022 dist-custom-elements output + + + +

ES2022 dist-custom-elements output

+ + + + diff --git a/test/wdio/ts-target/extends-external-with-mixin/es2022.dist.html b/test/wdio/ts-target/extends-external-with-mixin/es2022.dist.html new file mode 100644 index 00000000000..41da9014dde --- /dev/null +++ b/test/wdio/ts-target/extends-external-with-mixin/es2022.dist.html @@ -0,0 +1,11 @@ + + + ES2022 dist output + + + +

ES2022 dist output

+ + + +