diff --git a/src/compiler/types/generate-app-types.ts b/src/compiler/types/generate-app-types.ts index 5f8d8c81e15..11dfe45c282 100644 --- a/src/compiler/types/generate-app-types.ts +++ b/src/compiler/types/generate-app-types.ts @@ -1,6 +1,7 @@ import type * as d from '../../declarations'; import { COMPONENTS_DTS_HEADER, sortImportNames } from './types-utils'; import { generateComponentTypes } from './generate-component-types'; +import { generateEventDetailTypes } from './generate-event-detail-types'; import { GENERATED_DTS, getComponentsDtsSrcFilePath } from '../output-targets/output-utils'; import { isAbsolute, relative, resolve } from 'path'; import { normalizePath } from '@utils'; @@ -68,6 +69,7 @@ const generateComponentTypesFile = (config: d.Config, buildCtx: d.BuildCtx, areT const c: string[] = []; const allTypes = new Map(); const components = buildCtx.components.filter((m) => !m.isCollectionDependency); + const componentEventDetailTypes: d.TypesModule[] = []; const modules: d.TypesModule[] = components.map((cmp) => { /** @@ -77,6 +79,12 @@ const generateComponentTypesFile = (config: d.Config, buildCtx: d.BuildCtx, areT * grow as more components (with additional types) are processed. */ typeImportData = updateReferenceTypeImports(typeImportData, allTypes, cmp, cmp.sourceFilePath); + if (cmp.events.length > 0) { + /** + * Only generate event detail types for components that have events. + */ + componentEventDetailTypes.push(generateEventDetailTypes(cmp)); + } return generateComponentTypes(cmp, typeImportData, areTypesInternal); }); @@ -107,7 +115,11 @@ const generateComponentTypesFile = (config: d.Config, buildCtx: d.BuildCtx, areT }) ); - c.push(`export namespace Components {\n${modules.map((m) => `${m.component}`).join('\n')}\n}`); + c.push(`export namespace Components {`); + c.push(...modules.map((m) => `${m.component}`)); + c.push(`}`); + + c.push(...componentEventDetailTypes.map((m) => `${m.component}`)); c.push(`declare global {`); diff --git a/src/compiler/types/generate-component-types.ts b/src/compiler/types/generate-component-types.ts index 06c130c3209..205f06b6719 100644 --- a/src/compiler/types/generate-component-types.ts +++ b/src/compiler/types/generate-component-types.ts @@ -22,7 +22,7 @@ export const generateComponentTypes = ( const propAttributes = generatePropTypes(cmp, typeImportData); const methodAttributes = generateMethodTypes(cmp, typeImportData); - const eventAttributes = generateEventTypes(cmp, typeImportData); + const eventAttributes = generateEventTypes(cmp, typeImportData, tagNameAsPascal); const componentAttributes = attributesToMultiLineString( [...propAttributes, ...methodAttributes], diff --git a/src/compiler/types/generate-event-detail-types.ts b/src/compiler/types/generate-event-detail-types.ts new file mode 100644 index 00000000000..bdfbfb06dea --- /dev/null +++ b/src/compiler/types/generate-event-detail-types.ts @@ -0,0 +1,37 @@ +import type * as d from '../../declarations'; +import { dashToPascalCase } from '@utils'; + +/** + * Generates the custom event interface for each component that combines the `CustomEvent` interface with + * the HTMLElement target. This is used to allow implementers to use strict typings on event handlers. + * + * The generated interface accepts a generic for the event detail type. This allows implementers to use + * custom typings for individual events without Stencil needing to generate an interface for each event. + * + * @param cmp The component compiler metadata + * @returns The generated interface type definition. + */ +export const generateEventDetailTypes = (cmp: d.ComponentCompilerMeta): d.TypesModule => { + const tagName = cmp.tagName.toLowerCase(); + const tagNameAsPascal = dashToPascalCase(tagName); + const htmlElementName = `HTML${tagNameAsPascal}Element`; + + const isDep = cmp.isCollectionDependency; + + const cmpEventInterface = `${tagNameAsPascal}CustomEvent`; + const cmpInterface = [ + `export interface ${cmpEventInterface} extends CustomEvent {`, + ` detail: T;`, + ` target: ${htmlElementName};`, + `}`, + ]; + return { + isDep, + tagName, + tagNameAsPascal, + htmlElementName, + component: cmpInterface.join('\n'), + jsx: cmpInterface.join('\n'), + element: cmpInterface.join('\n'), + }; +}; diff --git a/src/compiler/types/generate-event-types.ts b/src/compiler/types/generate-event-types.ts index b034d4ab3c1..1c692c97406 100644 --- a/src/compiler/types/generate-event-types.ts +++ b/src/compiler/types/generate-event-types.ts @@ -6,13 +6,20 @@ import { updateTypeIdentifierNames } from './stencil-types'; * Generates the individual event types for all @Event() decorated events in a component * @param cmpMeta component runtime metadata for a single component * @param typeImportData locally/imported/globally used type names, which may be used to prevent naming collisions + * @param cmpClassName The pascal cased name of the component class * @returns the generated type metadata */ -export const generateEventTypes = (cmpMeta: d.ComponentCompilerMeta, typeImportData: d.TypesImportData): d.TypeInfo => { +export const generateEventTypes = ( + cmpMeta: d.ComponentCompilerMeta, + typeImportData: d.TypesImportData, + cmpClassName: string +): d.TypeInfo => { return cmpMeta.events.map((cmpEvent) => { const name = `on${toTitleCase(cmpEvent.name)}`; - const type = getEventType(cmpEvent, typeImportData, cmpMeta.sourceFilePath); - return { + const cmpEventDetailInterface = `${cmpClassName}CustomEvent`; + const type = getEventType(cmpEvent, cmpEventDetailInterface, typeImportData, cmpMeta.sourceFilePath); + + const typeInfo: d.TypeInfo[0] = { name, type, optional: false, @@ -20,18 +27,21 @@ export const generateEventTypes = (cmpMeta: d.ComponentCompilerMeta, typeImportD internal: cmpEvent.internal, jsdoc: getTextDocs(cmpEvent.docs), }; + return typeInfo; }); }; /** * Determine the correct type name for all type(s) used by a class member annotated with `@Event()` * @param cmpEvent the compiler metadata for a single `@Event()` + * @param cmpEventDetailInterface the name of the custom event type to use in the generated type * @param typeImportData locally/imported/globally used type names, which may be used to prevent naming collisions * @param componentSourcePath the path to the component on disk * @returns the type associated with a `@Event()` */ const getEventType = ( cmpEvent: d.ComponentCompilerEvent, + cmpEventDetailInterface: string, typeImportData: d.TypesImportData, componentSourcePath: string ): string => { @@ -44,5 +54,5 @@ const getEventType = ( componentSourcePath, cmpEvent.complexType.original ); - return `(event: CustomEvent<${updatedTypeName}>) => void`; + return `(event: ${cmpEventDetailInterface}<${updatedTypeName}>) => void`; }; diff --git a/src/compiler/types/tests/ComponentCompilerMeta.stub.ts b/src/compiler/types/tests/ComponentCompilerMeta.stub.ts index 669f829b9e7..54f419f91b9 100644 --- a/src/compiler/types/tests/ComponentCompilerMeta.stub.ts +++ b/src/compiler/types/tests/ComponentCompilerMeta.stub.ts @@ -12,10 +12,12 @@ export const stubComponentCompilerMeta = ( ): d.ComponentCompilerMeta => { // TODO(STENCIL-378): Continue to build out default stub, remove the type assertion on `default` const defaults: d.ComponentCompilerMeta = { + isCollectionDependency: false, events: [], methods: [], properties: [], sourceFilePath: '/some/stubbed/path/my-component.tsx', + tagName: 'stub-cmp', virtualProperties: [], } as d.ComponentCompilerMeta; diff --git a/src/compiler/types/tests/generate-event-detail-types.spec.ts b/src/compiler/types/tests/generate-event-detail-types.spec.ts new file mode 100644 index 00000000000..ab055a8e2c3 --- /dev/null +++ b/src/compiler/types/tests/generate-event-detail-types.spec.ts @@ -0,0 +1,32 @@ +import type * as d from '../../../declarations'; +import { stubComponentCompilerMeta } from './ComponentCompilerMeta.stub'; +import { generateEventDetailTypes } from '../generate-event-detail-types'; + +describe('generate-event-detail-types', () => { + describe('generateEventDetailTypes', () => { + it('returns the correct type module data for a component', () => { + const tagName = 'event-detail-test-tag'; + const tagNameAsPascal = 'EventDetailTestTag'; + + const expectedTypeInfo = `export interface ${tagNameAsPascal}CustomEvent extends CustomEvent { + detail: T; + target: HTML${tagNameAsPascal}Element; +}`; + const componentMeta = stubComponentCompilerMeta({ + tagName, + }); + + const actualEventDetailTypes = generateEventDetailTypes(componentMeta); + + expect(actualEventDetailTypes).toEqual({ + component: expectedTypeInfo, + element: expectedTypeInfo, + htmlElementName: `HTML${tagNameAsPascal}Element`, + isDep: false, + jsx: expectedTypeInfo, + tagName, + tagNameAsPascal, + }); + }); + }); +}); diff --git a/src/compiler/types/tests/generate-event-types.spec.ts b/src/compiler/types/tests/generate-event-types.spec.ts index d0f7bcb639e..f30edf9b757 100644 --- a/src/compiler/types/tests/generate-event-types.spec.ts +++ b/src/compiler/types/tests/generate-event-types.spec.ts @@ -47,8 +47,9 @@ describe('generate-event-types', () => { it('returns an empty array when no events are provided', () => { const stubImportTypes = stubTypesImportData(); const componentMeta = stubComponentCompilerMeta(); + const cmpClassName = 'MyComponent'; - expect(generateEventTypes(componentMeta, stubImportTypes)).toEqual([]); + expect(generateEventTypes(componentMeta, stubImportTypes, cmpClassName)).toEqual([]); }); it('prefixes the event name with "on"', () => { @@ -56,8 +57,9 @@ describe('generate-event-types', () => { const componentMeta = stubComponentCompilerMeta({ events: [stubComponentCompilerEvent()], }); + const cmpClassName = 'MyComponent'; - const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes); + const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes, cmpClassName); expect(actualTypeInfo).toHaveLength(1); expect(actualTypeInfo[0].name).toBe('onMyEvent'); @@ -68,11 +70,12 @@ describe('generate-event-types', () => { const componentMeta = stubComponentCompilerMeta({ events: [stubComponentCompilerEvent()], }); + const cmpClassName = 'MyComponent'; - const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes); + const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes, cmpClassName); expect(actualTypeInfo).toHaveLength(1); - expect(actualTypeInfo[0].type).toBe('(event: CustomEvent) => void'); + expect(actualTypeInfo[0].type).toBe('(event: MyComponentCustomEvent) => void'); }); it('uses an updated type name to avoid naming collisions', () => { @@ -83,11 +86,12 @@ describe('generate-event-types', () => { const componentMeta = stubComponentCompilerMeta({ events: [stubComponentCompilerEvent()], }); + const cmpClassName = 'MyComponent'; - const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes); + const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes, cmpClassName); expect(actualTypeInfo).toHaveLength(1); - expect(actualTypeInfo[0].type).toBe(`(event: CustomEvent<${updatedTypeName}>) => void`); + expect(actualTypeInfo[0].type).toBe(`(event: MyComponentCustomEvent<${updatedTypeName}>) => void`); }); it('derives CustomEvent type when there is no original typing field', () => { @@ -102,8 +106,9 @@ describe('generate-event-types', () => { const componentMeta = stubComponentCompilerMeta({ events: [componentEvent], }); + const cmpClassName = 'MyComponent'; - const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes); + const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes, cmpClassName); expect(actualTypeInfo).toHaveLength(1); expect(actualTypeInfo[0].type).toBe('CustomEvent'); @@ -122,11 +127,12 @@ describe('generate-event-types', () => { name: 'onMyEvent', optional: false, required: false, - type: '(event: CustomEvent) => void', + type: '(event: MyComponentCustomEvent) => void', }, ]; + const cmpClassName = 'MyComponent'; - const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes); + const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes, cmpClassName); expect(actualTypeInfo).toEqual(expectedTypeInfo); }); @@ -150,6 +156,7 @@ describe('generate-event-types', () => { events: [componentEvent1, componentEvent2], }); const stubImportTypes = stubTypesImportData(); + const cmpClassName = 'MyComponent'; const expectedTypeInfo: d.TypeInfo = [ { @@ -158,7 +165,7 @@ describe('generate-event-types', () => { name: 'onMyEvent', optional: false, required: false, - type: '(event: CustomEvent) => void', + type: '(event: MyComponentCustomEvent) => void', }, { jsdoc: '', @@ -170,7 +177,7 @@ describe('generate-event-types', () => { }, ]; - const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes); + const actualTypeInfo = generateEventTypes(componentMeta, stubImportTypes, cmpClassName); expect(actualTypeInfo).toEqual(expectedTypeInfo); }); diff --git a/test/karma/test-app/components.d.ts b/test/karma/test-app/components.d.ts index 076fda64dbe..d75d97e3766 100644 --- a/test/karma/test-app/components.d.ts +++ b/test/karma/test-app/components.d.ts @@ -6,6 +6,7 @@ */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; import { SomeTypes } from "./util"; +import { TestEventDetail } from "./event-custom-type/cmp"; export namespace Components { interface AppendChild { } @@ -109,6 +110,10 @@ export namespace Components { "propVal": number; "someMethod": () => Promise; } + interface EventBasic { + } + interface EventCustomType { + } interface ExternalImportA { } interface ExternalImportB { @@ -328,6 +333,34 @@ export namespace Components { interface Tag88 { } } +export interface EsmImportCustomEvent extends CustomEvent { + detail: T; + target: HTMLEsmImportElement; +} +export interface EventBasicCustomEvent extends CustomEvent { + detail: T; + target: HTMLEventBasicElement; +} +export interface EventCustomTypeCustomEvent extends CustomEvent { + detail: T; + target: HTMLEventCustomTypeElement; +} +export interface LifecycleAsyncBCustomEvent extends CustomEvent { + detail: T; + target: HTMLLifecycleAsyncBElement; +} +export interface LifecycleAsyncCCustomEvent extends CustomEvent { + detail: T; + target: HTMLLifecycleAsyncCElement; +} +export interface LifecycleBasicBCustomEvent extends CustomEvent { + detail: T; + target: HTMLLifecycleBasicBElement; +} +export interface LifecycleBasicCCustomEvent extends CustomEvent { + detail: T; + target: HTMLLifecycleBasicCElement; +} declare global { interface HTMLAppendChildElement extends Components.AppendChild, HTMLStencilElement { } @@ -551,6 +584,18 @@ declare global { prototype: HTMLEsmImportElement; new (): HTMLEsmImportElement; }; + interface HTMLEventBasicElement extends Components.EventBasic, HTMLStencilElement { + } + var HTMLEventBasicElement: { + prototype: HTMLEventBasicElement; + new (): HTMLEventBasicElement; + }; + interface HTMLEventCustomTypeElement extends Components.EventCustomType, HTMLStencilElement { + } + var HTMLEventCustomTypeElement: { + prototype: HTMLEventCustomTypeElement; + new (): HTMLEventCustomTypeElement; + }; interface HTMLExternalImportAElement extends Components.ExternalImportA, HTMLStencilElement { } var HTMLExternalImportAElement: { @@ -1141,6 +1186,8 @@ declare global { "dynamic-import": HTMLDynamicImportElement; "es5-addclass-svg": HTMLEs5AddclassSvgElement; "esm-import": HTMLEsmImportElement; + "event-basic": HTMLEventBasicElement; + "event-custom-type": HTMLEventCustomTypeElement; "external-import-a": HTMLExternalImportAElement; "external-import-b": HTMLExternalImportBElement; "external-import-c": HTMLExternalImportCElement; @@ -1332,9 +1379,15 @@ declare namespace LocalJSX { interface Es5AddclassSvg { } interface EsmImport { - "onSomeEvent"?: (event: CustomEvent) => void; + "onSomeEvent"?: (event: EsmImportCustomEvent) => void; "propVal"?: number; } + interface EventBasic { + "onTestEvent"?: (event: EventBasicCustomEvent) => void; + } + interface EventCustomType { + "onTestEvent"?: (event: EventCustomTypeCustomEvent) => void; + } interface ExternalImportA { } interface ExternalImportB { @@ -1362,25 +1415,25 @@ declare namespace LocalJSX { interface LifecycleAsyncA { } interface LifecycleAsyncB { - "onLifecycleLoad"?: (event: CustomEvent) => void; - "onLifecycleUpdate"?: (event: CustomEvent) => void; + "onLifecycleLoad"?: (event: LifecycleAsyncBCustomEvent) => void; + "onLifecycleUpdate"?: (event: LifecycleAsyncBCustomEvent) => void; "value"?: string; } interface LifecycleAsyncC { - "onLifecycleLoad"?: (event: CustomEvent) => void; - "onLifecycleUpdate"?: (event: CustomEvent) => void; + "onLifecycleLoad"?: (event: LifecycleAsyncCCustomEvent) => void; + "onLifecycleUpdate"?: (event: LifecycleAsyncCCustomEvent) => void; "value"?: string; } interface LifecycleBasicA { } interface LifecycleBasicB { - "onLifecycleLoad"?: (event: CustomEvent) => void; - "onLifecycleUpdate"?: (event: CustomEvent) => void; + "onLifecycleLoad"?: (event: LifecycleBasicBCustomEvent) => void; + "onLifecycleUpdate"?: (event: LifecycleBasicBCustomEvent) => void; "value"?: string; } interface LifecycleBasicC { - "onLifecycleLoad"?: (event: CustomEvent) => void; - "onLifecycleUpdate"?: (event: CustomEvent) => void; + "onLifecycleLoad"?: (event: LifecycleBasicCCustomEvent) => void; + "onLifecycleUpdate"?: (event: LifecycleBasicCCustomEvent) => void; "value"?: string; } interface LifecycleNestedA { @@ -1599,6 +1652,8 @@ declare namespace LocalJSX { "dynamic-import": DynamicImport; "es5-addclass-svg": Es5AddclassSvg; "esm-import": EsmImport; + "event-basic": EventBasic; + "event-custom-type": EventCustomType; "external-import-a": ExternalImportA; "external-import-b": ExternalImportB; "external-import-c": ExternalImportC; @@ -1734,6 +1789,8 @@ declare module "@stencil/core" { "dynamic-import": LocalJSX.DynamicImport & JSXBase.HTMLAttributes; "es5-addclass-svg": LocalJSX.Es5AddclassSvg & JSXBase.HTMLAttributes; "esm-import": LocalJSX.EsmImport & JSXBase.HTMLAttributes; + "event-basic": LocalJSX.EventBasic & JSXBase.HTMLAttributes; + "event-custom-type": LocalJSX.EventCustomType & JSXBase.HTMLAttributes; "external-import-a": LocalJSX.ExternalImportA & JSXBase.HTMLAttributes; "external-import-b": LocalJSX.ExternalImportB & JSXBase.HTMLAttributes; "external-import-c": LocalJSX.ExternalImportC & JSXBase.HTMLAttributes; diff --git a/test/karma/test-app/event-basic/cmp.tsx b/test/karma/test-app/event-basic/cmp.tsx new file mode 100644 index 00000000000..af0d5b7fefa --- /dev/null +++ b/test/karma/test-app/event-basic/cmp.tsx @@ -0,0 +1,32 @@ +import { Component, h, Event, EventEmitter, State, Listen } from '@stencil/core'; + +@Component({ + tag: 'event-basic', +}) +export class EventBasic { + @Event() testEvent: EventEmitter; + + @State() counter = 0; + + @Listen('testEvent') + testEventHandler() { + this.counter++; + } + + componentDidLoad() { + this.testEvent.emit(); + } + + render() { + return ( +
+

testEvent is emitted on componentDidLoad

+
+

+ Emission count: {this.counter} +

+
+
+ ); + } +} diff --git a/test/karma/test-app/event-basic/index.html b/test/karma/test-app/event-basic/index.html new file mode 100644 index 00000000000..97d1a0f3ec4 --- /dev/null +++ b/test/karma/test-app/event-basic/index.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/karma/test-app/event-basic/karma.spec.ts b/test/karma/test-app/event-basic/karma.spec.ts new file mode 100644 index 00000000000..62e9543aa35 --- /dev/null +++ b/test/karma/test-app/event-basic/karma.spec.ts @@ -0,0 +1,15 @@ +import { setupDomTests } from '../util'; + +describe('event-basic', function () { + const { setupDom, tearDownDom } = setupDomTests(document); + let app: HTMLElement; + + beforeEach(async () => { + app = await setupDom('/event-basic/index.html'); + }); + afterEach(tearDownDom); + + it('should dispatch an event on load', () => { + expect(app.querySelector('#counter').textContent).toBe('1'); + }); +}); diff --git a/test/karma/test-app/event-custom-type/cmp.tsx b/test/karma/test-app/event-custom-type/cmp.tsx new file mode 100644 index 00000000000..044b3dab5bf --- /dev/null +++ b/test/karma/test-app/event-custom-type/cmp.tsx @@ -0,0 +1,45 @@ +import { Component, h, Event, EventEmitter, State, Listen } from '@stencil/core'; + +import { EventCustomTypeCustomEvent } from '../components'; + +export interface TestEventDetail { + value: string; +} + +@Component({ + tag: 'event-custom-type', +}) +export class EventCustomType { + @Event() testEvent: EventEmitter; + + @State() counter = 0; + @State() lastEventValue: TestEventDetail; + + @Listen('testEvent') + testEventHandler(newValue: EventCustomTypeCustomEvent) { + this.counter++; + this.lastEventValue = newValue.detail; + } + + componentDidLoad() { + this.testEvent.emit({ + value: 'Test value', + }); + } + + render() { + return ( +
+

testEvent is emitted on componentDidLoad

+
+

+ Emission count: {this.counter} +

+

+ Last emitted value: {JSON.stringify(this.lastEventValue)} +

+
+
+ ); + } +} diff --git a/test/karma/test-app/event-custom-type/index.html b/test/karma/test-app/event-custom-type/index.html new file mode 100644 index 00000000000..ad37f6c53b4 --- /dev/null +++ b/test/karma/test-app/event-custom-type/index.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/karma/test-app/event-custom-type/karma.spec.ts b/test/karma/test-app/event-custom-type/karma.spec.ts new file mode 100644 index 00000000000..6adc219d457 --- /dev/null +++ b/test/karma/test-app/event-custom-type/karma.spec.ts @@ -0,0 +1,20 @@ +import { setupDomTests, waitForChanges } from '../util'; + +describe('event-basic', function () { + const { setupDom, tearDownDom } = setupDomTests(document); + let app: HTMLElement; + + beforeEach(async () => { + app = await setupDom('/event-custom-type/index.html'); + }); + afterEach(tearDownDom); + + it('should dispatch an event on load', () => { + expect(app.querySelector('#counter').textContent).toBe('1'); + }); + + it('should emit a complex type', async () => { + await waitForChanges(); + expect(app.querySelector('#lastValue').textContent).toBe('{"value":"Test value"}'); + }); +});