Skip to content

Commit

Permalink
feat(runtime): @Prop / @State now work with runtime decorators (#…
Browse files Browse the repository at this point in the history
…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 <[email protected]>
Co-authored-by: Christian Bromann <[email protected]>
  • Loading branch information
3 people authored Jan 16, 2025
1 parent efb40d5 commit 82fc857
Show file tree
Hide file tree
Showing 23 changed files with 671 additions and 367 deletions.
13 changes: 4 additions & 9 deletions src/client/client-host-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,24 +126,19 @@ const reWireGetterSetter = (instance: any, hostRef: d.HostRef) => {
const members = Object.entries(cmpMeta.$members$ ?? {});

members.map(([memberName, [memberFlags]]) => {
if (
BUILD.state &&
BUILD.prop &&
(memberFlags & MEMBER_FLAGS.Getter) === 0 &&
(memberFlags & MEMBER_FLAGS.Prop || memberFlags & MEMBER_FLAGS.State)
) {
if ((BUILD.state || BUILD.prop) && (memberFlags & MEMBER_FLAGS.Prop || memberFlags & MEMBER_FLAGS.State)) {
const ogValue = instance[memberName];

// Get the original Stencil prototype `get` / `set`
const lazyDescriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(instance), memberName);
const ogDescriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(instance), memberName);

// Re-wire original accessors to the new instance
Object.defineProperty(instance, memberName, {
get() {
return lazyDescriptor.get.call(this);
return ogDescriptor.get.call(this);
},
set(newValue) {
lazyDescriptor.set.call(this, newValue);
ogDescriptor.set.call(this, newValue);
},
configurable: true,
enumerable: true,
Expand Down
2 changes: 1 addition & 1 deletion src/hydrate/platform/hydrate-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export function hydrateApp(
registerHost(elm, Cstr.cmpMeta);

// proxy the host element with the component's metadata
proxyHostElement(elm, Cstr.cmpMeta);
proxyHostElement(elm, Cstr);
}
}
}
Expand Down
44 changes: 33 additions & 11 deletions src/hydrate/platform/proxy-host-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { CMP_FLAGS, MEMBER_FLAGS } from '@utils';

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

export function proxyHostElement(elm: d.HostElement, cmpMeta: d.ComponentRuntimeMeta): void {
export function proxyHostElement(elm: d.HostElement, cstr: d.ComponentConstructor): void {
const cmpMeta = cstr.cmpMeta;

if (typeof elm.componentOnReady !== 'function') {
elm.componentOnReady = componentOnReady;
}
Expand All @@ -32,11 +34,9 @@ export function proxyHostElement(elm: d.HostElement, cmpMeta: d.ComponentRuntime

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

members.forEach(([memberName, m]) => {
const memberFlags = m[0];

members.forEach(([memberName, [memberFlags, metaAttributeName]]) => {
if (memberFlags & MEMBER_FLAGS.Prop) {
const attributeName = m[1] || memberName;
const attributeName = metaAttributeName || memberName;
let attrValue = elm.getAttribute(attributeName);

/**
Expand All @@ -57,8 +57,17 @@ export function proxyHostElement(elm: d.HostElement, cmpMeta: d.ComponentRuntime
}
}

const { get: origGetter, set: origSetter } =
Object.getOwnPropertyDescriptor((cstr as any).prototype, memberName) || {};
let parsedAttrValue: any;

if (attrValue != null) {
const parsedAttrValue = parsePropertyValue(attrValue, memberFlags);
parsedAttrValue = parsePropertyValue(attrValue, memberFlags);
if (origSetter) {
// we have an original setter, so let's set the value via that.
origSetter.apply(elm, [parsedAttrValue]);
parsedAttrValue = origGetter ? origGetter.apply(elm) : parsedAttrValue;
}
hostRef?.$instanceValues$?.set(memberName, parsedAttrValue);
}

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

// create the getter/setter on the host element for this property name
// if we have a parsed value from an attribute use that first.
// otherwise if we have a getter already applied, use that.
// we'll do this for both the element and the component instance.
// this makes sure attribute values take priority over default values.
function getter(this: d.RuntimeRef) {
return ![undefined, null].includes(parsedAttrValue)
? parsedAttrValue
: origGetter
? origGetter.apply(this)
: getValue(this, memberName);
}
Object.defineProperty(elm, memberName, {
get(this: d.RuntimeRef) {
// proxyComponent, get value
return getValue(this, memberName);
},
get: getter,
set(this: d.RuntimeRef, newValue) {
// proxyComponent, set value
setValue(this, memberName, newValue, cmpMeta);
},
configurable: true,
enumerable: true,
});

Object.defineProperty((cstr as any).prototype, memberName, {
get: getter,
configurable: true,
enumerable: true,
});
} else if (memberFlags & MEMBER_FLAGS.Method) {
Object.defineProperty(elm, memberName, {
value(this: d.HostElement, ...args: any[]) {
Expand Down
236 changes: 148 additions & 88 deletions src/runtime/proxy-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import { getValue, setValue } from './set-value';
* 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
Expand Down Expand Up @@ -57,110 +62,164 @@ export const proxyComponent = (
// 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))
) {
if ((memberFlags & MEMBER_FLAGS.Getter) === 0) {
// proxyComponent - prop
// 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 } = Object.getOwnPropertyDescriptor(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) {
// proxyComponent, get value
return getValue(this, memberName);
},
set(this: d.RuntimeRef, newValue) {
// only during dev time
if (BUILD.isDev) {
const ref = getHostRef(this);
if (
// we are proxying the instance (not element)
(flags & PROXY_FLAGS.isElementConstructor) === 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 (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);
}
// proxyComponent, set value
setValue(this, memberName, newValue, cmpMeta);
},
configurable: true,
enumerable: true,
});
} else if (flags & PROXY_FLAGS.isElementConstructor && memberFlags & MEMBER_FLAGS.Getter) {
if (BUILD.lazyLoad) {
// lazily maps the element get / set to the class get / set
// proxyComponent - lazy prop getter
Object.defineProperty(prototype, memberName, {
get(this: d.RuntimeRef) {
const ref = getHostRef(this);
const instance = BUILD.lazyLoad && ref ? ref.$lazyInstance$ : prototype;
if (!instance) return;
}

return instance[memberName];
},
configurable: true,
enumerable: true,
});
}
if (memberFlags & MEMBER_FLAGS.Setter) {
// proxyComponent - lazy and non-lazy. Catches original set to fire updates (for @Watch)
const origSetter = Object.getOwnPropertyDescriptor(prototype, memberName).set;
Object.defineProperty(prototype, memberName, {
set(this: d.RuntimeRef, newValue) {
// non-lazy setter - amends original set to fire update
const ref = getHostRef(this);
if (origSetter) {
const currentValue = ref.$hostElement$[memberName as keyof d.HostElement];
if (!ref.$instanceValues$.get(memberName) && currentValue) {
// the prop `set()` doesn't fire during `constructor()`:
// no initial value gets set (in instanceValues)
// meaning 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
origSetter.apply(this, [parsePropertyValue(newValue, cmpMeta.$members$[memberName][0])]);
setValue(this, memberName, ref.$hostElement$[memberName as keyof d.HostElement], cmpMeta);
return;
Object.defineProperty(prototype, memberName, {
set(this: d.RuntimeRef, newValue) {
const ref = getHostRef(this);

// 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
(cmpMeta.$members$[memberName][0] & MEMBER_FLAGS.Prop) !== 0 &&
// the member is not mutable
(cmpMeta.$members$[memberName][0] & 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);
} else if (!ref.$instanceValues$.get(memberName) && currentValue) {
// on init get make sure the hostRef matches the element (via prop / attr)

// the prop `set()` doesn't necessarily fire during `constructor()`,
// so 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
origSetter.apply(this, [parsePropertyValue(newValue, memberFlags)]);
// 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.$onReadyPromise$.then(() => {
// 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;
}
});
}
if (!ref) return;
return;
}

// we need to wait for the lazy 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) {
// the prop `set()` doesn't fire during `constructor()`:
// no initial value gets set (in instanceValues)
// meaning 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, cmpMeta.$members$[memberName][0]);
setValue(this, memberName, ref.$lazyInstance$[memberName], cmpMeta);
};
// 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

// If there's a value from an attribute, (before the class is defined), queue & set async
if (ref.$lazyInstance$) {
setterSetVal();
} else {
ref.$onReadyPromise$.then(() => setterSetVal());
// 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);
setValue(this, memberName, ref.$lazyInstance$[memberName], cmpMeta);
};

if (ref.$lazyInstance$) {
setterSetVal();
} else {
// the class is yet to be loaded / defined so queue an async call
ref.$onReadyPromise$.then(() => setterSetVal());
}
}
},
});
} else if (
BUILD.lazyLoad &&
BUILD.method &&
Expand Down Expand Up @@ -262,8 +321,9 @@ export const proxyComponent = (
const propDesc = Object.getOwnPropertyDescriptor(prototype, propName);
// 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
if (!propDesc.get || !!propDesc.set) {
this[propName] = newValue === null && typeof this[propName] === 'boolean' ? false : newValue;
newValue = newValue === null && typeof this[propName] === 'boolean' ? (false as any) : newValue;
if (newValue !== this[propName] && (!propDesc.get || !!propDesc.set)) {
this[propName] = newValue;
}
});
};
Expand Down
Loading

0 comments on commit 82fc857

Please sign in to comment.