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 the bind 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 the bind lifecycle
  • Declaring the binding (v2’s equivalent of bind) 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 of propertyChanged 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 a propertyChanged 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 during bind makes more sense than opt-out
  • consistent propertyChanged callback behavior irrespective of the presence/absence of binding 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 whether propertyChanged is invoked during bind.

  • Option 2: v2 behavior, opt-in propertyChanged is never invoked during bind. If you need them to be invoked, you must do so by manually invoking them from the binding() hook

  • Option 2: v2 behavior, opt-out propertyChanged is always invoked during bind. If you need to ignore them during bind, 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

Most upvoted comments

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 how propertyChanged 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:

  1. The change handler call signature: @bindable is (newValue, oldValue) and @observable is (newValue, oldValue, key)
  2. The change handler invocation time: @bindable happens after a microtask (via the task queue), @observable happens immediately and synchronously
  3. The property value storage: @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.
  4. The observer initialization time: @bindable has lazy initialization: it stores metadata on the object, and the bind lifecycle initializes observers based on this metadata. @observable is immediately initialized from within the decorator.
  5. Subscriber cardinality: @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:

  1. Yes. The call signature will become the same (key will be removed as this is not needed in an already named change handler)
  2. Yes. Binding in v2 is fully synchronous, so both mechanisms will be synchronous in v2 instead of only @observable.
  3. Yes. While this difference in theory makes @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
  4. Probably not. We certainly can’t make @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.
  5. Probably yes. An inherent property of using a separate observer object is you can get multicast change notification, so theoretically @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:

myValueChanged(newValue, oldValue) {
    if (oldValue !== undefined) {
        // This is not an initial change
    }
}

Is it possible to use a decorator instead of if (!LifecycleFlags.fromBind(flags) { /* … */ } something like @disablePropertyChangeDetection on bind?

Good point, we could add a decorator, though with option 2 that would be @invokeOnBind as by default they are not invoked on bind

To 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.