Skip to content

Commit

Permalink
feat(runtime): watch native HTML attributes (#4760)
Browse files Browse the repository at this point in the history
* wip(working-state)

* isolate use cases

* only execute after initial value is set

* remove unused params

* observed attributes includes members and native attributes

* revert some logic for "member" watchers

* get lazy builds working

* use instance when binding callback

* add test

* remove `watchable` from state and prop transformers

* jsdoc for new method
  • Loading branch information
tanner-reits committed Sep 14, 2023
1 parent 1065463 commit fc86c23
Show file tree
Hide file tree
Showing 13 changed files with 150 additions and 58 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,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<string>();
// 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,
Expand All @@ -124,7 +123,7 @@ const visitClassDeclaration = (
filteredMethodsAndFields,
);
elementDecoratorsToStatic(diagnostics, decoratedMembers, typeChecker, filteredMethodsAndFields);
watchDecoratorsToStatic(config, diagnostics, decoratedMembers, watchable, filteredMethodsAndFields);
watchDecoratorsToStatic(decoratedMembers, filteredMethodsAndFields);
listenDecoratorsToStatic(diagnostics, decoratedMembers, filteredMethodsAndFields);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -35,12 +34,11 @@ export const propDecoratorsToStatic = (
decoratedProps: ts.ClassElement[],
typeChecker: ts.TypeChecker,
program: ts.Program,
watchable: Set<string>,
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) {
Expand All @@ -55,15 +53,13 @@ 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 = (
diagnostics: d.Diagnostic[],
typeChecker: ts.TypeChecker,
program: ts.Program,
prop: ts.PropertyDeclaration,
watchable: Set<string>,
): ts.PropertyAssignment | null => {
const propDecorator = retrieveTsDecorators(prop)?.find(isDecoratorNamed('Prop'));
if (propDecorator == null) {
Expand Down Expand Up @@ -120,7 +116,7 @@ const parsePropDecorator = (
ts.factory.createStringLiteral(propName),
convertValueToLiteral(propMeta),
);
watchable.add(propName);

return staticProp;
};

Expand Down
14 changes: 4 additions & 10 deletions src/compiler/transformers/decorators-to-static/state-decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
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) {
Expand All @@ -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<string>): 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),
Expand Down
28 changes: 5 additions & 23 deletions src/compiler/transformers/decorators-to-static/watch-decorator.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,12 @@
import { augmentDiagnosticWithNode, buildError, buildWarn, flatOne } from '@utils';
import { flatOne } from '@utils';
import ts from 'typescript';

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<string>,
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(parseWatchDecorator);

const flatWatchers = flatOne(watchers);

Expand All @@ -23,22 +15,12 @@ export const watchDecoratorsToStatic = (
}
};

const parseWatchDecorator = (
config: d.Config,
diagnostics: d.Diagnostic[],
watchable: Set<string>,
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) => {
const [propName] = getDeclarationParameters<string>(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,
Expand Down
3 changes: 3 additions & 0 deletions src/declarations/stencil-private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1393,6 +1393,9 @@ export type ComponentRuntimeMetaCompact = [

/** listeners */
ComponentRuntimeHostListener[]?,

/** watchers */
ComponentConstructorWatchers?,
];

/**
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/bootstrap-lazy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,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
Expand Down
61 changes: 48 additions & 13 deletions src/runtime/proxy-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -133,25 +133,60 @@ export const proxyComponent = (
// if the propName exists on the prototype of `Cstr`, this update may be a result of Stencil using native
// 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 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);
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 elm = BUILD.lazyLoad ? hostRef.$hostElement$ : this;
const instance = BUILD.lazyLoad ? hostRef.$lazyInstance$ : (elm as any);
const entry = cmpMeta.$watchers$[attrName];
entry?.forEach((callbackName) => {
if (instance[callbackName] != null) {
instance[callbackName].call(instance, newValue, oldValue, attrName);
}
});
}

return;
}

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;
attrNameToPropName.set(attrName, propName);
if (BUILD.reflect && m[0] & MEMBER_FLAGS.ReflectAttr) {
cmpMeta.$attrsToReflect$.push([propName, attrName]);
}
return attrName;
});
// Create an array of attributes to observe
// This list in comprised of all strings used within a `@Watch()` decorator
// 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 = 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;
}),
]),
);
}
}

Expand Down
21 changes: 21 additions & 0 deletions src/utils/format-component-runtime-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
};

Expand All @@ -56,6 +58,25 @@ 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 = {};

compilerMeta.watchers.forEach(({ propName, methodName }) => {
watchers[propName] = [...(watchers[propName] ?? []), methodName];
});

return watchers;
};

const formatComponentRuntimeMembers = (
compilerMeta: d.ComponentCompilerMeta,
includeMethods = true,
Expand Down
13 changes: 13 additions & 0 deletions test/karma/test-app/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,8 @@ export namespace Components {
}
interface Tag88 {
}
interface WatchNativeAttributes {
}
}
export interface EsmImportCustomEvent<T> extends CustomEvent<T> {
detail: T;
Expand Down Expand Up @@ -1182,6 +1184,12 @@ declare global {
prototype: HTMLTag88Element;
new (): HTMLTag88Element;
};
interface HTMLWatchNativeAttributesElement extends Components.WatchNativeAttributes, HTMLStencilElement {
}
var HTMLWatchNativeAttributesElement: {
prototype: HTMLWatchNativeAttributesElement;
new (): HTMLWatchNativeAttributesElement;
};
interface HTMLElementTagNameMap {
"attribute-basic": HTMLAttributeBasicElement;
"attribute-basic-root": HTMLAttributeBasicRootElement;
Expand Down Expand Up @@ -1318,6 +1326,7 @@ declare global {
"svg-class": HTMLSvgClassElement;
"tag-3d-component": HTMLTag3dComponentElement;
"tag-88": HTMLTag88Element;
"watch-native-attributes": HTMLWatchNativeAttributesElement;
}
}
declare namespace LocalJSX {
Expand Down Expand Up @@ -1660,6 +1669,8 @@ declare namespace LocalJSX {
}
interface Tag88 {
}
interface WatchNativeAttributes {
}
interface IntrinsicElements {
"attribute-basic": AttributeBasic;
"attribute-basic-root": AttributeBasicRoot;
Expand Down Expand Up @@ -1796,6 +1807,7 @@ declare namespace LocalJSX {
"svg-class": SvgClass;
"tag-3d-component": Tag3dComponent;
"tag-88": Tag88;
"watch-native-attributes": WatchNativeAttributes;
}
}
export { LocalJSX as JSX };
Expand Down Expand Up @@ -1937,6 +1949,7 @@ declare module "@stencil/core" {
"svg-class": LocalJSX.SvgClass & JSXBase.HTMLAttributes<HTMLSvgClassElement>;
"tag-3d-component": LocalJSX.Tag3dComponent & JSXBase.HTMLAttributes<HTMLTag3dComponentElement>;
"tag-88": LocalJSX.Tag88 & JSXBase.HTMLAttributes<HTMLTag88Element>;
"watch-native-attributes": LocalJSX.WatchNativeAttributes & JSXBase.HTMLAttributes<HTMLWatchNativeAttributesElement>;
}
}
}
2 changes: 1 addition & 1 deletion test/karma/test-app/input-basic/karma.spec.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
22 changes: 22 additions & 0 deletions test/karma/test-app/watch-native-attributes/cmp.tsx
Original file line number Diff line number Diff line change
@@ -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 [
<p>Label: {this.el.getAttribute('aria-label')}</p>,
<p>Callback triggered: {`${this.callbackTriggered}`}</p>,
];
}
}
6 changes: 6 additions & 0 deletions test/karma/test-app/watch-native-attributes/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!doctype html>
<meta charset="utf8" />
<script src="./build/testapp.esm.js" type="module"></script>
<script src="./build/testapp.js" nomodule></script>

<watch-native-attributes aria-label="myStartingLabel"></watch-native-attributes>
Loading

0 comments on commit fc86c23

Please sign in to comment.