Skip to content

Commit 82fc857

Browse files
johnjenkinsJohn Jenkinschristian-bromann
authored
feat(runtime): @Prop / @State now work with runtime decorators (#6084)
* fix: make decorators great again * chore: WIP. Pretty much there * chore: pretty much there.. sans tests * chore: tidying up * chore: make it work properly again * chore: first unit test plus edge smoothing * chore: pretty much dun * chore: clearer jsdoc * chore: tidy * chore: formatting * feat: refactor `proxy-component` to respect any accessors * chore: minor * chore: testing init * chore: tests working * chore: fix test * chore: rv console * chore: update comment * chore: update comment * chore: revert * chore: idiot * chore: damn yankee spelling ;) * chore: make test more reliable * chore: make this one more reliable too --------- Co-authored-by: John Jenkins <john.jenkins@nanoporetech.com> Co-authored-by: Christian Bromann <git@bromann.dev>
1 parent efb40d5 commit 82fc857

23 files changed

+671
-367
lines changed

src/client/client-host-ref.ts

+4-9
Original file line numberDiff line numberDiff line change
@@ -126,24 +126,19 @@ const reWireGetterSetter = (instance: any, hostRef: d.HostRef) => {
126126
const members = Object.entries(cmpMeta.$members$ ?? {});
127127

128128
members.map(([memberName, [memberFlags]]) => {
129-
if (
130-
BUILD.state &&
131-
BUILD.prop &&
132-
(memberFlags & MEMBER_FLAGS.Getter) === 0 &&
133-
(memberFlags & MEMBER_FLAGS.Prop || memberFlags & MEMBER_FLAGS.State)
134-
) {
129+
if ((BUILD.state || BUILD.prop) && (memberFlags & MEMBER_FLAGS.Prop || memberFlags & MEMBER_FLAGS.State)) {
135130
const ogValue = instance[memberName];
136131

137132
// Get the original Stencil prototype `get` / `set`
138-
const lazyDescriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(instance), memberName);
133+
const ogDescriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(instance), memberName);
139134

140135
// Re-wire original accessors to the new instance
141136
Object.defineProperty(instance, memberName, {
142137
get() {
143-
return lazyDescriptor.get.call(this);
138+
return ogDescriptor.get.call(this);
144139
},
145140
set(newValue) {
146-
lazyDescriptor.set.call(this, newValue);
141+
ogDescriptor.set.call(this, newValue);
147142
},
148143
configurable: true,
149144
enumerable: true,

src/hydrate/platform/hydrate-app.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export function hydrateApp(
9191
registerHost(elm, Cstr.cmpMeta);
9292

9393
// proxy the host element with the component's metadata
94-
proxyHostElement(elm, Cstr.cmpMeta);
94+
proxyHostElement(elm, Cstr);
9595
}
9696
}
9797
}

src/hydrate/platform/proxy-host-element.ts

+33-11
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { CMP_FLAGS, MEMBER_FLAGS } from '@utils';
55

66
import type * as d from '../../declarations';
77

8-
export function proxyHostElement(elm: d.HostElement, cmpMeta: d.ComponentRuntimeMeta): void {
8+
export function proxyHostElement(elm: d.HostElement, cstr: d.ComponentConstructor): void {
9+
const cmpMeta = cstr.cmpMeta;
10+
911
if (typeof elm.componentOnReady !== 'function') {
1012
elm.componentOnReady = componentOnReady;
1113
}
@@ -32,11 +34,9 @@ export function proxyHostElement(elm: d.HostElement, cmpMeta: d.ComponentRuntime
3234

3335
const members = Object.entries(cmpMeta.$members$);
3436

35-
members.forEach(([memberName, m]) => {
36-
const memberFlags = m[0];
37-
37+
members.forEach(([memberName, [memberFlags, metaAttributeName]]) => {
3838
if (memberFlags & MEMBER_FLAGS.Prop) {
39-
const attributeName = m[1] || memberName;
39+
const attributeName = metaAttributeName || memberName;
4040
let attrValue = elm.getAttribute(attributeName);
4141

4242
/**
@@ -57,8 +57,17 @@ export function proxyHostElement(elm: d.HostElement, cmpMeta: d.ComponentRuntime
5757
}
5858
}
5959

60+
const { get: origGetter, set: origSetter } =
61+
Object.getOwnPropertyDescriptor((cstr as any).prototype, memberName) || {};
62+
let parsedAttrValue: any;
63+
6064
if (attrValue != null) {
61-
const parsedAttrValue = parsePropertyValue(attrValue, memberFlags);
65+
parsedAttrValue = parsePropertyValue(attrValue, memberFlags);
66+
if (origSetter) {
67+
// we have an original setter, so let's set the value via that.
68+
origSetter.apply(elm, [parsedAttrValue]);
69+
parsedAttrValue = origGetter ? origGetter.apply(elm) : parsedAttrValue;
70+
}
6271
hostRef?.$instanceValues$?.set(memberName, parsedAttrValue);
6372
}
6473

@@ -71,19 +80,32 @@ export function proxyHostElement(elm: d.HostElement, cmpMeta: d.ComponentRuntime
7180
delete (elm as any)[memberName];
7281
}
7382

74-
// create the getter/setter on the host element for this property name
83+
// if we have a parsed value from an attribute use that first.
84+
// otherwise if we have a getter already applied, use that.
85+
// we'll do this for both the element and the component instance.
86+
// this makes sure attribute values take priority over default values.
87+
function getter(this: d.RuntimeRef) {
88+
return ![undefined, null].includes(parsedAttrValue)
89+
? parsedAttrValue
90+
: origGetter
91+
? origGetter.apply(this)
92+
: getValue(this, memberName);
93+
}
7594
Object.defineProperty(elm, memberName, {
76-
get(this: d.RuntimeRef) {
77-
// proxyComponent, get value
78-
return getValue(this, memberName);
79-
},
95+
get: getter,
8096
set(this: d.RuntimeRef, newValue) {
8197
// proxyComponent, set value
8298
setValue(this, memberName, newValue, cmpMeta);
8399
},
84100
configurable: true,
85101
enumerable: true,
86102
});
103+
104+
Object.defineProperty((cstr as any).prototype, memberName, {
105+
get: getter,
106+
configurable: true,
107+
enumerable: true,
108+
});
87109
} else if (memberFlags & MEMBER_FLAGS.Method) {
88110
Object.defineProperty(elm, memberName, {
89111
value(this: d.HostElement, ...args: any[]) {

src/runtime/proxy-component.ts

+148-88
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import { getValue, setValue } from './set-value';
1212
* constructor, including getters and setters for the `@Prop` and `@State`
1313
* decorators, callbacks for when attributes change, and so on.
1414
*
15+
* On a lazy loaded component, this is wired up to both the class instance
16+
* and the element separately. A `hostRef` keeps the 2 in sync.
17+
*
18+
* On a traditional component, this is wired up to the element only.
19+
*
1520
* @param Cstr the constructor for a component that we need to process
1621
* @param cmpMeta metadata collected previously about the component
1722
* @param flags a number used to store a series of bit flags
@@ -57,110 +62,164 @@ export const proxyComponent = (
5762
// It's better to have a const than two Object.entries()
5863
const members = Object.entries(cmpMeta.$members$ ?? {});
5964
members.map(([memberName, [memberFlags]]) => {
65+
// is this member a `@Prop` or it's a `@State`
66+
// AND either native component-element or it's a lazy class instance
6067
if (
6168
(BUILD.prop || BUILD.state) &&
6269
(memberFlags & MEMBER_FLAGS.Prop ||
6370
((!BUILD.lazyLoad || flags & PROXY_FLAGS.proxyState) && memberFlags & MEMBER_FLAGS.State))
6471
) {
65-
if ((memberFlags & MEMBER_FLAGS.Getter) === 0) {
66-
// proxyComponent - prop
72+
// preserve any getters / setters that already exist on the prototype;
73+
// we'll call them via our new accessors. On a lazy component, this would only be called on the class instance.
74+
const { get: origGetter, set: origSetter } = Object.getOwnPropertyDescriptor(prototype, memberName) || {};
75+
if (origGetter) cmpMeta.$members$[memberName][0] |= MEMBER_FLAGS.Getter;
76+
if (origSetter) cmpMeta.$members$[memberName][0] |= MEMBER_FLAGS.Setter;
77+
78+
if (flags & PROXY_FLAGS.isElementConstructor || !origGetter) {
79+
// if it's an Element (native or proxy)
80+
// OR it's a lazy class instance and doesn't have a getter
6781
Object.defineProperty(prototype, memberName, {
6882
get(this: d.RuntimeRef) {
69-
// proxyComponent, get value
70-
return getValue(this, memberName);
71-
},
72-
set(this: d.RuntimeRef, newValue) {
73-
// only during dev time
74-
if (BUILD.isDev) {
75-
const ref = getHostRef(this);
76-
if (
77-
// we are proxying the instance (not element)
78-
(flags & PROXY_FLAGS.isElementConstructor) === 0 &&
79-
// the element is not constructing
80-
(ref && ref.$flags$ & HOST_FLAGS.isConstructingInstance) === 0 &&
81-
// the member is a prop
82-
(memberFlags & MEMBER_FLAGS.Prop) !== 0 &&
83-
// the member is not mutable
84-
(memberFlags & MEMBER_FLAGS.Mutable) === 0
85-
) {
86-
consoleDevWarn(
87-
`@Prop() "${memberName}" on <${cmpMeta.$tagName$}> is immutable but was modified from within the component.\nMore information: https://stenciljs.com/docs/properties#prop-mutability`,
88-
);
83+
if (BUILD.lazyLoad) {
84+
if ((cmpMeta.$members$[memberName][0] & MEMBER_FLAGS.Getter) === 0) {
85+
// no getter - let's return value now
86+
return getValue(this, memberName);
8987
}
88+
const ref = getHostRef(this);
89+
const instance = ref ? ref.$lazyInstance$ : prototype;
90+
if (!instance) return;
91+
return instance[memberName];
92+
}
93+
if (!BUILD.lazyLoad) {
94+
return origGetter ? origGetter.apply(this) : getValue(this, memberName);
9095
}
91-
// proxyComponent, set value
92-
setValue(this, memberName, newValue, cmpMeta);
9396
},
9497
configurable: true,
9598
enumerable: true,
9699
});
97-
} else if (flags & PROXY_FLAGS.isElementConstructor && memberFlags & MEMBER_FLAGS.Getter) {
98-
if (BUILD.lazyLoad) {
99-
// lazily maps the element get / set to the class get / set
100-
// proxyComponent - lazy prop getter
101-
Object.defineProperty(prototype, memberName, {
102-
get(this: d.RuntimeRef) {
103-
const ref = getHostRef(this);
104-
const instance = BUILD.lazyLoad && ref ? ref.$lazyInstance$ : prototype;
105-
if (!instance) return;
100+
}
106101

107-
return instance[memberName];
108-
},
109-
configurable: true,
110-
enumerable: true,
111-
});
112-
}
113-
if (memberFlags & MEMBER_FLAGS.Setter) {
114-
// proxyComponent - lazy and non-lazy. Catches original set to fire updates (for @Watch)
115-
const origSetter = Object.getOwnPropertyDescriptor(prototype, memberName).set;
116-
Object.defineProperty(prototype, memberName, {
117-
set(this: d.RuntimeRef, newValue) {
118-
// non-lazy setter - amends original set to fire update
119-
const ref = getHostRef(this);
120-
if (origSetter) {
121-
const currentValue = ref.$hostElement$[memberName as keyof d.HostElement];
122-
if (!ref.$instanceValues$.get(memberName) && currentValue) {
123-
// the prop `set()` doesn't fire during `constructor()`:
124-
// no initial value gets set (in instanceValues)
125-
// meaning watchers fire even though the value hasn't changed.
126-
// So if there's a current value and no initial value, let's set it now.
127-
ref.$instanceValues$.set(memberName, currentValue);
128-
}
129-
// this sets the value via the `set()` function which
130-
// might not end up changing the underlying value
131-
origSetter.apply(this, [parsePropertyValue(newValue, cmpMeta.$members$[memberName][0])]);
132-
setValue(this, memberName, ref.$hostElement$[memberName as keyof d.HostElement], cmpMeta);
133-
return;
102+
Object.defineProperty(prototype, memberName, {
103+
set(this: d.RuntimeRef, newValue) {
104+
const ref = getHostRef(this);
105+
106+
// only during dev
107+
if (BUILD.isDev) {
108+
if (
109+
// we are proxying the instance (not element)
110+
(flags & PROXY_FLAGS.isElementConstructor) === 0 &&
111+
// if the class has a setter, then the Element can update instance values, so ignore
112+
(cmpMeta.$members$[memberName][0] & MEMBER_FLAGS.Setter) === 0 &&
113+
// the element is not constructing
114+
(ref && ref.$flags$ & HOST_FLAGS.isConstructingInstance) === 0 &&
115+
// the member is a prop
116+
(cmpMeta.$members$[memberName][0] & MEMBER_FLAGS.Prop) !== 0 &&
117+
// the member is not mutable
118+
(cmpMeta.$members$[memberName][0] & MEMBER_FLAGS.Mutable) === 0
119+
) {
120+
consoleDevWarn(
121+
`@Prop() "${memberName}" on <${cmpMeta.$tagName$}> is immutable but was modified from within the component.\nMore information: https://stenciljs.com/docs/properties#prop-mutability`,
122+
);
123+
}
124+
}
125+
126+
if (origSetter) {
127+
// Lazy class instance or native component-element only:
128+
// we have an original setter, so we need to set our value via that.
129+
130+
// do we have a value already?
131+
const currentValue =
132+
memberFlags & MEMBER_FLAGS.State
133+
? this[memberName as keyof d.RuntimeRef]
134+
: ref.$hostElement$[memberName as keyof d.HostElement];
135+
136+
if (typeof currentValue === 'undefined' && ref.$instanceValues$.get(memberName)) {
137+
// no host value but a value already set on the hostRef,
138+
// this means the setter was added at run-time (e.g. via a decorator).
139+
// We want any value set on the element to override the default class instance value.
140+
newValue = ref.$instanceValues$.get(memberName);
141+
} else if (!ref.$instanceValues$.get(memberName) && currentValue) {
142+
// on init get make sure the hostRef matches the element (via prop / attr)
143+
144+
// the prop `set()` doesn't necessarily fire during `constructor()`,
145+
// so no initial value gets set in the hostRef.
146+
// This means watchers fire even though the value hasn't changed.
147+
// So if there's a current value and no initial value, let's set it now.
148+
ref.$instanceValues$.set(memberName, currentValue);
149+
}
150+
// this sets the value via the `set()` function which
151+
// *might* not end up changing the underlying value
152+
origSetter.apply(this, [parsePropertyValue(newValue, memberFlags)]);
153+
// if it's a State property, we need to get the value from the instance
154+
newValue =
155+
memberFlags & MEMBER_FLAGS.State
156+
? this[memberName as keyof d.RuntimeRef]
157+
: ref.$hostElement$[memberName as keyof d.HostElement];
158+
setValue(this, memberName, newValue, cmpMeta);
159+
return;
160+
}
161+
162+
if (!BUILD.lazyLoad) {
163+
// we can set the value directly now if it's a native component-element
164+
setValue(this, memberName, newValue, cmpMeta);
165+
return;
166+
}
167+
168+
if (BUILD.lazyLoad) {
169+
// Lazy class instance OR proxy Element with no setter:
170+
// set the element value directly now
171+
if (
172+
(flags & PROXY_FLAGS.isElementConstructor) === 0 ||
173+
(cmpMeta.$members$[memberName][0] & MEMBER_FLAGS.Setter) === 0
174+
) {
175+
setValue(this, memberName, newValue, cmpMeta);
176+
// if this is a value set on an Element *before* the instance has initialized (e.g. via an html attr)...
177+
if (flags & PROXY_FLAGS.isElementConstructor && !ref.$lazyInstance$) {
178+
// wait for lazy instance...
179+
ref.$onReadyPromise$.then(() => {
180+
// check if this instance member has a setter doesn't match what's already on the element
181+
if (
182+
cmpMeta.$members$[memberName][0] & MEMBER_FLAGS.Setter &&
183+
ref.$lazyInstance$[memberName] !== ref.$instanceValues$.get(memberName)
184+
) {
185+
// this catches cases where there's a run-time only setter (e.g. via a decorator)
186+
// *and* no initial value, so the initial setter never gets called
187+
ref.$lazyInstance$[memberName] = newValue;
188+
}
189+
});
134190
}
135-
if (!ref) return;
191+
return;
192+
}
136193

137-
// we need to wait for the lazy instance to be ready
138-
// before we can set it's value via it's setter function
139-
const setterSetVal = () => {
140-
const currentValue = ref.$lazyInstance$[memberName];
141-
if (!ref.$instanceValues$.get(memberName) && currentValue) {
142-
// the prop `set()` doesn't fire during `constructor()`:
143-
// no initial value gets set (in instanceValues)
144-
// meaning watchers fire even though the value hasn't changed.
145-
// So if there's a current value and no initial value, let's set it now.
146-
ref.$instanceValues$.set(memberName, currentValue);
147-
}
148-
// this sets the value via the `set()` function which
149-
// might not end up changing the underlying value
150-
ref.$lazyInstance$[memberName] = parsePropertyValue(newValue, cmpMeta.$members$[memberName][0]);
151-
setValue(this, memberName, ref.$lazyInstance$[memberName], cmpMeta);
152-
};
194+
// lazy element with a setter
195+
// we might need to wait for the lazy class instance to be ready
196+
// before we can set it's value via it's setter function
197+
const setterSetVal = () => {
198+
const currentValue = ref.$lazyInstance$[memberName];
199+
if (!ref.$instanceValues$.get(memberName) && currentValue) {
200+
// on init get make sure the hostRef matches class instance
153201

154-
// If there's a value from an attribute, (before the class is defined), queue & set async
155-
if (ref.$lazyInstance$) {
156-
setterSetVal();
157-
} else {
158-
ref.$onReadyPromise$.then(() => setterSetVal());
202+
// the prop `set()` doesn't fire during `constructor()`:
203+
// no initial value gets set in the hostRef.
204+
// This means watchers fire even though the value hasn't changed.
205+
// So if there's a current value and no initial value, let's set it now.
206+
ref.$instanceValues$.set(memberName, currentValue);
159207
}
160-
},
161-
});
162-
}
163-
}
208+
// this sets the value via the `set()` function which
209+
// might not end up changing the underlying value
210+
ref.$lazyInstance$[memberName] = parsePropertyValue(newValue, memberFlags);
211+
setValue(this, memberName, ref.$lazyInstance$[memberName], cmpMeta);
212+
};
213+
214+
if (ref.$lazyInstance$) {
215+
setterSetVal();
216+
} else {
217+
// the class is yet to be loaded / defined so queue an async call
218+
ref.$onReadyPromise$.then(() => setterSetVal());
219+
}
220+
}
221+
},
222+
});
164223
} else if (
165224
BUILD.lazyLoad &&
166225
BUILD.method &&
@@ -262,8 +321,9 @@ export const proxyComponent = (
262321
const propDesc = Object.getOwnPropertyDescriptor(prototype, propName);
263322
// test whether this property either has no 'getter' or if it does, does it also have a 'setter'
264323
// before attempting to write back to component props
265-
if (!propDesc.get || !!propDesc.set) {
266-
this[propName] = newValue === null && typeof this[propName] === 'boolean' ? false : newValue;
324+
newValue = newValue === null && typeof this[propName] === 'boolean' ? (false as any) : newValue;
325+
if (newValue !== this[propName] && (!propDesc.get || !!propDesc.set)) {
326+
this[propName] = newValue;
267327
}
268328
});
269329
};

0 commit comments

Comments
 (0)