diff --git a/src/compiler/docs/generate-doc-data.ts b/src/compiler/docs/generate-doc-data.ts index 01b5ead45da..766405219d7 100644 --- a/src/compiler/docs/generate-doc-data.ts +++ b/src/compiler/docs/generate-doc-data.ts @@ -204,6 +204,9 @@ const getRealProperties = (properties: d.ComponentCompilerProperty[]): d.JsonDoc optional: member.optional, required: member.required, + + getter: member.getter, + setter: member.setter, })); }; @@ -227,6 +230,9 @@ const getVirtualProperties = (virtualProps: d.ComponentCompilerVirtualProperty[] optional: true, required: false, + + getter: undefined, + setter: undefined, })); }; diff --git a/src/compiler/docs/test/markdown-props.spec.ts b/src/compiler/docs/test/markdown-props.spec.ts index a7e6eb250bf..ba1e543538c 100644 --- a/src/compiler/docs/test/markdown-props.spec.ts +++ b/src/compiler/docs/test/markdown-props.spec.ts @@ -15,6 +15,8 @@ describe('markdown props', () => { reflectToAttr: false, docsTags: [], values: [], + getter: false, + setter: false, }, { name: 'hello', @@ -28,6 +30,8 @@ describe('markdown props', () => { reflectToAttr: false, docsTags: [], values: [], + getter: false, + setter: false, }, ]).join('\n'); expect(markdown).toEqual(`## Properties @@ -54,6 +58,8 @@ describe('markdown props', () => { reflectToAttr: false, docsTags: [], values: [], + getter: false, + setter: false, }, ]).join('\n'); @@ -80,6 +86,8 @@ describe('markdown props', () => { reflectToAttr: false, docsTags: [], values: [], + getter: false, + setter: false, }, ]).join('\n'); @@ -106,6 +114,8 @@ describe('markdown props', () => { reflectToAttr: false, docsTags: [], values: [], + getter: false, + setter: false, }, ]).join('\n'); diff --git a/src/compiler/transformers/decorators-to-static/convert-decorators.ts b/src/compiler/transformers/decorators-to-static/convert-decorators.ts index 49930c46bb5..2a7e55d1de9 100644 --- a/src/compiler/transformers/decorators-to-static/convert-decorators.ts +++ b/src/compiler/transformers/decorators-to-static/convert-decorators.ts @@ -229,6 +229,19 @@ const removeStencilMethodDecorators = ( member.type, member.body, ); + } else if (ts.isGetAccessor(member)) { + return ts.factory.updateGetAccessorDeclaration( + member, + ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined, + member.name, + member.parameters, + member.type, + member.body, + ); + } else if (ts.isSetAccessor(member)) { + const err = buildError(diagnostics); + err.messageText = 'A get accessor should be decorated before a set accessor'; + augmentDiagnosticWithNode(err, member); } else if (ts.isPropertyDeclaration(member)) { if (shouldInitializeInConstructor(member, importAliasMap)) { // if the current class member is decorated with either 'State' or diff --git a/src/compiler/transformers/decorators-to-static/prop-decorator.ts b/src/compiler/transformers/decorators-to-static/prop-decorator.ts index 9924785ce99..c82445513c4 100644 --- a/src/compiler/transformers/decorators-to-static/prop-decorator.ts +++ b/src/compiler/transformers/decorators-to-static/prop-decorator.ts @@ -38,8 +38,8 @@ export const propDecoratorsToStatic = ( decoratorName: string, ): void => { const properties = decoratedProps - .filter(ts.isPropertyDeclaration) - .map((prop) => parsePropDecorator(diagnostics, typeChecker, program, prop, decoratorName)) + .filter((prop) => ts.isPropertyDeclaration(prop) || ts.isGetAccessor(prop)) + .map((prop) => parsePropDecorator(diagnostics, typeChecker, program, prop, decoratorName, newMembers)) .filter((prop): prop is ts.PropertyAssignment => prop != null); if (properties.length > 0) { @@ -55,14 +55,16 @@ export const propDecoratorsToStatic = ( * @param program a {@link ts.Program} object * @param prop the TypeScript `PropertyDeclaration` to parse * @param decoratorName the name of the decorator to look for + * @param newMembers a collection of parsed `@Prop` annotated class members. Used for `get()` decorated props to find a corresponding `set()` * @returns a property assignment expression to be added to the Stencil component's class */ const parsePropDecorator = ( diagnostics: d.Diagnostic[], typeChecker: ts.TypeChecker, program: ts.Program, - prop: ts.PropertyDeclaration, + prop: ts.PropertyDeclaration | ts.GetAccessorDeclaration, decoratorName: string, + newMembers: ts.ClassElement[], ): ts.PropertyAssignment | null => { const propDecorator = retrieveTsDecorators(prop)?.find(isDecoratorNamed(decoratorName)); if (propDecorator == null) { @@ -92,6 +94,7 @@ const parsePropDecorator = ( const symbol = typeChecker.getSymbolAtLocation(prop.name); const type = typeChecker.getTypeAtLocation(prop); const typeStr = propTypeFromTSType(type); + const foundSetter = ts.isGetAccessor(prop) ? findSetter(propName, newMembers) : null; const propMeta: d.ComponentCompilerStaticProperty = { type: typeStr, @@ -100,6 +103,8 @@ const parsePropDecorator = ( required: prop.exclamationToken !== undefined && propName !== 'mode', optional: prop.questionToken !== undefined, docs: serializeSymbol(typeChecker, symbol), + getter: ts.isGetAccessor(prop), + setter: !!foundSetter, }; // prop can have an attribute if type is NOT "unknown" @@ -109,9 +114,30 @@ const parsePropDecorator = ( } // extract default value - const initializer = prop.initializer; - if (initializer) { - propMeta.defaultValue = initializer.getText(); + if (ts.isPropertyDeclaration(prop) && prop.initializer) { + propMeta.defaultValue = prop.initializer.getText(); + } else if (ts.isGetAccessorDeclaration(prop)) { + // shallow comb to find default value for a getter + const returnStatement = prop.body?.statements.find((st) => ts.isReturnStatement(st)) as ts.ReturnStatement; + const returnExpression = returnStatement.expression; + + if (returnExpression && ts.isLiteralExpression(returnExpression)) { + // the getter has a literal return value + propMeta.defaultValue = returnExpression.getText(); + } else if (returnExpression && ts.isPropertyAccessExpression(returnExpression)) { + const nameToFind = returnExpression.name.getText(); + const foundProp = findGetProp(nameToFind, newMembers); + + if (foundProp && foundProp.initializer) { + propMeta.defaultValue = foundProp.initializer.getText(); + + if (propMeta.type === 'unknown') { + const type = typeChecker.getTypeAtLocation(foundProp); + propMeta.type = propTypeFromTSType(type); + propMeta.complexType = getComplexType(typeChecker, foundProp, type, program); + } + } + } } const staticProp = ts.factory.createPropertyAssignment( @@ -164,7 +190,7 @@ const getReflect = (diagnostics: d.Diagnostic[], propDecorator: ts.Decorator, pr const getComplexType = ( typeChecker: ts.TypeChecker, - node: ts.PropertyDeclaration, + node: ts.PropertyDeclaration | ts.GetAccessorDeclaration, type: ts.Type, program: ts.Program, ): d.ComponentCompilerPropertyComplexType => { @@ -293,3 +319,26 @@ const isAny = (t: ts.Type): boolean => { } return false; }; + +/** + * Attempts to find a `set` member of the class when there is a corresponding getter + * @param propName - the property name of the setter to find + * @param members - all the component class members + * @returns the found typescript AST setter node + */ +const findSetter = (propName: string, members: ts.ClassElement[]): ts.SetAccessorDeclaration | undefined => { + return members.find((m) => ts.isSetAccessor(m) && m.name.getText() === propName) as + | ts.SetAccessorDeclaration + | undefined; +}; + +/** + * When attempting to find the default value of a decorated `get` prop, if a member like `this.something` + * is returned, this method is used to comb the class members to attempt to get it's default value + * @param propName - the property name of the member to find + * @param members - all the component class members + * @returns the found typescript AST class member + */ +const findGetProp = (propName: string, members: ts.ClassElement[]): ts.PropertyDeclaration | undefined => { + return members.find((m) => ts.isPropertyDeclaration(m) && m.name.getText() === propName) as ts.PropertyDeclaration; +}; diff --git a/src/compiler/transformers/static-to-meta/props.ts b/src/compiler/transformers/static-to-meta/props.ts index 1788324b8b4..12cc0f66945 100644 --- a/src/compiler/transformers/static-to-meta/props.ts +++ b/src/compiler/transformers/static-to-meta/props.ts @@ -37,6 +37,8 @@ export const parseStaticProps = (staticMembers: ts.ClassElement[]): d.ComponentC complexType: val.complexType, docs: val.docs, internal: isInternal(val.docs), + getter: !!val.getter, + setter: !!val.setter, }; }); }; diff --git a/src/compiler/transformers/test/convert-decorators.spec.ts b/src/compiler/transformers/test/convert-decorators.spec.ts index 44ab03e555a..f85dd5dc2f7 100644 --- a/src/compiler/transformers/test/convert-decorators.spec.ts +++ b/src/compiler/transformers/test/convert-decorators.spec.ts @@ -33,6 +33,8 @@ describe('convert-decorators', () => { "required": false, "optional": false, "docs": { "tags": [], "text": "" }, + "getter": false, + "setter": false, "attribute": "val", "reflect": false, "defaultValue": "\\"initial value\\"" @@ -84,6 +86,8 @@ describe('convert-decorators', () => { complexType: { original: 'string', resolved: 'string', references: {} }, docs: { tags: [], text: '' }, internal: false, + getter: false, + setter: false, }, ]); }); @@ -110,6 +114,8 @@ describe('convert-decorators', () => { complexType: { original: 'string', resolved: 'string', references: {} }, docs: { tags: [], text: '' }, internal: false, + getter: false, + setter: false, }, ]); }); diff --git a/src/compiler/transformers/test/parse-comments.spec.ts b/src/compiler/transformers/test/parse-comments.spec.ts index 6656551595b..27cf55d97b8 100644 --- a/src/compiler/transformers/test/parse-comments.spec.ts +++ b/src/compiler/transformers/test/parse-comments.spec.ts @@ -56,6 +56,8 @@ describe('parse comments', () => { reflect: false, required: false, type: 'string', + getter: false, + setter: false, }); expect(t.method).toEqual({ complexType: { diff --git a/src/compiler/transformers/test/parse-props.spec.ts b/src/compiler/transformers/test/parse-props.spec.ts index 9bb89e979ba..3dda4719f11 100644 --- a/src/compiler/transformers/test/parse-props.spec.ts +++ b/src/compiler/transformers/test/parse-props.spec.ts @@ -25,6 +25,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'string', + getter: false, + setter: false, }, }); @@ -66,6 +68,8 @@ describe('parse props', () => { }, docs: { tags: [], text: '' }, internal: false, + getter: false, + setter: false, }); }); @@ -98,6 +102,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'any', + getter: false, + setter: false, }, }); }); @@ -126,6 +132,8 @@ describe('parse props', () => { reflect: false, required: true, type: 'string', + getter: false, + setter: false, }, }); expect(t.property?.required).toBe(true); @@ -156,6 +164,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'string', + getter: false, + setter: false, }, }); expect(t.property?.mutable).toBe(true); @@ -185,6 +195,8 @@ describe('parse props', () => { reflect: true, required: false, type: 'string', + getter: false, + setter: false, }, }); expect(t.property?.reflect).toBe(true); @@ -213,6 +225,8 @@ describe('parse props', () => { optional: false, required: false, type: 'unknown', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('unknown'); @@ -249,6 +263,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'any', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('any'); @@ -281,6 +297,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'string', + getter: false, + setter: false, }, }); expect(t.property?.name).toBe('multiWord'); @@ -311,6 +329,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'string', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('string'); @@ -341,6 +361,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'number', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('number'); @@ -371,6 +393,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'boolean', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('boolean'); @@ -401,6 +425,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'any', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('any'); @@ -432,6 +458,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'string', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('string'); @@ -463,6 +491,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'number', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('number'); @@ -494,6 +524,8 @@ describe('parse props', () => { reflect: false, required: false, type: 'boolean', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('boolean'); @@ -526,9 +558,236 @@ describe('parse props', () => { reflect: false, required: false, type: 'any', + getter: false, + setter: false, }, }); expect(t.property?.type).toBe('any'); expect(t.property?.attribute).toBe('val'); }); + + it('should infer string type from `get()` return value', () => { + const t = transpileModule(` + @Component({tag: 'cmp-a'}) + export class CmpA { + @Prop() + get val() { + return 'hello'; + }; + } + `); + + expect(getStaticGetter(t.outputText, 'properties')).toEqual({ + val: { + attribute: 'val', + complexType: { + references: {}, + resolved: 'string', + original: 'string', + }, + docs: { + text: '', + tags: [], + }, + defaultValue: `'hello'`, + mutable: false, + optional: false, + reflect: false, + required: false, + type: 'string', + getter: true, + setter: false, + }, + }); + expect(t.property?.type).toBe('string'); + expect(t.property?.attribute).toBe('val'); + }); + + it('should infer number type from `get()` property access expression', () => { + const t = transpileModule(` + @Component({tag: 'cmp-a'}) + export class CmpA { + private _numberVal = 3; + @Prop() + get val() { + return this._numberVal; + }; + } + `); + + expect(getStaticGetter(t.outputText, 'properties')).toEqual({ + val: { + attribute: 'val', + complexType: { + references: {}, + resolved: 'number', + original: 'number', + }, + docs: { + text: '', + tags: [], + }, + defaultValue: `3`, + mutable: false, + optional: false, + reflect: false, + required: false, + type: 'number', + getter: true, + setter: false, + }, + }); + expect(t.property?.type).toBe('number'); + expect(t.property?.attribute).toBe('val'); + }); + + it('should infer boolean type from `get()` property access expression', () => { + const t = transpileModule(` + @Component({tag: 'cmp-a'}) + export class CmpA { + private _boolVal = false; + @Prop() + get val() { + return this._boolVal; + }; + } + `); + + expect(getStaticGetter(t.outputText, 'properties')).toEqual({ + val: { + attribute: 'val', + complexType: { + references: {}, + resolved: 'boolean', + original: 'boolean', + }, + docs: { + text: '', + tags: [], + }, + defaultValue: `false`, + mutable: false, + optional: false, + reflect: false, + required: false, + type: 'boolean', + getter: true, + setter: false, + }, + }); + expect(t.property?.type).toBe('boolean'); + expect(t.property?.attribute).toBe('val'); + }); + + it('should correctly parse a get / set prop with an inferred enum type', () => { + const t = transpileModule(` + export enum Mode { + DEFAULT = 'default' + } + @Component({tag: 'cmp-a'}) + export class CmpA { + private _val: Mode; + @Prop() + get val() { + return this._val; + }; + } + `); + + // Using the `properties` array directly here since the `transpileModule` + // method doesn't like the top-level enum export with the current `target` and + // `module` values for the tsconfig + expect(t.properties[0]).toEqual({ + name: 'val', + type: 'string', + attribute: 'val', + reflect: false, + mutable: false, + required: false, + optional: false, + defaultValue: undefined, + complexType: { + original: 'Mode', + resolved: 'Mode', + references: { + Mode: { location: 'local', path: 'module.tsx', id: 'module.tsx::Mode' }, + }, + }, + docs: { tags: [], text: '' }, + internal: false, + getter: true, + setter: false, + }); + }); + + it('should correctly parse a get / set prop with an inferred literal type', () => { + const t = transpileModule(` + @Component({tag: 'cmp-a'}) + export class CmpA { + private _val: 'Something' | 'Else' = 'Something'; + @Prop() + get val() { + return this._val; + }; + } + `); + + expect(t.properties[0]).toEqual({ + name: 'val', + type: 'string', + attribute: 'val', + reflect: false, + mutable: false, + required: false, + optional: false, + defaultValue: "'Something'", + complexType: { + original: '"Something" | "Else"', + resolved: '"Else" | "Something"', + references: {}, + }, + docs: { tags: [], text: '' }, + internal: false, + getter: true, + setter: false, + }); + }); + + it('should not infer type from `get()` property access expression when getter type is explicit', () => { + const t = transpileModule(` + @Component({tag: 'cmp-a'}) + export class CmpA { + private _boolVal: boolean = false; + @Prop() + get val(): string { + return this._boolVal; + }; + } + `); + + expect(getStaticGetter(t.outputText, 'properties')).toEqual({ + val: { + attribute: 'val', + complexType: { + references: {}, + resolved: 'string', + original: 'string', + }, + docs: { + text: '', + tags: [], + }, + defaultValue: `false`, + mutable: false, + optional: false, + reflect: false, + required: false, + type: 'string', + getter: true, + setter: false, + }, + }); + expect(t.property?.type).toBe('string'); + expect(t.property?.attribute).toBe('val'); + }); }); diff --git a/src/compiler/transformers/transform-utils.ts b/src/compiler/transformers/transform-utils.ts index 8672b3cc7c2..4bd93aec7b7 100644 --- a/src/compiler/transformers/transform-utils.ts +++ b/src/compiler/transformers/transform-utils.ts @@ -1049,7 +1049,10 @@ const createConstructorBodyWithSuper = (): ts.ExpressionStatement => { * @param typeChecker a reference to the {@link ts.TypeChecker} * @returns the name of the property in string form */ -export const tsPropDeclNameAsString = (node: ts.PropertyDeclaration, typeChecker: ts.TypeChecker): string => { +export const tsPropDeclNameAsString = ( + node: ts.PropertyDeclaration | ts.GetAccessorDeclaration, + typeChecker: ts.TypeChecker, +): string => { const declarationName: ts.DeclarationName = ts.getNameOfDeclaration(node); // The name of a class field declaration can be a computed property name, diff --git a/src/compiler/types/generate-prop-types.ts b/src/compiler/types/generate-prop-types.ts index 67ac9c0fda7..bb8ebbf25a2 100644 --- a/src/compiler/types/generate-prop-types.ts +++ b/src/compiler/types/generate-prop-types.ts @@ -11,14 +11,22 @@ import { updateTypeIdentifierNames } from './stencil-types'; */ export const generatePropTypes = (cmpMeta: d.ComponentCompilerMeta, typeImportData: d.TypesImportData): d.TypeInfo => { return [ - ...cmpMeta.properties.map((cmpProp) => ({ - name: cmpProp.name, - type: getType(cmpProp, typeImportData, cmpMeta.sourceFilePath), - optional: cmpProp.optional, - required: cmpProp.required, - internal: cmpProp.internal, - jsdoc: getTextDocs(cmpProp.docs), - })), + ...cmpMeta.properties.map((cmpProp) => { + let doc = getTextDocs(cmpProp.docs); + if (cmpProp.getter && !cmpProp.setter && !doc?.match('@readonly')) { + cmpProp.docs = cmpProp.docs || { tags: [], text: '' }; + cmpProp.docs.tags = [...(cmpProp.docs.tags || []), { name: 'readonly', text: '' }]; + doc = getTextDocs(cmpProp.docs); + } + return { + name: cmpProp.name, + type: getType(cmpProp, typeImportData, cmpMeta.sourceFilePath), + optional: cmpProp.optional, + required: cmpProp.required, + internal: cmpProp.internal, + jsdoc: doc, + }; + }), ...cmpMeta.virtualProperties.map((cmpProp) => ({ name: cmpProp.name, type: cmpProp.type, diff --git a/src/compiler/types/tests/ComponentCompilerProperty.stub.ts b/src/compiler/types/tests/ComponentCompilerProperty.stub.ts index b7018a6eb2e..4819c44ccdc 100644 --- a/src/compiler/types/tests/ComponentCompilerProperty.stub.ts +++ b/src/compiler/types/tests/ComponentCompilerProperty.stub.ts @@ -31,6 +31,8 @@ export const stubComponentCompilerProperty = ( reflect: false, required: false, type: 'number', + getter: undefined, + setter: undefined, }; return { ...defaults, ...overrides }; diff --git a/src/compiler/types/tests/generate-prop-types.spec.ts b/src/compiler/types/tests/generate-prop-types.spec.ts index f6b51bed41d..75ef6d78dda 100644 --- a/src/compiler/types/tests/generate-prop-types.spec.ts +++ b/src/compiler/types/tests/generate-prop-types.spec.ts @@ -1,5 +1,4 @@ import type * as d from '../../../declarations'; -import * as Util from '../../../utils/util'; import { generatePropTypes } from '../generate-prop-types'; import * as StencilTypes from '../stencil-types'; import { stubComponentCompilerMeta } from './ComponentCompilerMeta.stub'; @@ -13,7 +12,6 @@ describe('generate-prop-types', () => { ReturnType, Parameters >; - let getTextDocsSpy: jest.SpyInstance, Parameters>; beforeEach(() => { updateTypeIdentifierNamesSpy = jest.spyOn(StencilTypes, 'updateTypeIdentifierNames'); @@ -25,14 +23,10 @@ describe('generate-prop-types', () => { initialType: string, ) => initialType, ); - - getTextDocsSpy = jest.spyOn(Util, 'getTextDocs'); - getTextDocsSpy.mockReturnValue(''); }); afterEach(() => { updateTypeIdentifierNamesSpy.mockRestore(); - getTextDocsSpy.mockRestore(); }); it('returns an empty array when no props are provided', () => { @@ -141,5 +135,59 @@ describe('generate-prop-types', () => { expect(actualTypeInfo).toEqual(expectedTypeInfo); }); + + it('appends `@readonly` to jsdoc when the property has a getter and no setter', () => { + const stubImportTypes = stubTypesImportData(); + const componentMeta = stubComponentCompilerMeta({ + properties: [ + stubComponentCompilerProperty({ + getter: true, + setter: false, + }), + ], + }); + + const expectedTypeInfo: d.TypeInfo = [ + { + jsdoc: '@readonly', + internal: false, + name: 'propName', + optional: false, + required: false, + type: 'UserCustomPropType', + }, + ]; + + const actualTypeInfo = generatePropTypes(componentMeta, stubImportTypes); + + expect(actualTypeInfo).toEqual(expectedTypeInfo); + }); + + it('does not include `@readonly` to jsdoc when the property has a getter and a setter', () => { + const stubImportTypes = stubTypesImportData(); + const componentMeta = stubComponentCompilerMeta({ + properties: [ + stubComponentCompilerProperty({ + getter: true, + setter: true, + }), + ], + }); + + const expectedTypeInfo: d.TypeInfo = [ + { + jsdoc: '', + internal: false, + name: 'propName', + optional: false, + required: false, + type: 'UserCustomPropType', + }, + ]; + + const actualTypeInfo = generatePropTypes(componentMeta, stubImportTypes); + + expect(actualTypeInfo).toEqual(expectedTypeInfo); + }); }); }); diff --git a/src/declarations/stencil-private.ts b/src/declarations/stencil-private.ts index 781285d0677..9f023a5f6c6 100644 --- a/src/declarations/stencil-private.ts +++ b/src/declarations/stencil-private.ts @@ -684,6 +684,8 @@ export interface ComponentCompilerStaticProperty { reflect?: boolean; docs: CompilerJsDoc; defaultValue?: string; + getter: boolean; + setter: boolean; } /** diff --git a/src/declarations/stencil-public-docs.ts b/src/declarations/stencil-public-docs.ts index db881322309..cb2bf89104d 100644 --- a/src/declarations/stencil-public-docs.ts +++ b/src/declarations/stencil-public-docs.ts @@ -264,6 +264,14 @@ export interface JsonDocsProp { * ``` */ required: boolean; + /** + * `true` if the prop has a `get()`. `false` otherwise + */ + getter: boolean; + /** + * `true` if the prop has a `set()`. `false` otherwise + */ + setter: boolean; } export interface JsonDocsMethod { diff --git a/src/runtime/proxy-component.ts b/src/runtime/proxy-component.ts index 8f7d26a097c..73d645725cb 100644 --- a/src/runtime/proxy-component.ts +++ b/src/runtime/proxy-component.ts @@ -1,5 +1,5 @@ import { BUILD } from '@app-data'; -import { consoleDevWarn, getHostRef, plt } from '@platform'; +import { consoleDevWarn, getHostRef, parsePropertyValue, plt } from '@platform'; import { CMP_FLAGS } from '@utils'; import type * as d from '../declarations'; @@ -62,37 +62,105 @@ export const proxyComponent = ( (memberFlags & MEMBER_FLAGS.Prop || ((!BUILD.lazyLoad || flags & PROXY_FLAGS.proxyState) && memberFlags & MEMBER_FLAGS.State)) ) { - // proxyComponent - prop - Object.defineProperty(prototype, memberName, { - get(this: d.RuntimeRef) { - // proxyComponent, get value - return getValue(this, memberName); - }, - set(this: d.RuntimeRef, newValue) { - // only during dev time - if (BUILD.isDev) { - const ref = getHostRef(this); - if ( - // we are proxying the instance (not element) - (flags & PROXY_FLAGS.isElementConstructor) === 0 && - // the element is not constructing - (ref && ref.$flags$ & HOST_FLAGS.isConstructingInstance) === 0 && - // the member is a prop - (memberFlags & MEMBER_FLAGS.Prop) !== 0 && - // the member is not mutable - (memberFlags & MEMBER_FLAGS.Mutable) === 0 - ) { - consoleDevWarn( - `@Prop() "${memberName}" on <${cmpMeta.$tagName$}> is immutable but was modified from within the component.\nMore information: https://stenciljs.com/docs/properties#prop-mutability`, - ); + if ((memberFlags & MEMBER_FLAGS.Getter) === 0) { + // proxyComponent - prop + Object.defineProperty(prototype, memberName, { + get(this: d.RuntimeRef) { + // proxyComponent, get value + return getValue(this, memberName); + }, + set(this: d.RuntimeRef, newValue) { + // only during dev time + if (BUILD.isDev) { + const ref = getHostRef(this); + if ( + // we are proxying the instance (not element) + (flags & PROXY_FLAGS.isElementConstructor) === 0 && + // the element is not constructing + (ref && ref.$flags$ & HOST_FLAGS.isConstructingInstance) === 0 && + // the member is a prop + (memberFlags & MEMBER_FLAGS.Prop) !== 0 && + // the member is not mutable + (memberFlags & MEMBER_FLAGS.Mutable) === 0 + ) { + consoleDevWarn( + `@Prop() "${memberName}" on <${cmpMeta.$tagName$}> is immutable but was modified from within the component.\nMore information: https://stenciljs.com/docs/properties#prop-mutability`, + ); + } } - } - // proxyComponent, set value - setValue(this, memberName, newValue, cmpMeta); - }, - configurable: true, - enumerable: true, - }); + // proxyComponent, set value + setValue(this, memberName, newValue, cmpMeta); + }, + configurable: true, + enumerable: true, + }); + } else if (flags & PROXY_FLAGS.isElementConstructor && memberFlags & MEMBER_FLAGS.Getter) { + if (BUILD.lazyLoad) { + // lazily maps the element get / set to the class get / set + // proxyComponent - lazy prop getter + Object.defineProperty(prototype, memberName, { + get(this: d.RuntimeRef) { + const ref = getHostRef(this); + const instance = BUILD.lazyLoad && ref ? ref.$lazyInstance$ : prototype; + if (!instance) return; + + return instance[memberName]; + }, + configurable: true, + enumerable: true, + }); + } + if (memberFlags & MEMBER_FLAGS.Setter) { + // proxyComponent - lazy and non-lazy. Catches original set to fire updates (for @Watch) + const origSetter = Object.getOwnPropertyDescriptor(prototype, memberName).set; + Object.defineProperty(prototype, memberName, { + set(this: d.RuntimeRef, newValue) { + // non-lazy setter - amends original set to fire update + const ref = getHostRef(this); + if (origSetter) { + const currentValue = ref.$hostElement$[memberName as keyof d.HostElement]; + if (!ref.$instanceValues$.get(memberName) && currentValue) { + // the prop `set()` doesn't fire during `constructor()`: + // no initial value gets set (in instanceValues) + // meaning watchers fire even though the value hasn't changed. + // So if there's a current value and no initial value, let's set it now. + ref.$instanceValues$.set(memberName, currentValue); + } + // this sets the value via the `set()` function which + // might not end up changing the underlying value + origSetter.apply(this, [parsePropertyValue(newValue, cmpMeta.$members$[memberName][0])]); + setValue(this, memberName, ref.$hostElement$[memberName as keyof d.HostElement], cmpMeta); + return; + } + if (!ref) return; + + // we need to wait for the lazy instance to be ready + // before we can set it's value via it's setter function + const setterSetVal = () => { + const currentValue = ref.$lazyInstance$[memberName]; + if (!ref.$instanceValues$.get(memberName) && currentValue) { + // the prop `set()` doesn't fire during `constructor()`: + // no initial value gets set (in instanceValues) + // meaning watchers fire even though the value hasn't changed. + // So if there's a current value and no initial value, let's set it now. + ref.$instanceValues$.set(memberName, currentValue); + } + // this sets the value via the `set()` function which + // might not end up changing the underlying value + ref.$lazyInstance$[memberName] = parsePropertyValue(newValue, cmpMeta.$members$[memberName][0]); + setValue(this, memberName, ref.$lazyInstance$[memberName], cmpMeta); + }; + + // If there's a value from an attribute, (before the class is defined), queue & set async + if (ref.$lazyInstance$) { + setterSetVal(); + } else { + ref.$onReadyPromise$.then(() => setterSetVal()); + } + }, + }); + } + } } else if ( BUILD.lazyLoad && BUILD.method && @@ -191,7 +259,12 @@ export const proxyComponent = ( return; } - this[propName] = newValue === null && typeof this[propName] === 'boolean' ? false : newValue; + const propDesc = Object.getOwnPropertyDescriptor(prototype, propName); + // test whether this property either has no 'getter' or if it does, does it also have a 'setter' + // before attempting to write back to component props + if (!propDesc.get || !!propDesc.set) { + this[propName] = newValue === null && typeof this[propName] === 'boolean' ? false : newValue; + } }); }; diff --git a/src/runtime/test/attr.spec.tsx b/src/runtime/test/attr.spec.tsx index 4b6afe4ad84..87dd32ebd9c 100644 --- a/src/runtime/test/attr.spec.tsx +++ b/src/runtime/test/attr.spec.tsx @@ -262,6 +262,14 @@ describe('attribute', () => { @Prop({ reflect: true, mutable: true }) dynamicStr: string; @Prop({ reflect: true }) dynamicNu: number; + private _getset = 'prop via getter'; + @Prop({ reflect: true }) + get getSet() { + return this._getset; + } + set getSet(newVal: string) { + this._getset = newVal; + } componentWillLoad() { this.dynamicStr = 'value'; @@ -275,7 +283,7 @@ describe('attribute', () => { }); expect(root).toEqualHtml(` - + `); root.str = 'second'; @@ -284,11 +292,12 @@ describe('attribute', () => { root.null = 'no null'; root.bool = true; root.otherBool = false; + root.getSet = 'prop set via setter'; await waitForChanges(); expect(root).toEqualHtml(` - + `); }); diff --git a/src/runtime/test/hydrate-prop-types.spec.tsx b/src/runtime/test/hydrate-prop-types.spec.tsx index 26aed0ae0bd..9cf01ce75b1 100644 --- a/src/runtime/test/hydrate-prop-types.spec.tsx +++ b/src/runtime/test/hydrate-prop-types.spec.tsx @@ -48,4 +48,59 @@ describe('hydrate prop types', () => { `); }); + + it('handles getters and setters', async () => { + @Component({ tag: 'cmp-a' }) + class CmpA { + _num = 0; + + @Prop() + get num() { + return this._num; + } + set num(value) { + this._num = value; + } + + componentWillRender() { + if (this.num < 100) { + this.num += 100; + } + } + + render() { + return {this.num}; + } + } + // @ts-ignore + const serverHydrated = await newSpecPage({ + components: [CmpA], + html: ``, + hydrateServerSide: true, + }); + expect(serverHydrated.root).toEqualHtml(` + + + + 101 + + `); + + // @ts-ignore + const clientHydrated = await newSpecPage({ + components: [CmpA], + html: serverHydrated.root.outerHTML, + hydrateClientSide: true, + }); + expect(clientHydrated.root['s-id']).toBe('1'); + expect(clientHydrated.root['s-cr'].nodeType).toBe(8); + expect(clientHydrated.root['s-cr']['s-cn']).toBe(true); + + expect(clientHydrated.root).toEqualHtml(` + + + 101 + + `); + }); }); diff --git a/src/runtime/test/prop.spec.tsx b/src/runtime/test/prop.spec.tsx index 13c3dbcaf32..fdea7a4811a 100644 --- a/src/runtime/test/prop.spec.tsx +++ b/src/runtime/test/prop.spec.tsx @@ -31,27 +31,36 @@ describe('prop', () => { @Prop() boolTrue = true; @Prop() str = 'string'; @Prop() num = 88; + private _accessor = 'accessor'; + @Prop() + get accessor() { + return this._accessor; + } + set accessor(newVal) { + this._accessor = newVal; + } render() { - return `${this.boolFalse}-${this.boolTrue}-${this.str}-${this.num}`; + return `${this.boolFalse}-${this.boolTrue}-${this.str}-${this.num}-${this.accessor}`; } } const { root } = await newSpecPage({ components: [CmpA], - html: ``, + html: ``, }); expect(root).toEqualHtml(` - - true-false-attr-99 + + true-false-attr-99-accessed! `); - expect(root.textContent).toBe('true-false-attr-99'); + expect(root.textContent).toBe('true-false-attr-99-accessed!'); expect(root.boolFalse).toBe(true); expect(root.boolTrue).toBe(false); expect(root.str).toBe('attr'); expect(root.num).toBe(99); + expect(root.accessor).toBe('accessed!'); }); it('set default values', async () => { @@ -61,8 +70,16 @@ describe('prop', () => { @Prop() boolTrue = true; @Prop() str = 'string'; @Prop() num = 88; + private _accessor = 'accessor'; + @Prop() + get accessor() { + return this._accessor; + } + set accessor(newVal) { + this._accessor = newVal; + } render() { - return `${this.boolFalse}-${this.boolTrue}-${this.str}-${this.num}`; + return `${this.boolFalse}-${this.boolTrue}-${this.str}-${this.num}-${this.accessor}`; } } @@ -72,14 +89,15 @@ describe('prop', () => { }); expect(root).toEqualHtml(` - false-true-string-88 + false-true-string-88-accessor `); - expect(root.textContent).toBe('false-true-string-88'); + expect(root.textContent).toBe('false-true-string-88-accessor'); expect(root.boolFalse).toBe(false); expect(root.boolTrue).toBe(true); expect(root.str).toBe('string'); expect(root.num).toBe(88); + expect(root.accessor).toBe('accessor'); }); it('only update on even numbers', async () => { @@ -123,4 +141,46 @@ describe('prop', () => { 4 `); }); + + it('only updates on even numbers via a setter', async () => { + @Component({ tag: 'cmp-a' }) + class CmpA { + private _num = 1; + @Prop() + get num() { + return this._num; + } + set num(newValue: number) { + if (newValue % 2 === 0) this._num = newValue; + } + render() { + return `${this.num}`; + } + } + + const { root, waitForChanges } = await newSpecPage({ + components: [CmpA], + html: ``, + }); + + expect(root).toEqualHtml(` + 1 + `); + + root.num = 2; + await waitForChanges(); + expect(root).toEqualHtml(` + 2 + `); + root.num = 3; + await waitForChanges(); + expect(root).toEqualHtml(` + 2 + `); + root.num = 4; + await waitForChanges(); + expect(root).toEqualHtml(` + 4 + `); + }); }); diff --git a/src/runtime/test/watch.spec.tsx b/src/runtime/test/watch.spec.tsx index 5a9d61aeea1..e0740f7f621 100644 --- a/src/runtime/test/watch.spec.tsx +++ b/src/runtime/test/watch.spec.tsx @@ -174,4 +174,51 @@ describe('watch', () => { await waitForChanges(); expect(root).toEqualHtml(`3 5 5`); }); + + it('correctly calls watch when @Prop uses `set()', async () => { + @Component({ tag: 'cmp-a' }) + class CmpA { + method1Called = 0; + + private _prop1 = 1; + @Prop() + get prop1() { + return this._prop1; + } + set prop1(newProp: number) { + if (typeof newProp !== 'number') return; + this._prop1 = newProp; + } + + @Watch('prop1') + method1() { + this.method1Called++; + } + + componentDidLoad() { + expect(this.method1Called).toBe(0); + expect(this.prop1).toBe(1); + } + } + + const { root, rootInstance } = await newSpecPage({ + components: [CmpA], + html: ``, + }); + jest.spyOn(rootInstance, 'method1'); + + // set same values, watch should not be called + root.prop1 = 1; + expect(rootInstance.method1).toHaveBeenCalledTimes(0); + + // set different values + root.prop1 = 100; + expect(rootInstance.method1).toHaveBeenCalledTimes(1); + expect(rootInstance.method1).toHaveBeenLastCalledWith(100, 1, 'prop1'); + + // guard has prevented the watch from being called + rootInstance.prop1 = 'bye'; + expect(rootInstance.method1).toHaveBeenCalledTimes(1); + expect(rootInstance.method1).toHaveBeenLastCalledWith(100, 1, 'prop1'); + }); }); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 42e02b77f82..d8b9c7df291 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -13,6 +13,9 @@ export const enum MEMBER_FLAGS { ReflectAttr = 1 << 9, Mutable = 1 << 10, + Getter = 1 << 11, + Setter = 1 << 12, + Prop = String | Number | Boolean | Any | Unknown, HasAttribute = String | Number | Boolean | Any, PropLike = Prop | State, diff --git a/src/utils/format-component-runtime-meta.ts b/src/utils/format-component-runtime-meta.ts index 4c5c6b57fb8..7981653d20f 100644 --- a/src/utils/format-component-runtime-meta.ts +++ b/src/utils/format-component-runtime-meta.ts @@ -114,6 +114,12 @@ const formatFlags = (compilerProperty: d.ComponentCompilerProperty) => { if (compilerProperty.reflect) { type |= MEMBER_FLAGS.ReflectAttr; } + if (compilerProperty.getter) { + type |= MEMBER_FLAGS.Getter; + } + if (compilerProperty.setter) { + type |= MEMBER_FLAGS.Setter; + } return type; }; diff --git a/test/docs-json/docs.d.ts b/test/docs-json/docs.d.ts index bd033a1a955..916397b2f0c 100644 --- a/test/docs-json/docs.d.ts +++ b/test/docs-json/docs.d.ts @@ -316,6 +316,14 @@ export interface JsonDocsProp { * ``` */ required: boolean; + /** + * `true` if the prop has a `get()`. `false` otherwise + */ + getter: boolean; + /** + * `true` if the prop has a `set()`. `false` otherwise + */ + setter: boolean; } export interface JsonDocsMethod { name: string; diff --git a/test/end-to-end/src/app-root/app-root.e2e.ts b/test/end-to-end/src/app-root/app-root.e2e.ts index b830e530ba1..ea23cc52d48 100644 --- a/test/end-to-end/src/app-root/app-root.e2e.ts +++ b/test/end-to-end/src/app-root/app-root.e2e.ts @@ -22,7 +22,9 @@ describe('goto root url', () => { // select the "prop-cmp" element within the page (same as querySelector) // and once it's received, then return the element's "textContent" property const elm = await page.find('prop-cmp >>> div'); - expect(elm).toEqualText('Hello, my name is Stencil JS'); + expect(elm).toEqualText( + 'Hello, my name is Stencil JS. My full name being Mr Stencil JS. I like to wear life preservers.', + ); await page.compareScreenshot('navigate to homepage', { fullPage: false, @@ -34,11 +36,11 @@ describe('goto root url', () => { it('should navigate to the index.html page with custom url searchParams', async () => { // create a new puppeteer page const page = await newE2EPage({ - url: '/?first=Doc&last=Brown', + url: '/?first=Doc&last=Brown&clothes=lab coats', }); const elm = await page.find('prop-cmp >>> div'); - expect(elm).toEqualText('Hello, my name is Doc Brown'); + expect(elm).toEqualText('Hello, my name is Doc Brown. My full name being Mr Doc Brown. I like to wear lab coats.'); await page.compareScreenshot('navigate to homepage with querystrings'); }); diff --git a/test/end-to-end/src/app-root/app-root.tsx b/test/end-to-end/src/app-root/app-root.tsx index e464fdf4351..44b2cc36b31 100644 --- a/test/end-to-end/src/app-root/app-root.tsx +++ b/test/end-to-end/src/app-root/app-root.tsx @@ -32,11 +32,13 @@ export class AppRoot { @format something = '12'; @State() first: string; @State() last: MeString; + @State() clothes: string; componentWillLoad() { const url = new URL(window.location.href); this.first = url.searchParams.get('first') || 'Stencil'; this.last = url.searchParams.get('last') || 'JS'; + this.clothes = url.searchParams.get('clothes') || 'life preservers'; console.log('lodash', _.camelCase('LODASH')); console.log('lodash-es', _es.camelCase('LODASH-ES')); } @@ -53,7 +55,7 @@ export class AppRoot { render() { return ( - +
); diff --git a/test/end-to-end/src/components.d.ts b/test/end-to-end/src/components.d.ts index ca01979e5d1..3f4a2a67c67 100644 --- a/test/end-to-end/src/components.d.ts +++ b/test/end-to-end/src/components.d.ts @@ -111,7 +111,12 @@ export namespace Components { interface PrerenderCmp { } interface PropCmp { + "clothes": string; "first": string; + /** + * @readonly + */ + "fullName": string; "lastName": string; /** * Mode @@ -545,7 +550,12 @@ declare namespace LocalJSX { interface PrerenderCmp { } interface PropCmp { + "clothes"?: string; "first"?: string; + /** + * @readonly + */ + "fullName"?: string; "lastName"?: string; /** * Mode diff --git a/test/end-to-end/src/prop-cmp/prop-cmp.e2e.ts b/test/end-to-end/src/prop-cmp/prop-cmp.e2e.ts index 4a42a94ff27..c29fcc93efb 100644 --- a/test/end-to-end/src/prop-cmp/prop-cmp.e2e.ts +++ b/test/end-to-end/src/prop-cmp/prop-cmp.e2e.ts @@ -23,6 +23,7 @@ describe('@Prop', () => { // let's set new property values on the component elm.first = 'Marty'; elm.lastName = 'McFly'; + elm.clothes = 'down filled jackets'; }); // we just made a change and now the async queue need to process it @@ -31,15 +32,41 @@ describe('@Prop', () => { // select the "prop-cmp" element within the page (same as querySelector) const elm = await page.find('prop-cmp >>> div'); - expect(elm).toEqualText('Hello, my name is Marty McFly'); + expect(elm).toEqualText( + 'Hello, my name is Marty McFly. My full name being Mr Marty McFly. I like to wear down filled jackets.', + ); }); it('should set props from attributes', async () => { await page.setContent(` - + `); const elm = await page.find('prop-cmp >>> div'); - expect(elm).toEqualText('Hello, my name is Marty McFly'); + expect(elm).toEqualText( + 'Hello, my name is Marty McFly. My full name being Mr Marty McFly. I like to wear down filled jackets.', + ); + }); + + it('should not set read-only props', async () => { + await page.setContent(` + + `); + + const elm = await page.find('prop-cmp >>> div'); + expect(elm).toEqualText( + 'Hello, my name is Marty McFly. My full name being Mr Marty McFly. I like to wear life preservers.', + ); + }); + + it('should not set read-only props or override conditional setters', async () => { + await page.setContent(` + + `); + + const elm = await page.find('prop-cmp >>> div'); + expect(elm).toEqualText( + 'Hello, my name is Marty McFly. My full name being Mr Marty McFly. I like to wear life preservers.', + ); }); }); diff --git a/test/end-to-end/src/prop-cmp/prop-cmp.tsx b/test/end-to-end/src/prop-cmp/prop-cmp.tsx index 581bc5c31ce..425838924f7 100644 --- a/test/end-to-end/src/prop-cmp/prop-cmp.tsx +++ b/test/end-to-end/src/prop-cmp/prop-cmp.tsx @@ -13,8 +13,20 @@ import { saveAs } from 'file-saver'; shadow: true, }) export class PropCmp { + private _clothes = 'life preservers'; @Prop() first: string; @Prop() lastName: string; + @Prop() + get fullName() { + return 'Mr ' + this.first + ' ' + this.lastName; + } + @Prop() + get clothes() { + return this._clothes; + } + set clothes(newVal: string) { + if (newVal === 'lab coats' || newVal === 'down filled jackets') this._clothes = newVal; + } saveAs() { saveAs('data', 'filename.txt'); @@ -24,7 +36,8 @@ export class PropCmp { return (
- Hello, my name is {this.first} {this.lastName} + Hello, my name is {this.first} {this.lastName}. My full name being {this.fullName}. I like to wear{' '} + {this.clothes}.
diff --git a/test/end-to-end/src/prop-cmp/readme.md b/test/end-to-end/src/prop-cmp/readme.md index 6772ce5a17a..a004e16baee 100644 --- a/test/end-to-end/src/prop-cmp/readme.md +++ b/test/end-to-end/src/prop-cmp/readme.md @@ -7,11 +7,13 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| ---------- | ----------- | ----------- | -------- | ----------- | -| `first` | `first` | | `string` | `undefined` | -| `lastName` | `last-name` | | `string` | `undefined` | -| `mode` | `mode` | Mode | `any` | `undefined` | +| Property | Attribute | Description | Type | Default | +| ---------- | ----------- | ----------- | -------- | ------------------- | +| `clothes` | `clothes` | | `string` | `'life preservers'` | +| `first` | `first` | | `string` | `undefined` | +| `fullName` | `full-name` | | `string` | `undefined` | +| `lastName` | `last-name` | | `string` | `undefined` | +| `mode` | `mode` | Mode | `any` | `undefined` | ## Dependencies diff --git a/test/wdio/attribute-basic/cmp-root.tsx b/test/wdio/attribute-basic/cmp-root.tsx index f06e8bea651..4e16830dff8 100644 --- a/test/wdio/attribute-basic/cmp-root.tsx +++ b/test/wdio/attribute-basic/cmp-root.tsx @@ -17,6 +17,7 @@ export class AttributeBasicRoot { cmp.setAttribute('single', 'single-update'); cmp.setAttribute('multi-word', 'multiWord-update'); cmp.setAttribute('my-custom-attr', 'my-custom-attr-update'); + cmp.setAttribute('getter', 'getter-update'); } render() { diff --git a/test/wdio/attribute-basic/cmp.test.tsx b/test/wdio/attribute-basic/cmp.test.tsx index d3a4dfe9c35..1ea1eb16858 100644 --- a/test/wdio/attribute-basic/cmp.test.tsx +++ b/test/wdio/attribute-basic/cmp.test.tsx @@ -14,6 +14,7 @@ describe('attribute-basic', () => { await expect($('.multiWord')).toHaveText('multiWord'); await expect($('.customAttr')).toHaveText('my-custom-attr'); await expect($('.htmlForLabel')).toHaveAttribute('for', 'a'); + await expect($('.getter')).toHaveText('getter'); const button = await $('button'); await button.click(); @@ -21,5 +22,6 @@ describe('attribute-basic', () => { await expect($('.single')).toHaveText('single-update'); await expect($('.multiWord')).toHaveText('multiWord-update'); await expect($('.customAttr')).toHaveText('my-custom-attr-update'); + await expect($('.getter')).toHaveText('getter-update'); }); }); diff --git a/test/wdio/attribute-basic/cmp.tsx b/test/wdio/attribute-basic/cmp.tsx index 2f08054e53a..374bae6943c 100644 --- a/test/wdio/attribute-basic/cmp.tsx +++ b/test/wdio/attribute-basic/cmp.tsx @@ -4,9 +4,17 @@ import { Component, h, Prop } from '@stencil/core'; tag: 'attribute-basic', }) export class AttributeBasic { + private _getter = 'getter'; @Prop() single = 'single'; @Prop() multiWord = 'multiWord'; @Prop({ attribute: 'my-custom-attr' }) customAttr = 'my-custom-attr'; + @Prop() + get getter() { + return this._getter; + } + set getter(newVal: string) { + this._getter = newVal; + } render() { return ( @@ -14,6 +22,7 @@ export class AttributeBasic {
{this.single}
{this.multiWord}
{this.customAttr}
+
{this.getter}