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:
propertyChangedcallbacks are invoked during thebindlifecycle by default.- If you declare the
bindmethod 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:
propertyChangedcallbacks are never invoked during thebindlifecycle- 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 ofpropertyChangedcallbacks 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
bindinghook than from apropertyChangedcallback.
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
propertyChangedcallbacks duringbindmakes more sense than opt-out - consistent
propertyChangedcallback behavior irrespective of the presence/absence ofbindingmakes 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 whetherpropertyChangedis invoked during bind. -
Option 2: v2 behavior, opt-in
propertyChangedis 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
propertyChangedis 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
bindlater in the game can conflict with assumptions made howpropertyChangedwould 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/compatpackage for migrating from v1 to v2?@arnederuwe There are a few differences between
@bindableand@observablein v1:@bindableis(newValue, oldValue)and@observableis(newValue, oldValue, key)@bindablehappens after a microtask (via the task queue),@observablehappens immediately and synchronously@bindablecreates an observer that’s stored on a__observerslookup 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.@bindablehas lazy initialization: it stores metadata on the object, and thebindlifecycle initializes observers based on this metadata.@observableis immediately initialized from within the decorator.@observableonly has one change handler on the observed object itself.@bindablehas 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:
keywill be removed as this is not needed in an already named change handler)@observable.@observablemore 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@observableshould also work with an observer just like@bindable@observablelazy because there is no JIT compilation process to initialize observers, and making@bindableeager would not only have performance implications but would also remove a number of new options in v2, such as proxy mode, batching and timeslicing.@observablecould 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
bindmethod 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
@invokeOnBindas 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.