-
-
Notifications
You must be signed in to change notification settings - Fork 840
Expand file tree
/
Copy pathproxy-component.ts
More file actions
431 lines (400 loc) · 20 KB
/
proxy-component.ts
File metadata and controls
431 lines (400 loc) · 20 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
import { BUILD } from '@app-data';
import { consoleDevWarn, getHostRef, parsePropertyValue, plt } from '@platform';
import type * as d from '../declarations';
import { CMP_FLAGS, HOST_FLAGS, MEMBER_FLAGS, WATCH_FLAGS } from '../utils/constants';
import { getPropertyDescriptor } from '../utils/get-prop-descriptor';
import { normalizeWatchers } from './normalize-watchers';
import { FORM_ASSOCIATED_CUSTOM_ELEMENT_CALLBACKS, PROXY_FLAGS } from './runtime-constants';
import { getValue, setValue } from './set-value';
/**
* Attach a series of runtime constructs to a compiled Stencil component
* constructor, including getters and setters for the `@Prop` and `@State`
* decorators, callbacks for when attributes change, and so on.
*
* On a lazy loaded component, this is wired up to both the class instance
* and the element separately. A `hostRef` keeps the 2 in sync.
*
* On a traditional component, this is wired up to the element only.
*
* @param Cstr the constructor for a component that we need to process
* @param cmpMeta metadata collected previously about the component
* @param flags a number used to store a series of bit flags
* @returns a reference to the same constructor passed in (but now mutated)
*/
export const proxyComponent = (
Cstr: d.ComponentConstructor,
cmpMeta: d.ComponentRuntimeMeta,
flags: number,
): d.ComponentConstructor => {
const prototype = (Cstr as any).prototype;
if (BUILD.isTesting) {
if (prototype.__stencilAugmented) {
// @ts-expect-error - we don't want to re-augment the prototype. This happens during spec tests.
return;
}
prototype.__stencilAugmented = true;
}
/**
* proxy form associated custom element lifecycle callbacks
* @ref https://web.dev/articles/more-capable-form-controls#lifecycle_callbacks
*/
if (BUILD.formAssociated && cmpMeta.$flags$ & CMP_FLAGS.formAssociated && flags & PROXY_FLAGS.isElementConstructor) {
FORM_ASSOCIATED_CUSTOM_ELEMENT_CALLBACKS.forEach((cbName) => {
const originalFormAssociatedCallback = prototype[cbName];
Object.defineProperty(prototype, cbName, {
value(this: d.HostElement, ...args: any[]) {
const hostRef = getHostRef(this);
const instance: d.ComponentInterface = BUILD.lazyLoad ? hostRef?.$lazyInstance$ : this;
if (!instance) {
hostRef?.$onReadyPromise$?.then((asyncInstance: d.ComponentInterface) => {
const cb = asyncInstance[cbName];
typeof cb === 'function' && cb.call(asyncInstance, ...args);
});
} else {
// Use the method on `instance` if `lazyLoad` is set, otherwise call the original method to avoid an infinite loop.
const cb = BUILD.lazyLoad ? instance[cbName] : originalFormAssociatedCallback;
typeof cb === 'function' && cb.call(instance, ...args);
}
},
});
});
}
if ((BUILD.member && cmpMeta.$members$) || BUILD.propChangeCallback) {
if (BUILD.propChangeCallback) {
if (Cstr.watchers && !cmpMeta.$watchers$) {
cmpMeta.$watchers$ = normalizeWatchers(Cstr.watchers);
}
if (Cstr.deserializers && !cmpMeta.$deserializers$) {
cmpMeta.$deserializers$ = Cstr.deserializers;
}
if (Cstr.serializers && !cmpMeta.$serializers$) {
cmpMeta.$serializers$ = Cstr.serializers;
}
}
// It's better to have a const than two Object.entries()
const members = Object.entries(cmpMeta.$members$ ?? {});
members.map(([memberName, [memberFlags]]) => {
// is this member a `@Prop` or it's a `@State`
// AND either native component-element or it's a lazy class instance
if (
(BUILD.prop || BUILD.state) &&
(memberFlags & MEMBER_FLAGS.Prop ||
((!BUILD.lazyLoad || flags & PROXY_FLAGS.proxyState) && memberFlags & MEMBER_FLAGS.State))
) {
// preserve any getters / setters that already exist on the prototype;
// we'll call them via our new accessors. On a lazy component, this would only be called on the class instance.
const { get: origGetter, set: origSetter } = getPropertyDescriptor(prototype, memberName) || {};
if (origGetter) cmpMeta.$members$[memberName][0] |= MEMBER_FLAGS.Getter;
if (origSetter) cmpMeta.$members$[memberName][0] |= MEMBER_FLAGS.Setter;
if (flags & PROXY_FLAGS.isElementConstructor || !origGetter) {
// if it's an Element (native or proxy)
// OR it's a lazy class instance and doesn't have a getter
Object.defineProperty(prototype, memberName, {
get(this: d.RuntimeRef) {
if (BUILD.lazyLoad) {
if ((cmpMeta.$members$[memberName][0] & MEMBER_FLAGS.Getter) === 0) {
// no getter - let's return value now
return getValue(this, memberName);
}
const ref = getHostRef(this);
const instance = ref ? ref.$lazyInstance$ : prototype;
if (!instance) return;
return instance[memberName];
}
if (!BUILD.lazyLoad) {
return origGetter ? origGetter.apply(this) : getValue(this, memberName);
}
},
configurable: true,
enumerable: true,
});
}
Object.defineProperty(prototype, memberName, {
set(this: d.RuntimeRef, newValue) {
const ref = getHostRef(this);
if (!ref) {
return;
}
// only during dev
if (BUILD.isDev) {
if (
// we are proxying the instance (not element)
(flags & PROXY_FLAGS.isElementConstructor) === 0 &&
// if the class has a setter, then the Element can update instance values, so ignore
(cmpMeta.$members$[memberName][0] & MEMBER_FLAGS.Setter) === 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 (origSetter) {
// Lazy class instance or native component-element only:
// we have an original setter, so we need to set our value via that.
// do we have a value already?
const currentValue =
memberFlags & MEMBER_FLAGS.State
? this[memberName as keyof d.RuntimeRef]
: ref.$hostElement$[memberName as keyof d.HostElement];
if (typeof currentValue === 'undefined' && ref.$instanceValues$.get(memberName)) {
// no host value but a value already set on the hostRef,
// this means the setter was added at run-time (e.g. via a decorator).
// We want any value set on the element to override the default class instance value.
newValue = ref.$instanceValues$.get(memberName);
}
// this sets the value via the `set()` function which
// *might* not end up changing the underlying value
origSetter.apply(this, [
parsePropertyValue(
newValue,
memberFlags,
BUILD.formAssociated && !!(cmpMeta.$flags$ & CMP_FLAGS.formAssociated),
),
]);
// if it's a State property, we need to get the value from the instance
newValue =
memberFlags & MEMBER_FLAGS.State
? this[memberName as keyof d.RuntimeRef]
: ref.$hostElement$[memberName as keyof d.HostElement];
setValue(this, memberName, newValue, cmpMeta);
return;
}
if (!BUILD.lazyLoad) {
// we can set the value directly now if it's a native component-element
setValue(this, memberName, newValue, cmpMeta);
return;
}
if (BUILD.lazyLoad) {
// Lazy class instance OR proxy Element with no setter:
// set the element value directly now
if (
(flags & PROXY_FLAGS.isElementConstructor) === 0 ||
(cmpMeta.$members$[memberName][0] & MEMBER_FLAGS.Setter) === 0
) {
setValue(this, memberName, newValue, cmpMeta);
// if this is a value set on an Element *before* the instance has initialized (e.g. via an html attr)...
if (flags & PROXY_FLAGS.isElementConstructor && !ref.$lazyInstance$) {
// wait for lazy instance...
ref.$fetchedCbList$.push(() => {
// check if this instance member has a setter doesn't match what's already on the element
if (
cmpMeta.$members$[memberName][0] & MEMBER_FLAGS.Setter &&
ref.$lazyInstance$[memberName] !== ref.$instanceValues$.get(memberName)
) {
// this catches cases where there's a run-time only setter (e.g. via a decorator)
// *and* no initial value, so the initial setter never gets called
ref.$lazyInstance$[memberName] = newValue;
}
});
}
return;
}
// lazy element with a setter
// we might need to wait for the lazy class 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) {
// on init `get()` make sure the hostRef matches class instance
// the prop `set()` doesn't fire during `constructor()`:
// no initial value gets set in the hostRef.
// This means 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,
memberFlags,
BUILD.formAssociated && !!(cmpMeta.$flags$ & CMP_FLAGS.formAssociated),
);
setValue(this, memberName, ref.$lazyInstance$[memberName], cmpMeta);
};
if (ref.$lazyInstance$) {
setterSetVal();
} else {
// the class is yet to be loaded / defined so queue the call
ref.$fetchedCbList$.push(() => {
setterSetVal();
});
}
}
},
});
} else if (
BUILD.lazyLoad &&
BUILD.method &&
flags & PROXY_FLAGS.isElementConstructor &&
memberFlags & MEMBER_FLAGS.Method
) {
// proxyComponent - method
Object.defineProperty(prototype, memberName, {
value(this: d.HostElement, ...args: any[]) {
const ref = getHostRef(this);
return ref?.$onInstancePromise$?.then(() => ref.$lazyInstance$?.[memberName](...args));
},
});
}
});
if (BUILD.observeAttribute && (!BUILD.lazyLoad || flags & PROXY_FLAGS.isElementConstructor)) {
const attrNameToPropName = new Map();
prototype.attributeChangedCallback = function (attrName: string, oldValue: string, newValue: string) {
plt.jmp(() => {
const propName = attrNameToPropName.get(attrName);
const hostRef = getHostRef(this);
if (
BUILD.serializer &&
hostRef.$serializerValues$.has(propName) &&
hostRef.$serializerValues$.get(propName) === newValue
) {
// The newValue is the same as a saved serialized value from a prop update.
// The prop can be intentionally different from the attribute;
// updating the underlying prop here can cause an infinite loop.
return;
}
// In a web component lifecycle the attributeChangedCallback runs prior to connectedCallback
// in the case where an attribute was set inline.
// ```html
// <my-component some-attribute="some-value"></my-component>
// ```
//
// There is an edge case where a developer sets the attribute inline on a custom element and then
// programmatically changes it before it has been upgraded as shown below:
//
// ```html
// <!-- this component has _not_ been upgraded yet -->
// <my-component id="test" some-attribute="some-value"></my-component>
// <script>
// // grab non-upgraded component
// el = document.querySelector("#test");
// el.someAttribute = "another-value";
// // upgrade component
// customElements.define('my-component', MyComponent);
// </script>
// ```
// In this case if we do not un-shadow here and use the value of the shadowing property, attributeChangedCallback
// will be called with `newValue = "some-value"` and will set the shadowed property (this.someAttribute = "another-value")
// to the value that was set inline i.e. "some-value" from above example. When
// the connectedCallback attempts to un-shadow it will use "some-value" as the initial value rather than "another-value"
//
// The case where the attribute was NOT set inline but was not set programmatically shall be handled/un-shadowed
// by connectedCallback as this attributeChangedCallback will not fire.
//
// https://developers.google.com/web/fundamentals/web-components/best-practices#lazy-properties
//
// TODO(STENCIL-16) we should think about whether or not we actually want to be reflecting the attributes to
// properties here given that this goes against best practices outlined here
// https://developers.google.com/web/fundamentals/web-components/best-practices#avoid-reentrancy
if (this.hasOwnProperty(propName) && BUILD.lazyLoad) {
newValue = this[propName];
delete this[propName];
}
if (BUILD.deserializer && cmpMeta.$deserializers$ && cmpMeta.$deserializers$[propName]) {
const setVal = (methodName: string, instance: any) => {
const deserializeVal = instance?.[methodName](newValue, propName);
if (deserializeVal !== this[propName]) {
this[propName] = deserializeVal;
}
};
for (const deserializer of cmpMeta.$deserializers$[propName]) {
const [[methodName]] = Object.entries(deserializer);
if (BUILD.lazyLoad) {
if (hostRef.$lazyInstance$) {
setVal(methodName, hostRef.$lazyInstance$);
} else {
// If the instance is not ready, we can queue the update
hostRef.$fetchedCbList$.push(() => {
setVal(methodName, hostRef.$lazyInstance$);
});
}
} else {
setVal(methodName, this);
}
}
return;
} else if (
prototype.hasOwnProperty(propName) &&
typeof this[propName] === 'number' &&
// cast type to number to avoid TS compiler issues
this[propName] == (newValue as unknown as number)
) {
// 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 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 (hostRef && flags && !(flags & HOST_FLAGS.isConstructingInstance) && 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((watcher) => {
const [[watchMethodName, watcherFlags]] = Object.entries(watcher);
if (
instance[watchMethodName] != null &&
(flags & HOST_FLAGS.isWatchReady || watcherFlags & WATCH_FLAGS.Immediate)
) {
instance[watchMethodName].call(instance, newValue, oldValue, attrName);
}
});
}
return;
}
const propFlags = members.find(([m]) => m === propName);
const isBooleanTarget = propFlags && propFlags[1][0] & MEMBER_FLAGS.Boolean;
// Guard: skip when the attribute was removed but the current prop is
// already undefined. Both mean "not set" for a boolean prop, so the assignment would be
// spurious. Without this guard the setter fires reentrant mid-render when
// taskQueue:'immediate' is used, corrupting the vdom patch and crashing with
// "Cannot read properties of null (reading 'nodeType')".
const isSpuriousBooleanRemoval = isBooleanTarget && newValue === null && this[propName] === undefined;
// special handling of boolean attributes. Null (removal) means false.
// everything else means true (including an empty string
if (isBooleanTarget) {
(newValue as any) = newValue === null || newValue === 'false' ? false : true;
}
// 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
const propDesc = Object.getOwnPropertyDescriptor(prototype, propName);
if (!isSpuriousBooleanRemoval && newValue != this[propName] && (!propDesc.get || !!propDesc.set)) {
this[propName] = newValue;
}
});
};
// 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;
}),
]),
);
}
}
return Cstr;
};