From cc4b340e7fa0aada5fed246340377682f5331873 Mon Sep 17 00:00:00 2001 From: Tanner Reits Date: Fri, 1 Sep 2023 10:07:10 -0400 Subject: [PATCH 01/11] wip(working-state) --- .../decorators-to-static/watch-decorator.ts | 15 ++---- src/runtime/proxy-component.ts | 46 ++++++++++++++----- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/compiler/transformers/decorators-to-static/watch-decorator.ts b/src/compiler/transformers/decorators-to-static/watch-decorator.ts index cad9640547f..23ce2781877 100644 --- a/src/compiler/transformers/decorators-to-static/watch-decorator.ts +++ b/src/compiler/transformers/decorators-to-static/watch-decorator.ts @@ -1,4 +1,4 @@ -import { augmentDiagnosticWithNode, buildError, buildWarn, flatOne } from '@utils'; +import { flatOne } from '@utils'; import ts from 'typescript'; import type * as d from '../../../declarations'; @@ -24,21 +24,16 @@ export const watchDecoratorsToStatic = ( }; const parseWatchDecorator = ( - config: d.Config, - diagnostics: d.Diagnostic[], - watchable: Set, + _config: d.Config, + _diagnostics: d.Diagnostic[], + _watchable: Set, method: ts.MethodDeclaration, ): d.ComponentCompilerWatch[] => { const methodName = method.name.getText(); const decorators = retrieveTsDecorators(method) ?? []; return decorators.filter(isDecoratorNamed('Watch')).map((decorator) => { const [propName] = getDeclarationParameters(decorator); - if (!watchable.has(propName)) { - const diagnostic = config.devMode ? buildWarn(diagnostics) : buildError(diagnostics); - diagnostic.messageText = `@Watch('${propName}') is trying to watch for changes in a property that does not exist. - Make sure only properties decorated with @State() or @Prop() are watched.`; - augmentDiagnosticWithNode(diagnostic, decorator); - } + return { propName, methodName, diff --git a/src/runtime/proxy-component.ts b/src/runtime/proxy-component.ts index 86048a42659..5f4d5f70289 100644 --- a/src/runtime/proxy-component.ts +++ b/src/runtime/proxy-component.ts @@ -136,22 +136,46 @@ export const proxyComponent = ( return; } - this[propName] = newValue === null && typeof this[propName] === 'boolean' ? false : newValue; + // Not sure if this would need a conditional, but this is what we would do if the watched name is not a "member" + // Would somehow need a map of attrName back to the function to use as a callback + // Need to account for possibility of multiple callbacks + const entry = cmpMeta.$watchers$[attrName]; + entry?.forEach((callbackName) => { + if (this[callbackName] != null) { + this[callbackName].call(this, newValue, _oldValue); + } + }); + + // Would this ever actually get reached? + // IDK how this works if the prop doesn't exist. This will be an issue if we allow watching built-ins + // because there won't be a class member mapping to the name. Not an issue atm since we can only watch + // Stencil "members" (state and props) + // this[propName] = newValue === null && typeof this[propName] === 'boolean' ? false : newValue; }); }; - // create an array of attributes to observe - // and also create a map of html attribute name to js property name - Cstr.observedAttributes = members - .filter(([_, m]) => m[0] & MEMBER_FLAGS.HasAttribute) // filter to only keep props that should match attributes - .map(([propName, m]) => { - const attrName = m[1] || propName; + // Create an array of attributes to observe + // This list in comprised of all strings used within a `@Watch()` decorator + // on a component. This can include watchable Stencil class members like `@Prop()`s + // and `@State()`s, as well as native HTML attributes like aria attributes. + // As such, there is no way to guarantee type-safety here that a used hasn't entered + // an invalid attribute. + Cstr.observedAttributes = Object.keys(cmpMeta.$watchers$).map((attrName) => { + // Do some special stuff if this is a "member" + const member = members.find(([name]) => name === attrName); + if (member) { + const propName = member[1][1] ?? attrName; attrNameToPropName.set(attrName, propName); - if (BUILD.reflect && m[0] & MEMBER_FLAGS.ReflectAttr) { - cmpMeta.$attrsToReflect$.push([propName, attrName]); + + if (BUILD.reflect && member[1][0] & MEMBER_FLAGS.ReflectAttr) { + cmpMeta.$attrsToReflect$.push([attrName, propName]); } - return attrName; - }); + + attrName = propName; + } + + return attrName; + }); } } From 8a9c4f6d5a4cbcc1d8a9cc21b1ad8a011c8a0dee Mon Sep 17 00:00:00 2001 From: Tanner Reits Date: Fri, 1 Sep 2023 11:47:48 -0400 Subject: [PATCH 02/11] isolate use cases --- src/runtime/proxy-component.ts | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/runtime/proxy-component.ts b/src/runtime/proxy-component.ts index 5f4d5f70289..1ecb45d44eb 100644 --- a/src/runtime/proxy-component.ts +++ b/src/runtime/proxy-component.ts @@ -125,6 +125,7 @@ export const proxyComponent = ( if (this.hasOwnProperty(propName)) { newValue = this[propName]; delete this[propName]; + this[propName] = newValue === null && typeof this[propName] === 'boolean' ? false : newValue; } else if ( prototype.hasOwnProperty(propName) && typeof this[propName] === 'number' && @@ -134,23 +135,16 @@ export const proxyComponent = ( // APIs to reflect props as attributes. Calls to `setAttribute(someElement, propName)` will result in // `propName` to be converted to a `DOMString`, which may not be what we want for other primitive props. return; + } else { + // At this point we should know this is not a "member", so we can treat it like watching an attribute + // on a vanilla web component + const entry = cmpMeta.$watchers$[attrName]; + entry?.forEach((callbackName) => { + if (this[callbackName] != null) { + this[callbackName].call(this, newValue, _oldValue); + } + }); } - - // Not sure if this would need a conditional, but this is what we would do if the watched name is not a "member" - // Would somehow need a map of attrName back to the function to use as a callback - // Need to account for possibility of multiple callbacks - const entry = cmpMeta.$watchers$[attrName]; - entry?.forEach((callbackName) => { - if (this[callbackName] != null) { - this[callbackName].call(this, newValue, _oldValue); - } - }); - - // Would this ever actually get reached? - // IDK how this works if the prop doesn't exist. This will be an issue if we allow watching built-ins - // because there won't be a class member mapping to the name. Not an issue atm since we can only watch - // Stencil "members" (state and props) - // this[propName] = newValue === null && typeof this[propName] === 'boolean' ? false : newValue; }); }; From f4d4b29a83560103813738003951506fbb7de67c Mon Sep 17 00:00:00 2001 From: Tanner Reits Date: Tue, 5 Sep 2023 10:42:24 -0400 Subject: [PATCH 03/11] only execute after initial value is set --- src/runtime/proxy-component.ts | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/runtime/proxy-component.ts b/src/runtime/proxy-component.ts index 1ecb45d44eb..7e4ec8a002b 100644 --- a/src/runtime/proxy-component.ts +++ b/src/runtime/proxy-component.ts @@ -85,7 +85,7 @@ export const proxyComponent = ( if (BUILD.observeAttribute && (!BUILD.lazyLoad || flags & PROXY_FLAGS.isElementConstructor)) { const attrNameToPropName = new Map(); - prototype.attributeChangedCallback = function (attrName: string, _oldValue: string, newValue: string) { + prototype.attributeChangedCallback = function (attrName: string, oldValue: string, newValue: string) { plt.jmp(() => { const propName = attrNameToPropName.get(attrName); @@ -138,12 +138,25 @@ export const proxyComponent = ( } else { // At this point we should know this is not a "member", so we can treat it like watching an attribute // on a vanilla web component - const entry = cmpMeta.$watchers$[attrName]; - entry?.forEach((callbackName) => { - if (this[callbackName] != null) { - this[callbackName].call(this, newValue, _oldValue); - } - }); + const hostRef = getHostRef(this); + const flags = hostRef?.$flags$; + + // We only want to trigger the callback(s) if: + // 1. The instance is ready + // 2. The watchers are ready + // 3. The value has changed + if ( + !(flags & HOST_FLAGS.isConstructingInstance) && + flags & HOST_FLAGS.isWatchReady && + newValue !== oldValue + ) { + const entry = cmpMeta.$watchers$[attrName]; + entry?.forEach((callbackName) => { + if (this[callbackName] != null) { + this[callbackName].call(this, newValue, oldValue, attrName); + } + }); + } } }); }; From 361c26218f0212cea1c14e02fcb7d944fa2c1ae8 Mon Sep 17 00:00:00 2001 From: Tanner Reits Date: Tue, 5 Sep 2023 11:49:20 -0400 Subject: [PATCH 04/11] remove unused params --- .../convert-decorators.ts | 2 +- .../decorators-to-static/watch-decorator.ts | 19 +++---------------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/src/compiler/transformers/decorators-to-static/convert-decorators.ts b/src/compiler/transformers/decorators-to-static/convert-decorators.ts index 51bafe8e8bc..c90135d454a 100644 --- a/src/compiler/transformers/decorators-to-static/convert-decorators.ts +++ b/src/compiler/transformers/decorators-to-static/convert-decorators.ts @@ -119,7 +119,7 @@ const visitClassDeclaration = ( filteredMethodsAndFields, ); elementDecoratorsToStatic(diagnostics, decoratedMembers, typeChecker, filteredMethodsAndFields); - watchDecoratorsToStatic(config, diagnostics, decoratedMembers, watchable, filteredMethodsAndFields); + watchDecoratorsToStatic(decoratedMembers, filteredMethodsAndFields); listenDecoratorsToStatic(diagnostics, decoratedMembers, filteredMethodsAndFields); } diff --git a/src/compiler/transformers/decorators-to-static/watch-decorator.ts b/src/compiler/transformers/decorators-to-static/watch-decorator.ts index 23ce2781877..0c535a6cea2 100644 --- a/src/compiler/transformers/decorators-to-static/watch-decorator.ts +++ b/src/compiler/transformers/decorators-to-static/watch-decorator.ts @@ -5,16 +5,8 @@ import type * as d from '../../../declarations'; import { convertValueToLiteral, createStaticGetter, retrieveTsDecorators } from '../transform-utils'; import { getDeclarationParameters, isDecoratorNamed } from './decorator-utils'; -export const watchDecoratorsToStatic = ( - config: d.Config, - diagnostics: d.Diagnostic[], - decoratedProps: ts.ClassElement[], - watchable: Set, - newMembers: ts.ClassElement[], -) => { - const watchers = decoratedProps - .filter(ts.isMethodDeclaration) - .map((method) => parseWatchDecorator(config, diagnostics, watchable, method)); +export const watchDecoratorsToStatic = (decoratedProps: ts.ClassElement[], newMembers: ts.ClassElement[]) => { + const watchers = decoratedProps.filter(ts.isMethodDeclaration).map((method) => parseWatchDecorator(method)); const flatWatchers = flatOne(watchers); @@ -23,12 +15,7 @@ export const watchDecoratorsToStatic = ( } }; -const parseWatchDecorator = ( - _config: d.Config, - _diagnostics: d.Diagnostic[], - _watchable: Set, - method: ts.MethodDeclaration, -): d.ComponentCompilerWatch[] => { +const parseWatchDecorator = (method: ts.MethodDeclaration): d.ComponentCompilerWatch[] => { const methodName = method.name.getText(); const decorators = retrieveTsDecorators(method) ?? []; return decorators.filter(isDecoratorNamed('Watch')).map((decorator) => { From 5ebaf766112a7ade92d3122cc8a9be1136041e7e Mon Sep 17 00:00:00 2001 From: Tanner Reits Date: Tue, 5 Sep 2023 14:36:55 -0400 Subject: [PATCH 05/11] observed attributes includes members and native attributes --- src/runtime/proxy-component.ts | 35 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/runtime/proxy-component.ts b/src/runtime/proxy-component.ts index 7e4ec8a002b..ded4328a4f6 100644 --- a/src/runtime/proxy-component.ts +++ b/src/runtime/proxy-component.ts @@ -163,26 +163,25 @@ export const proxyComponent = ( // Create an array of attributes to observe // This list in comprised of all strings used within a `@Watch()` decorator - // on a component. This can include watchable Stencil class members like `@Prop()`s - // and `@State()`s, as well as native HTML attributes like aria attributes. - // As such, there is no way to guarantee type-safety here that a used hasn't entered + // on a component as well as any Stencil-specific "members" (`@Prop()`s and `@State()`s). + // As such, there is no way to guarantee type-safety here that a user hasn't entered // an invalid attribute. - Cstr.observedAttributes = Object.keys(cmpMeta.$watchers$).map((attrName) => { - // Do some special stuff if this is a "member" - const member = members.find(([name]) => name === attrName); - if (member) { - const propName = member[1][1] ?? attrName; - attrNameToPropName.set(attrName, propName); - - if (BUILD.reflect && member[1][0] & MEMBER_FLAGS.ReflectAttr) { - cmpMeta.$attrsToReflect$.push([attrName, propName]); - } - - attrName = propName; - } + Cstr.observedAttributes = Array.from( + new Set([ + ...Object.keys(cmpMeta.$watchers$ ?? {}), + ...members + .filter(([_, m]) => m[0] & MEMBER_FLAGS.HasAttribute) + .map(([propName, m]) => { + const attrName = m[1] || propName; + attrNameToPropName.set(attrName, propName); + if (BUILD.reflect && m[0] & MEMBER_FLAGS.ReflectAttr) { + cmpMeta.$attrsToReflect$.push([propName, attrName]); + } - return attrName; - }); + return attrName; + }), + ]), + ); } } From b1ba2db69f70f90cae363c341ff1fc24da1a0b0e Mon Sep 17 00:00:00 2001 From: Tanner Reits Date: Tue, 5 Sep 2023 15:56:24 -0400 Subject: [PATCH 06/11] revert some logic for "member" watchers --- src/runtime/proxy-component.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/runtime/proxy-component.ts b/src/runtime/proxy-component.ts index ded4328a4f6..fb44d5e0d97 100644 --- a/src/runtime/proxy-component.ts +++ b/src/runtime/proxy-component.ts @@ -125,7 +125,6 @@ export const proxyComponent = ( if (this.hasOwnProperty(propName)) { newValue = this[propName]; delete this[propName]; - this[propName] = newValue === null && typeof this[propName] === 'boolean' ? false : newValue; } else if ( prototype.hasOwnProperty(propName) && typeof this[propName] === 'number' && @@ -135,7 +134,7 @@ export const proxyComponent = ( // APIs to reflect props as attributes. Calls to `setAttribute(someElement, propName)` will result in // `propName` to be converted to a `DOMString`, which may not be what we want for other primitive props. return; - } else { + } else if (propName == null) { // At this point we should know this is not a "member", so we can treat it like watching an attribute // on a vanilla web component const hostRef = getHostRef(this); @@ -157,7 +156,11 @@ export const proxyComponent = ( } }); } + + return; } + + this[propName] = newValue === null && typeof this[propName] === 'boolean' ? false : newValue; }); }; From 6248025a257282235c77cb4f862dfa7fe0e0fdd2 Mon Sep 17 00:00:00 2001 From: Tanner Reits Date: Tue, 5 Sep 2023 17:20:03 -0400 Subject: [PATCH 07/11] get lazy builds working --- src/declarations/stencil-private.ts | 3 +++ src/runtime/bootstrap-lazy.ts | 2 +- src/runtime/proxy-component.ts | 6 ++++-- src/utils/format-component-runtime-meta.ts | 12 ++++++++++++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/declarations/stencil-private.ts b/src/declarations/stencil-private.ts index ebec17a3658..607ae6611a7 100644 --- a/src/declarations/stencil-private.ts +++ b/src/declarations/stencil-private.ts @@ -1386,6 +1386,9 @@ export type ComponentRuntimeMetaCompact = [ /** listeners */ ComponentRuntimeHostListener[]?, + + /** watchers */ + ComponentConstructorWatchers?, ]; /** diff --git a/src/runtime/bootstrap-lazy.ts b/src/runtime/bootstrap-lazy.ts index 4c86aa7e5ef..ab7e189ff9d 100644 --- a/src/runtime/bootstrap-lazy.ts +++ b/src/runtime/bootstrap-lazy.ts @@ -69,7 +69,7 @@ export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d. cmpMeta.$attrsToReflect$ = []; } if (BUILD.watchCallback) { - cmpMeta.$watchers$ = {}; + cmpMeta.$watchers$ = compactMeta[4] ?? {}; } if (BUILD.shadowDom && !supportsShadow && cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation) { // TODO(STENCIL-854): Remove code related to legacy shadowDomShim field diff --git a/src/runtime/proxy-component.ts b/src/runtime/proxy-component.ts index fb44d5e0d97..24156e98820 100644 --- a/src/runtime/proxy-component.ts +++ b/src/runtime/proxy-component.ts @@ -149,10 +149,12 @@ export const proxyComponent = ( flags & HOST_FLAGS.isWatchReady && newValue !== oldValue ) { + const elm = BUILD.lazyLoad ? hostRef.$hostElement$ : this; + const instance = BUILD.lazyLoad ? hostRef.$lazyInstance$ : (elm as any); const entry = cmpMeta.$watchers$[attrName]; entry?.forEach((callbackName) => { - if (this[callbackName] != null) { - this[callbackName].call(this, newValue, oldValue, attrName); + if (instance[callbackName] != null) { + instance[callbackName].call(this, newValue, oldValue, attrName); } }); } diff --git a/src/utils/format-component-runtime-meta.ts b/src/utils/format-component-runtime-meta.ts index 92967a694c7..ab3c72b8c60 100644 --- a/src/utils/format-component-runtime-meta.ts +++ b/src/utils/format-component-runtime-meta.ts @@ -38,11 +38,13 @@ export const formatComponentRuntimeMeta = ( const members = formatComponentRuntimeMembers(compilerMeta, includeMethods); const hostListeners = formatHostListeners(compilerMeta); + const watchers = formatComponentRuntimeWatchers(compilerMeta); return trimFalsy([ flags, compilerMeta.tagName, Object.keys(members).length > 0 ? members : undefined, hostListeners.length > 0 ? hostListeners : undefined, + Object.keys(watchers).length > 0 ? watchers : undefined, ]); }; @@ -56,6 +58,16 @@ export const stringifyRuntimeData = (data: any) => { return json; }; +const formatComponentRuntimeWatchers = (compilerMeta: d.ComponentCompilerMeta) => { + const watchers: d.ComponentConstructorWatchers = {}; + + compilerMeta.watchers.forEach(({ propName, methodName }) => { + watchers[propName] = [...(watchers[propName] ?? []), methodName]; + }); + + return watchers; +}; + const formatComponentRuntimeMembers = ( compilerMeta: d.ComponentCompilerMeta, includeMethods = true, From 39ec30ddfc3bdcda50bc34a849d43cc723a9c40d Mon Sep 17 00:00:00 2001 From: Tanner Reits Date: Wed, 6 Sep 2023 10:16:58 -0400 Subject: [PATCH 08/11] use instance when binding callback --- src/runtime/proxy-component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/proxy-component.ts b/src/runtime/proxy-component.ts index 24156e98820..ba45ee8dd57 100644 --- a/src/runtime/proxy-component.ts +++ b/src/runtime/proxy-component.ts @@ -154,7 +154,7 @@ export const proxyComponent = ( const entry = cmpMeta.$watchers$[attrName]; entry?.forEach((callbackName) => { if (instance[callbackName] != null) { - instance[callbackName].call(this, newValue, oldValue, attrName); + instance[callbackName].call(instance, newValue, oldValue, attrName); } }); } From b2440d6eb76d192ae7c440bc07be1b6a30db0d66 Mon Sep 17 00:00:00 2001 From: Tanner Reits Date: Wed, 6 Sep 2023 10:18:52 -0400 Subject: [PATCH 09/11] add test --- test/karma/test-app/components.d.ts | 13 +++++++++++ test/karma/test-app/input-basic/karma.spec.ts | 2 +- .../test-app/watch-native-attributes/cmp.tsx | 22 +++++++++++++++++++ .../watch-native-attributes/index.html | 6 +++++ .../watch-native-attributes/karma.spec.ts | 21 ++++++++++++++++++ 5 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 test/karma/test-app/watch-native-attributes/cmp.tsx create mode 100644 test/karma/test-app/watch-native-attributes/index.html create mode 100644 test/karma/test-app/watch-native-attributes/karma.spec.ts diff --git a/test/karma/test-app/components.d.ts b/test/karma/test-app/components.d.ts index 72ec5a39857..88de56875b9 100644 --- a/test/karma/test-app/components.d.ts +++ b/test/karma/test-app/components.d.ts @@ -340,6 +340,8 @@ export namespace Components { } interface Tag88 { } + interface WatchNativeAttributes { + } } export interface EsmImportCustomEvent extends CustomEvent { detail: T; @@ -1174,6 +1176,12 @@ declare global { prototype: HTMLTag88Element; new (): HTMLTag88Element; }; + interface HTMLWatchNativeAttributesElement extends Components.WatchNativeAttributes, HTMLStencilElement { + } + var HTMLWatchNativeAttributesElement: { + prototype: HTMLWatchNativeAttributesElement; + new (): HTMLWatchNativeAttributesElement; + }; interface HTMLElementTagNameMap { "append-child": HTMLAppendChildElement; "attribute-basic": HTMLAttributeBasicElement; @@ -1309,6 +1317,7 @@ declare global { "svg-class": HTMLSvgClassElement; "tag-3d-component": HTMLTag3dComponentElement; "tag-88": HTMLTag88Element; + "watch-native-attributes": HTMLWatchNativeAttributesElement; } } declare namespace LocalJSX { @@ -1649,6 +1658,8 @@ declare namespace LocalJSX { } interface Tag88 { } + interface WatchNativeAttributes { + } interface IntrinsicElements { "append-child": AppendChild; "attribute-basic": AttributeBasic; @@ -1784,6 +1795,7 @@ declare namespace LocalJSX { "svg-class": SvgClass; "tag-3d-component": Tag3dComponent; "tag-88": Tag88; + "watch-native-attributes": WatchNativeAttributes; } } export { LocalJSX as JSX }; @@ -1924,6 +1936,7 @@ declare module "@stencil/core" { "svg-class": LocalJSX.SvgClass & JSXBase.HTMLAttributes; "tag-3d-component": LocalJSX.Tag3dComponent & JSXBase.HTMLAttributes; "tag-88": LocalJSX.Tag88 & JSXBase.HTMLAttributes; + "watch-native-attributes": LocalJSX.WatchNativeAttributes & JSXBase.HTMLAttributes; } } } diff --git a/test/karma/test-app/input-basic/karma.spec.ts b/test/karma/test-app/input-basic/karma.spec.ts index 44e677018ad..5f280199719 100644 --- a/test/karma/test-app/input-basic/karma.spec.ts +++ b/test/karma/test-app/input-basic/karma.spec.ts @@ -1,6 +1,6 @@ import { setupDomTests, waitForChanges } from '../util'; -describe('attribute-basic', function () { +describe('input-basic', function () { const { setupDom, tearDownDom } = setupDomTests(document); let app: HTMLElement; diff --git a/test/karma/test-app/watch-native-attributes/cmp.tsx b/test/karma/test-app/watch-native-attributes/cmp.tsx new file mode 100644 index 00000000000..3c26e7e896e --- /dev/null +++ b/test/karma/test-app/watch-native-attributes/cmp.tsx @@ -0,0 +1,22 @@ +import { Component, Element, h, State, Watch } from '@stencil/core'; + +@Component({ + tag: 'watch-native-attributes', +}) +export class WatchNativeAttributes { + @Element() el!: HTMLElement; + + @State() callbackTriggered = false; + + @Watch('aria-label') + onAriaLabelChange() { + this.callbackTriggered = true; + } + + render() { + return [ +

Label: {this.el.getAttribute('aria-label')}

, +

Callback triggered: {`${this.callbackTriggered}`}

, + ]; + } +} diff --git a/test/karma/test-app/watch-native-attributes/index.html b/test/karma/test-app/watch-native-attributes/index.html new file mode 100644 index 00000000000..c7d3421ef2c --- /dev/null +++ b/test/karma/test-app/watch-native-attributes/index.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/karma/test-app/watch-native-attributes/karma.spec.ts b/test/karma/test-app/watch-native-attributes/karma.spec.ts new file mode 100644 index 00000000000..152797eab00 --- /dev/null +++ b/test/karma/test-app/watch-native-attributes/karma.spec.ts @@ -0,0 +1,21 @@ +import { setupDomTests, waitForChanges } from '../util'; + +describe('watch native attributes', () => { + const { setupDom, tearDownDom } = setupDomTests(document); + let app: HTMLElement; + + beforeEach(async () => { + app = await setupDom('/watch-native-attributes/index.html'); + }); + afterEach(tearDownDom); + + it('triggers the callback for the watched attribute', async () => { + const cmp = app.querySelector('watch-native-attributes'); + expect(cmp.innerText).toBe('Label: myStartingLabel\n\nCallback triggered: false'); + + cmp.setAttribute('aria-label', 'myNewLabel'); + await waitForChanges(); + + expect(cmp.innerText).toBe('Label: myNewLabel\n\nCallback triggered: true'); + }); +}); From d12ac84ea47bab49b3b25bf9b9d68be99f9669f4 Mon Sep 17 00:00:00 2001 From: Tanner Reits Date: Thu, 7 Sep 2023 15:06:26 -0400 Subject: [PATCH 10/11] remove `watchable` from state and prop transformers --- .../decorators-to-static/convert-decorators.ts | 5 ++--- .../decorators-to-static/prop-decorator.ts | 8 ++------ .../decorators-to-static/state-decorator.ts | 14 ++++---------- .../decorators-to-static/watch-decorator.ts | 2 +- 4 files changed, 9 insertions(+), 20 deletions(-) diff --git a/src/compiler/transformers/decorators-to-static/convert-decorators.ts b/src/compiler/transformers/decorators-to-static/convert-decorators.ts index c90135d454a..f4d6f678111 100644 --- a/src/compiler/transformers/decorators-to-static/convert-decorators.ts +++ b/src/compiler/transformers/decorators-to-static/convert-decorators.ts @@ -103,11 +103,10 @@ const visitClassDeclaration = ( componentDecoratorToStatic(config, typeChecker, diagnostics, classNode, filteredMethodsAndFields, componentDecorator); // stores a reference to fields that should be watched for changes - const watchable = new Set(); // parse member decorators (Prop, State, Listen, Event, Method, Element and Watch) if (decoratedMembers.length > 0) { - propDecoratorsToStatic(diagnostics, decoratedMembers, typeChecker, program, watchable, filteredMethodsAndFields); - stateDecoratorsToStatic(decoratedMembers, watchable, filteredMethodsAndFields); + propDecoratorsToStatic(diagnostics, decoratedMembers, typeChecker, program, filteredMethodsAndFields); + stateDecoratorsToStatic(decoratedMembers, filteredMethodsAndFields); eventDecoratorsToStatic(diagnostics, decoratedMembers, typeChecker, program, filteredMethodsAndFields); methodDecoratorsToStatic( config, diff --git a/src/compiler/transformers/decorators-to-static/prop-decorator.ts b/src/compiler/transformers/decorators-to-static/prop-decorator.ts index 91e60c76d49..395a943c638 100644 --- a/src/compiler/transformers/decorators-to-static/prop-decorator.ts +++ b/src/compiler/transformers/decorators-to-static/prop-decorator.ts @@ -26,7 +26,6 @@ import { getDeclarationParameters, isDecoratorNamed } from './decorator-utils'; * Only those decorated with `@Prop()` will be parsed. * @param typeChecker a reference to the TypeScript type checker * @param program a {@link ts.Program} object - * @param watchable a collection of class members that can be watched for changes using Stencil's `@Watch` decorator * @param newMembers a collection that parsed `@Prop` annotated class members should be pushed to as a side effect of * calling this function */ @@ -35,12 +34,11 @@ export const propDecoratorsToStatic = ( decoratedProps: ts.ClassElement[], typeChecker: ts.TypeChecker, program: ts.Program, - watchable: Set, newMembers: ts.ClassElement[], ): void => { const properties = decoratedProps .filter(ts.isPropertyDeclaration) - .map((prop) => parsePropDecorator(diagnostics, typeChecker, program, prop, watchable)) + .map((prop) => parsePropDecorator(diagnostics, typeChecker, program, prop)) .filter((prop): prop is ts.PropertyAssignment => prop != null); if (properties.length > 0) { @@ -55,7 +53,6 @@ export const propDecoratorsToStatic = ( * @param typeChecker a reference to the TypeScript type checker * @param program a {@link ts.Program} object * @param prop the TypeScript `PropertyDeclaration` to parse - * @param watchable a collection of class members that can be watched for changes using Stencil's `@Watch` decorator * @returns a property assignment expression to be added to the Stencil component's class */ const parsePropDecorator = ( @@ -63,7 +60,6 @@ const parsePropDecorator = ( typeChecker: ts.TypeChecker, program: ts.Program, prop: ts.PropertyDeclaration, - watchable: Set, ): ts.PropertyAssignment | null => { const propDecorator = retrieveTsDecorators(prop)?.find(isDecoratorNamed('Prop')); if (propDecorator == null) { @@ -120,7 +116,7 @@ const parsePropDecorator = ( ts.factory.createStringLiteral(propName), convertValueToLiteral(propMeta), ); - watchable.add(propName); + return staticProp; }; diff --git a/src/compiler/transformers/decorators-to-static/state-decorator.ts b/src/compiler/transformers/decorators-to-static/state-decorator.ts index 7c0a2d8c3dc..e74ab4271f2 100644 --- a/src/compiler/transformers/decorators-to-static/state-decorator.ts +++ b/src/compiler/transformers/decorators-to-static/state-decorator.ts @@ -11,17 +11,12 @@ import { isDecoratorNamed } from './decorator-utils'; * with which they can be replaced. * * @param decoratedProps TypeScript AST nodes representing class members - * @param watchable set of names of fields which should be watched for changes * @param newMembers an out param containing new class members */ -export const stateDecoratorsToStatic = ( - decoratedProps: ts.ClassElement[], - watchable: Set, - newMembers: ts.ClassElement[], -) => { +export const stateDecoratorsToStatic = (decoratedProps: ts.ClassElement[], newMembers: ts.ClassElement[]) => { const states = decoratedProps .filter(ts.isPropertyDeclaration) - .map((prop) => stateDecoratorToStatic(prop, watchable)) + .map(stateDecoratorToStatic) .filter((state): state is ts.PropertyAssignment => !!state); if (states.length > 0) { @@ -38,18 +33,17 @@ export const stateDecoratorsToStatic = ( * decorated with other decorators. * * @param prop A TypeScript AST node representing a class property declaration - * @param watchable set of names of fields which should be watched for changes * @returns a property assignment AST Node which maps the name of the state * prop to an empty object */ -const stateDecoratorToStatic = (prop: ts.PropertyDeclaration, watchable: Set): ts.PropertyAssignment | null => { +const stateDecoratorToStatic = (prop: ts.PropertyDeclaration): ts.PropertyAssignment | null => { const stateDecorator = retrieveTsDecorators(prop)?.find(isDecoratorNamed('State')); if (stateDecorator == null) { return null; } const stateName = prop.name.getText(); - watchable.add(stateName); + return ts.factory.createPropertyAssignment( ts.factory.createStringLiteral(stateName), ts.factory.createObjectLiteralExpression([], true), diff --git a/src/compiler/transformers/decorators-to-static/watch-decorator.ts b/src/compiler/transformers/decorators-to-static/watch-decorator.ts index 0c535a6cea2..2bbd1746a43 100644 --- a/src/compiler/transformers/decorators-to-static/watch-decorator.ts +++ b/src/compiler/transformers/decorators-to-static/watch-decorator.ts @@ -6,7 +6,7 @@ import { convertValueToLiteral, createStaticGetter, retrieveTsDecorators } from import { getDeclarationParameters, isDecoratorNamed } from './decorator-utils'; export const watchDecoratorsToStatic = (decoratedProps: ts.ClassElement[], newMembers: ts.ClassElement[]) => { - const watchers = decoratedProps.filter(ts.isMethodDeclaration).map((method) => parseWatchDecorator(method)); + const watchers = decoratedProps.filter(ts.isMethodDeclaration).map(parseWatchDecorator); const flatWatchers = flatOne(watchers); From eb734118e28a3b64b8239c2b193e7e23b5dcd1bf Mon Sep 17 00:00:00 2001 From: Tanner Reits Date: Thu, 14 Sep 2023 10:10:54 -0400 Subject: [PATCH 11/11] jsdoc for new method --- src/utils/format-component-runtime-meta.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/utils/format-component-runtime-meta.ts b/src/utils/format-component-runtime-meta.ts index ab3c72b8c60..1047b7f1439 100644 --- a/src/utils/format-component-runtime-meta.ts +++ b/src/utils/format-component-runtime-meta.ts @@ -58,6 +58,15 @@ export const stringifyRuntimeData = (data: any) => { return json; }; +/** + * Transforms Stencil compiler metadata into a {@link d.ComponentCompilerMeta} object. + * This handles processing any compiler metadata transformed from components' uses of `@Watch()`. + * The map of watched attributes to their callback(s) will be immediately available + * to the runtime at bootstrap. + * + * @param compilerMeta Component metadata gathered during compilation + * @returns An object mapping watched attributes to their respective callback(s) + */ const formatComponentRuntimeWatchers = (compilerMeta: d.ComponentCompilerMeta) => { const watchers: d.ComponentConstructorWatchers = {};