Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 @@ -119,7 +118,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
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
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);
}
Comment on lines -36 to -41
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do lose a compile-time check with this change. Only way around it would be to maintain a list of all the valid HTML attributes to check against which would be a nightmare.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if we're opening it up to any attribute then this makes sense, it's the developers' responsibility to make sure that the string used is a valid one

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only way around it would be to maintain a list of all the valid HTML attributes to check against which would be a nightmare.

Agreed - we run into this with mock doc & checking for reserved keys today, and don't have a great mechanism for checking if we're missing a standardized HTML attribute.

RE This comment in the PR summary:

The Framework team has asked for us to support this for some time.

Is the Framework's request based on supporting aria-* attributes only? Or is there more they're looking to support? I'm wondering in there's a compromise to be had here if we opened this up to just a limited subset of attributes (in this example, those prefixed with aria-*).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only examples they've given have been aria, but not sure if they would use others as well. What would be the benefit of restricting this though? And, inversely, what is the risk of allowing users to watch any attribute?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think (maybe) the benefit here is that if we limited this to aria-*, we'd have a more finite list (and perhaps stable) list of attributes to check against, which would influence my opinion on some of the comments here.

I don't think there's a particular risk I'm concerned about ATM - I'm just looking for some additional details regarding the context of their request. Maybe we can add/attach that to this PR summary or a JIRA ticket?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Framework we maintain a list of attributes to inherit here: https://github.com/ionic-team/ionic-framework/blob/f82709595d5031a759e35b11dbcccb0ea5870ce0/core/src/utils/helpers.ts#L118-L175

But our reasoning is to not inherit all attributes that exist on the host. I'm not really sure why you'd restrict what attribute a developer can watch a value change on. That's based on the implementation details of the component, not a compiler constraint.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sean-perkins That's great to know!

Getting that context is what I'm really after here to both:

  1. influence my understanding of this particular piece of code
  2. add historical context to this PR - this helps prevent the issue where we don't capture this info anywhere in VCS/issue tracking and that information gets lost (which is sadly, lacking in older commits in Stencil/something I'd like us to prevent reverting to)

In that case, I think this code/logic fine as is 👍


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 @@ -1386,6 +1386,9 @@ export type ComponentRuntimeMetaCompact = [

/** listeners */
ComponentRuntimeHostListener[]?,

/** watchers */
ComponentConstructorWatchers?,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needed to add this so we can have a map of the watchers available when bootstrapping the lazy-build before the module is loaded. Otherwise, the class instance that gets registered with the custom element registry won't include any of the watched native attributes in the observedAttributes array.

];

/**
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 @@ -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
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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the stuff in this block comes from the existing setValue() function

// 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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't include all "members" here (regardless if they have an associate watcher), then they components will not set their values and update appropriately.

.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 @@ -340,6 +340,8 @@ export namespace Components {
}
interface Tag88 {
}
interface WatchNativeAttributes {
}
}
export interface EsmImportCustomEvent<T> extends CustomEvent<T> {
detail: T;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1309,6 +1317,7 @@ declare global {
"svg-class": HTMLSvgClassElement;
"tag-3d-component": HTMLTag3dComponentElement;
"tag-88": HTMLTag88Element;
"watch-native-attributes": HTMLWatchNativeAttributesElement;
}
}
declare namespace LocalJSX {
Expand Down Expand Up @@ -1649,6 +1658,8 @@ declare namespace LocalJSX {
}
interface Tag88 {
}
interface WatchNativeAttributes {
}
interface IntrinsicElements {
"append-child": AppendChild;
"attribute-basic": AttributeBasic;
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -1924,6 +1936,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