Search code examples
bindingaureliaproperty-observer

Best way to ignore your own changes using an Aurelia Property Observer


I'm hoping someone can offer some guidance.

It is about making use of the Aurelia Observers in the best possible manner.

The example is a made-up example to illustrate the perceived problem.

Let's say I have three number properties: hours, mins, secs aka H, M, S and I wish to build an object that monitors H, M, S and each time any of these three changes, it produces a property T which is string that looks like a duration eg. "hh:mm:ss" using the H, M, S properties with appropriate zero padding etc.

Conversely, the property T is bound to in the user interface and therefore if the user modifies T (eg. via an input field), I need to decompose the pieces of T into their H, M, S components and push the individual values back into the H, M, S properties.

It should not be lost upon anyone that this behaviour is remarkably similar to the Aurelia value converter behaviour in terms of "toView" and "fromView" behaviour, except that we are dealing with 3 properties on 1 side, and 1 property on the other side.

Implementation-wise, I can use the BindingEngine or ObserverLocator to create observers on the H, M, S and T properties, and subscribe to those change notifications. Easy peasy for that bit.

My question is ultimately this:

When H changes, that will trigger a recompute of T, therefore I will write a new value to T.

Changing T will trigger a change notification for T, whose handler will then decompose the value of T and write new values back to H, M, S.

Writing back to H, M, S triggers another change notification to produce a new T, and so forth.

It seems self-evident that it is - at the very least - DESIRABLE that when my "converter" object writes to the H, M, S, T properties then it should prepare itself in some way to ignore the change notification that it expects will be forthcoming as a consequence.

However, [the ease of] saying it and doing it are two different things.

My question is -- is it really necessary at all? If it is desirable, how do I go about doing it in the easiest manner possible. The impediments to doing it are as follows:

There must be a real change to a value to produce a change notification, so you need to know in advance whether you are "expecting" to receive one. The much more difficult issue is that the change notifications are issued via the Aurelia "synthetic" micro-task queue, so you are very much unaware when you will receive that micro-task call.

So, is there a good solution to this problem? Does anyone actually worry about this, or do they simply rely on Point 1 producing a self-limiting outcome? That is, there might be a couple of cycles of change notifications but the process will eventually acquiesce?


Solution

  • The simplest way to implement an N-way binding (in this case 4-way) is by using change handlers combined with "ignore" flags to prevent infinite recursion.

    The concept below is quite similar to how some of these problems are solved internally in the Aurelia framework - it is robust, performant, and about as "natural" as it gets.

    @autoinject()
    export class MyViewModel {
        @bindable({ changeHandler: "hmsChanged" })
        public h: string;
    
        @bindable({ changeHandler: "hmsChanged" })
        public m: string;
    
        @bindable({ changeHandler: "hmsChanged" })
        public s: string;
    
        @bindable()
        public time: string;
    
        private ignoreHMSChanged: boolean;
        private ignoreTimeChanged: boolean;
    
        constructor(private tq: TaskQueue) { }
    
        public hmsChanged(): void {
            if (this.ignoreHMSChanged) return;
            this.ignoreTimeChanged = true;
            this.time = `${this.h}:${this.m}:${this.s}`;
            this.tq.queueMicroTask(() => this.ignoreTimeChanged = false);
        }
    
        public timeChanged(): void {
            if (this.ignoreTimeChanged) return;
            this.ignoreHMSChanged = true;
            const hmsParts = this.time.split(":");
            this.h = hmsParts[0];
            this.m = hmsParts[1];
            this.s = hmsParts[2];
            this.tq.queueMicroTask(() => this.ignoreHMSChanged = false);
        }
    }
    

    If you need a generic solution you can reuse across multiple ViewModels where you have different kinds of N-way bindings, you could do so with a BindingBehavior (and possibly even the combination of 2 ValueConverters).

    But the logic that needs to be implemented in those would be conceptually similar to my example above.

    In fact, if there were a premade solution to this problem in the framework (say, for example, a setting of the @bindable() decorator) then the internal logic of that solution would also be similar. You'll always need those flags as well as to delay their resetting via a micro task.

    The only way to avoid using a micro task would be to change some internals of the framework such that a context can be passed along which can tell the dispatcher of change notifications "this change came from a change handler, so don't trigger that change handler again"