Better know a framework #27: using watch decorator

When using @watch on a method, I think having newValue as first parameter is quite an expected behavior. When using @watch on a class, though, it feels like it’s sometimes better to have the instance as the first parameter. But if we are to shuffle the parameters based on @watch usage, it could feel weird or inconsistent. If this was controllable, then inside an app, we could encounter different styles in different sections, and won’t be able to even recognise anything quickly, probably a funnier situation.

For the @watch decorator, or automatic observation in general, there’s a need to know when to start/stop the observation, for that maybe observing static properties/fields doesn’t feel like feasible. It can still be done via user-controlled observation API though.

For the ability to distinguish between the source expression of the callback method, it may be useful, actually. We could add that. Though it would be incompatible with a future version of @watch, where we support real function as a way to collect dependencies.

Nice suggestions, btw :+1:

1 Like

Yes. I agree with the fact that the intuitively best argument order of the decorator would be dependent on its context (as a class decorator or property decorator). I understand the difficulty of this design decision. Since it is pretty challenging (if not impossible) to determine how the community will use this decorator, I doubt if that criterium should be taken into account for your design decision here. Perhaps the choice should be mainly based on other criteria, like usage consistency, expected future usage (regarding TypeScript and JavaScript evolution), internal scalability in Aurelia, etc. Anyway, to me, as a TypeScript-oriented developer, I would prefer property decoration over class decoration, and thus - together with the consistency argument - the current order (newValue, oldValue, instance) does seem to be the most logical choice to me. Unless it is already known that this choice would complicate any important future design choices in Aurelia.

I also agree with the static issues. It was just a question. Using singletons would be a design choice as well, and a very valid one in the Aurelia architecture. IMHO, it would probably be better to avoid using static stuff as much as possible.

For the source expression issues (if they would be important for me as an Aurelia consumer), instead of this:

class App {

  counter1 = 0;
  counter2 = 0;

  @watch(abc => abc.counter1)
  @watch(abc => abc.counter2)
  log(newValue) {
    // which expression triggered me???
  }
}

… I could just as easily do something like this:

class App {
  counter1 = 0;
  counter2 = 0;

  @watch(abc => abc.counter1)
  logCounter1(newValue) {
    // log counter 1
    this.log(newValue, 'counter1');
  }

  @watch(abc => abc.counter2)
  logCounter2(newValue) {
    // log counter 2
    this.log(newValue, 'counter2');
  }

  log(newValue, expressionName) {
    // Great, I now know who triggered me.
  }
}

… or with a class decorator:

@watch(abc => abc.counter1, (newValue, oldValue, app) => app.log(newValue, 'counter1'))
@watch(abc => abc.counter2, (newValue, oldValue, app) => app.log(newValue, 'counter2'))
class App {
  counter1 = 0;
  counter2 = 0;

  log(newValue, expressionName) {
    // Great, I now know who triggered me.
  }
}

… if it is necessary. Perhaps it would be more straightforward to handle specific logic in the logCounter1 and logCounter2 methods themselves.

1 Like

for me, even a simple decorator that just calls the function (without any parameters) is already good enough. :slight_smile:

but of course “knowledge is power” and I would prefer to have the power and knowledge of what exactly trigger the watch.,

I think that keeping the same order of parameters in all use-cases is an absolute must.

  1. it’s a lot simpler in term of the plugin code [for future maintaining]. no need to have multiple path’s in the code for multiple usage/context styles.
  2. it’s a lot simpler in term of the user code. you learn once, you use many times. in different contexts.

I think that we should have the below list of parameters for all use-cases.

@watch(abc => abc.counter1)
@watch(abc => abc.counter2)
log(newValue, oldValue, instance, expressionName) {
}

this is consistent with already familiar aurelia api’s, like @observable and @bindable [with the addition of instance etc.]
it simplify the (likely) most often used use-case where we will have only 1 watched expression and we will only want to know about the newValue.

if the expression is a complex one [eg someObj.someProperty.something.value],
instance should be something? or someObj?
the answer to that design question also dictate what will go into expressionName.
instance: something, expressionName: ‘value’.
instance: someObj, expressionName: ‘someProperty.something.value’.

2 Likes

Yes, I agree about those proposed parameters. But I think that the @watch signature requires an additional argument as well, which will be passed as the fourth parameter to the method. Or is it possible to let Aurelia “automatically” pass a generated readable/usable value in the method’s expressionName argument?

Adding such an additional argument for the expression name to the decorator might make its implementation quite complex, I guess, since it should also already support a second parameter when used as a class decorator…

But if this change would interfere with upcoming features, as @bigopon fears, I would not mind if it does not get implemented this way. IMHO, there are other ways to get the information I need (if I actually need it).

2 Likes

Well, I think expressions could be more complex. What would be expressionName’s value in the scenario below?:

class App {
  counter1 = 0;
  counter2 = 0;

  @watch(abc => abc.counter1 < abc.counter2)
  log(newValue, oldValue, instance, expressionName) {
  }
}

(The intention of the above code is to invoke the log method based on a comparison of counter1 and counter2. The passed newValue and oldValue arguments will be booleans in this case.)

And, by the way, as I see it, instance will always refer to either the instance of the decorated class on which the change is detected (when @watch is used as a class decorator) or the decorated method’s class instance on which the change is detected (when @watch is used as a property decorator). But I might be mistaken, so it would indeed be useful if this could be explicitly clarified.

2 Likes

I think expression like @watch(abc => abc.counter1 < abc.counter2) should not be allowed at all.
one should simply observe the changes to the core values, and apply logic inside the function.

I assume that the watch decorator is using the BindingEngine behind the scene.
and therefor will likely suffer some limitations as well (like observing array mutation etc.)

2 Likes

@avrahamcool

I assume that the watch decorator is using the BindingEngine behind the scene.

Yes it is using similar but more efficient way of doing that, since it doesn’t need to be as generic as the api at: BindingEngine.prototype.expressionObserver. This means it support a lot, but they are expected to come from the instance (abc.counter1, abc.d.e.f.g.h). An example: of what won’t work as intended

const someStaticObject = {
  counter: 0
}

export class Abc {
  counter = 0;

  @watch(abc => abc.counter > someStaticObject.counter)
  logBalanceChanged() {
    // ...
  }
}

@watch(abc => abc.counter > someStaticObject.counter) will be turned into an expression like this: counter > someStaticObject.counter, and thus, results in the observation of property named someStaticObject on the observed class instance instead of our static object. This is a limitation that is not easily overcome in v1, as you need to assign that static object to all instances:

const someStaticObject = {
  counter: 0
}
export class Abc {
  counter = 0;
+  someStaticObject = someStaticObject

  @watch(abc => abc.counter > someStaticObject.counter)
  logBalanceChanged() {
    // ...
  }
}

I’m preparing some basic work to support a feature of @watch so we don’t have to “serialize” the watch expression function body to parse the expression.


For:

I think expression like @watch(abc => abc.counter1 < abc.counter2) should not be allowed at all.

This is quite a simple expression, I think it should be supported. Basically whatever that is valid in a normal view should be valid here too, otherwise it’d be a bit unpredictable. In the end, we are only dealing with a subset of JS expression anyway, so it should be ok. For array observation limitation, we can combine the @watch with the @deepComputedFrom plugin here GitHub - bigopon/aurelia-deep-computed: A plugin for declaring deep/shallow computed observation

Edit: to be clear: for initial version of @watch in v1, complex expression are difficult to support, but our stance should be that eventually they are supported.

2 Likes