aurelia: [RFC] propertyChanged callbacks in v2: a good breaking change or a bad one?
đź’¬ RFC
Summary
We have introduced a breaking change in the way propertyChanged
callbacks behave, but we could easily revert that breaking change. Should we, though? We need opinions.
🔦 Context
v1
If your component has no bind
method, the initialization logic will place that method on your component and it would invoke those callbacks. So:
propertyChanged
callbacks are invoked during thebind
lifecycle by default.- If you declare the
bind
method in your component, they are not invoked (because the method that invokes them is not placed on your component).
v2
We have the improved architecture and flexibility to do pretty much anything here, but we need to pick something as a default. In line with improving unobtrusiveness (e.g. not placing methods on your components), we temporarily decided on the following:
propertyChanged
callbacks are never invoked during thebind
lifecycle- Declaring the
binding
(v2’s equivalent ofbind
) method does not affect any behavior
This is based on some assumptions, which may or may not be correct:
- Often, you only need to do something when a value changes (as opposed to being set for the first time)
- If you need to do something specific during
bind
, the changed behavior ofpropertyChanged
callbacks can catch you off-guard. It requires you to be aware of a framework internal that is not necessarily intuitive. - If you need to do something during initial set of one or more properties, it’s typically irrespective of which specific property changed; rather it’s a component-wide initialization that may use several properties. It makes more sense to invoke this logic from
binding
hook than from apropertyChanged
callback.
But it’s a breaking change
And that in itself might also catch people off-guard, so we need to find out if above assumptions are correct in the sense that:
- opt-in for
propertyChanged
callbacks duringbind
makes more sense than opt-out - consistent
propertyChanged
callback behavior irrespective of the presence/absence ofbinding
makes more sense than hook-dependent behavior
Again, we have the flexibility to do pretty much anything here, we don’t have some of the constraints we did in v1. We just need to make a choice for something which we then won’t be changing for the years to come.
đź’» Examples
Assume a simple component with a single bindable property.
We set its initial value to 42
from the app (not shown here), and after initialization we set it to 43
, and observe the console log.
v1
without bind
hook
export class Component {
@bindable property;
propertyChanged(newValue, oldValue) {
console.log('propertyChanged', newValue, oldValue);
}
}
startup log
enter component controller.bind
'propertyChanged', 42, undefined
leave component controller.bind
'propertyChanged', 43, 42
with bind
hook
export class Component {
@bindable property;
propertyChanged(newValue, oldValue) {
console.log('propertyChanged', newValue, oldValue);
}
bind() {
console.log('bind', this.property);
}
}
startup log
enter component controller.bind
'bind', 42
leave component controller.bind
'propertyChanged', 43, 42
v2 with opt-in (as it is now)
Note: flags
is a numeric property represented by const enum LifecycleFlags
. They’re shown as strings in the log to understand better how they could be utilized here. Also, flags which are irrelevant for the purpose of this examples (e.g. fromStartTask
) are omitted for brevity.
without binding
hook
export class Component {
@bindable property;
propertyChanged(newValue, oldValue, flags) {
console.log('propertyChanged', newValue, oldValue, flags);
}
}
startup log
enter component controller.bind
leave component controller.bind
'propertyChanged', 43, 42, 'updateTarget'
with binding
hook
export class Component {
@bindable property;
propertyChanged(newValue, oldValue, flags) {
console.log('propertyChanged', newValue, oldValue, flags);
}
binding() {
console.log('bind', this.property);
}
}
startup log
enter component controller.bind
'binding', 42
leave component controller.bind
'propertyChanged', 43, 42, 'updateTarget'
v2 with opt-out
without binding
hook
export class Component {
@bindable property;
propertyChanged(newValue, oldValue, flags) {
console.log('propertyChanged', newValue, oldValue, flags);
}
}
startup log
enter component controller.bind
'propertyChanged', 42, undefined, 'updateTarget | fromBind'
leave component controller.bind
'propertyChanged', 43, 42, 'updateTarget'
with binding
hook
export class Component {
@bindable property;
propertyChanged(newValue, oldValue, flags) {
console.log('propertyChanged', newValue, oldValue, flags);
}
binding() {
console.log('bind', this.property);
}
}
startup log
enter component controller.bind
'propertyChanged', 42, undefined, 'updateTarget | fromBind'
'binding', 42
leave component controller.bind
'propertyChanged', 43, 42, 'updateTarget'
TL;DR
What makes the most sense to you, the user?
-
Option 1: v1 behavior The presence/absence of
bind()
method determines whetherpropertyChanged
is invoked during bind. -
Option 2: v2 behavior, opt-in
propertyChanged
is never invoked duringbind
. If you need them to be invoked, you must do so by manually invoking them from thebinding()
hook -
Option 2: v2 behavior, opt-out
propertyChanged
is always invoked duringbind
. If you need to ignore them duringbind
, you can use the flags (e.g.if (!LifecycleFlags.fromBind(flags) { /* ... */ }
) to conditionally execute your logic.
About this issue
- Original URL
- State: closed
- Created 5 years ago
- Comments: 18 (15 by maintainers)
Commits related to this issue
- Update tests for breaking change #670 — committed to rluba/aurelia by rluba 5 years ago
My preference would be for option 2.
I’m not a fan of option 1, as it introduces “magic” where it behaves differently based on circumstances. I’ve been bitten by this in v1, as introducing
bind
later in the game can conflict with assumptions made howpropertyChanged
would be called when it was implemented, which can result in hard to debug situations.I like the consistency that both the v2 options provide. Of those I have a slight dislike for option 3, as
if (!LifecycleFlags.fromBind(flags) { /* ... */ }
can look a bit scary to people new to the framework who don’t yet know its intricate details, while the other could just be a callout in the documentation.EDIT: How easy would it be to restore the old v1 behaviour (with some logging of compat warnings) in an
@aurelia/compat
package for migrating from v1 to v2?@arnederuwe There are a few differences between
@bindable
and@observable
in v1:@bindable
is(newValue, oldValue)
and@observable
is(newValue, oldValue, key)
@bindable
happens after a microtask (via the task queue),@observable
happens immediately and synchronously@bindable
creates an observer that’s stored on a__observers
lookup on the object and the property value is stored inside the observer.@observable
, being much more lightweight, stores the value directly on the object in a non-enumerable property prefixed with underscore.@bindable
has lazy initialization: it stores metadata on the object, and thebind
lifecycle initializes observers based on this metadata.@observable
is immediately initialized from within the decorator.@observable
only has one change handler on the observed object itself.@bindable
has this change handler as well, but is also capable of having an arbitrary number of subscribers via component-to-component or component-to-view bindings.What we can reconcile:
key
will be removed as this is not needed in an already named change handler)@observable
.@observable
more lightweight and thus faster, it probably isn’t actually faster due to the additional polymorphism from adding hidden properties, so as far as I’m concerned@observable
should also work with an observer just like@bindable
@observable
lazy because there is no JIT compilation process to initialize observers, and making@bindable
eager would not only have performance implications but would also remove a number of new options in v2, such as proxy mode, batching and timeslicing.@observable
could be used for properties that still need to be bound to by components or views.If I missed anything, let me know
@EisenbergEffect @bigopon would you agree?
My vote is for option 2 as well. I have often needed to implement the
bind
method in my v1 apps or do a check inside of my property change handler like this to circumvent:Good point, we could add a decorator, though with option 2 that would be
@invokeOnBind
as by default they are not invoked on bindTo me, option 2 represents magic, from the perspective of reactivity: that it needs to halt during initialization and start only after that. Option 1, while as first may catch folks off guard because they may accidentally disabled initial reactivity, it’s still relatively easy to understand. We rarely see folks coming back 2nd time and say this behavior completely sucks. Every app/plugin contains both complex and simple components (custom elements/attributes). For the simple components, having to hook into the lifecycle to ensure my reactivity will play normally is quite a ceremonial thing to do, especially when I have the medium number of bindables (not 1, but not 4+). For complex components, it’s quite different. Lifecycle hook is needed and initial reactivity is needed to be turned off anyway, and folks can easily control it from there.
Yes from me for 1, and opt out for 2.