Skip to content

Commit fc86c23

Browse files
authored
feat(runtime): watch native HTML attributes (#4760)
* 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
1 parent 1065463 commit fc86c23

File tree

13 files changed

+150
-58
lines changed

13 files changed

+150
-58
lines changed

src/compiler/transformers/decorators-to-static/convert-decorators.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,10 @@ const visitClassDeclaration = (
108108
componentDecoratorToStatic(config, typeChecker, diagnostics, classNode, filteredMethodsAndFields, componentDecorator);
109109

110110
// stores a reference to fields that should be watched for changes
111-
const watchable = new Set<string>();
112111
// parse member decorators (Prop, State, Listen, Event, Method, Element and Watch)
113112
if (decoratedMembers.length > 0) {
114-
propDecoratorsToStatic(diagnostics, decoratedMembers, typeChecker, program, watchable, filteredMethodsAndFields);
115-
stateDecoratorsToStatic(decoratedMembers, watchable, filteredMethodsAndFields);
113+
propDecoratorsToStatic(diagnostics, decoratedMembers, typeChecker, program, filteredMethodsAndFields);
114+
stateDecoratorsToStatic(decoratedMembers, filteredMethodsAndFields);
116115
eventDecoratorsToStatic(diagnostics, decoratedMembers, typeChecker, program, filteredMethodsAndFields);
117116
methodDecoratorsToStatic(
118117
config,
@@ -124,7 +123,7 @@ const visitClassDeclaration = (
124123
filteredMethodsAndFields,
125124
);
126125
elementDecoratorsToStatic(diagnostics, decoratedMembers, typeChecker, filteredMethodsAndFields);
127-
watchDecoratorsToStatic(config, diagnostics, decoratedMembers, watchable, filteredMethodsAndFields);
126+
watchDecoratorsToStatic(decoratedMembers, filteredMethodsAndFields);
128127
listenDecoratorsToStatic(diagnostics, decoratedMembers, filteredMethodsAndFields);
129128
}
130129

src/compiler/transformers/decorators-to-static/prop-decorator.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import { getDeclarationParameters, isDecoratorNamed } from './decorator-utils';
2626
* Only those decorated with `@Prop()` will be parsed.
2727
* @param typeChecker a reference to the TypeScript type checker
2828
* @param program a {@link ts.Program} object
29-
* @param watchable a collection of class members that can be watched for changes using Stencil's `@Watch` decorator
3029
* @param newMembers a collection that parsed `@Prop` annotated class members should be pushed to as a side effect of
3130
* calling this function
3231
*/
@@ -35,12 +34,11 @@ export const propDecoratorsToStatic = (
3534
decoratedProps: ts.ClassElement[],
3635
typeChecker: ts.TypeChecker,
3736
program: ts.Program,
38-
watchable: Set<string>,
3937
newMembers: ts.ClassElement[],
4038
): void => {
4139
const properties = decoratedProps
4240
.filter(ts.isPropertyDeclaration)
43-
.map((prop) => parsePropDecorator(diagnostics, typeChecker, program, prop, watchable))
41+
.map((prop) => parsePropDecorator(diagnostics, typeChecker, program, prop))
4442
.filter((prop): prop is ts.PropertyAssignment => prop != null);
4543

4644
if (properties.length > 0) {
@@ -55,15 +53,13 @@ export const propDecoratorsToStatic = (
5553
* @param typeChecker a reference to the TypeScript type checker
5654
* @param program a {@link ts.Program} object
5755
* @param prop the TypeScript `PropertyDeclaration` to parse
58-
* @param watchable a collection of class members that can be watched for changes using Stencil's `@Watch` decorator
5956
* @returns a property assignment expression to be added to the Stencil component's class
6057
*/
6158
const parsePropDecorator = (
6259
diagnostics: d.Diagnostic[],
6360
typeChecker: ts.TypeChecker,
6461
program: ts.Program,
6562
prop: ts.PropertyDeclaration,
66-
watchable: Set<string>,
6763
): ts.PropertyAssignment | null => {
6864
const propDecorator = retrieveTsDecorators(prop)?.find(isDecoratorNamed('Prop'));
6965
if (propDecorator == null) {
@@ -120,7 +116,7 @@ const parsePropDecorator = (
120116
ts.factory.createStringLiteral(propName),
121117
convertValueToLiteral(propMeta),
122118
);
123-
watchable.add(propName);
119+
124120
return staticProp;
125121
};
126122

src/compiler/transformers/decorators-to-static/state-decorator.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,12 @@ import { isDecoratorNamed } from './decorator-utils';
1111
* with which they can be replaced.
1212
*
1313
* @param decoratedProps TypeScript AST nodes representing class members
14-
* @param watchable set of names of fields which should be watched for changes
1514
* @param newMembers an out param containing new class members
1615
*/
17-
export const stateDecoratorsToStatic = (
18-
decoratedProps: ts.ClassElement[],
19-
watchable: Set<string>,
20-
newMembers: ts.ClassElement[],
21-
) => {
16+
export const stateDecoratorsToStatic = (decoratedProps: ts.ClassElement[], newMembers: ts.ClassElement[]) => {
2217
const states = decoratedProps
2318
.filter(ts.isPropertyDeclaration)
24-
.map((prop) => stateDecoratorToStatic(prop, watchable))
19+
.map(stateDecoratorToStatic)
2520
.filter((state): state is ts.PropertyAssignment => !!state);
2621

2722
if (states.length > 0) {
@@ -38,18 +33,17 @@ export const stateDecoratorsToStatic = (
3833
* decorated with other decorators.
3934
*
4035
* @param prop A TypeScript AST node representing a class property declaration
41-
* @param watchable set of names of fields which should be watched for changes
4236
* @returns a property assignment AST Node which maps the name of the state
4337
* prop to an empty object
4438
*/
45-
const stateDecoratorToStatic = (prop: ts.PropertyDeclaration, watchable: Set<string>): ts.PropertyAssignment | null => {
39+
const stateDecoratorToStatic = (prop: ts.PropertyDeclaration): ts.PropertyAssignment | null => {
4640
const stateDecorator = retrieveTsDecorators(prop)?.find(isDecoratorNamed('State'));
4741
if (stateDecorator == null) {
4842
return null;
4943
}
5044

5145
const stateName = prop.name.getText();
52-
watchable.add(stateName);
46+
5347
return ts.factory.createPropertyAssignment(
5448
ts.factory.createStringLiteral(stateName),
5549
ts.factory.createObjectLiteralExpression([], true),

src/compiler/transformers/decorators-to-static/watch-decorator.ts

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,12 @@
1-
import { augmentDiagnosticWithNode, buildError, buildWarn, flatOne } from '@utils';
1+
import { flatOne } from '@utils';
22
import ts from 'typescript';
33

44
import type * as d from '../../../declarations';
55
import { convertValueToLiteral, createStaticGetter, retrieveTsDecorators } from '../transform-utils';
66
import { getDeclarationParameters, isDecoratorNamed } from './decorator-utils';
77

8-
export const watchDecoratorsToStatic = (
9-
config: d.Config,
10-
diagnostics: d.Diagnostic[],
11-
decoratedProps: ts.ClassElement[],
12-
watchable: Set<string>,
13-
newMembers: ts.ClassElement[],
14-
) => {
15-
const watchers = decoratedProps
16-
.filter(ts.isMethodDeclaration)
17-
.map((method) => parseWatchDecorator(config, diagnostics, watchable, method));
8+
export const watchDecoratorsToStatic = (decoratedProps: ts.ClassElement[], newMembers: ts.ClassElement[]) => {
9+
const watchers = decoratedProps.filter(ts.isMethodDeclaration).map(parseWatchDecorator);
1810

1911
const flatWatchers = flatOne(watchers);
2012

@@ -23,22 +15,12 @@ export const watchDecoratorsToStatic = (
2315
}
2416
};
2517

26-
const parseWatchDecorator = (
27-
config: d.Config,
28-
diagnostics: d.Diagnostic[],
29-
watchable: Set<string>,
30-
method: ts.MethodDeclaration,
31-
): d.ComponentCompilerWatch[] => {
18+
const parseWatchDecorator = (method: ts.MethodDeclaration): d.ComponentCompilerWatch[] => {
3219
const methodName = method.name.getText();
3320
const decorators = retrieveTsDecorators(method) ?? [];
3421
return decorators.filter(isDecoratorNamed('Watch')).map((decorator) => {
3522
const [propName] = getDeclarationParameters<string>(decorator);
36-
if (!watchable.has(propName)) {
37-
const diagnostic = config.devMode ? buildWarn(diagnostics) : buildError(diagnostics);
38-
diagnostic.messageText = `@Watch('${propName}') is trying to watch for changes in a property that does not exist.
39-
Make sure only properties decorated with @State() or @Prop() are watched.`;
40-
augmentDiagnosticWithNode(diagnostic, decorator);
41-
}
23+
4224
return {
4325
propName,
4426
methodName,

src/declarations/stencil-private.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1393,6 +1393,9 @@ export type ComponentRuntimeMetaCompact = [
13931393

13941394
/** listeners */
13951395
ComponentRuntimeHostListener[]?,
1396+
1397+
/** watchers */
1398+
ComponentConstructorWatchers?,
13961399
];
13971400

13981401
/**

src/runtime/bootstrap-lazy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d.
7575
cmpMeta.$attrsToReflect$ = [];
7676
}
7777
if (BUILD.watchCallback) {
78-
cmpMeta.$watchers$ = {};
78+
cmpMeta.$watchers$ = compactMeta[4] ?? {};
7979
}
8080
if (BUILD.shadowDom && !supportsShadow && cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation) {
8181
// TODO(STENCIL-854): Remove code related to legacy shadowDomShim field

src/runtime/proxy-component.ts

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export const proxyComponent = (
8585
if (BUILD.observeAttribute && (!BUILD.lazyLoad || flags & PROXY_FLAGS.isElementConstructor)) {
8686
const attrNameToPropName = new Map();
8787

88-
prototype.attributeChangedCallback = function (attrName: string, _oldValue: string, newValue: string) {
88+
prototype.attributeChangedCallback = function (attrName: string, oldValue: string, newValue: string) {
8989
plt.jmp(() => {
9090
const propName = attrNameToPropName.get(attrName);
9191

@@ -133,25 +133,60 @@ export const proxyComponent = (
133133
// if the propName exists on the prototype of `Cstr`, this update may be a result of Stencil using native
134134
// APIs to reflect props as attributes. Calls to `setAttribute(someElement, propName)` will result in
135135
// `propName` to be converted to a `DOMString`, which may not be what we want for other primitive props.
136+
return;
137+
} else if (propName == null) {
138+
// At this point we should know this is not a "member", so we can treat it like watching an attribute
139+
// on a vanilla web component
140+
const hostRef = getHostRef(this);
141+
const flags = hostRef?.$flags$;
142+
143+
// We only want to trigger the callback(s) if:
144+
// 1. The instance is ready
145+
// 2. The watchers are ready
146+
// 3. The value has changed
147+
if (
148+
!(flags & HOST_FLAGS.isConstructingInstance) &&
149+
flags & HOST_FLAGS.isWatchReady &&
150+
newValue !== oldValue
151+
) {
152+
const elm = BUILD.lazyLoad ? hostRef.$hostElement$ : this;
153+
const instance = BUILD.lazyLoad ? hostRef.$lazyInstance$ : (elm as any);
154+
const entry = cmpMeta.$watchers$[attrName];
155+
entry?.forEach((callbackName) => {
156+
if (instance[callbackName] != null) {
157+
instance[callbackName].call(instance, newValue, oldValue, attrName);
158+
}
159+
});
160+
}
161+
136162
return;
137163
}
138164

139165
this[propName] = newValue === null && typeof this[propName] === 'boolean' ? false : newValue;
140166
});
141167
};
142168

143-
// create an array of attributes to observe
144-
// and also create a map of html attribute name to js property name
145-
Cstr.observedAttributes = members
146-
.filter(([_, m]) => m[0] & MEMBER_FLAGS.HasAttribute) // filter to only keep props that should match attributes
147-
.map(([propName, m]) => {
148-
const attrName = m[1] || propName;
149-
attrNameToPropName.set(attrName, propName);
150-
if (BUILD.reflect && m[0] & MEMBER_FLAGS.ReflectAttr) {
151-
cmpMeta.$attrsToReflect$.push([propName, attrName]);
152-
}
153-
return attrName;
154-
});
169+
// Create an array of attributes to observe
170+
// This list in comprised of all strings used within a `@Watch()` decorator
171+
// on a component as well as any Stencil-specific "members" (`@Prop()`s and `@State()`s).
172+
// As such, there is no way to guarantee type-safety here that a user hasn't entered
173+
// an invalid attribute.
174+
Cstr.observedAttributes = Array.from(
175+
new Set([
176+
...Object.keys(cmpMeta.$watchers$ ?? {}),
177+
...members
178+
.filter(([_, m]) => m[0] & MEMBER_FLAGS.HasAttribute)
179+
.map(([propName, m]) => {
180+
const attrName = m[1] || propName;
181+
attrNameToPropName.set(attrName, propName);
182+
if (BUILD.reflect && m[0] & MEMBER_FLAGS.ReflectAttr) {
183+
cmpMeta.$attrsToReflect$.push([propName, attrName]);
184+
}
185+
186+
return attrName;
187+
}),
188+
]),
189+
);
155190
}
156191
}
157192

src/utils/format-component-runtime-meta.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,13 @@ export const formatComponentRuntimeMeta = (
3838

3939
const members = formatComponentRuntimeMembers(compilerMeta, includeMethods);
4040
const hostListeners = formatHostListeners(compilerMeta);
41+
const watchers = formatComponentRuntimeWatchers(compilerMeta);
4142
return trimFalsy([
4243
flags,
4344
compilerMeta.tagName,
4445
Object.keys(members).length > 0 ? members : undefined,
4546
hostListeners.length > 0 ? hostListeners : undefined,
47+
Object.keys(watchers).length > 0 ? watchers : undefined,
4648
]);
4749
};
4850

@@ -56,6 +58,25 @@ export const stringifyRuntimeData = (data: any) => {
5658
return json;
5759
};
5860

61+
/**
62+
* Transforms Stencil compiler metadata into a {@link d.ComponentCompilerMeta} object.
63+
* This handles processing any compiler metadata transformed from components' uses of `@Watch()`.
64+
* The map of watched attributes to their callback(s) will be immediately available
65+
* to the runtime at bootstrap.
66+
*
67+
* @param compilerMeta Component metadata gathered during compilation
68+
* @returns An object mapping watched attributes to their respective callback(s)
69+
*/
70+
const formatComponentRuntimeWatchers = (compilerMeta: d.ComponentCompilerMeta) => {
71+
const watchers: d.ComponentConstructorWatchers = {};
72+
73+
compilerMeta.watchers.forEach(({ propName, methodName }) => {
74+
watchers[propName] = [...(watchers[propName] ?? []), methodName];
75+
});
76+
77+
return watchers;
78+
};
79+
5980
const formatComponentRuntimeMembers = (
6081
compilerMeta: d.ComponentCompilerMeta,
6182
includeMethods = true,

test/karma/test-app/components.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,8 @@ export namespace Components {
342342
}
343343
interface Tag88 {
344344
}
345+
interface WatchNativeAttributes {
346+
}
345347
}
346348
export interface EsmImportCustomEvent<T> extends CustomEvent<T> {
347349
detail: T;
@@ -1182,6 +1184,12 @@ declare global {
11821184
prototype: HTMLTag88Element;
11831185
new (): HTMLTag88Element;
11841186
};
1187+
interface HTMLWatchNativeAttributesElement extends Components.WatchNativeAttributes, HTMLStencilElement {
1188+
}
1189+
var HTMLWatchNativeAttributesElement: {
1190+
prototype: HTMLWatchNativeAttributesElement;
1191+
new (): HTMLWatchNativeAttributesElement;
1192+
};
11851193
interface HTMLElementTagNameMap {
11861194
"attribute-basic": HTMLAttributeBasicElement;
11871195
"attribute-basic-root": HTMLAttributeBasicRootElement;
@@ -1318,6 +1326,7 @@ declare global {
13181326
"svg-class": HTMLSvgClassElement;
13191327
"tag-3d-component": HTMLTag3dComponentElement;
13201328
"tag-88": HTMLTag88Element;
1329+
"watch-native-attributes": HTMLWatchNativeAttributesElement;
13211330
}
13221331
}
13231332
declare namespace LocalJSX {
@@ -1660,6 +1669,8 @@ declare namespace LocalJSX {
16601669
}
16611670
interface Tag88 {
16621671
}
1672+
interface WatchNativeAttributes {
1673+
}
16631674
interface IntrinsicElements {
16641675
"attribute-basic": AttributeBasic;
16651676
"attribute-basic-root": AttributeBasicRoot;
@@ -1796,6 +1807,7 @@ declare namespace LocalJSX {
17961807
"svg-class": SvgClass;
17971808
"tag-3d-component": Tag3dComponent;
17981809
"tag-88": Tag88;
1810+
"watch-native-attributes": WatchNativeAttributes;
17991811
}
18001812
}
18011813
export { LocalJSX as JSX };
@@ -1937,6 +1949,7 @@ declare module "@stencil/core" {
19371949
"svg-class": LocalJSX.SvgClass & JSXBase.HTMLAttributes<HTMLSvgClassElement>;
19381950
"tag-3d-component": LocalJSX.Tag3dComponent & JSXBase.HTMLAttributes<HTMLTag3dComponentElement>;
19391951
"tag-88": LocalJSX.Tag88 & JSXBase.HTMLAttributes<HTMLTag88Element>;
1952+
"watch-native-attributes": LocalJSX.WatchNativeAttributes & JSXBase.HTMLAttributes<HTMLWatchNativeAttributesElement>;
19401953
}
19411954
}
19421955
}

test/karma/test-app/input-basic/karma.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { setupDomTests, waitForChanges } from '../util';
22

3-
describe('attribute-basic', function () {
3+
describe('input-basic', function () {
44
const { setupDom, tearDownDom } = setupDomTests(document);
55
let app: HTMLElement;
66

0 commit comments

Comments
 (0)