diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e2bf19eb06..6a413601d7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +# ⛴ [4.37.0](https://github.com/stenciljs/core/compare/v4.36.3...v4.37.0) (2025-09-13) + + +### Bug Fixes + +* **dist-custom-elements:** apply `initializeNextTick` config ([dbcdeff](https://github.com/stenciljs/core/commit/dbcdeff26a9b258f860c5774497e31b84690c7af)) +* **dist-custom-elements:** apply `initializeNextTick` config setting ([#6382](https://github.com/stenciljs/core/issues/6382)) ([7bdf9fb](https://github.com/stenciljs/core/commit/7bdf9fbba0c84305cb7e0d749e0407ced5246b2f)) +* **runtime:** make sure watchers can fire immediately if the custom element is already defined ([#6381](https://github.com/stenciljs/core/issues/6381)) ([4fb9140](https://github.com/stenciljs/core/commit/4fb914024b7a3a760a60feb3ecee21bd3d2c2749)) + + +### Features + +* new core api - Mixin ([#6375](https://github.com/stenciljs/core/issues/6375)) ([08f6583](https://github.com/stenciljs/core/commit/08f65838787866ce8749489e9ede36bcdfe15f0a)) +* **runtime:** allow class extending ([#6362](https://github.com/stenciljs/core/issues/6362)) ([0456db1](https://github.com/stenciljs/core/commit/0456db148456911ba8cfb0af4af69ed2022763f9)) + + +### BREAKING CHANGES + +* **runtime:** Watchers will fire earlier than before, but this is the expected behavior + + + ## 🐈 [4.36.3](https://github.com/stenciljs/core/compare/v4.36.2...v4.36.3) (2025-08-20) diff --git a/package-lock.json b/package-lock.json index 8893cce58b9..4934c5d87fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@stencil/core", - "version": "4.36.3", + "version": "4.37.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@stencil/core", - "version": "4.36.3", + "version": "4.37.0", "license": "MIT", "bin": { "stencil": "bin/stencil" diff --git a/package.json b/package.json index 0592031ef6a..8753e5c1b56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@stencil/core", - "version": "4.36.3", + "version": "4.37.0", "license": "MIT", "main": "./internal/stencil-core/index.cjs", "module": "./internal/stencil-core/index.js", @@ -121,7 +121,7 @@ "test.dist": "npm run ts scripts/index.ts -- --validate-build", "test.end-to-end": "cd test/end-to-end && npm ci && npm test && npm run test.dist", "test.jest": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js", - "test.type-tests": "cd ./test/wdio && npm install && npm run build.main && cd ../../ && tsc -p test/type-tests/tsconfig.json", + "test.type-tests": "cd ./test/wdio && npm install && npm run build.test-sibling && npm run build.main && cd ../../ && tsc -p test/type-tests/tsconfig.json", "test.wdio": "cd test/wdio && npm ci && npm run test", "test.wdio.testOnly": "cd test/wdio && npm ci && npm run wdio", "test.prod": "npm run test.dist && npm run test.end-to-end && npm run test.jest && npm run test.wdio && npm run test.testing && npm run test.analysis", diff --git a/src/client/client-host-ref.ts b/src/client/client-host-ref.ts index 00d0c3f633a..14f870b52a0 100644 --- a/src/client/client-host-ref.ts +++ b/src/client/client-host-ref.ts @@ -25,6 +25,7 @@ export const getHostRef = (ref: d.RuntimeRef): d.HostRef | undefined => { * @param hostRef that instances `HostRef` object */ export const registerInstance = (lazyInstance: any, hostRef: d.HostRef) => { + if (!hostRef) return; lazyInstance.__stencil__getHostRef = () => hostRef; hostRef.$lazyInstance$ = lazyInstance; diff --git a/src/compiler/build/compiler-ctx.ts b/src/compiler/build/compiler-ctx.ts index 69eac45fb0b..9bbb1146441 100644 --- a/src/compiler/build/compiler-ctx.ts +++ b/src/compiler/build/compiler-ctx.ts @@ -88,6 +88,8 @@ export const getModuleLegacy = (compilerCtx: d.CompilerCtx, sourceFilePath: stri sourceFilePath: sourceFilePath, jsFilePath: jsFilePath, cmps: [], + isExtended: false, + isMixin: false, coreRuntimeApis: [], outputTargetCoreRuntimeApis: {}, collectionName: null, diff --git a/src/compiler/bundle/typescript-plugin.ts b/src/compiler/bundle/typescript-plugin.ts index ad8d491b81a..c3fa2ae4a96 100644 --- a/src/compiler/bundle/typescript-plugin.ts +++ b/src/compiler/bundle/typescript-plugin.ts @@ -57,7 +57,7 @@ export const typescriptPlugin = ( if (isAbsolute(id)) { const fsFilePath = normalizeFsPath(id); const mod = getModule(compilerCtx, fsFilePath); - if (mod && mod.cmps.length > 0) { + if (mod?.cmps) { const tsResult = ts.transpileModule(mod.staticSourceFileText, { compilerOptions: config.tsCompilerOptions, fileName: mod.sourceFilePath, diff --git a/src/compiler/output-targets/dist-custom-elements/custom-elements-build-conditionals.ts b/src/compiler/output-targets/dist-custom-elements/custom-elements-build-conditionals.ts index 9f2b8519a27..bf1ad250b09 100644 --- a/src/compiler/output-targets/dist-custom-elements/custom-elements-build-conditionals.ts +++ b/src/compiler/output-targets/dist-custom-elements/custom-elements-build-conditionals.ts @@ -25,6 +25,8 @@ export const getCustomElementsBuildConditionals = ( build.hydrateServerSide = false; build.asyncQueue = config.taskQueue === 'congestionAsync'; build.taskQueue = config.taskQueue !== 'immediate'; + build.initializeNextTick = config.extras.initializeNextTick; + updateBuildConditionals(config, build); build.devTools = false; diff --git a/src/compiler/output-targets/dist-lazy/generate-cjs.ts b/src/compiler/output-targets/dist-lazy/generate-cjs.ts index c3070e0963d..9a94baf62aa 100644 --- a/src/compiler/output-targets/dist-lazy/generate-cjs.ts +++ b/src/compiler/output-targets/dist-lazy/generate-cjs.ts @@ -4,6 +4,7 @@ import type { OutputOptions, RollupBuild } from 'rollup'; import type * as d from '../../../declarations'; import { generateRollupOutput } from '../../app-core/bundle-app-core'; import { generateLazyModules } from './generate-lazy-module'; +import { lazyBundleIdPlugin } from './lazy-bundleid-plugin'; export const generateCjs = async ( config: d.ValidatedConfig, @@ -22,6 +23,7 @@ export const generateCjs = async ( entryFileNames: '[name].cjs.js', assetFileNames: '[name]-[hash][extname]', sourcemap: config.sourceMap, + plugins: [lazyBundleIdPlugin(buildCtx, config, false, '.cjs')], }; if (!!config.extras.experimentalImportInjection || !!config.extras.enableImportInjection) { @@ -44,7 +46,6 @@ export const generateCjs = async ( results, 'es2017', false, - '.cjs', ); await generateShortcuts(compilerCtx, results, cjsOutputs); diff --git a/src/compiler/output-targets/dist-lazy/generate-esm-browser.ts b/src/compiler/output-targets/dist-lazy/generate-esm-browser.ts index dae0165c9fe..9ad86d3d4f4 100644 --- a/src/compiler/output-targets/dist-lazy/generate-esm-browser.ts +++ b/src/compiler/output-targets/dist-lazy/generate-esm-browser.ts @@ -4,6 +4,7 @@ import type { OutputOptions, RollupBuild } from 'rollup'; import type * as d from '../../../declarations'; import { generateRollupOutput } from '../../app-core/bundle-app-core'; import { generateLazyModules } from './generate-lazy-module'; +import { lazyBundleIdPlugin } from './lazy-bundleid-plugin'; export const generateEsmBrowser = async ( config: d.ValidatedConfig, @@ -22,6 +23,7 @@ export const generateEsmBrowser = async ( chunkFileNames: config.hashFileNames ? 'p-[hash].js' : '[name]-[hash].js', assetFileNames: config.hashFileNames ? 'p-[hash][extname]' : '[name]-[hash][extname]', sourcemap: config.sourceMap, + plugins: [lazyBundleIdPlugin(buildCtx, config, config.hashFileNames, '')], }; const output = await generateRollupOutput(rollupBuild, esmOpts, config, buildCtx.entryModules); @@ -39,7 +41,6 @@ export const generateEsmBrowser = async ( output, 'es2017', true, - '', ); } } diff --git a/src/compiler/output-targets/dist-lazy/generate-esm.ts b/src/compiler/output-targets/dist-lazy/generate-esm.ts index c3f33ee6cfb..fdf9920bced 100644 --- a/src/compiler/output-targets/dist-lazy/generate-esm.ts +++ b/src/compiler/output-targets/dist-lazy/generate-esm.ts @@ -5,6 +5,7 @@ import type * as d from '../../../declarations'; import type { RollupResult } from '../../../declarations'; import { generateRollupOutput } from '../../app-core/bundle-app-core'; import { generateLazyModules } from './generate-lazy-module'; +import { lazyBundleIdPlugin } from './lazy-bundleid-plugin'; export const generateEsm = async ( config: d.ValidatedConfig, @@ -22,6 +23,7 @@ export const generateEsm = async ( entryFileNames: '[name].js', assetFileNames: '[name]-[hash][extname]', sourcemap: config.sourceMap, + plugins: [lazyBundleIdPlugin(buildCtx, config, false, '')], }; const outputTargetType = esmOutputs[0].type; const output = await generateRollupOutput(rollupBuild, esmOpts, config, buildCtx.entryModules); @@ -39,7 +41,6 @@ export const generateEsm = async ( output, 'es2017', false, - '', ); const es5destinations = esmEs5Outputs @@ -54,7 +55,6 @@ export const generateEsm = async ( output, 'es5', false, - '', ); if (config.buildEs5) { diff --git a/src/compiler/output-targets/dist-lazy/generate-lazy-module.ts b/src/compiler/output-targets/dist-lazy/generate-lazy-module.ts index c888c5da39f..f9460cfea48 100644 --- a/src/compiler/output-targets/dist-lazy/generate-lazy-module.ts +++ b/src/compiler/output-targets/dist-lazy/generate-lazy-module.ts @@ -21,7 +21,6 @@ export const generateLazyModules = async ( results: d.RollupResult[], sourceTarget: d.SourceTarget, isBrowserBuild: boolean, - sufix: string, ): Promise => { if (!Array.isArray(destinations) || destinations.length === 0) { return []; @@ -43,7 +42,6 @@ export const generateLazyModules = async ( sourceTarget, shouldMinify, isBrowserBuild, - sufix, ); }), ); @@ -190,10 +188,8 @@ const generateLazyEntryModule = async ( sourceTarget: d.SourceTarget, shouldMinify: boolean, isBrowserBuild: boolean, - sufix: string, ): Promise => { const entryModule = buildCtx.entryModules.find((entryModule) => entryModule.entryKey === rollupResult.entryKey); - const shouldHash = config.hashFileNames && isBrowserBuild; const { code, sourceMap } = await convertChunk( config, @@ -207,17 +203,7 @@ const generateLazyEntryModule = async ( rollupResult.map, ); - const output = await writeLazyModule( - config, - compilerCtx, - outputTargetType, - destinations, - entryModule, - shouldHash, - code, - sourceMap, - sufix, - ); + const output = await writeLazyModule(compilerCtx, outputTargetType, destinations, code, sourceMap, rollupResult); return { rollupResult, diff --git a/src/compiler/output-targets/dist-lazy/generate-system.ts b/src/compiler/output-targets/dist-lazy/generate-system.ts index 6a9cb9666c2..70c74a460f4 100644 --- a/src/compiler/output-targets/dist-lazy/generate-system.ts +++ b/src/compiler/output-targets/dist-lazy/generate-system.ts @@ -5,6 +5,7 @@ import type * as d from '../../../declarations'; import { getAppBrowserCorePolyfills } from '../../app-core/app-polyfills'; import { generateRollupOutput } from '../../app-core/bundle-app-core'; import { generateLazyModules } from './generate-lazy-module'; +import { lazyBundleIdPlugin } from './lazy-bundleid-plugin'; export const generateSystem = async ( config: d.ValidatedConfig, @@ -23,6 +24,7 @@ export const generateSystem = async ( chunkFileNames: config.hashFileNames ? 'p-[hash].system.js' : '[name]-[hash].system.js', assetFileNames: config.hashFileNames ? 'p-[hash][extname]' : '[name]-[hash][extname]', sourcemap: config.sourceMap, + plugins: [lazyBundleIdPlugin(buildCtx, config, config.hashFileNames, '.system')], }; const results = await generateRollupOutput(rollupBuild, esmOpts, config, buildCtx.entryModules); if (results != null) { @@ -38,7 +40,6 @@ export const generateSystem = async ( results, 'es5', true, - '.system', ); await generateSystemLoaders(config, compilerCtx, results, systemOutputs); diff --git a/src/compiler/output-targets/dist-lazy/lazy-bundleid-plugin.ts b/src/compiler/output-targets/dist-lazy/lazy-bundleid-plugin.ts new file mode 100644 index 00000000000..39a3bfaeaca --- /dev/null +++ b/src/compiler/output-targets/dist-lazy/lazy-bundleid-plugin.ts @@ -0,0 +1,75 @@ +import MagicString from 'magic-string'; + +import type { OutputChunk, Plugin } from 'rollup'; +import type * as d from '../../../declarations'; + +/** + * A Rollup plugin to generate unique bundle IDs for lazy-loaded modules. + * @param buildCtx The build context + * @param config The validated configuration + * @param shouldHash Whether to hash the bundle ID + * @param suffix The suffix to append to the bundle ID + * @returns A Rollup plugin + */ +export const lazyBundleIdPlugin = ( + buildCtx: d.BuildCtx, + config: d.ValidatedConfig, + shouldHash: boolean, + suffix: string, +): Plugin => { + const getBundleId = async (entryKey: string, code: string, suffix: string): Promise => { + if (shouldHash && config.sys?.generateContentHash) { + const hash = await config.sys.generateContentHash(code, config.hashedFileNameLength); + return `p-${hash}${suffix}`; + } + + const components = entryKey.split('.'); + let bundleId = components[0]; + if (components.length > 2) { + bundleId = `${bundleId}_${components.length - 1}`; + } + + return bundleId + suffix; + }; + + return { + name: 'lazyBundleIdPlugin', + async generateBundle(_, bundle) { + const files = Object.entries(bundle as any); + const map = new Map(); + + for (const [_key, file] of files) { + if (!file.isEntry) continue; + + const entryModule = buildCtx.entryModules.find((em) => em.entryKey === file.name); + if (!entryModule) continue; + + map.set(file.fileName, (await getBundleId(file.name, file.code, suffix)) + '.entry.js'); + } + + if (!map.size) return; + + for (const [_key, file] of files) { + if (!file.isEntry) continue; + + file.facadeModuleId = map.get(file.fileName) || file.facadeModuleId; + file.fileName = map.get(file.fileName) || file.fileName; + + const magicString = new MagicString(file.code); + + file.imports.forEach((imported: string, i) => { + const replaced = map.get(imported); + if (replaced) { + magicString.replaceAll(imported, replaced); + file.imports[i] = replaced; + } + }); + file.code = magicString.toString(); + + if (config.sourceMap) { + file.map = magicString.generateMap(); + } + } + }, + }; +}; diff --git a/src/compiler/output-targets/dist-lazy/write-lazy-entry-module.ts b/src/compiler/output-targets/dist-lazy/write-lazy-entry-module.ts index 36cc6289373..d6a46f92b06 100644 --- a/src/compiler/output-targets/dist-lazy/write-lazy-entry-module.ts +++ b/src/compiler/output-targets/dist-lazy/write-lazy-entry-module.ts @@ -3,20 +3,17 @@ import { getSourceMappingUrlForEndOfFile, join } from '@utils'; import type * as d from '../../../declarations'; export const writeLazyModule = async ( - config: d.ValidatedConfig, compilerCtx: d.CompilerCtx, outputTargetType: string, destinations: string[], - entryModule: d.EntryModule, - shouldHash: boolean, code: string, sourceMap: d.SourceMap, - sufix: string, + rollupResult?: d.RollupChunkResult, ): Promise => { // code = replaceStylePlaceholders(entryModule.cmps, modeName, code); - const bundleId = await getBundleId(config, entryModule.entryKey, shouldHash, code, sufix); - const fileName = `${bundleId}.entry.js`; + const fileName = rollupResult.fileName; + const bundleId = fileName.replace('.entry.js', ''); if (sourceMap) { code = code + getSourceMappingUrlForEndOfFile(fileName); @@ -37,24 +34,3 @@ export const writeLazyModule = async ( code, }; }; - -const getBundleId = async ( - config: d.ValidatedConfig, - entryKey: string, - shouldHash: boolean, - code: string, - sufix: string, -): Promise => { - if (shouldHash) { - const hash = await config.sys.generateContentHash(code, config.hashedFileNameLength); - return `p-${hash}${sufix}`; - } - - const components = entryKey.split('.'); - let bundleId = components[0]; - if (components.length > 2) { - bundleId = `${bundleId}_${components.length - 1}`; - } - - return bundleId + sufix; -}; diff --git a/src/compiler/transformers/component-hydrate/tranform-to-hydrate-component.ts b/src/compiler/transformers/component-hydrate/tranform-to-hydrate-component.ts index 535e53a9179..6286b5c3bca 100644 --- a/src/compiler/transformers/component-hydrate/tranform-to-hydrate-component.ts +++ b/src/compiler/transformers/component-hydrate/tranform-to-hydrate-component.ts @@ -4,7 +4,7 @@ import type * as d from '../../../declarations'; import { addImports } from '../add-imports'; import { addLegacyApis } from '../core-runtime-apis'; import { updateStyleImports } from '../style-imports'; -import { getComponentMeta, getModuleFromSourceFile } from '../transform-utils'; +import { getComponentMeta, getModuleFromSourceFile, updateMixin } from '../transform-utils'; import { updateHydrateComponentClass } from './hydrate-component'; export const hydrateComponentTransform = ( @@ -20,6 +20,8 @@ export const hydrateComponentTransform = ( const cmp = getComponentMeta(compilerCtx, tsSourceFile, node); if (cmp != null) { return updateHydrateComponentClass(node, moduleFile, cmp); + } else if (compilerCtx.moduleMap.get(tsSourceFile.fileName)?.isMixin) { + return updateMixin(node, moduleFile, cmp, transformOpts); } } diff --git a/src/compiler/transformers/component-lazy/attach-internals.ts b/src/compiler/transformers/component-lazy/attach-internals.ts index 5f09699e521..c553ccc3bda 100644 --- a/src/compiler/transformers/component-lazy/attach-internals.ts +++ b/src/compiler/transformers/component-lazy/attach-internals.ts @@ -42,6 +42,9 @@ import { HOST_REF_ARG } from './constants'; * @returns a list of expression statements */ export function createLazyAttachInternalsBinding(cmp: d.ComponentCompilerMeta): ts.Statement[] { + if (!cmp?.attachInternalsMemberName) { + return []; + } if (cmp.attachInternalsMemberName) { return [ ts.factory.createIfStatement( diff --git a/src/compiler/transformers/component-lazy/lazy-constructor.ts b/src/compiler/transformers/component-lazy/lazy-constructor.ts index d507d621baf..1d6251da6d8 100644 --- a/src/compiler/transformers/component-lazy/lazy-constructor.ts +++ b/src/compiler/transformers/component-lazy/lazy-constructor.ts @@ -46,7 +46,7 @@ export const updateLazyComponentConstructor = ( * @param moduleFile information about a module containing a Stencil component * @returns an expression statement for a call to the `registerInstance` helper */ -const registerInstanceStatement = (moduleFile: d.Module): ts.ExpressionStatement => { +const registerInstanceStatement = (moduleFile: d.Module) => { addCoreRuntimeApi(moduleFile, RUNTIME_APIS.registerInstance); return ts.factory.createExpressionStatement( diff --git a/src/compiler/transformers/component-lazy/transform-lazy-component.ts b/src/compiler/transformers/component-lazy/transform-lazy-component.ts index 7f55679b0bd..a18d5fdc658 100644 --- a/src/compiler/transformers/component-lazy/transform-lazy-component.ts +++ b/src/compiler/transformers/component-lazy/transform-lazy-component.ts @@ -4,7 +4,7 @@ import type * as d from '../../../declarations'; import { addImports } from '../add-imports'; import { addLegacyApis } from '../core-runtime-apis'; import { updateStyleImports } from '../style-imports'; -import { getComponentMeta, getModuleFromSourceFile } from '../transform-utils'; +import { getComponentMeta, getModuleFromSourceFile, updateMixin } from '../transform-utils'; import { updateLazyComponentClass } from './lazy-component'; /** @@ -32,8 +32,12 @@ export const lazyComponentTransform = ( const visitNode = (node: ts.Node): any => { if (ts.isClassDeclaration(node)) { const cmp = getComponentMeta(compilerCtx, tsSourceFile, node); + const module = compilerCtx.moduleMap.get(tsSourceFile.fileName); + if (cmp != null) { return updateLazyComponentClass(transformOpts, styleStatements, node, moduleFile, cmp); + } else if (module?.isMixin) { + return updateMixin(node, moduleFile, cmp, transformOpts); } } return ts.visitEachChild(node, visitNode, transformCtx); diff --git a/src/compiler/transformers/component-native/native-component.ts b/src/compiler/transformers/component-native/native-component.ts index cf1a53b80b7..30f9b872e56 100644 --- a/src/compiler/transformers/component-native/native-component.ts +++ b/src/compiler/transformers/component-native/native-component.ts @@ -5,6 +5,7 @@ import type * as d from '../../../declarations'; import { addOutputTargetCoreRuntimeApi, HTML_ELEMENT, RUNTIME_APIS } from '../core-runtime-apis'; import { transformHostData } from '../host-data-transform'; import { removeStaticMetaProperties } from '../remove-static-meta-properties'; +import { foundSuper, updateConstructor } from '../transform-utils'; import { updateComponentClass } from '../update-component-class'; import { addWatchers } from '../watcher-meta-transform'; import { addNativeConnectedCallback } from './native-connected-callback'; @@ -27,6 +28,8 @@ import { addNativeStaticStyle } from './native-static-style'; * @param classNode the class to transform * @param moduleFile information about the class' home module * @param cmp metadata about the stencil component of interest + * @param compilerCtx the compiler context + * @param tsSourceFile the TypeScript source file containing the class * @returns an updated class */ export const updateNativeComponentClass = ( @@ -40,6 +43,46 @@ export const updateNativeComponentClass = ( return updateComponentClass(transformOpts, withHeritageClauses, withHeritageClauses.heritageClauses, members); }; +/** + * Updates classes that are extended by Stencil components: + * - extend `HTMLElement` if necessary + * - ensure the constructor has a `super()` call + * - remove static metadata properties + * + * @param node the class node to update + * @param moduleFile the module file containing the class + * @param transformOpts transformation options + * @returns the updated class node + */ +export const updateNativeExtendedClass = ( + node: ts.ClassDeclaration, + moduleFile: d.Module, + transformOpts: d.TransformOptions, +) => { + let withHeritageClauses = updateNativeHostComponentHeritageClauses(node, moduleFile); + const ctor = withHeritageClauses.members.find(ts.isConstructorDeclaration); + + if (!foundSuper(ctor?.body?.statements)) { + const params: ts.ParameterDeclaration[] = Array.from(ctor?.parameters ?? []); + + withHeritageClauses = ts.factory.updateClassDeclaration( + withHeritageClauses, + withHeritageClauses.modifiers, + withHeritageClauses.name, + withHeritageClauses.typeParameters, + withHeritageClauses.heritageClauses, + updateConstructor(withHeritageClauses, Array.from(withHeritageClauses.members), [], params, true), + ); + } + + return updateComponentClass( + transformOpts, + withHeritageClauses, + withHeritageClauses.heritageClauses, + removeStaticMetaProperties(withHeritageClauses), + ); +}; + /** * Update or generate a heritage clause (e.g. `extends [IDENTIFIER]`) for a * Stencil component to extend `HTMLElement` @@ -57,10 +100,8 @@ const updateNativeHostComponentHeritageClauses = ( return classNode; } - if (moduleFile.cmps.length >= 1) { - // we'll need to import `HTMLElement` in order to extend it - addOutputTargetCoreRuntimeApi(moduleFile, DIST_CUSTOM_ELEMENTS, RUNTIME_APIS.HTMLElement); - } + // we'll need to import `HTMLElement` in order to extend it + addOutputTargetCoreRuntimeApi(moduleFile, DIST_CUSTOM_ELEMENTS, RUNTIME_APIS.HTMLElement); const heritageClause = ts.factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [ ts.factory.createExpressionWithTypeArguments(ts.factory.createIdentifier(HTML_ELEMENT), []), @@ -89,6 +130,8 @@ const updateNativeHostComponentHeritageClauses = ( * @param classNode the class to transform * @param moduleFile information about the class' home module * @param cmp metadata about the stencil component of interest + * @param compilerCtx the compiler context + * @param tsSourceFile the TypeScript source file containing the class * @returns an updated list of class elements */ const updateNativeHostComponentMembers = ( diff --git a/src/compiler/transformers/component-native/native-constructor.ts b/src/compiler/transformers/component-native/native-constructor.ts index e3ee5e4919e..e6fd0c8dae4 100644 --- a/src/compiler/transformers/component-native/native-constructor.ts +++ b/src/compiler/transformers/component-native/native-constructor.ts @@ -18,6 +18,8 @@ import { createNativeAttachInternalsBinding } from './attach-internals'; * @param moduleFile the Stencil module representation of the component class * @param cmp the component metadata generated for the component * @param classNode the TypeScript syntax tree node for the class + * @param compilerCtx the compiler context, which provides access to the module map + * @param tsSourceFile the TypeScript source file containing the class */ export const updateNativeConstructor = ( classMembers: ts.ClassElement[], @@ -29,12 +31,16 @@ export const updateNativeConstructor = ( return; } + const cstrMethodArgs = [ + ts.factory.createParameterDeclaration(undefined, undefined, ts.factory.createIdentifier('registerHost')), + ]; + const nativeCstrStatements: ts.Statement[] = [ ...nativeInit(cmp), ...addCreateEvents(moduleFile, cmp), ...createNativeAttachInternalsBinding(cmp), ]; - updateConstructor(classNode, classMembers, nativeCstrStatements); + updateConstructor(classNode, classMembers, nativeCstrStatements, cstrMethodArgs, cmp.doesExtend); }; /** @@ -42,8 +48,8 @@ export const updateNativeConstructor = ( * @param cmp the component's metadata * @returns the generated expression statements */ -const nativeInit = (cmp: d.ComponentCompilerMeta): ReadonlyArray => { - const initStatements = [nativeRegisterHostStatement()]; +const nativeInit = (cmp: d.ComponentCompilerMeta): ReadonlyArray => { + const initStatements: (ts.ExpressionStatement | ts.IfStatement)[] = [nativeRegisterHostStatement()]; if (cmp.encapsulation === 'shadow') { initStatements.push(nativeAttachShadowStatement()); } @@ -55,14 +61,26 @@ const nativeInit = (cmp: d.ComponentCompilerMeta): ReadonlyArray { - // Create an expression statement, `this.__registerHost();` - return ts.factory.createExpressionStatement( - ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression(ts.factory.createThis(), ts.factory.createIdentifier('__registerHost')), - undefined, - undefined, +const nativeRegisterHostStatement = (): ts.IfStatement => { + // Create an expression statement, `if (registerHost !== false) this.__registerHost();` + return ts.factory.createIfStatement( + ts.factory.createBinaryExpression( + ts.factory.createIdentifier('registerHost'), + ts.SyntaxKind.ExclamationEqualsEqualsToken, + ts.factory.createFalse(), ), + ts.factory.createBlock([ + ts.factory.createExpressionStatement( + ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createThis(), + ts.factory.createIdentifier('__registerHost'), + ), + undefined, + undefined, + ), + ), + ]), ); }; diff --git a/src/compiler/transformers/component-native/tranform-to-native-component.ts b/src/compiler/transformers/component-native/tranform-to-native-component.ts index 62fa9db526a..215c18dd4f4 100644 --- a/src/compiler/transformers/component-native/tranform-to-native-component.ts +++ b/src/compiler/transformers/component-native/tranform-to-native-component.ts @@ -8,7 +8,7 @@ import { addLegacyApis } from '../core-runtime-apis'; import { defineCustomElement } from '../define-custom-element'; import { updateStyleImports } from '../style-imports'; import { getComponentMeta, getModuleFromSourceFile } from '../transform-utils'; -import { updateNativeComponentClass } from './native-component'; +import { updateNativeComponentClass, updateNativeExtendedClass } from './native-component'; /** * A function that returns a transformation factory. The returned factory @@ -46,6 +46,8 @@ export const nativeComponentTransform = ( const cmp = getComponentMeta(compilerCtx, tsSourceFile, node); if (cmp != null) { return updateNativeComponentClass(transformOpts, node, moduleFile, cmp); + } else if (compilerCtx.moduleMap.get(tsSourceFile.fileName)?.isExtended) { + return updateNativeExtendedClass(node, moduleFile, transformOpts); } } diff --git a/src/compiler/transformers/create-event.ts b/src/compiler/transformers/create-event.ts index 3ace3bfdc13..c1546b38587 100644 --- a/src/compiler/transformers/create-event.ts +++ b/src/compiler/transformers/create-event.ts @@ -11,6 +11,10 @@ import { addCoreRuntimeApi, CREATE_EVENT, RUNTIME_APIS } from './core-runtime-ap * @returns the generated event creation code */ export const addCreateEvents = (moduleFile: d.Module, cmp: d.ComponentCompilerMeta): ts.ExpressionStatement[] => { + if (!cmp?.events?.length) { + // no events to create, so return an empty array + return []; + } return cmp.events.map((ev) => { addCoreRuntimeApi(moduleFile, RUNTIME_APIS.createEvent); diff --git a/src/compiler/transformers/decorators-to-static/component-decorator.ts b/src/compiler/transformers/decorators-to-static/component-decorator.ts index 26f19198f89..0ca76c0fb08 100644 --- a/src/compiler/transformers/decorators-to-static/component-decorator.ts +++ b/src/compiler/transformers/decorators-to-static/component-decorator.ts @@ -95,16 +95,6 @@ const validateComponent = ( cmpNode: ts.ClassDeclaration, componentDecorator: ts.Decorator, ) => { - const extendNode = - cmpNode.heritageClauses && cmpNode.heritageClauses.find((c) => c.token === ts.SyntaxKind.ExtendsKeyword); - if (extendNode) { - const err = buildError(diagnostics); - err.messageText = `Classes decorated with @Component can not extend from a base class. - Stencil needs to be able to switch between different base classes in order to implement the different output targets such as: lazy and raw web components.`; - augmentDiagnosticWithNode(err, extendNode); - return false; - } - if (componentOptions.shadow && componentOptions.scoped) { const err = buildError(diagnostics); err.messageText = `Components cannot be "scoped" and "shadow" at the same time, they are mutually exclusive configurations.`; diff --git a/src/compiler/transformers/decorators-to-static/convert-decorators.ts b/src/compiler/transformers/decorators-to-static/convert-decorators.ts index 0f0387375dd..b10f178ad4d 100644 --- a/src/compiler/transformers/decorators-to-static/convert-decorators.ts +++ b/src/compiler/transformers/decorators-to-static/convert-decorators.ts @@ -91,20 +91,29 @@ const visitClassDeclaration = ( const importAliasMap = new ImportAliasMap(sourceFile); const componentDecorator = retrieveTsDecorators(classNode)?.find(isDecoratorNamed(importAliasMap.get('Component'))); - if (!componentDecorator) { - return classNode; - } - const classMembers = classNode.members; const decoratedMembers = classMembers.filter((member) => (retrieveTsDecorators(member)?.length ?? 0) > 0); + if (!decoratedMembers.length && !componentDecorator) { + return classNode; + } + // create an array of all class members which are _not_ methods decorated // with a Stencil decorator. We do this here because we'll implement the // behavior specified for those decorated methods later on. const filteredMethodsAndFields = removeStencilMethodDecorators(Array.from(classMembers), diagnostics, importAliasMap); - // parse component decorator - componentDecoratorToStatic(config, typeChecker, diagnostics, classNode, filteredMethodsAndFields, componentDecorator); + if (componentDecorator) { + // parse component decorator + componentDecoratorToStatic( + config, + typeChecker, + diagnostics, + classNode, + filteredMethodsAndFields, + componentDecorator, + ); + } // stores a reference to fields that should be watched for changes // parse member decorators (Prop, State, Listen, Event, Method, Element and Watch) diff --git a/src/compiler/transformers/detect-modern-prop-decls.ts b/src/compiler/transformers/detect-modern-prop-decls.ts index b7401f57e8c..4a089a1c3d6 100644 --- a/src/compiler/transformers/detect-modern-prop-decls.ts +++ b/src/compiler/transformers/detect-modern-prop-decls.ts @@ -23,21 +23,22 @@ import { getStaticValue } from './transform-utils'; * } * ``` * This detects the presence of these prop declarations, - * switches on a flag so we can handle them ar runtime. + * switches on a flag so we can handle them at runtime. * * @param classNode the parental class node * @param cmp metadata about the stencil component of interest + * @returns true if the class has modern property declarations, false otherwise */ -export const detectModernPropDeclarations = (classNode: ts.ClassDeclaration, cmp: d.ComponentCompilerFeatures) => { +export const detectModernPropDeclarations = (classNode: ts.ClassDeclaration) => { const parsedProps: { [key: string]: d.ComponentCompilerProperty } = getStaticValue(classNode.members, 'properties'); const parsedStates: { [key: string]: d.ComponentCompilerProperty } = getStaticValue(classNode.members, 'states'); if (!parsedProps && !parsedStates) { - cmp.hasModernPropertyDecls = false; - return; + return false; } const members = [...Object.entries(parsedProps || {}), ...Object.entries(parsedStates || {})]; + let hasModernPropertyDecls = false; for (const [propName, meta] of members) { // comb through the class' body members to find a corresponding, 'modern' prop initializer @@ -54,7 +55,9 @@ export const detectModernPropDeclarations = (classNode: ts.ClassDeclaration, cmp if (!prop) continue; - cmp.hasModernPropertyDecls = true; + hasModernPropertyDecls = true; break; } + + return hasModernPropertyDecls; }; diff --git a/src/compiler/transformers/static-to-meta/class-extension.ts b/src/compiler/transformers/static-to-meta/class-extension.ts new file mode 100644 index 00000000000..7d9cedcab6a --- /dev/null +++ b/src/compiler/transformers/static-to-meta/class-extension.ts @@ -0,0 +1,318 @@ +import ts from 'typescript'; +import { augmentDiagnosticWithNode, buildWarn } from '@utils'; +import { tsResolveModuleName } from '../../sys/typescript/typescript-resolve-module'; +import { isStaticGetter } from '../transform-utils'; +import { parseStaticEvents } from './events'; +import { parseStaticListeners } from './listeners'; +import { parseStaticMethods } from './methods'; +import { parseStaticProps } from './props'; +import { parseStaticStates } from './states'; +import { parseStaticWatchers } from './watchers'; + +import type * as d from '../../../declarations'; +import { detectModernPropDeclarations } from '../detect-modern-prop-decls'; + +type DeDupeMember = + | d.ComponentCompilerProperty + | d.ComponentCompilerState + | d.ComponentCompilerMethod + | d.ComponentCompilerListener + | d.ComponentCompilerEvent + | d.ComponentCompilerWatch; + +/** + * Given two arrays of static members, return a new array containing only the + * members from the first array that are not present in the second array. + * This is used to de-dupe static members that are inherited from a parent class. + * + * @param dedupeMembers the array of static members to de-dupe + * @param staticMembers the array of static members to compare against + * @returns an array of static members that are not present in the second array + */ +const deDupeMembers = (dedupeMembers: T[], staticMembers: T[]) => { + return dedupeMembers.filter( + (s) => + !staticMembers.some((d) => { + if ((d as d.ComponentCompilerWatch).methodName) { + return (d as any).methodName === (s as any).methodName; + } + return (d as any).name === (s as any).name; + }), + ); +}; + +/** + * A recursive function that walks the AST to find a class declaration. + * @param node the current AST node + * @param depth the current depth in the AST + * @param name optional name of the class to find + * @returns the found class declaration or undefined + */ +function findClassWalk(node?: ts.Node, name?: string): ts.ClassDeclaration | undefined { + if (!node) return undefined; + if (node && ts.isClassDeclaration(node) && (!name || node.name?.text === name)) { + return node; + } + let found: ts.ClassDeclaration | undefined; + + ts.forEachChild(node, (child) => { + if (found) return; + const result = findClassWalk(child, name); + if (result) found = result; + }); + + return found; +} + +/** + * A function that checks if a statement matches a named declaration. + * @param name the name to match + * @returns a function that checks if a statement is a named declaration + */ +function matchesNamedDeclaration(name: string) { + return function (stmt: ts.Statement): stmt is ts.ClassDeclaration | ts.FunctionDeclaration | ts.VariableStatement { + // ClassDeclaration: class Foo {} + if (ts.isClassDeclaration(stmt) && stmt.name?.text === name) { + return true; + } + + // FunctionDeclaration: function Foo() {} + if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === name) { + return true; + } + + // VariableStatement: const Foo = ... + if (ts.isVariableStatement(stmt)) { + for (const decl of stmt.declarationList.declarations) { + if (ts.isIdentifier(decl.name) && decl.name.text === name) { + return true; + } + } + } + + return false; + }; +} + +/** + * A recursive function that builds a tree of classes that extend from each other. + * + * @param compilerCtx the current compiler context + * @param classDeclaration a class declaration to analyze + * @param dependentClasses a flat array tree of classes that extend from each other + * @param typeChecker the TypeScript type checker + * @returns a flat array of classes that extend from each other, including the current class + */ +function buildExtendsTree( + compilerCtx: d.CompilerCtx, + classDeclaration: ts.ClassDeclaration, + dependentClasses: { classNode: ts.ClassDeclaration; fileName: string }[], + typeChecker: ts.TypeChecker, + buildCtx: d.BuildCtx, +) { + const hasHeritageClauses = classDeclaration.heritageClauses; + if (!hasHeritageClauses?.length) return dependentClasses; + + const extendsClause = hasHeritageClauses.find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword); + if (!extendsClause) return dependentClasses; + + let classIdentifiers: ts.Identifier[] = []; + let foundClassDeclaration: ts.ClassDeclaration | undefined; + // used when the class we found is wrapped in a mixin factory function - + // the extender ctor will be from a dynamic function argument - so we stop recursing + let keepLooking = true; + + extendsClause.types.forEach((type) => { + if ( + ts.isExpressionWithTypeArguments(type) && + ts.isCallExpression(type.expression) && + type.expression.expression.getText() === 'Mixin' + ) { + // handle mixin case: extends Mixin(SomeClassFactoryFunction1, SomeClassFactoryFunction2) + classIdentifiers = type.expression.arguments.filter(ts.isIdentifier); + } else if (ts.isIdentifier(type.expression)) { + // handle simple case: extends SomeClass + classIdentifiers = [type.expression]; + } + }); + + classIdentifiers.forEach((extendee) => { + 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 aliasedSymbol = symbol ? typeChecker.getAliasedSymbol(symbol) : undefined; + foundClassDeclaration = aliasedSymbol?.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]; + foundClassDeclaration = findClassWalk(node); + keepLooking = false; + } + + if (foundClassDeclaration && !dependentClasses.some((dc) => dc.classNode === foundClassDeclaration)) { + const foundModule = compilerCtx.moduleMap.get(foundClassDeclaration.getSourceFile().fileName); + + if (foundModule) { + const source = foundModule.staticSourceFile as ts.SourceFile; + const sourceClass = findClassWalk(source, foundClassDeclaration.name?.getText()); + + if (sourceClass) { + dependentClasses.push({ classNode: sourceClass, fileName: source.fileName }); + if (keepLooking) { + buildExtendsTree(compilerCtx, foundClassDeclaration, dependentClasses, typeChecker, buildCtx); + } + } + } + } + } catch (_e) { + // sad path (normally >1 levels removed): the extends type does not resolve so let's find it manually: + + const currentSource = classDeclaration.getSourceFile(); + if (!currentSource) return; + + // let's see if we can find the class in the current source file first + const matchedStatement = currentSource.statements.find(matchesNamedDeclaration(extendee.getText())); + + if (matchedStatement && ts.isClassDeclaration(matchedStatement)) { + foundClassDeclaration = matchedStatement; + } else if (matchedStatement) { + // 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 + foundClassDeclaration = findClassWalk(matchedStatement); + keepLooking = false; + } + + if (foundClassDeclaration && !dependentClasses.some((dc) => dc.classNode === foundClassDeclaration)) { + // we found the class declaration in the current module + dependentClasses.push({ classNode: foundClassDeclaration, fileName: currentSource.fileName }); + if (keepLooking) { + buildExtendsTree(compilerCtx, foundClassDeclaration, dependentClasses, typeChecker, buildCtx); + } + return; + } + + // if not found, let's check the import statements + const importStatements = currentSource.statements.filter(ts.isImportDeclaration); + importStatements.forEach((statement) => { + // 1) loop through import declarations in the current source file + if (statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause?.namedBindings)) { + statement.importClause?.namedBindings.elements.forEach((element) => { + // 2) loop through the named bindings of the import declaration + + if (element.name.getText() === extendee.getText()) { + // 3) check the name matches the `extends` type expression + const className = element.propertyName?.getText() || element.name.getText(); + const foundFile = tsResolveModuleName( + buildCtx.config, + compilerCtx, + statement.moduleSpecifier.getText().replaceAll(/['"]/g, ''), + currentSource.fileName, + ); + + if (foundFile?.resolvedModule && className) { + // 4) resolve the module name to a file + const foundModule = compilerCtx.moduleMap.get(foundFile.resolvedModule.resolvedFileName); + + // 5) look for the corresponding resolved statement + const matchedStatement = (foundModule?.staticSourceFile as ts.SourceFile).statements.find( + matchesNamedDeclaration(className), + ); + foundClassDeclaration = matchedStatement + ? ts.isClassDeclaration(matchedStatement) + ? matchedStatement + : undefined + : undefined; + + if (!foundClassDeclaration && matchedStatement) { + // 5.b) 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 + foundClassDeclaration = findClassWalk(matchedStatement); + keepLooking = false; + } + + if (foundClassDeclaration && !dependentClasses.some((dc) => dc.classNode === foundClassDeclaration)) { + // 6) if we found the class declaration, push it and check if it itself extends from another class + dependentClasses.push({ classNode: foundClassDeclaration, fileName: currentSource.fileName }); + if (keepLooking) { + buildExtendsTree(compilerCtx, foundClassDeclaration, dependentClasses, typeChecker, buildCtx); + } + return; + } + } + } + }); + } + }); + } + }); + + return dependentClasses; +} + +/** + * Given a class declaration, this function will analyze its heritage clauses + * to find any extended classes, and then parse the static members of those + * extended classes to merge them into the current class's metadata. + * + * @param compilerCtx + * @param typeChecker + * @param buildCtx + * @param cmpNode + * @param staticMembers + * @returns an object containing merged metadata from extended classes + */ +export function mergeExtendedClassMeta( + compilerCtx: d.CompilerCtx, + typeChecker: ts.TypeChecker, + buildCtx: d.BuildCtx, + cmpNode: ts.ClassDeclaration, + staticMembers: ts.ClassElement[], +) { + const tree = buildExtendsTree(compilerCtx, cmpNode, [], typeChecker, buildCtx); + let hasMixin = false; + let doesExtend = false; + let properties = parseStaticProps(staticMembers); + let states = parseStaticStates(staticMembers); + let methods = parseStaticMethods(staticMembers); + let listeners = parseStaticListeners(staticMembers); + let events = parseStaticEvents(staticMembers); + let watchers = parseStaticWatchers(staticMembers); + let classMethods = cmpNode.members.filter(ts.isMethodDeclaration); + + tree.forEach((extendedClass) => { + const extendedStaticMembers = extendedClass.classNode.members.filter(isStaticGetter); + const mixinProps = parseStaticProps(extendedStaticMembers) ?? []; + const mixinStates = parseStaticStates(extendedStaticMembers) ?? []; + const mixinMethods = parseStaticMethods(extendedStaticMembers) ?? []; + const isMixin = mixinProps.length > 0 || mixinStates.length > 0; + const module = compilerCtx.moduleMap.get(extendedClass.fileName); + if (!module) return; + + module.isMixin = isMixin; + module.isExtended = true; + doesExtend = true; + + if (isMixin && !detectModernPropDeclarations(extendedClass.classNode)) { + const err = buildWarn(buildCtx.diagnostics); + const target = buildCtx.config.tsCompilerOptions?.target; + err.messageText = `Component classes can only extend from other Stencil decorated base classes when targetting more modern JavaScript (ES2022 and above). + ${target ? `Your current TypeScript configuration is set to target \`${ts.ScriptTarget[target]}\`.` : ''} Please amend your tsconfig.json.`; + if (!buildCtx.config._isTesting) augmentDiagnosticWithNode(err, extendedClass.classNode); + } + + properties = [...deDupeMembers(mixinProps, properties), ...properties]; + states = [...deDupeMembers(mixinStates, states), ...states]; + methods = [...deDupeMembers(mixinMethods, methods), ...methods]; + listeners = [...deDupeMembers(parseStaticListeners(extendedStaticMembers) ?? [], listeners), ...listeners]; + events = [...deDupeMembers(parseStaticEvents(extendedStaticMembers) ?? [], events), ...events]; + watchers = [...deDupeMembers(parseStaticWatchers(extendedStaticMembers) ?? [], watchers), ...watchers]; + classMethods = [...classMethods, ...(extendedClass.classNode.members.filter(ts.isMethodDeclaration) ?? [])]; + + if (isMixin) hasMixin = true; + }); + + return { hasMixin, doesExtend, properties, states, methods, listeners, events, watchers, classMethods }; +} diff --git a/src/compiler/transformers/static-to-meta/class-methods.ts b/src/compiler/transformers/static-to-meta/class-methods.ts index 3cfa8f82e35..f189eb4a75e 100644 --- a/src/compiler/transformers/static-to-meta/class-methods.ts +++ b/src/compiler/transformers/static-to-meta/class-methods.ts @@ -3,16 +3,11 @@ import ts from 'typescript'; import type * as d from '../../../declarations'; import { isMethod } from '../transform-utils'; -export const parseClassMethods = (cmpNode: ts.ClassDeclaration, cmpMeta: d.ComponentCompilerMeta) => { - const classMembers = cmpNode.members; - if (!classMembers || classMembers.length === 0) { +export const parseClassMethods = (classMethods: ts.MethodDeclaration[], cmpMeta: d.ComponentCompilerMeta) => { + if (!classMethods?.length) { return; } - const classMethods = classMembers.filter((m) => ts.isMethodDeclaration(m)); - if (classMethods.length === 0) { - return; - } const hasHostData = classMethods.some((m) => isMethod(m, 'hostData')); cmpMeta.hasAttributeChangedCallbackFn = classMethods.some((m) => isMethod(m, 'attributeChangedCallback')); diff --git a/src/compiler/transformers/static-to-meta/component.ts b/src/compiler/transformers/static-to-meta/component.ts index def44c6de12..9ec39d0f6f9 100644 --- a/src/compiler/transformers/static-to-meta/component.ts +++ b/src/compiler/transformers/static-to-meta/component.ts @@ -1,4 +1,4 @@ -import { join, normalizePath, relative, unique } from '@utils'; +import { augmentDiagnosticWithNode, buildWarn, join, normalizePath, relative, unique } from '@utils'; import { dirname, isAbsolute } from 'path'; import ts from 'typescript'; @@ -12,15 +12,10 @@ import { parseCallExpression } from './call-expression'; import { parseClassMethods } from './class-methods'; import { parseStaticElementRef } from './element-ref'; import { parseStaticEncapsulation, parseStaticShadowDelegatesFocus } from './encapsulation'; -import { parseStaticEvents } from './events'; import { parseFormAssociated } from './form-associated'; -import { parseStaticListeners } from './listeners'; -import { parseStaticMethods } from './methods'; -import { parseStaticProps } from './props'; -import { parseStaticStates } from './states'; import { parseStringLiteral } from './string-literal'; import { parseStaticStyles } from './styles'; -import { parseStaticWatchers } from './watchers'; +import { mergeExtendedClassMeta } from './class-extension'; const BLACKLISTED_COMPONENT_METHODS = [ /** @@ -47,6 +42,7 @@ const BLACKLISTED_COMPONENT_METHODS = [ * @param typeChecker a TypeScript type checker instance * @param cmpNode the TypeScript class declaration for the component * @param moduleFile Stencil's IR for a module, used here as an out param + * @param buildCtx the current build context, used to surface diagnostics * @param transformOpts options which control various aspects of the * transformation * @returns the TypeScript class declaration IR instance with which the @@ -57,6 +53,7 @@ export const parseStaticComponentMeta = ( typeChecker: ts.TypeChecker, cmpNode: ts.ClassDeclaration, moduleFile: d.Module, + buildCtx: d.BuildCtx, transformOpts?: d.TransformOptions, ): ts.ClassDeclaration => { if (cmpNode.members == null) { @@ -68,6 +65,13 @@ export const parseStaticComponentMeta = ( return cmpNode; } + const { doesExtend, properties, states, methods, listeners, events, watchers, classMethods } = mergeExtendedClassMeta( + compilerCtx, + typeChecker, + buildCtx, + cmpNode, + staticMembers, + ); const symbol = typeChecker ? typeChecker.getSymbolAtLocation(cmpNode.name) : undefined; const docs = serializeSymbol(typeChecker, symbol); const isCollectionDependency = moduleFile.isCollectionDependency; @@ -82,13 +86,14 @@ export const parseStaticComponentMeta = ( elementRef: parseStaticElementRef(staticMembers), encapsulation, shadowDelegatesFocus: !!parseStaticShadowDelegatesFocus(encapsulation, staticMembers), - properties: parseStaticProps(staticMembers), + properties, virtualProperties: parseVirtualProps(docs), - states: parseStaticStates(staticMembers), - methods: parseStaticMethods(staticMembers), - listeners: parseStaticListeners(staticMembers), - events: parseStaticEvents(staticMembers), - watchers: parseStaticWatchers(staticMembers), + states, + methods, + listeners, + events, + watchers, + doesExtend, styles: parseStaticStyles(compilerCtx, tagName, moduleFile.sourceFilePath, isCollectionDependency, staticMembers), internal: isInternal(docs), assetsDirs: parseAssetsDirs(staticMembers, moduleFile.jsFilePath), @@ -156,19 +161,20 @@ export const parseStaticComponentMeta = ( directDependencies: [], }; - const visitComponentChildNode = (node: ts.Node) => { - validateComponentMembers(node); + const visitComponentChildNode = (node: ts.Node, buildCtx: d.BuildCtx) => { + validateComponentMembers(node, buildCtx); if (ts.isCallExpression(node)) { parseCallExpression(cmp, node); } else if (ts.isStringLiteral(node)) { parseStringLiteral(cmp, node); } - node.forEachChild(visitComponentChildNode); + node.forEachChild((child) => visitComponentChildNode(child, buildCtx)); }; - visitComponentChildNode(cmpNode); - parseClassMethods(cmpNode, cmp); - detectModernPropDeclarations(cmpNode, cmp); + visitComponentChildNode(cmpNode, buildCtx); + parseClassMethods(classMethods, cmp); + const hasModernPropertyDecls = detectModernPropDeclarations(cmpNode); + cmp.hasModernPropertyDecls = hasModernPropertyDecls; cmp.htmlAttrNames = unique(cmp.htmlAttrNames); cmp.htmlTagNames = unique(cmp.htmlTagNames); @@ -192,7 +198,7 @@ export const parseStaticComponentMeta = ( return cmpNode; }; -const validateComponentMembers = (node: ts.Node) => { +const validateComponentMembers = (node: ts.Node, buildCtx: d.BuildCtx) => { /** * validate if: */ @@ -224,9 +230,9 @@ const validateComponentMembers = (node: ts.Node) => { ) ) { const componentName = node.parent.name.getText(); - throw new Error( - `The component "${componentName}" has a getter called "${propName}". This getter is reserved for use by Stencil components and should not be defined by the user.`, - ); + const err = buildWarn(buildCtx.diagnostics); + err.messageText = `The component "${componentName}" has a getter called "${propName}". This getter is reserved for use by Stencil components and should not be defined by the user.`; + augmentDiagnosticWithNode(err, node); } } }; diff --git a/src/compiler/transformers/static-to-meta/parse-static.ts b/src/compiler/transformers/static-to-meta/parse-static.ts index 8b3b4bcfc4a..07b4ec40e96 100644 --- a/src/compiler/transformers/static-to-meta/parse-static.ts +++ b/src/compiler/transformers/static-to-meta/parse-static.ts @@ -46,7 +46,7 @@ export const updateModule = ( const visitNode = (node: ts.Node) => { if (ts.isClassDeclaration(node)) { - parseStaticComponentMeta(compilerCtx, typeChecker, node, moduleFile); + parseStaticComponentMeta(compilerCtx, typeChecker, node, moduleFile, buildCtx, undefined); return; } else if (ts.isImportDeclaration(node)) { parseModuleImport(config, compilerCtx, buildCtx, moduleFile, srcDirPath, node, true); diff --git a/src/compiler/transformers/static-to-meta/visitor.ts b/src/compiler/transformers/static-to-meta/visitor.ts index 0d9a6aa8545..82a5c082b3a 100644 --- a/src/compiler/transformers/static-to-meta/visitor.ts +++ b/src/compiler/transformers/static-to-meta/visitor.ts @@ -22,7 +22,7 @@ export const convertStaticToMeta = ( const visitNode = (node: ts.Node): ts.VisitResult => { if (ts.isClassDeclaration(node)) { - return parseStaticComponentMeta(compilerCtx, typeChecker, node, moduleFile, transformOpts); + return parseStaticComponentMeta(compilerCtx, typeChecker, node, moduleFile, buildCtx, transformOpts); } else if (ts.isImportDeclaration(node)) { parseModuleImport(config, compilerCtx, buildCtx, moduleFile, dirPath, node, !transformOpts.isolatedModules); } else if (ts.isCallExpression(node)) { diff --git a/src/compiler/transformers/test/convert-decorators.spec.ts b/src/compiler/transformers/test/convert-decorators.spec.ts index 018a0a22e7a..9f6bcb0b9e0 100644 --- a/src/compiler/transformers/test/convert-decorators.spec.ts +++ b/src/compiler/transformers/test/convert-decorators.spec.ts @@ -194,6 +194,9 @@ describe('convert-decorators', () => { this.count = 0; super(); } + static get is() { + return 'cmp-a'; + } static get states() { return { "count": {} }; }}`, @@ -269,7 +272,7 @@ describe('convert-decorators', () => { it('should preserve statements in an existing constructor w/ non-decorated field', async () => { const t = transpileModule(` @Component({ - tag: 'example', + tag: 'example-tag', }) export class Example implements FooBar { private classProps: Array; @@ -283,14 +286,18 @@ describe('convert-decorators', () => { await c`export class Example { constructor() { this.classProps = ["variant", "theme"]; - }}`, + } + static get is() { + return 'example-tag'; + } + }`, ); }); it('should preserve statements in an existing constructor super, decorated field', async () => { const t = transpileModule(` @Component({ - tag: 'example', + tag: 'example-tag', }) export class Example extends Parent { @Prop() foo: string = "bar"; diff --git a/src/compiler/transformers/test/native-component.spec.ts b/src/compiler/transformers/test/native-component.spec.ts index b1a62944a05..ba38645d2ff 100644 --- a/src/compiler/transformers/test/native-component.spec.ts +++ b/src/compiler/transformers/test/native-component.spec.ts @@ -1,3 +1,4 @@ +import * as ts from 'typescript'; import * as d from '@stencil/core/declarations'; import { mockCompilerCtx } from '@stencil/core/testing'; @@ -45,6 +46,61 @@ describe('nativeComponentTransform', () => { ); }); + it('passes false to super of stencil decorated class calls', async () => { + const code = ` + class PlainClass { + @Prop() baz: number; + } + @Component({ + tag: 'cmp-b', + }) + export class CmpB extends PlainClass { + @Prop() bar: number; + } + @Component({ + tag: 'cmp-a', + }) + export class CmpA extends CmpB { + @Prop() foo: number; + } + `; + + const transformer = nativeComponentTransform(compilerCtx, transformOpts); + const transpiledModule = transpileModule(code, null, compilerCtx, [], [transformer], [], { + target: ts.ScriptTarget.ESNext, + }); + + expect(await formatCode(transpiledModule.outputText)).toContain( + await c`__stencil_defineCustomElement(CmpA, [0, 'cmp-a', { baz: [2], bar: [2], foo: [2] }])`, + ); + + expect(await formatCode(transpiledModule.outputText)).toContain( + await c`const CmpB = class extends PlainClass { + constructor(registerHost) { + super(false); + if (registerHost !== false) { + this.__registerHost(); + } + super(); + } + bar; + };`, + ); + + expect(await formatCode(transpiledModule.outputText)).toContain( + await c`const CmpA = class extends CmpB { + constructor(registerHost) { + super(false); + if (registerHost !== false) { + this.__registerHost(); + } + super(); + } + foo; + };`, + ); + }); + describe('updateNativeComponentClass', () => { it("adds __attachShadow() calls when a component doesn't have a constructor", () => { const code = ` @@ -127,9 +183,11 @@ describe('nativeComponentTransform', () => { expect(await formatCode(transpiledModule.outputText)).toContain( await c`const CmpA = class extends HTMLElement { - constructor() { + constructor(registerHost) { super(); - this.__registerHost(); + if (registerHost !== false) { + this.__registerHost(); + } } static get formAssociated() { return true; @@ -154,9 +212,11 @@ describe('nativeComponentTransform', () => { expect(await formatCode(transpiledModule.outputText)).toContain( await c`const CmpA = class extends HTMLElement { - constructor() { + constructor(registerHost) { super(); - this.__registerHost(); + if (registerHost !== false) { + this.__registerHost(); + } this.internals = this.attachInternals(); } static get formAssociated() { @@ -197,9 +257,11 @@ describe('nativeComponentTransform', () => { await formatCode(`import { defineCustomElement as __stencil_defineCustomElement, HTMLElement } from '@stencil/core'; ${expectedImport} const CmpA = class extends HTMLElement { - constructor() { + constructor(registerHost) { super(); - this.__registerHost(); + if (registerHost !== false) { + this.__registerHost(); + } } static get style() { return ${expectedStyleReturn}; diff --git a/src/compiler/transformers/test/parse-component.spec.ts b/src/compiler/transformers/test/parse-component.spec.ts index 93a02c7f22f..6da76f65fda 100644 --- a/src/compiler/transformers/test/parse-component.spec.ts +++ b/src/compiler/transformers/test/parse-component.spec.ts @@ -1,3 +1,5 @@ +import * as ts from 'typescript'; + import { getStaticGetter, transpileModule } from './transpile'; describe('parse component', () => { @@ -67,4 +69,94 @@ describe('parse component', () => { expect(t.componentClassName).toBe('CmpA'); }); + + it('should derive meta data from extended tree of classes', async () => { + const t = transpileModule(` + @Component({tag: 'cmp-a'}) + class CmpA extends Parent { + @Prop() foo: string; + } + class Parent extends GrandParent { + render() {} + } + class GrandParent { + connectedCallback() {} + } + `); + + expect(t.cmp.hasRenderFn).toBe(true); + expect(t.cmp.hasConnectedCallbackFn).toBe(true); + }); + + it('should derive `isExtended` and `isMixin`', async () => { + let t = transpileModule( + ` + @Component({tag: 'cmp-a'}) + class CmpA extends Parent { + @Prop() foo: string; + } + class Parent extends GrandParent { + render() {} + } + class GrandParent { + connectedCallback() {} + } + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ES2022 }, + ); + + expect(t.isExtended).toBe(true); + expect(t.isMixin).toBe(false); + + t = transpileModule( + ` + @Component({tag: 'cmp-a'}) + class CmpA { + @Prop() foo: string; + } + @Component({tag: 'cmp-b'}) + class CmpB extends CmpA { + @Prop() foo: string; + } + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ES2022 }, + ); + + expect(t.isExtended).toBe(true); + expect(t.isMixin).toBe(true); + }); + + it('should throw error if target is less than es2022', async () => { + try { + transpileModule( + ` + @Component({tag: 'cmp-a'}) + class CmpA extends Parent { + @Prop() foo: string = 'cmp a foo'; + } + class Parent { + @Prop() foo: string = 'parent foo'; + } + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ES2021 }, + ); + } catch (e: any) { + expect(e.message).toContain('ES2022 and above'); + } + }); }); diff --git a/src/compiler/transformers/test/parse-events.spec.ts b/src/compiler/transformers/test/parse-events.spec.ts index abe8c27fd1b..0327bd4d6bd 100644 --- a/src/compiler/transformers/test/parse-events.spec.ts +++ b/src/compiler/transformers/test/parse-events.spec.ts @@ -1,3 +1,5 @@ +import * as ts from 'typescript'; + import { transpileModule } from './transpile'; describe('parse events', () => { @@ -111,4 +113,64 @@ describe('parse events', () => { }, }); }); + + it('should merge extended class events meta', async () => { + const t = transpileModule( + ` + @Component({tag: 'cmp-a'}) + class CmpA extends Parent { + @Event({bubbles: true}) anEvent: EventEmitter; + } + class Parent extends GrandParent { + @Event({bubbles: false}) anEvent: EventEmitter; + } + class GrandParent { + @Event({bubbles: false}) anGrandParentEvent: EventEmitter; + } + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ESNext }, + ); + + expect(t.events).toEqual([ + { + bubbles: false, + cancelable: true, + complexType: { + original: 'string', + references: {}, + resolved: 'string', + }, + composed: true, + docs: { + tags: [], + text: '', + }, + internal: false, + method: 'anGrandParentEvent', + name: 'anGrandParentEvent', + }, + { + bubbles: true, + cancelable: true, + complexType: { + original: 'string', + references: {}, + resolved: 'string', + }, + composed: true, + docs: { + tags: [], + text: '', + }, + internal: false, + method: 'anEvent', + name: 'anEvent', + }, + ]); + }); }); diff --git a/src/compiler/transformers/test/parse-listeners.spec.ts b/src/compiler/transformers/test/parse-listeners.spec.ts index 1cb9cd4bd11..d91b17026ae 100644 --- a/src/compiler/transformers/test/parse-listeners.spec.ts +++ b/src/compiler/transformers/test/parse-listeners.spec.ts @@ -1,3 +1,5 @@ +import * as ts from 'typescript'; + import { transpileModule } from './transpile'; describe('parse listeners', () => { @@ -135,4 +137,47 @@ describe('parse listeners', () => { }, ]); }); + + it('should merge extended class listeners meta', async () => { + const t = transpileModule( + ` + @Component({tag: 'cmp-a'}) + class CmpA extends Parent { + @Listen('foo', {target: 'body'}) + fooHandler() {} + } + class Parent extends GrandParent { + @Listen('foo') + fooHandler() {} + } + class GrandParent { + @Listen('bar') + barHandler() {} + } + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ESNext }, + ); + + expect(t.listeners).toEqual([ + { + capture: false, + method: 'barHandler', + name: 'bar', + passive: false, + target: undefined, + }, + { + capture: false, + method: 'fooHandler', + name: 'foo', + passive: false, + target: 'body', + }, + ]); + }); }); diff --git a/src/compiler/transformers/test/parse-methods.spec.ts b/src/compiler/transformers/test/parse-methods.spec.ts index bde7446e384..039775663b8 100644 --- a/src/compiler/transformers/test/parse-methods.spec.ts +++ b/src/compiler/transformers/test/parse-methods.spec.ts @@ -1,3 +1,5 @@ +import * as ts from 'typescript'; + import { getStaticGetter, transpileModule } from './transpile'; describe('parse methods', () => { @@ -55,4 +57,64 @@ describe('parse methods', () => { name: 'someMethod', }); }); + + it('should merge extended class method meta', async () => { + const t = transpileModule( + ` + @Component({tag: 'cmp-a'}) + class CmpA extends Parent { + @Method() async foo(): string { + return 'CmpA'; + } + } + class Parent extends GrandParent { + @Method() async foo(): string[] { + return ['Parent']; + } + } + class GrandParent { + @Method() async bar(): string { + return 'GrandParent'; + } + } + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ESNext }, + ); + + expect(t.methods).toEqual([ + { + complexType: { + parameters: [], + references: {}, + return: 'string', + signature: '() => string', + }, + docs: { + tags: [], + text: '', + }, + internal: false, + name: 'bar', + }, + { + complexType: { + parameters: [], + references: {}, + return: 'string', + signature: '() => string', + }, + docs: { + tags: [], + text: '', + }, + internal: false, + name: 'foo', + }, + ]); + }); }); diff --git a/src/compiler/transformers/test/parse-mixin.spec.ts b/src/compiler/transformers/test/parse-mixin.spec.ts new file mode 100644 index 00000000000..b2022b48dc4 --- /dev/null +++ b/src/compiler/transformers/test/parse-mixin.spec.ts @@ -0,0 +1,149 @@ +import * as ts from 'typescript'; + +import { transpileModule } from './transpile'; +// import { c, formatCode } from './utils'; + +describe('parse mixin', () => { + it('merges mixin class meta', () => { + const t = transpileModule( + ` + @Component({tag: 'cmp-a'}) + class CmpA extends Mixin(ParentWrap, GrandParentWrap) { + @Method() async fooMethod(): string { + return 'CmpA'; + } + @Prop() fooProp: string = 'cmp a foo'; + } + + const ParentWrap = (Base) => { + class Parent extends Base { + @Method() async fooMethod(): string[] { + return ['Parent']; + } + @Prop() fooProp: string = 'parent foo'; + @Prop() barProp: string = 'parent bar'; + } + return Parent; + } + + function GrandParentWrap(Base) { + class GrandParent extends Base { + @Method() async barMethod(): string { + return 'GrandParent'; + } + @Prop() barProp: string = 'grandparent bar'; + @Prop() bazProp: string = 'grandparent baz'; + } + return GrandParent; + } + + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ESNext }, + ); + + expect(t.methods).toEqual([ + { + complexType: { + parameters: [], + references: {}, + return: 'string', + signature: '() => string', + }, + docs: { + tags: [], + text: '', + }, + internal: false, + name: 'barMethod', + }, + { + complexType: { + parameters: [], + references: {}, + return: 'string', + signature: '() => string', + }, + docs: { + tags: [], + text: '', + }, + internal: false, + name: 'fooMethod', + }, + ]); + + expect(t.properties).toEqual([ + { + attribute: 'baz-prop', + complexType: { + original: 'string', + references: {}, + resolved: 'string', + }, + defaultValue: "'grandparent baz'", + docs: { + tags: [], + text: '', + }, + getter: false, + internal: false, + mutable: false, + name: 'bazProp', + optional: false, + reflect: false, + required: false, + setter: false, + type: 'string', + }, + { + attribute: 'bar-prop', + complexType: { + original: 'string', + references: {}, + resolved: 'string', + }, + defaultValue: "'parent bar'", + docs: { + tags: [], + text: '', + }, + getter: false, + internal: false, + mutable: false, + name: 'barProp', + optional: false, + reflect: false, + required: false, + setter: false, + type: 'string', + }, + { + attribute: 'foo-prop', + complexType: { + original: 'string', + references: {}, + resolved: 'string', + }, + defaultValue: "'cmp a foo'", + docs: { + tags: [], + text: '', + }, + getter: false, + internal: false, + mutable: false, + name: 'fooProp', + optional: false, + reflect: false, + required: false, + setter: false, + type: 'string', + }, + ]); + }); +}); diff --git a/src/compiler/transformers/test/parse-props.spec.ts b/src/compiler/transformers/test/parse-props.spec.ts index 397738c0890..86b4f89d4a5 100644 --- a/src/compiler/transformers/test/parse-props.spec.ts +++ b/src/compiler/transformers/test/parse-props.spec.ts @@ -1,3 +1,5 @@ +import * as ts from 'typescript'; + import { getStaticGetter, transpileModule } from './transpile'; import { c, formatCode } from './utils'; @@ -851,4 +853,98 @@ describe('parse props', () => { _a = dynVal;`, ); }); + + it('should merge extended class property meta', async () => { + const t = transpileModule( + ` + @Component({tag: 'cmp-a'}) + class CmpA extends Parent { + @Prop() foo: string = 'cmp a foo'; + } + class Parent extends GrandParent { + @Prop() foo: string = 'parent foo'; + @Prop() bar: string = 'parent bar'; + } + class GrandParent { + @Prop() bar: string = 'grandparent bar'; + @Prop() baz: string = 'grandparent baz'; + } + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ESNext }, + ); + + expect(t.properties).toEqual([ + { + attribute: 'baz', + complexType: { + original: 'string', + references: {}, + resolved: 'string', + }, + defaultValue: "'grandparent baz'", + docs: { + tags: [], + text: '', + }, + getter: false, + internal: false, + mutable: false, + name: 'baz', + optional: false, + reflect: false, + required: false, + setter: false, + type: 'string', + }, + { + attribute: 'bar', + complexType: { + original: 'string', + references: {}, + resolved: 'string', + }, + defaultValue: "'parent bar'", + docs: { + tags: [], + text: '', + }, + getter: false, + internal: false, + mutable: false, + name: 'bar', + optional: false, + reflect: false, + required: false, + setter: false, + type: 'string', + }, + { + attribute: 'foo', + complexType: { + original: 'string', + references: {}, + resolved: 'string', + }, + defaultValue: "'cmp a foo'", + docs: { + tags: [], + text: '', + }, + getter: false, + internal: false, + mutable: false, + name: 'foo', + optional: false, + reflect: false, + required: false, + setter: false, + type: 'string', + }, + ]); + }); }); diff --git a/src/compiler/transformers/test/parse-states.spec.ts b/src/compiler/transformers/test/parse-states.spec.ts index 404491b0b20..f9144ef8d4b 100644 --- a/src/compiler/transformers/test/parse-states.spec.ts +++ b/src/compiler/transformers/test/parse-states.spec.ts @@ -1,3 +1,5 @@ +import * as ts from 'typescript'; + import { transpileModule } from './transpile'; import { formatCode } from './utils'; @@ -18,4 +20,29 @@ describe('parse states', () => { `), ); }); + + it('should merge extended class state meta', async () => { + const t = transpileModule( + ` + @Component({tag: 'cmp-a'}) + class CmpA extends Parent { + @State() foo: string = 'cmp a foo'; + } + class Parent extends GrandParent { + @State() foo: string = 'parent foo'; + } + class GrandParent { + @State() bar: string = 'grandparent bar'; + } + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ESNext }, + ); + + expect(t.states).toEqual([{ name: 'bar' }, { name: 'foo' }]); + }); }); diff --git a/src/compiler/transformers/test/parse-watch.spec.ts b/src/compiler/transformers/test/parse-watch.spec.ts index d50291448f7..2b803b63530 100644 --- a/src/compiler/transformers/test/parse-watch.spec.ts +++ b/src/compiler/transformers/test/parse-watch.spec.ts @@ -1,3 +1,5 @@ +import * as ts from 'typescript'; + import { getStaticGetter, transpileModule } from './transpile'; describe('parse watch', () => { @@ -30,4 +32,56 @@ describe('parse watch', () => { { methodName: 'onStateUpdated', propName: 'state1' }, ]); }); + + it('should merge extended class watchers meta', async () => { + const t = transpileModule( + ` + @Component({tag: 'cmp-a'}) + class CmpA extends Parent { + @Watch('foo') + fooHandler() { + return 'CmpA'; + } + } + class Parent extends GrandParent { + @Watch('foo') + anotherFooHandler() { + return 'Parent'; + } + } + class GrandParent { + @Watch('bar') + barHandler() { + return 'GrandParent'; + } + + @Watch('foo') + fooHandler() { + return 'GrandParent'; + } + } + `, + undefined, + undefined, + [], + [], + [], + { target: ts.ScriptTarget.ESNext }, + ); + + expect(t.watchers).toEqual([ + { + methodName: 'barHandler', + propName: 'bar', + }, + { + methodName: 'anotherFooHandler', + propName: 'foo', + }, + { + methodName: 'fooHandler', + propName: 'foo', + }, + ]); + }); }); diff --git a/src/compiler/transformers/test/transform-utils.spec.ts b/src/compiler/transformers/test/transform-utils.spec.ts index 5350f3ea4b3..14bce653b0d 100644 --- a/src/compiler/transformers/test/transform-utils.spec.ts +++ b/src/compiler/transformers/test/transform-utils.spec.ts @@ -6,6 +6,7 @@ import { retrieveModifierLike, retrieveTsDecorators, retrieveTsModifiers, + updateConstructor, } from '../transform-utils'; describe('transform-utils', () => { @@ -200,4 +201,123 @@ describe('transform-utils', () => { expect(modifiers![0]).toEqual(initialModifiers[0]); }); }); + + describe('updateConstructor', () => { + function printClassMembers(classNode: ts.ClassDeclaration, classMembers: ts.ClassElement[]) { + const updatedClass = ts.factory.updateClassDeclaration( + classNode, + classNode.modifiers, + classNode.name, + classNode.typeParameters, + classNode.heritageClauses, + classMembers, + ); + const printer: ts.Printer = ts.createPrinter(); + let sourceFile = ts.createSourceFile('dummy.ts', '', ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS); + sourceFile = ts.factory.updateSourceFile(sourceFile, [updatedClass]); + return printer.printFile(sourceFile).replace(/\n/g, '').replace(/ /g, ' '); + } + + it('returns the same constructor when no parameters are provided', () => { + const classNode = ts.factory.createClassDeclaration([], 'MyClass', undefined, undefined, [ + ts.factory.createConstructorDeclaration([], [], ts.factory.createBlock([], false)), + ]); + const updatedMembers = updateConstructor(classNode, Array.from(classNode.members), []); + + expect(printClassMembers(classNode, updatedMembers)).toBe(`class MyClass { constructor() { }}`); + }); + + it('adds a constructor when none is present and statements are provided', () => { + const ctorStatements = [ts.factory.createExpressionStatement(ts.factory.createIdentifier('someMethod()'))]; + + const classNode = ts.factory.createClassDeclaration([], 'MyClass', undefined, undefined, [ + ts.factory.createMethodDeclaration( + [], + undefined, + ts.factory.createIdentifier('myMethod'), + undefined, + undefined, + [], + undefined, + ts.factory.createBlock([], false), + ), + ts.factory.createPropertyDeclaration( + [], + ts.factory.createIdentifier('myProperty'), + undefined, + undefined, + undefined, + ), + ]); + + const updatedMembers = updateConstructor(classNode, Array.from(classNode.members), ctorStatements); + + expect(printClassMembers(classNode, updatedMembers)).toBe( + `class MyClass { constructor() { someMethod(); } myMethod() { } myProperty;}`, + ); + }); + + it('adds super call when class extends another class', () => { + const classNode = ts.factory.createClassDeclaration( + [], + 'MyClass', + undefined, + [ + ts.factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [ + ts.factory.createExpressionWithTypeArguments(ts.factory.createIdentifier('BaseClass'), []), + ]), + ], + [ts.factory.createConstructorDeclaration([], [], ts.factory.createBlock([], false))], + ); + + expect(printClassMembers(classNode, updateConstructor(classNode, Array.from(classNode.members), []))).toBe( + `class MyClass extends BaseClass { constructor() { super(); }}`, + ); + }); + + it('makes sure super call is the first statement in the constructor body', () => { + const classNode = ts.factory.createClassDeclaration( + [], + 'MyClass', + undefined, + [ + ts.factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [ + ts.factory.createExpressionWithTypeArguments(ts.factory.createIdentifier('BaseClass'), []), + ]), + ], + [ + ts.factory.createConstructorDeclaration( + [], + [], + ts.factory.createBlock( + [ts.factory.createExpressionStatement(ts.factory.createIdentifier('someMethod()'))], + false, + ), + ), + ], + ); + + expect(printClassMembers(classNode, updateConstructor(classNode, Array.from(classNode.members), []))).toBe( + `class MyClass extends BaseClass { constructor() { super(); someMethod(); }}`, + ); + }); + + it('adds false argument to super call when no parameters are provided', () => { + const classNode = ts.factory.createClassDeclaration( + [], + 'MyClass', + undefined, + [ + ts.factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [ + ts.factory.createExpressionWithTypeArguments(ts.factory.createIdentifier('BaseClass'), []), + ]), + ], + [ts.factory.createConstructorDeclaration([], [], ts.factory.createBlock([], false))], + ); + + expect( + printClassMembers(classNode, updateConstructor(classNode, Array.from(classNode.members), [], [], true)), + ).toBe(`class MyClass extends BaseClass { constructor() { super(false); }}`); + }); + }); }); diff --git a/src/compiler/transformers/test/transpile.ts b/src/compiler/transformers/test/transpile.ts index 48bd4b7419d..8ac6b0c1ccf 100644 --- a/src/compiler/transformers/test/transpile.ts +++ b/src/compiler/transformers/test/transpile.ts @@ -116,6 +116,19 @@ export function transpileModule( ...beforeTransformers, ], after: [ + (context) => { + let newSource: ts.SourceFile; + const visitNode = (node: ts.Node): ts.Node => { + // just a patch for testing - source file resolution gets + // lost in the after transform phase + node.getSourceFile = () => newSource; + return ts.visitEachChild(node, visitNode, context); + }; + return (sourceFile: ts.SourceFile): ts.SourceFile => { + newSource = sourceFile; + return visitNode(sourceFile) as ts.SourceFile; + }; + }, convertStaticToMeta(config, compilerCtx, buildCtx, tsTypeChecker, null, transformOpts), ...afterTransformers, ], @@ -139,6 +152,13 @@ export function transpileModule( const methods = cmp ? cmp.methods : null; const method = methods ? methods[0] : null; const elementRef = cmp ? cmp.elementRef : null; + const watchers = cmp ? cmp.watchers : null; + const isMixin = cmp ? moduleFile.isMixin : false; + const isExtended = cmp ? moduleFile.isExtended : false; + + if (buildCtx.hasError || buildCtx.hasWarning) { + throw new Error(buildCtx.diagnostics[0].messageText as string); + } return { buildCtx, @@ -158,11 +178,14 @@ export function transpileModule( moduleFile, outputText, properties, + watchers, property, state, states, tagName, virtualProperties, + isMixin, + isExtended, }; } diff --git a/src/compiler/transformers/transform-utils.ts b/src/compiler/transformers/transform-utils.ts index 07e69cec380..16be791ff3a 100644 --- a/src/compiler/transformers/transform-utils.ts +++ b/src/compiler/transformers/transform-utils.ts @@ -2,8 +2,11 @@ import { normalizePath } from '@utils'; import ts from 'typescript'; import type * as d from '../../declarations'; +import { updateLazyComponentConstructor } from './component-lazy/lazy-constructor'; import { StencilStaticGetter } from './decorators-to-static/decorators-constants'; +import { removeStaticMetaProperties } from './remove-static-meta-properties'; import { addToLibrary, findTypeWithName, getHomeModule, getOriginalTypeName } from './type-library'; +import { updateComponentClass } from './update-component-class'; export const getScriptTarget = () => { // using a fn so the browser compiler doesn't require the global ts for startup @@ -731,6 +734,27 @@ export const getComponentMeta = ( return undefined; }; +/** + * Updates a mixin class; cleans up static metadata properties and + * adds the `registerInstance` method to the constructor + * + * @param classNode the class node to update + * @param moduleFile the module file containing the class node + * @param cmp the component metadata to update + * @param transformOpts the transformation options to use + * @returns the updated class node + */ +export const updateMixin = ( + classNode: ts.ClassDeclaration, + moduleFile: d.Module, + cmp: d.ComponentCompilerMeta, + transformOpts: d.TransformOptions, +) => { + const classMembers = removeStaticMetaProperties(classNode); + updateLazyComponentConstructor(classMembers, classNode, moduleFile, cmp); + return updateComponentClass(transformOpts, classNode, classNode.heritageClauses, classMembers); +}; + /** * Retrieves the tag name associated with a Stencil component, based on the 'is' static getter assigned to the class at compile time * @param staticMembers the static getters belonging to the Stencil component class @@ -949,6 +973,20 @@ export const retrieveTsModifiers = (node: ts.Node): ReadonlyArray | return ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined; }; +/** + * Helper method for finding a `super()` call in a constructor body. + * @param constructorBodyStatements the body statements of a constructor + * @returns the first statement in the constructor body that is a call to `super()` + */ +export function foundSuper(constructorBodyStatements: ts.NodeArray) { + return constructorBodyStatements?.find( + (s) => + ts.isExpressionStatement(s) && + ts.isCallExpression(s.expression) && + s.expression.expression.kind === ts.SyntaxKind.SuperKeyword, + ); +} + /** * Helper util for updating the constructor on a class declaration AST node. * @@ -957,6 +995,8 @@ export const retrieveTsModifiers = (node: ts.Node): ReadonlyArray | * @param statements a list of statements which should be added to the * constructor * @param parameters an optional list of parameters for the constructor + * @param includeFalseArg whether to include a `false` argument in the `super()` call. + * This is used in native Stencil components which extend other Stencil components to stop the base component from being initialized. * @returns a list of updated class elements */ export const updateConstructor = ( @@ -964,6 +1004,7 @@ export const updateConstructor = ( classMembers: ts.ClassElement[], statements: ts.Statement[], parameters?: ts.ParameterDeclaration[], + includeFalseArg?: boolean, ): ts.ClassElement[] => { const constructorIndex = classMembers.findIndex((m) => m.kind === ts.SyntaxKind.Constructor); const constructorMethod = classMembers[constructorIndex]; @@ -971,24 +1012,31 @@ export const updateConstructor = ( if (constructorIndex < 0 && !statements?.length && !needsSuper(classNode)) return classMembers; if (constructorIndex >= 0 && ts.isConstructorDeclaration(constructorMethod)) { - const constructorBodyStatements: ts.NodeArray = - constructorMethod.body?.statements ?? ts.factory.createNodeArray(); - const hasSuper = constructorBodyStatements.some((s) => s.kind === ts.SyntaxKind.SuperKeyword); + const constructorBodyStatements = constructorMethod.body?.statements; + let foundSuperCall = foundSuper(constructorBodyStatements); - if (!hasSuper && needsSuper(classNode)) { + if (!foundSuperCall && needsSuper(classNode)) { // if there is no super and it needs one the statements comprising the // body of the constructor should be: // // 1. the `super()` call // 2. the new statements we've created to initialize fields // 3. the statements currently comprising the body of the constructor - statements = [createConstructorBodyWithSuper(), ...statements, ...constructorBodyStatements]; + statements = [createConstructorBodyWithSuper(includeFalseArg), ...statements, ...constructorBodyStatements]; } else { - // if no super is needed then the body of the constructor should be: - // - // 1. the new statements we've created - // 2. the statements currently comprising the body of the constructor - statements = [...statements, ...constructorBodyStatements]; + const updatedStatements = constructorBodyStatements.filter((s) => s !== foundSuperCall); + // if no new super is needed. The body of the constructor should be: + // 1. Any current super call + // 2. the new statements we've created + // 3. the statements currently comprising the body of the constructor + if (foundSuperCall) { + if (includeFalseArg) { + foundSuperCall = createConstructorBodyWithSuper(includeFalseArg); + } + statements = [foundSuperCall, ...statements, ...updatedStatements]; + } else { + statements = [...statements, ...updatedStatements]; + } } classMembers[constructorIndex] = ts.factory.updateConstructorDeclaration( @@ -1001,7 +1049,7 @@ export const updateConstructor = ( // we don't seem to have a constructor, so let's create one and stick it // into the array of class elements if (needsSuper(classNode)) { - statements = [createConstructorBodyWithSuper(), ...statements]; + statements = [createConstructorBodyWithSuper(includeFalseArg), ...statements]; } // add the new constructor to the class members, putting it at the @@ -1010,7 +1058,6 @@ export const updateConstructor = ( ts.factory.createConstructorDeclaration(undefined, parameters ?? [], ts.factory.createBlock(statements, true)), ); } - return classMembers; }; @@ -1039,11 +1086,17 @@ const needsSuper = (classDeclaration: ts.ClassDeclaration): boolean => { /** * Create a statement with a call to `super()` suitable for including in the body of a constructor. + * @param includeFalseArg whether to include a `false` argument in the `super()` call. + * This is used in native Stencil components which extend other Stencil components to stop the base component from being initialized. * @returns a {@link ts.ExpressionStatement} node equivalent to `super()` */ -const createConstructorBodyWithSuper = (): ts.ExpressionStatement => { +const createConstructorBodyWithSuper = (includeFalseArg?: boolean): ts.ExpressionStatement => { return ts.factory.createExpressionStatement( - ts.factory.createCallExpression(ts.factory.createIdentifier('super'), undefined, undefined), + ts.factory.createCallExpression( + ts.factory.createIdentifier('super'), + undefined, + includeFalseArg ? [ts.factory.createFalse()] : undefined, + ), ); }; diff --git a/src/compiler/transformers/update-stencil-core-import.ts b/src/compiler/transformers/update-stencil-core-import.ts index ba4494388dc..f77e76b7f67 100644 --- a/src/compiler/transformers/update-stencil-core-import.ts +++ b/src/compiler/transformers/update-stencil-core-import.ts @@ -89,4 +89,5 @@ const KEEP_IMPORTS = new Set([ 'getRenderingRef', 'forceModeUpdate', 'setErrorHandler', + 'Mixin', ]); diff --git a/src/compiler/transpile/transpiled-module.ts b/src/compiler/transpile/transpiled-module.ts index 28588ffcd93..5c8b7dd7d60 100644 --- a/src/compiler/transpile/transpiled-module.ts +++ b/src/compiler/transpile/transpiled-module.ts @@ -31,6 +31,8 @@ export const createModule = ( staticSourceFile, staticSourceFileText, cmps: [], + isExtended: false, + isMixin: false, coreRuntimeApis: [], outputTargetCoreRuntimeApis: {}, collectionName: null, diff --git a/src/compiler/types/tests/ComponentCompilerMeta.stub.ts b/src/compiler/types/tests/ComponentCompilerMeta.stub.ts index 8bbfc1e59ac..e3c81cab9c1 100644 --- a/src/compiler/types/tests/ComponentCompilerMeta.stub.ts +++ b/src/compiler/types/tests/ComponentCompilerMeta.stub.ts @@ -18,6 +18,7 @@ export const stubComponentCompilerMeta = ( directDependencies: [], directDependents: [], docs: { text: 'docs', tags: [] }, + doesExtend: false, elementRef: '', encapsulation: 'none', events: [], diff --git a/src/declarations/stencil-private.ts b/src/declarations/stencil-private.ts index 55b784cc1a5..7c848c2dc82 100644 --- a/src/declarations/stencil-private.ts +++ b/src/declarations/stencil-private.ts @@ -633,6 +633,7 @@ export interface ComponentCompilerMeta extends ComponentCompilerFeatures { */ directDependents: string[]; docs: CompilerJsDoc; + doesExtend: boolean; elementRef: string; encapsulation: Encapsulation; events: ComponentCompilerEvent[]; @@ -1247,6 +1248,8 @@ export type ModuleMap = Map; */ export interface Module { cmps: ComponentCompilerMeta[]; + isMixin: boolean; + isExtended: 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/declarations/stencil-public-runtime.ts b/src/declarations/stencil-public-runtime.ts index 8417ab76ef1..ec93403edc4 100644 --- a/src/declarations/stencil-public-runtime.ts +++ b/src/declarations/stencil-public-runtime.ts @@ -4,6 +4,12 @@ declare type CustomMethodDecorator = ( descriptor: TypedPropertyDescriptor, ) => TypedPropertyDescriptor | void; +type UnionToIntersection = (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never; + +type MixinFactory = any>( + base: TBase, +) => abstract new (...args: ConstructorParameters) => any; + export interface ComponentDecorator { (opts?: ComponentOptions): ClassDecorator; } @@ -401,6 +407,28 @@ export declare function readTask(task: RafCallback): void; */ export declare const setErrorHandler: (handler: ErrorHandler) => void; +/** + * Compose multiple mixin classes into a single constructor. + * The resulting class has the combined instance types of all mixed-in classes. + * + * Example: + * ``` + * const AWrap = (Base) => {class A extends Base { propA = A }; return A;} + * const BWrap = (Base) => {class B extends Base { propB = B }; return B;} + * const CWrap = (Base) => {class C extends Base { propC = C }; return C;} + * + * class X extends Mixin(AWrap, BWrap, CWrap) { + * render() { return
{this.propA} {this.propB} {this.propC}
; } + * } + * ``` + * + * @param mixinFactories mixin factory functions that return a class which extends from the provided class. + * @returns a class that that is composed from extending each of the provided classes in the order they were provided. + */ +export declare function Mixin( + ...mixinFactories: TMixins +): abstract new (...args: any[]) => UnionToIntersection>>; + /** * This file gets copied to all distributions of stencil component collections. * - no imports diff --git a/src/hydrate/platform/index.ts b/src/hydrate/platform/index.ts index 710b09b87db..0af8d8341ef 100644 --- a/src/hydrate/platform/index.ts +++ b/src/hydrate/platform/index.ts @@ -135,6 +135,7 @@ export const getHostRef = (ref: d.RuntimeRef) => { }; export const registerInstance = (lazyInstance: any, hostRef: d.HostRef) => { + if (!hostRef) return undefined; lazyInstance.__stencil__getHostRef = () => hostRef; hostRef.$lazyInstance$ = lazyInstance; @@ -203,6 +204,7 @@ export { getValue, Host, insertVdomAnnotations, + Mixin, parsePropertyValue, postUpdateComponent, proxyComponent, diff --git a/src/internal/stencil-core/index.d.ts b/src/internal/stencil-core/index.d.ts index 74deed6fb4c..71ece2c6f6d 100644 --- a/src/internal/stencil-core/index.d.ts +++ b/src/internal/stencil-core/index.d.ts @@ -39,6 +39,7 @@ export { Host, Listen, Method, + Mixin, Prop, readTask, render, diff --git a/src/internal/stencil-core/index.js b/src/internal/stencil-core/index.js index e7ee8d7b7a9..b5329806026 100644 --- a/src/internal/stencil-core/index.js +++ b/src/internal/stencil-core/index.js @@ -7,6 +7,7 @@ export { getRenderingRef, h, Host, + Mixin, readTask, render, setAssetPath, diff --git a/src/runtime/index.ts b/src/runtime/index.ts index e60c8ab93ad..57224d81f7b 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -7,6 +7,7 @@ export { getElement } from './element'; export { createEvent } from './event-emitter'; export { Fragment } from './fragment'; export { addHostEventListeners } from './host-listener'; +export { Mixin } from './mixin'; export { getMode, setMode } from './mode'; export { setNonce } from './nonce'; export { parsePropertyValue } from './parse-property-value'; diff --git a/src/runtime/initialize-component.ts b/src/runtime/initialize-component.ts index 83f9ce8a616..60995085837 100644 --- a/src/runtime/initialize-component.ts +++ b/src/runtime/initialize-component.ts @@ -110,7 +110,14 @@ export const initializeComponent = async ( // wait for the CustomElementRegistry to mark the component as ready before setting `isWatchReady`. Otherwise, // watchers may fire prematurely if `customElements.get()`/`customElements.whenDefined()` resolves _before_ // Stencil has completed instantiating the component. - customElements.whenDefined(cmpTag).then(() => (hostRef.$flags$ |= HOST_FLAGS.isWatchReady)); + // customElements.whenDefined always returns the answer asynchronously (and slower than a queueMicrotask). + // Checking !!customElements.get(cmpTag) instead is synchronous. + const setWatchIsReady = () => (hostRef.$flags$ |= HOST_FLAGS.isWatchReady); + if (!!customElements.get(cmpTag)) { + setWatchIsReady(); + } else { + customElements.whenDefined(cmpTag).then(setWatchIsReady); + } } if (BUILD.style && Cstr && Cstr.style) { diff --git a/src/runtime/mixin.ts b/src/runtime/mixin.ts new file mode 100644 index 00000000000..01d40987c58 --- /dev/null +++ b/src/runtime/mixin.ts @@ -0,0 +1,9 @@ +import { BUILD } from '@app-data'; + +type Ctor = new (...args: any[]) => T; + +const baseClass: Ctor = BUILD.lazyLoad ? class {} : globalThis.HTMLElement || class {}; + +export function Mixin(...mixins: ((base: Ctor) => Ctor)[]) { + return mixins.reduceRight((acc, mixin) => mixin(acc), baseClass); +} diff --git a/src/testing/mocks.ts b/src/testing/mocks.ts index d4e8e0ce684..531f3851cb7 100644 --- a/src/testing/mocks.ts +++ b/src/testing/mocks.ts @@ -241,6 +241,8 @@ export function mockWindow(html?: string) { */ export const mockModule = (mod: Partial = {}): d.Module => ({ cmps: [], + isExtended: false, + isMixin: false, coreRuntimeApis: [], outputTargetCoreRuntimeApis: {}, collectionName: '', diff --git a/src/utils/es2022-rewire-class-members.ts b/src/utils/es2022-rewire-class-members.ts index fe9a3a261fb..b947a60268f 100644 --- a/src/utils/es2022-rewire-class-members.ts +++ b/src/utils/es2022-rewire-class-members.ts @@ -41,19 +41,23 @@ export const reWireGetterSetter = (instance: any, hostRef: d.HostRef) => { const ogValue = instance[memberName]; // Get the original Stencil prototype `get` / `set` - const ogDescriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(instance), memberName); + const ogDescriptor = + getPropertyDescriptor(Object.getPrototypeOf(instance), memberName) || + Object.getOwnPropertyDescriptor(instance, memberName); - // Re-wire original accessors to the new instance - Object.defineProperty(instance, memberName, { - get() { - return ogDescriptor.get.call(this); - }, - set(newValue) { - ogDescriptor.set.call(this, newValue); - }, - configurable: true, - enumerable: true, - }); + if (ogDescriptor) { + // Re-wire original accessors to the new instance + Object.defineProperty(instance, memberName, { + get() { + return ogDescriptor.get.call(this); + }, + set(newValue) { + ogDescriptor.set.call(this, newValue); + }, + configurable: true, + enumerable: true, + }); + } instance[memberName] = hostRef.$instanceValues$.has(memberName) ? hostRef.$instanceValues$.get(memberName) @@ -61,3 +65,18 @@ export const reWireGetterSetter = (instance: any, hostRef: d.HostRef) => { } }); }; + +/** + * Iterate through the prototype chain to find the property get / set descriptor for the provided member name. + * @param obj - The object to search on. + * @param memberName - The name of the member to find. + * @returns The property descriptor if found, otherwise undefined. + */ +function getPropertyDescriptor(obj: object, memberName: string): PropertyDescriptor | undefined { + while (obj) { + const desc = Object.getOwnPropertyDescriptor(obj, memberName); + if (desc?.get) return desc; + obj = Object.getPrototypeOf(obj); + } + return undefined; +} diff --git a/test/type-tests/tsconfig.json b/test/type-tests/tsconfig.json index 55bfae9c99a..80c62bde7d0 100644 --- a/test/type-tests/tsconfig.json +++ b/test/type-tests/tsconfig.json @@ -26,7 +26,7 @@ "paths": { "@stencil/core": ["../../internal"], "@stencil/core/internal": ["../../internal"], - "@test-sibling": ["./test-sibling"] + "test-sibling": ["./test-sibling"] } }, "include": [ diff --git a/test/wdio/global.ts b/test/wdio/global.ts index aa50ecd8292..9c7b244f21e 100644 --- a/test/wdio/global.ts +++ b/test/wdio/global.ts @@ -1,9 +1,9 @@ // this imports the build from the `./test-sibling` project. The ability to use // a Stencil component defined in that 'sibling' project is tested in the // `stencil-sibling` test suite -import '@test-sibling'; +import 'test-sibling'; import { setMode } from '@stencil/core'; -// @ts-expect-error - tests that rollup warnings don't break the build +// @ts-ignore import { setAssetPath } from '@stencil/core/internal/client/index'; const globalScript = () => { diff --git a/test/wdio/package-lock.json b/test/wdio/package-lock.json index c7040981f1d..f911aedc17f 100644 --- a/test/wdio/package-lock.json +++ b/test/wdio/package-lock.json @@ -19,6 +19,7 @@ "bootstrap": "^5.3.3", "normalize.css": "^8.0.1", "npm-run-all": "^4.1.5", + "test-sibling": "file:./test-sibling", "ts-node": "^10.9.2", "webdriverio": "^9.5.4", "workbox-build": "^4.3.1" @@ -26,7 +27,7 @@ }, "../..": { "name": "@stencil/core", - "version": "4.28.2", + "version": "4.36.2", "dev": true, "license": "MIT", "bin": { @@ -11902,6 +11903,10 @@ "node": "*" } }, + "node_modules/test-sibling": { + "resolved": "test-sibling", + "link": true + }, "node_modules/text-decoder": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", @@ -13887,6 +13892,9 @@ "engines": { "node": ">= 14" } + }, + "test-sibling": { + "dev": true } } } diff --git a/test/wdio/package.json b/test/wdio/package.json index 7825b9d01d9..7ca9e440a69 100644 --- a/test/wdio/package.json +++ b/test/wdio/package.json @@ -28,6 +28,7 @@ "bootstrap": "^5.3.3", "normalize.css": "^8.0.1", "npm-run-all": "^4.1.5", + "test-sibling": "file:./test-sibling", "ts-node": "^10.9.2", "webdriverio": "^9.5.4", "workbox-build": "^4.3.1" diff --git a/test/wdio/stencil.config-es2022.ts b/test/wdio/stencil.config-es2022.ts index fc1efb3f353..0d9f1c583b9 100644 --- a/test/wdio/stencil.config-es2022.ts +++ b/test/wdio/stencil.config-es2022.ts @@ -3,7 +3,7 @@ import type { Config } from '../../internal/index.js'; export const config: Config = { namespace: 'TestTSTarget', tsconfig: 'tsconfig-es2022.json', - srcDir: 'ts-target-props', + srcDir: 'ts-target', outputTargets: [ { type: 'dist', diff --git a/test/wdio/test-sibling/package.json b/test/wdio/test-sibling/package.json index e33739c6245..a65c8fafb9b 100644 --- a/test/wdio/test-sibling/package.json +++ b/test/wdio/test-sibling/package.json @@ -4,6 +4,12 @@ "module": "dist/index.js", "collection": "dist/collection/collection-manifest.json", "types": "dist/types/components.d.ts", + "exports": { + "./dist/collection/sibling-extended/sibling-extended": { + "import": "./dist/collection/sibling-extended/sibling-extended.js", + "types": "./dist/types/sibling-extended/sibling-extended.d.ts" + } + }, "volta": { "extends": "../package.json" }, diff --git a/test/wdio/test-sibling/src/components.d.ts b/test/wdio/test-sibling/src/components.d.ts index 1c2c926dbca..05e82f67e8d 100644 --- a/test/wdio/test-sibling/src/components.d.ts +++ b/test/wdio/test-sibling/src/components.d.ts @@ -6,10 +6,46 @@ */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; export namespace Components { + interface SiblingExtended { + "method1": () => Promise; + "method2": () => Promise; + /** + * @default 'ExtendedCmp text' + */ + "prop1": string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2": string; + } + interface SiblingExtendedBase { + "method1": () => Promise; + "method2": () => Promise; + /** + * @default 'ExtendedCmp text' + */ + "prop1": string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2": string; + } interface SiblingRoot { } } declare global { + interface HTMLSiblingExtendedElement extends Components.SiblingExtended, HTMLStencilElement { + } + var HTMLSiblingExtendedElement: { + prototype: HTMLSiblingExtendedElement; + new (): HTMLSiblingExtendedElement; + }; + interface HTMLSiblingExtendedBaseElement extends Components.SiblingExtendedBase, HTMLStencilElement { + } + var HTMLSiblingExtendedBaseElement: { + prototype: HTMLSiblingExtendedBaseElement; + new (): HTMLSiblingExtendedBaseElement; + }; interface HTMLSiblingRootElement extends Components.SiblingRoot, HTMLStencilElement { } var HTMLSiblingRootElement: { @@ -17,13 +53,37 @@ declare global { new (): HTMLSiblingRootElement; }; interface HTMLElementTagNameMap { + "sibling-extended": HTMLSiblingExtendedElement; + "sibling-extended-base": HTMLSiblingExtendedBaseElement; "sibling-root": HTMLSiblingRootElement; } } declare namespace LocalJSX { + interface SiblingExtended { + /** + * @default 'ExtendedCmp text' + */ + "prop1"?: string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2"?: string; + } + interface SiblingExtendedBase { + /** + * @default 'ExtendedCmp text' + */ + "prop1"?: string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2"?: string; + } interface SiblingRoot { } interface IntrinsicElements { + "sibling-extended": SiblingExtended; + "sibling-extended-base": SiblingExtendedBase; "sibling-root": SiblingRoot; } } @@ -31,6 +91,8 @@ export { LocalJSX as JSX }; declare module "@stencil/core" { export namespace JSX { interface IntrinsicElements { + "sibling-extended": LocalJSX.SiblingExtended & JSXBase.HTMLAttributes; + "sibling-extended-base": LocalJSX.SiblingExtendedBase & JSXBase.HTMLAttributes; "sibling-root": LocalJSX.SiblingRoot & JSXBase.HTMLAttributes; } } diff --git a/test/wdio/test-sibling/src/global.ts b/test/wdio/test-sibling/src/global.ts index fece2b1d8a9..884bb469279 100644 --- a/test/wdio/test-sibling/src/global.ts +++ b/test/wdio/test-sibling/src/global.ts @@ -1 +1,3 @@ -declare const Context: any; +const Context: any = {}; +export { Context }; +export default () => {}; diff --git a/test/wdio/test-sibling/src/sibling-extended-base/sibling-extended-base.tsx b/test/wdio/test-sibling/src/sibling-extended-base/sibling-extended-base.tsx new file mode 100644 index 00000000000..a15b0ce4e88 --- /dev/null +++ b/test/wdio/test-sibling/src/sibling-extended-base/sibling-extended-base.tsx @@ -0,0 +1,49 @@ +import { Component, h, Prop, State, Method, Watch } from '@stencil/core'; + +@Component({ + tag: 'sibling-extended-base', +}) +export class SiblingExtendedBase { + @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'; + } + + render() { + return ( +
+

Base Extended class prop 1: {this.prop1}

+

Base Extended class prop 2: {this.prop2}

+

Base Extended class state 1: {this.state1}

+

Base Extended class state 2: {this.state2}

+
+ ); + } +} diff --git a/test/wdio/test-sibling/src/sibling-extended/sibling-extended.tsx b/test/wdio/test-sibling/src/sibling-extended/sibling-extended.tsx new file mode 100644 index 00000000000..cf94121f3de --- /dev/null +++ b/test/wdio/test-sibling/src/sibling-extended/sibling-extended.tsx @@ -0,0 +1,21 @@ +import { Component, h } from '@stencil/core'; +import { SiblingExtendedBase } from '../sibling-extended-base/sibling-extended-base.js'; + +// used as a test (within `test/wdio/ts-target/extends-external/cmp.test.ts`) to verify Stencil components can +// extend from component classes built in a separate Stencil project. + +@Component({ + tag: 'sibling-extended', +}) +export class SiblingExtended extends SiblingExtendedBase { + 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/test-sibling/tsconfig.json b/test/wdio/test-sibling/tsconfig.json index 898049404db..e9625a15042 100644 --- a/test/wdio/test-sibling/tsconfig.json +++ b/test/wdio/test-sibling/tsconfig.json @@ -21,7 +21,7 @@ "noUnusedLocals": false, "noUnusedParameters": false, "pretty": true, - "target": "es2017", + "target": "es2022", "useUnknownInCatchVariables": true, "baseUrl": ".", "paths": { diff --git a/test/wdio/ts-target-props/components.d.ts b/test/wdio/ts-target-props/components.d.ts deleted file mode 100644 index 841b701ee06..00000000000 --- a/test/wdio/ts-target-props/components.d.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* eslint-disable */ -/* tslint:disable */ -/** - * This is an autogenerated file created by the Stencil compiler. - * It contains typing information for all components that exist in this project. - */ -import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; -export namespace Components { - interface TsTargetProps { - /** - * @default 'basicProp' - */ - "basicProp": string; - "decoratedGetterSetterProp": number; - /** - * @default -10 - */ - "decoratedProp": number; - "dynamicLifecycle": string[]; - } -} -declare global { - interface HTMLTsTargetPropsElement extends Components.TsTargetProps, HTMLStencilElement { - } - var HTMLTsTargetPropsElement: { - prototype: HTMLTsTargetPropsElement; - new (): HTMLTsTargetPropsElement; - }; - interface HTMLElementTagNameMap { - "ts-target-props": HTMLTsTargetPropsElement; - } -} -declare namespace LocalJSX { - interface TsTargetProps { - /** - * @default 'basicProp' - */ - "basicProp"?: string; - "decoratedGetterSetterProp"?: number; - /** - * @default -10 - */ - "decoratedProp"?: number; - "dynamicLifecycle"?: string[]; - } - interface IntrinsicElements { - "ts-target-props": TsTargetProps; - } -} -export { LocalJSX as JSX }; -declare module "@stencil/core" { - export namespace JSX { - interface IntrinsicElements { - "ts-target-props": LocalJSX.TsTargetProps & JSXBase.HTMLAttributes; - } - } -} diff --git a/test/wdio/ts-target/components.d.ts b/test/wdio/ts-target/components.d.ts new file mode 100644 index 00000000000..caeac3446b7 --- /dev/null +++ b/test/wdio/ts-target/components.d.ts @@ -0,0 +1,252 @@ +/* eslint-disable */ +/* tslint:disable */ +/** + * This is an autogenerated file created by the Stencil compiler. + * It contains typing information for all components that exist in this project. + */ +import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; +export namespace Components { + interface ExtendedCmp { + "method1": () => Promise; + "method2": () => Promise; + /** + * @default 'ExtendedCmp text' + */ + "prop1": string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2": string; + } + interface ExtendedCmpCmp { + "method1": () => Promise; + "method2": () => Promise; + /** + * @default 'ExtendedCmp text' + */ + "prop1": string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2": string; + } + interface ExtendsAbstract { + "method1": () => Promise; + "method2": () => Promise; + /** + * @default 'default text' + */ + "prop1": string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2": string; + } + interface ExtendsCmpCmp { + "method1": () => Promise; + "method2": () => Promise; + /** + * @default 'default text' + */ + "prop1": string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2": string; + } + interface ExtendsExternal { + "method1": () => Promise; + "method2": () => Promise; + /** + * @default 'default text' + */ + "prop1": string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2": string; + } + interface ExtendsMixinCmp { + "method1": () => Promise; + "method2": () => Promise; + "method3": () => Promise; + /** + * @default 'default text' + */ + "prop1": string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2": string; + /** + * @default 'mixin b text' + */ + "prop3": string; + } + interface TsTargetProps { + /** + * @default 'basicProp' + */ + "basicProp": string; + "decoratedGetterSetterProp": number; + /** + * @default -10 + */ + "decoratedProp": number; + "dynamicLifecycle": string[]; + } +} +declare global { + interface HTMLExtendedCmpElement extends Components.ExtendedCmp, HTMLStencilElement { + } + var HTMLExtendedCmpElement: { + prototype: HTMLExtendedCmpElement; + new (): HTMLExtendedCmpElement; + }; + interface HTMLExtendedCmpCmpElement extends Components.ExtendedCmpCmp, HTMLStencilElement { + } + var HTMLExtendedCmpCmpElement: { + prototype: HTMLExtendedCmpCmpElement; + new (): HTMLExtendedCmpCmpElement; + }; + interface HTMLExtendsAbstractElement extends Components.ExtendsAbstract, HTMLStencilElement { + } + var HTMLExtendsAbstractElement: { + prototype: HTMLExtendsAbstractElement; + new (): HTMLExtendsAbstractElement; + }; + interface HTMLExtendsCmpCmpElement extends Components.ExtendsCmpCmp, HTMLStencilElement { + } + var HTMLExtendsCmpCmpElement: { + prototype: HTMLExtendsCmpCmpElement; + new (): HTMLExtendsCmpCmpElement; + }; + interface HTMLExtendsExternalElement extends Components.ExtendsExternal, HTMLStencilElement { + } + var HTMLExtendsExternalElement: { + prototype: HTMLExtendsExternalElement; + new (): HTMLExtendsExternalElement; + }; + interface HTMLExtendsMixinCmpElement extends Components.ExtendsMixinCmp, HTMLStencilElement { + } + var HTMLExtendsMixinCmpElement: { + prototype: HTMLExtendsMixinCmpElement; + new (): HTMLExtendsMixinCmpElement; + }; + interface HTMLTsTargetPropsElement extends Components.TsTargetProps, HTMLStencilElement { + } + var HTMLTsTargetPropsElement: { + prototype: HTMLTsTargetPropsElement; + new (): HTMLTsTargetPropsElement; + }; + interface HTMLElementTagNameMap { + "extended-cmp": HTMLExtendedCmpElement; + "extended-cmp-cmp": HTMLExtendedCmpCmpElement; + "extends-abstract": HTMLExtendsAbstractElement; + "extends-cmp-cmp": HTMLExtendsCmpCmpElement; + "extends-external": HTMLExtendsExternalElement; + "extends-mixin-cmp": HTMLExtendsMixinCmpElement; + "ts-target-props": HTMLTsTargetPropsElement; + } +} +declare namespace LocalJSX { + interface ExtendedCmp { + /** + * @default 'ExtendedCmp text' + */ + "prop1"?: string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2"?: string; + } + interface ExtendedCmpCmp { + /** + * @default 'ExtendedCmp text' + */ + "prop1"?: string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2"?: string; + } + interface ExtendsAbstract { + /** + * @default 'default text' + */ + "prop1"?: string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2"?: string; + } + interface ExtendsCmpCmp { + /** + * @default 'default text' + */ + "prop1"?: string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2"?: string; + } + interface ExtendsExternal { + /** + * @default 'default text' + */ + "prop1"?: string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2"?: string; + } + interface ExtendsMixinCmp { + /** + * @default 'default text' + */ + "prop1"?: string; + /** + * @default 'ExtendedCmp prop2 text' + */ + "prop2"?: string; + /** + * @default 'mixin b text' + */ + "prop3"?: string; + } + interface TsTargetProps { + /** + * @default 'basicProp' + */ + "basicProp"?: string; + "decoratedGetterSetterProp"?: number; + /** + * @default -10 + */ + "decoratedProp"?: number; + "dynamicLifecycle"?: string[]; + } + interface IntrinsicElements { + "extended-cmp": ExtendedCmp; + "extended-cmp-cmp": ExtendedCmpCmp; + "extends-abstract": ExtendsAbstract; + "extends-cmp-cmp": ExtendsCmpCmp; + "extends-external": ExtendsExternal; + "extends-mixin-cmp": ExtendsMixinCmp; + "ts-target-props": TsTargetProps; + } +} +export { LocalJSX as JSX }; +declare module "@stencil/core" { + export namespace JSX { + interface IntrinsicElements { + "extended-cmp": LocalJSX.ExtendedCmp & JSXBase.HTMLAttributes; + "extended-cmp-cmp": LocalJSX.ExtendedCmpCmp & JSXBase.HTMLAttributes; + "extends-abstract": LocalJSX.ExtendsAbstract & JSXBase.HTMLAttributes; + "extends-cmp-cmp": LocalJSX.ExtendsCmpCmp & JSXBase.HTMLAttributes; + "extends-external": LocalJSX.ExtendsExternal & JSXBase.HTMLAttributes; + "extends-mixin-cmp": LocalJSX.ExtendsMixinCmp & JSXBase.HTMLAttributes; + "ts-target-props": LocalJSX.TsTargetProps & JSXBase.HTMLAttributes; + } + } +} diff --git a/test/wdio/ts-target/extends-abstract/cmp.test.ts b/test/wdio/ts-target/extends-abstract/cmp.test.ts new file mode 100644 index 00000000000..78044b3d43f --- /dev/null +++ b/test/wdio/ts-target/extends-abstract/cmp.test.ts @@ -0,0 +1,95 @@ +import { browser } from '@wdio/globals'; +import { setupIFrameTest } from '../../util.js'; +import { testSuites } from '../extends-test-suite.test.js'; + +/** + * Smoke tests for extending mixin classes. Built with + * `tsconfig-es2022.json` > `"target": "es2022"` `dist` and `dist-custom-elements` outputs. + */ + +describe('Checks component classes can extend from other, Stencil decorated abstract classes', () => { + describe('es2022 dist output', () => { + let frameContent: HTMLElement; + + beforeEach(async () => { + frameContent = await setupIFrameTest('/extends-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-abstract'); + await defaultValue(); + }); + + it('re-renders values via attributes', async () => { + const { viaAttributes } = await testSuites(frameContent, 'extends-abstract'); + await viaAttributes(); + }); + + it('re-renders values via props', async () => { + const { viaProps } = await testSuites(frameContent, 'extends-abstract'); + await viaProps(); + }); + + it('calls watch handlers', async () => { + const { watchHandlers } = await testSuites(frameContent, 'extends-abstract'); + await watchHandlers(); + }); + + it('calls methods', async () => { + const { methods } = await testSuites(frameContent, 'extends-abstract'); + await methods(); + }); + }); + + describe('es2022 dist-custom-elements output', () => { + let frameContent: HTMLElement; + + beforeEach(async () => { + await browser.switchToParentFrame(); + frameContent = await setupIFrameTest('/extends-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-abstract'); + await defaultValue(); + }); + + it('re-renders values via attributes', async () => { + const { viaAttributes } = await testSuites(frameContent, 'extends-abstract'); + await viaAttributes(); + }); + + it('re-renders values via props', async () => { + const { viaProps } = await testSuites(frameContent, 'extends-abstract'); + await viaProps(); + }); + + it('calls watch handlers', async () => { + const { watchHandlers } = await testSuites(frameContent, 'extends-abstract'); + await watchHandlers(); + }); + + it('calls methods', async () => { + const { methods } = await testSuites(frameContent, 'extends-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-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-abstract')).ssrViaProps(mod); + }); + }); +}); diff --git a/test/wdio/ts-target/extends-abstract/cmp.tsx b/test/wdio/ts-target/extends-abstract/cmp.tsx new file mode 100644 index 00000000000..3c5344a3738 --- /dev/null +++ b/test/wdio/ts-target/extends-abstract/cmp.tsx @@ -0,0 +1,35 @@ +import { Component, h, Prop, State, Method, Watch } from '@stencil/core'; +import { Mixin } from './mixin-class.js'; + +@Component({ + tag: 'extends-abstract', +}) +export class MixinCmp extends Mixin { + @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 state1: {this.state1}

+

Main class state2: {this.state2}

+
+ ); + } +} diff --git a/test/wdio/ts-target/extends-abstract/es2022.custom-element.html b/test/wdio/ts-target/extends-abstract/es2022.custom-element.html new file mode 100644 index 00000000000..88c7a87bdbb --- /dev/null +++ b/test/wdio/ts-target/extends-abstract/es2022.custom-element.html @@ -0,0 +1,13 @@ + + + Codestin Search App + + + +

ES2022 dist-custom-elements output

+ + + \ No newline at end of file diff --git a/test/wdio/ts-target/extends-abstract/es2022.dist.html b/test/wdio/ts-target/extends-abstract/es2022.dist.html new file mode 100644 index 00000000000..fccfc78068a --- /dev/null +++ b/test/wdio/ts-target/extends-abstract/es2022.dist.html @@ -0,0 +1,10 @@ + + + Codestin Search App + + + +

ES2022 dist output

+ + + \ No newline at end of file diff --git a/test/wdio/ts-target/extends-abstract/mixin-class.ts b/test/wdio/ts-target/extends-abstract/mixin-class.ts new file mode 100644 index 00000000000..9ed93e38520 --- /dev/null +++ b/test/wdio/ts-target/extends-abstract/mixin-class.ts @@ -0,0 +1,10 @@ +import { Prop, Watch } from '@stencil/core'; +import { MixinParent } from './mxin-class-parent.js'; + +export class Mixin extends MixinParent { + @Prop() prop1: string = 'ExtendedCmp text'; + @Watch('prop1') + prop1Changed(newValue: string) { + console.info('extended class handler prop1:', newValue); + } +} diff --git a/test/wdio/ts-target/extends-abstract/mxin-class-parent.ts b/test/wdio/ts-target/extends-abstract/mxin-class-parent.ts new file mode 100644 index 00000000000..96bf3ae3f5b --- /dev/null +++ b/test/wdio/ts-target/extends-abstract/mxin-class-parent.ts @@ -0,0 +1,35 @@ +import { Prop, State, Method, Watch } from '@stencil/core'; + +export class MixinParent { + @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/ts-target/extends-cmp/cmp.test.ts b/test/wdio/ts-target/extends-cmp/cmp.test.ts new file mode 100644 index 00000000000..fe0045c23de --- /dev/null +++ b/test/wdio/ts-target/extends-cmp/cmp.test.ts @@ -0,0 +1,95 @@ +import { browser } from '@wdio/globals'; +import { setupIFrameTest } from '../../util.js'; +import { testSuites } from '../extends-test-suite.test.js'; + +/** + * Smoke tests for extending component classes. Built with + * `tsconfig-es2022.json` > `"target": "es2022"` `dist` and `dist-custom-elements` outputs. + */ + +describe('Checks component classes can extend from other component classes', () => { + describe('es2022 dist output', () => { + let frameContent: HTMLElement; + + beforeEach(async () => { + frameContent = await setupIFrameTest('/extends-cmp/es2022.dist.html', 'es2022-dist'); + const frameEle = await browser.$('#es2022-dist'); + await frameEle.waitUntil(async () => !!frameContent.querySelector('.extended-prop-1'), { timeout: 5000 }); + }); + + it('renders default values', async () => { + const { defaultValue } = await testSuites(frameContent, 'extends-cmp-cmp', 'extended-cmp'); + await defaultValue(); + }); + + it('re-renders values via attributes', async () => { + const { viaAttributes } = await testSuites(frameContent, 'extends-cmp-cmp', 'extended-cmp'); + await viaAttributes(); + }); + + it('re-renders values via props', async () => { + const { viaProps } = await testSuites(frameContent, 'extends-cmp-cmp', 'extended-cmp'); + await viaProps(); + }); + + it('calls watch handlers', async () => { + const { watchHandlers } = await testSuites(frameContent, 'extends-cmp-cmp', 'extended-cmp'); + await watchHandlers(); + }); + + it('calls methods', async () => { + const { methods } = await testSuites(frameContent, 'extends-cmp-cmp', 'extended-cmp'); + await methods(); + }); + }); + + describe('es2022 dist-custom-elements output', () => { + let frameContent: HTMLElement; + + beforeEach(async () => { + await browser.switchToParentFrame(); + frameContent = await setupIFrameTest('/extends-cmp/es2022.custom-element.html', 'es2022-custom-elements'); + const frameEle = await browser.$('iframe#es2022-custom-elements'); + frameEle.waitUntil(async () => !!frameContent.querySelector('.extended-prop-1'), { timeout: 5000 }); + }); + + it('renders default values', async () => { + const { defaultValue } = await testSuites(frameContent, 'extends-cmp-cmp', 'extended-cmp'); + await defaultValue(); + }); + + it('re-renders values via attributes', async () => { + const { viaAttributes } = await testSuites(frameContent, 'extends-cmp-cmp', 'extended-cmp'); + await viaAttributes(); + }); + + it('re-renders values via props', async () => { + const { viaProps } = await testSuites(frameContent, 'extends-cmp-cmp', 'extended-cmp'); + await viaProps(); + }); + + it('calls watch handlers', async () => { + const { watchHandlers } = await testSuites(frameContent, 'extends-cmp-cmp', 'extended-cmp'); + await watchHandlers(); + }); + + it('calls methods', async () => { + const { methods } = await testSuites(frameContent, 'extends-cmp-cmp', 'extended-cmp'); + 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-cmp-cmp', 'extended-cmp')).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-cmp-cmp', 'extended-cmp')).ssrViaProps(mod); + }); + }); +}); diff --git a/test/wdio/ts-target/extends-cmp/cmp.tsx b/test/wdio/ts-target/extends-cmp/cmp.tsx new file mode 100644 index 00000000000..61e73980051 --- /dev/null +++ b/test/wdio/ts-target/extends-cmp/cmp.tsx @@ -0,0 +1,35 @@ +import { Component, h, Prop, State, Method, Watch } from '@stencil/core'; +import { ExtendedCmp } from './extended-cmp.js'; + +@Component({ + tag: 'extends-cmp-cmp', +}) +export class ExtendsCmpCmp extends ExtendedCmp { + @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 state1: {this.state1}

+

Main class state2: {this.state2}

+
+ ); + } +} diff --git a/test/wdio/ts-target/extends-cmp/es2022.custom-element.html b/test/wdio/ts-target/extends-cmp/es2022.custom-element.html new file mode 100644 index 00000000000..923a2a32e54 --- /dev/null +++ b/test/wdio/ts-target/extends-cmp/es2022.custom-element.html @@ -0,0 +1,16 @@ + + + Codestin Search App + + + +

ES2022 dist-custom-elements output

+ + + + \ No newline at end of file diff --git a/test/wdio/ts-target/extends-cmp/es2022.dist.html b/test/wdio/ts-target/extends-cmp/es2022.dist.html new file mode 100644 index 00000000000..2a852b3a191 --- /dev/null +++ b/test/wdio/ts-target/extends-cmp/es2022.dist.html @@ -0,0 +1,11 @@ + + + Codestin Search App + + + +

ES2022 dist output

+ + + + \ No newline at end of file diff --git a/test/wdio/ts-target/extends-cmp/extended-cmp-cmp.tsx b/test/wdio/ts-target/extends-cmp/extended-cmp-cmp.tsx new file mode 100644 index 00000000000..6b1ce322a33 --- /dev/null +++ b/test/wdio/ts-target/extends-cmp/extended-cmp-cmp.tsx @@ -0,0 +1,49 @@ +import { Component, h, Prop, State, Method, Watch } from '@stencil/core'; + +@Component({ + tag: 'extended-cmp-cmp', +}) +export class ExtendedCmpCmp { + @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'; + } + + render() { + return ( +
+

Base Extended class prop 1: {this.prop1}

+

Base Extended class prop 2: {this.prop2}

+

Base Extended class state 1: {this.state1}

+

Base Extended class state 2: {this.state2}

+
+ ); + } +} diff --git a/test/wdio/ts-target/extends-cmp/extended-cmp.tsx b/test/wdio/ts-target/extends-cmp/extended-cmp.tsx new file mode 100644 index 00000000000..b5153a67aee --- /dev/null +++ b/test/wdio/ts-target/extends-cmp/extended-cmp.tsx @@ -0,0 +1,18 @@ +import { Component, h } from '@stencil/core'; +import { ExtendedCmpCmp } from './extended-cmp-cmp.js'; + +@Component({ + tag: 'extended-cmp', +}) +export class ExtendedCmp extends ExtendedCmpCmp { + 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/extends-external/cmp.test.ts b/test/wdio/ts-target/extends-external/cmp.test.ts new file mode 100644 index 00000000000..99681ef655f --- /dev/null +++ b/test/wdio/ts-target/extends-external/cmp.test.ts @@ -0,0 +1,96 @@ +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 + * (The external library is built via as a separate Stencil project in `test-sibling`) and built with + * `tsconfig-es2022.json` > `"target": "es2022"` `dist` and `dist-custom-elements` outputs. + */ + +describe('Checks component classes can extend from external library component classes', () => { + describe('es2022 dist output', () => { + let frameContent: HTMLElement; + + beforeEach(async () => { + frameContent = await setupIFrameTest('/extends-external/es2022.dist.html', 'es2022-dist'); + const frameEle = await browser.$('#es2022-dist'); + await frameEle.waitUntil(async () => !!frameContent.querySelector('.extended-prop-1'), { timeout: 5000 }); + }); + + it('renders default values', async () => { + const { defaultValue } = await testSuites(frameContent, 'extends-external', 'sibling-extended'); + await defaultValue(); + }); + + it('re-renders values via attributes', async () => { + const { viaAttributes } = await testSuites(frameContent, 'extends-external', 'sibling-extended'); + await viaAttributes(); + }); + + it('re-renders values via props', async () => { + const { viaProps } = await testSuites(frameContent, 'extends-external', 'sibling-extended'); + await viaProps(); + }); + + it('calls watch handlers', async () => { + const { watchHandlers } = await testSuites(frameContent, 'extends-external', 'sibling-extended'); + await watchHandlers(); + }); + + it('calls methods', async () => { + const { methods } = await testSuites(frameContent, 'extends-external', 'sibling-extended'); + await methods(); + }); + }); + + describe('es2022 dist-custom-elements output', () => { + let frameContent: HTMLElement; + + beforeEach(async () => { + await browser.switchToParentFrame(); + frameContent = await setupIFrameTest('/extends-external/es2022.custom-element.html', 'es2022-custom-elements'); + const frameEle = await browser.$('iframe#es2022-custom-elements'); + frameEle.waitUntil(async () => !!frameContent.querySelector('.extended-prop-1'), { timeout: 5000 }); + }); + + it('renders default values', async () => { + const { defaultValue } = await testSuites(frameContent, 'extends-external', 'sibling-extended'); + await defaultValue(); + }); + + it('re-renders values via attributes', async () => { + const { viaAttributes } = await testSuites(frameContent, 'extends-external', 'sibling-extended'); + await viaAttributes(); + }); + + it('re-renders values via props', async () => { + const { viaProps } = await testSuites(frameContent, 'extends-external', 'sibling-extended'); + await viaProps(); + }); + + it('calls watch handlers', async () => { + const { watchHandlers } = await testSuites(frameContent, 'extends-external', 'sibling-extended'); + await watchHandlers(); + }); + + it('calls methods', async () => { + const { methods } = await testSuites(frameContent, 'extends-external', 'sibling-extended'); + 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', 'sibling-extended')).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', 'sibling-extended')).ssrViaProps(mod); + }); + }); +}); diff --git a/test/wdio/ts-target/extends-external/cmp.tsx b/test/wdio/ts-target/extends-external/cmp.tsx new file mode 100644 index 00000000000..4608fd8c942 --- /dev/null +++ b/test/wdio/ts-target/extends-external/cmp.tsx @@ -0,0 +1,35 @@ +import { Component, h, Prop, State, Method, Watch } from '@stencil/core'; +import { SiblingExtended } from 'test-sibling/dist/collection/sibling-extended/sibling-extended'; + +@Component({ + tag: 'extends-external', +}) +export class ExtendsCmpCmp extends SiblingExtended { + @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 state1: {this.state1}

+

Main class state2: {this.state2}

+
+ ); + } +} diff --git a/test/wdio/ts-target/extends-external/es2022.custom-element.html b/test/wdio/ts-target/extends-external/es2022.custom-element.html new file mode 100644 index 00000000000..d8ae8f39452 --- /dev/null +++ b/test/wdio/ts-target/extends-external/es2022.custom-element.html @@ -0,0 +1,16 @@ + + + Codestin Search App + + + +

ES2022 dist-custom-elements output

+ + + + \ No newline at end of file diff --git a/test/wdio/ts-target/extends-external/es2022.dist.html b/test/wdio/ts-target/extends-external/es2022.dist.html new file mode 100644 index 00000000000..c4b9956ff2b --- /dev/null +++ b/test/wdio/ts-target/extends-external/es2022.dist.html @@ -0,0 +1,11 @@ + + + Codestin Search App + + + +

ES2022 dist output

+ + + + \ No newline at end of file diff --git a/test/wdio/ts-target/extends-mixin/cmp.test.ts b/test/wdio/ts-target/extends-mixin/cmp.test.ts new file mode 100644 index 00000000000..e5d786dc1e3 --- /dev/null +++ b/test/wdio/ts-target/extends-mixin/cmp.test.ts @@ -0,0 +1,95 @@ +import { browser } from '@wdio/globals'; +import { setupIFrameTest } from '../../util.js'; +import { testSuites } from '../extends-test-suite.test.js'; + +/** + * Smoke tests for extending component classes. Built with + * `tsconfig-es2022.json` > `"target": "es2022"` `dist` and `dist-custom-elements` outputs. + */ + +describe('Checks component classes can extend via the Stencil Mixin function', () => { + describe('es2022 dist output', () => { + let frameContent: HTMLElement; + + beforeEach(async () => { + frameContent = await setupIFrameTest('/extends-mixin/es2022.dist.html', 'es2022-dist'); + const frameEle = await browser.$('#es2022-dist'); + frameEle.waitUntil(async () => !!frameContent.querySelector('.main-prop-1'), { timeout: 5000 }); + }); + + it('renders default values', async () => { + const { defaultValue } = await testSuites(frameContent, 'extends-mixin-cmp'); + await defaultValue(); + }); + + it('re-renders values via attributes', async () => { + const { viaAttributes } = await testSuites(frameContent, 'extends-mixin-cmp'); + await viaAttributes(); + }); + + it('re-renders values via props', async () => { + const { viaProps } = await testSuites(frameContent, 'extends-mixin-cmp'); + await viaProps(); + }); + + it('calls watch handlers', async () => { + const { watchHandlers } = await testSuites(frameContent, 'extends-mixin-cmp'); + await watchHandlers(); + }); + + it('calls methods', async () => { + const { methods } = await testSuites(frameContent, 'extends-mixin-cmp'); + await methods(); + }); + }); + + describe('es2022 dist-custom-elements output', () => { + let frameContent: HTMLElement; + + beforeEach(async () => { + await browser.switchToParentFrame(); + frameContent = await setupIFrameTest('/extends-mixin/es2022.custom-element.html', 'es2022-custom-elements'); + const frameEle = await browser.$('iframe#es2022-custom-elements'); + frameEle.waitUntil(async () => !!frameContent.querySelector('.main-prop-1'), { timeout: 5000 }); + }); + + it('renders default values', async () => { + const { defaultValue } = await testSuites(frameContent, 'extends-mixin-cmp'); + await defaultValue(); + }); + + it('re-renders values via attributes', async () => { + const { viaAttributes } = await testSuites(frameContent, 'extends-mixin-cmp'); + await viaAttributes(); + }); + + it('re-renders values via props', async () => { + const { viaProps } = await testSuites(frameContent, 'extends-mixin-cmp'); + await viaProps(); + }); + + it('calls watch handlers', async () => { + const { watchHandlers } = await testSuites(frameContent, 'extends-mixin-cmp'); + await watchHandlers(); + }); + + it('calls methods', async () => { + const { methods } = await testSuites(frameContent, 'extends-mixin-cmp'); + 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-mixin-cmp')).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-mixin-cmp')).ssrViaProps(mod); + }); + }); +}); diff --git a/test/wdio/ts-target/extends-mixin/cmp.tsx b/test/wdio/ts-target/extends-mixin/cmp.tsx new file mode 100644 index 00000000000..dddd8e4be5a --- /dev/null +++ b/test/wdio/ts-target/extends-mixin/cmp.tsx @@ -0,0 +1,38 @@ +import { Component, h, Prop, State, Method, Watch, Mixin } from '@stencil/core'; +import { MixinBFactory } from './mixin-b.js'; +import { MixinAFactory } from './mixin-a.js'; + +@Component({ + tag: 'extends-mixin-cmp', +}) +export class MixinCmp extends Mixin(MixinAFactory, MixinBFactory) { + @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 prop3: {this.prop3}

+

Main class state1: {this.state1}

+

Main class state2: {this.state2}

+

Main class state3: {this.state3}

+
+ ); + } +} diff --git a/test/wdio/ts-target/extends-mixin/es2022.custom-element.html b/test/wdio/ts-target/extends-mixin/es2022.custom-element.html new file mode 100644 index 00000000000..15246014d7f --- /dev/null +++ b/test/wdio/ts-target/extends-mixin/es2022.custom-element.html @@ -0,0 +1,13 @@ + + + Codestin Search App + + + +

ES2022 dist-custom-elements output

+ + + \ No newline at end of file diff --git a/test/wdio/ts-target/extends-mixin/es2022.dist.html b/test/wdio/ts-target/extends-mixin/es2022.dist.html new file mode 100644 index 00000000000..3b7719d341f --- /dev/null +++ b/test/wdio/ts-target/extends-mixin/es2022.dist.html @@ -0,0 +1,10 @@ + + + Codestin Search App + + + +

ES2022 dist output

+ + + \ No newline at end of file diff --git a/test/wdio/ts-target/extends-mixin/mixin-a.tsx b/test/wdio/ts-target/extends-mixin/mixin-a.tsx new file mode 100644 index 00000000000..44a72fab8f2 --- /dev/null +++ b/test/wdio/ts-target/extends-mixin/mixin-a.tsx @@ -0,0 +1,49 @@ +import { h, Prop, State, Method, Watch } from '@stencil/core'; + +export const MixinAFactory = (Base: any) => { + class MixinA extends Base { + @Prop() prop1: string = 'MixinA 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'; + } + + render() { + return ( +
+

Base Extended class prop 1: {this.prop1}

+

Base Extended class prop 2: {this.prop2}

+

Base Extended class state 1: {this.state1}

+

Base Extended class state 2: {this.state2}

+
+ ); + } + } + return MixinA; +}; diff --git a/test/wdio/ts-target/extends-mixin/mixin-b.tsx b/test/wdio/ts-target/extends-mixin/mixin-b.tsx new file mode 100644 index 00000000000..204f5de491d --- /dev/null +++ b/test/wdio/ts-target/extends-mixin/mixin-b.tsx @@ -0,0 +1,32 @@ +import { h, Prop, State, Method, Watch } from '@stencil/core'; + +export const MixinBFactory = (Base: any) => { + class MixinB extends Base { + @Prop() prop3: string = 'mixin b text'; + @Watch('prop3') + prop3Changed(newValue: string) { + console.info('mixin b handler prop3:', newValue); + } + + @State() state3: string = 'mixin b state text'; + @Watch('state3') + state3Changed(newValue: string) { + console.info('mixin b handler state3:', newValue); + } + + @Method() + async method3() { + this.prop3 = 'mixin b method3 called'; + } + + render() { + return ( +
+

Another class prop3: {this.prop3}

+

Another class state3: {this.state3}

+
+ ); + } + } + return MixinB; +}; diff --git a/test/wdio/ts-target/extends-test-suite.test.ts b/test/wdio/ts-target/extends-test-suite.test.ts new file mode 100644 index 00000000000..a793d158193 --- /dev/null +++ b/test/wdio/ts-target/extends-test-suite.test.ts @@ -0,0 +1,162 @@ +import { browser } from '@wdio/globals'; + +// @ts-ignore may not be existing when project hasn't been built +type HydrateModule = typeof import('../../hydrate/index.js'); + +export const testSuites = async (root: HTMLElement, mainTag: string, extendedTag?: string) => { + async function getTxt(selector: string) { + await browser.waitUntil(() => !!root.querySelector(selector), { timeout: 5000 }); + return root.querySelector(selector).textContent.trim(); + } + + function getTxtHtml(html: string, className: string) { + const match = html.match(new RegExp(`

(.*?)

`, 'g')); + if (match && match[0]) { + const textMatch = match[0].match(new RegExp(`

(.*?)

`)); + return textMatch ? textMatch[1].replace(//g, '').trim() : null; + } + return null; + } + + return { + defaultValue: async () => { + if (extendedTag) { + expect(await getTxt('.extended-prop-1')).toBe('Extended class prop 1: ExtendedCmp text'); + expect(await getTxt('.extended-prop-2')).toBe('Extended class prop 2: ExtendedCmp prop2 text'); + expect(await getTxt('.extended-state-1')).toBe('Extended class state 1: ExtendedCmp state text'); + expect(await getTxt('.extended-state-2')).toBe('Extended class state 2: ExtendedCmp state2 text'); + } + expect(await getTxt('.main-prop-1')).toBe('Main class prop1: default text'); + expect(await getTxt('.main-prop-2')).toBe('Main class prop2: ExtendedCmp prop2 text'); + expect(await getTxt('.main-state-1')).toBe('Main class state1: default state text'); + expect(await getTxt('.main-state-2')).toBe('Main class state2: ExtendedCmp state2 text'); + }, + viaAttributes: async () => { + if (extendedTag) { + root.querySelector(extendedTag).setAttribute('prop-1', 'extended via attribute'); + root.querySelector(extendedTag).setAttribute('prop-2', 'extended via attribute'); + } + root.querySelector(mainTag).setAttribute('prop-1', 'main via attribute'); + root.querySelector(mainTag).setAttribute('prop-2', 'main via attribute'); + + await browser.pause(100); + + if (extendedTag) { + expect(await getTxt('.extended-prop-1')).toBe('Extended class prop 1: extended via attribute'); + expect(await getTxt('.extended-prop-2')).toBe('Extended class prop 2: extended via attribute'); + } + expect(await getTxt('.main-prop-1')).toBe('Main class prop1: main via attribute'); + expect(await getTxt('.main-prop-2')).toBe('Main class prop2: main via attribute'); + }, + viaProps: async () => { + if (extendedTag) { + root.querySelector(extendedTag).prop1 = 'extended via prop'; + root.querySelector(extendedTag).prop2 = 'extended via prop'; + } + root.querySelector(mainTag).prop1 = 'main via prop'; + root.querySelector(mainTag).prop2 = 'main via prop'; + + await browser.pause(100); + + if (extendedTag) { + expect(await getTxt('.extended-prop-1')).toBe('Extended class prop 1: extended via prop'); + expect(await getTxt('.extended-prop-2')).toBe('Extended class prop 2: extended via prop'); + } + expect(await getTxt('.main-prop-1')).toBe('Main class prop1: main via prop'); + expect(await getTxt('.main-prop-2')).toBe('Main class prop2: main via prop'); + }, + watchHandlers: async () => { + const iframeWin = root.ownerDocument.defaultView; + let originalConsoleLog = iframeWin.console.info; + + const logMessages: string[] = []; + iframeWin.console.info = (...args: any[]) => { + logMessages.push(args.map(String).join(' ')); + }; + + if (extendedTag) { + root.querySelector(extendedTag).setAttribute('prop-1', 'extended via attribute'); + root.querySelector(extendedTag).setAttribute('prop-2', 'extended via attribute'); + } + root.querySelector(mainTag).setAttribute('prop-1', 'main via attribute'); + root.querySelector(mainTag).setAttribute('prop-2', 'main via attribute'); + + await browser.pause(100); + + if (extendedTag) { + root.querySelector(extendedTag).prop1 = 'extended via prop'; + root.querySelector(extendedTag).prop2 = 'extended via prop'; + } + root.querySelector(mainTag).prop1 = 'main via prop'; + root.querySelector(mainTag).prop2 = 'main via prop'; + + await browser.pause(100); + + if (extendedTag) { + expect(logMessages).toEqual([ + 'extended class handler prop1: extended via attribute', + 'extended class handler prop2: extended via attribute', + 'main class handler prop1: main via attribute', + 'extended class handler prop2: main via attribute', + 'extended class handler prop1: extended via prop', + 'extended class handler prop2: extended via prop', + 'main class handler prop1: main via prop', + 'extended class handler prop2: main via prop', + ]); + } else { + expect(logMessages).toEqual([ + 'main class handler prop1: main via attribute', + 'extended class handler prop2: main via attribute', + 'main class handler prop1: main via prop', + 'extended class handler prop2: main via prop', + ]); + } + + iframeWin.console.info = originalConsoleLog; + }, + methods: async () => { + if (extendedTag) { + const component1 = root.querySelector(extendedTag); + await component1.method1(); + await browser.pause(50); + expect(await getTxt('.extended-prop-1')).toBe('Extended class prop 1: ExtendedCmp method1 called'); + + await component1.method2(); + await browser.pause(50); + expect(await getTxt('.extended-prop-1')).toBe('Extended class prop 1: ExtendedCmp method2 called'); + } + + const component2 = root.querySelector(mainTag); + await component2.method1(); + await browser.pause(50); + expect(await getTxt('.main-prop-1')).toBe('Main class prop1: main class method1 called'); + + await component2.method2(); + await browser.pause(50); + expect(await getTxt('.main-prop-1')).toBe('Main class prop1: ExtendedCmp method2 called'); + }, + ssrViaAttrs: async (hydrationModule: any) => { + const renderToString: HydrateModule['renderToString'] = hydrationModule.renderToString; + const { html } = await renderToString(` + <${mainTag} + prop-1="main via attr" + prop-2="main via attr" + > + `); + expect(await getTxtHtml(html, 'main-prop-1')).toBe('Main class prop1: main via attr'); + expect(await getTxtHtml(html, 'main-prop-2')).toBe('Main class prop2: main via attr'); + }, + ssrViaProps: async (hydrationModule: any) => { + const renderToString: HydrateModule['renderToString'] = hydrationModule.renderToString; + const { html } = await renderToString(`<${mainTag}>`, { + beforeHydrate: (doc: Document) => { + const el = doc.querySelector(mainTag); + el.prop1 = 'main via prop'; + el.prop2 = 'main via prop'; + }, + }); + expect(await getTxtHtml(html, 'main-prop-1')).toBe('Main class prop1: main via prop'); + expect(await getTxtHtml(html, 'main-prop-2')).toBe('Main class prop2: main via prop'); + }, + }; +}; diff --git a/test/wdio/ts-target-props/cmp.test.tsx b/test/wdio/ts-target/ts-target-props/cmp.test.tsx similarity index 99% rename from test/wdio/ts-target-props/cmp.test.tsx rename to test/wdio/ts-target/ts-target-props/cmp.test.tsx index b1f85c14b92..281a0a49f70 100644 --- a/test/wdio/ts-target-props/cmp.test.tsx +++ b/test/wdio/ts-target/ts-target-props/cmp.test.tsx @@ -2,7 +2,7 @@ import { h } from '@stencil/core'; import { render } from '@wdio/browser-runner/stencil'; import { $, browser } from '@wdio/globals'; -import { setupIFrameTest } from '../util.js'; +import { setupIFrameTest } from '../../util.js'; /** * Smoke tests for `tsconfig.json` > `"target": "es2022"` `dist` and `dist-custom-elements` outputs. diff --git a/test/wdio/ts-target-props/cmp.tsx b/test/wdio/ts-target/ts-target-props/cmp.tsx similarity index 100% rename from test/wdio/ts-target-props/cmp.tsx rename to test/wdio/ts-target/ts-target-props/cmp.tsx diff --git a/test/wdio/ts-target-props/es2022.custom-element.html b/test/wdio/ts-target/ts-target-props/es2022.custom-element.html similarity index 100% rename from test/wdio/ts-target-props/es2022.custom-element.html rename to test/wdio/ts-target/ts-target-props/es2022.custom-element.html diff --git a/test/wdio/ts-target-props/es2022.dist.html b/test/wdio/ts-target/ts-target-props/es2022.dist.html similarity index 100% rename from test/wdio/ts-target-props/es2022.dist.html rename to test/wdio/ts-target/ts-target-props/es2022.dist.html diff --git a/test/wdio/tsconfig-es2022.json b/test/wdio/tsconfig-es2022.json index e21308c86db..6e8e2387abb 100644 --- a/test/wdio/tsconfig-es2022.json +++ b/test/wdio/tsconfig-es2022.json @@ -4,6 +4,6 @@ "target": "es2022", "useDefineForClassFields": true }, - "include": ["ts-target-props"], - "exclude": ["ts-target-props/*.test.tsx"] + "include": ["ts-target"], + "exclude": ["ts-target/**/*.test.tsx", "ts-target/**/*.test.ts"] } diff --git a/test/wdio/tsconfig-stencil.json b/test/wdio/tsconfig-stencil.json index d94514fdf31..8a4f2ceedb9 100644 --- a/test/wdio/tsconfig-stencil.json +++ b/test/wdio/tsconfig-stencil.json @@ -4,6 +4,7 @@ "alwaysStrict": true, "allowSyntheticDefaultImports": true, "allowUnreachableCode": false, + "allowJs": true, "declaration": false, "resolveJsonModule": true, "experimentalDecorators": true, @@ -24,7 +25,7 @@ "baseUrl": ".", "skipLibCheck": true, "paths": { - "@test-sibling": ["./test-sibling"] + "test-sibling": ["./test-sibling"] } }, "include": ["./src/components.d.ts", "./**/*.spec.ts", "./**/*.tsx", "./util.ts", "./global.ts"], diff --git a/test/wdio/tsconfig.json b/test/wdio/tsconfig.json index e611bc4dbaa..e3d34e2fc43 100644 --- a/test/wdio/tsconfig.json +++ b/test/wdio/tsconfig.json @@ -25,7 +25,7 @@ "paths": { "@stencil/core": ["../../internal"], "@stencil/core/internal": ["../../internal"], - "@test-sibling": ["./test-sibling"] + "test-sibling": ["./test-sibling"] } }, "include": [ diff --git a/test/wdio/wdio.conf.ts b/test/wdio/wdio.conf.ts index 0d25965d457..60e342ba835 100644 --- a/test/wdio/wdio.conf.ts +++ b/test/wdio/wdio.conf.ts @@ -67,11 +67,9 @@ export const config: WebdriverIO.Config = { // The path of the spec files will be resolved relative from the directory of // of the config file unless it's absolute. // - specs: [['./**/*.test.tsx']], + specs: [['./**/*.test.tsx', './**/*.test.ts']], // Patterns to exclude. - exclude: [ - // 'path/to/excluded/files' - ], + exclude: ['./node_modules/**'], // // ============ // Capabilities @@ -341,11 +339,11 @@ if (['CHROME', 'ALL'].includes(BROWSER_CONFIGURATION)) { /** * Disable FF tests due to issues in the WebDriver protocol */ -// if (['FIREFOX', 'ALL'].includes(BROWSER_CONFIGURATION)) { -// (config.capabilities as WebdriverIO.Capabilities[]).push({ -// browserName: 'firefox' -// }); -// } +if (['FIREFOX'].includes(BROWSER_CONFIGURATION)) { + (config.capabilities as WebdriverIO.Capabilities[]).push({ + browserName: 'firefox', + }); +} if (['EDGE', 'ALL'].includes(BROWSER_CONFIGURATION)) { (config.capabilities as WebdriverIO.Capabilities[]).push({