September 4, 2015

Decorating AngularJS Directives to Support Rx.Observable: Part 2

In April I posted about decorating a subset of AngularJS’s most common and simple built-in directives to accept Rx.Observable bindings. One erroneous assumption I made in that post quickly became apparent when I actually attempted to use the decorator in a real application: these directives are not always bound to simple properties directly on the scope.

The attribute values passed to directives like ngShow are not merely scope property names; they are Angular expressions. This means they can also be function calls, arbitrarily complex boolean expressions, or the increasingly popular controller property names. None of those work with my original decorator implementation.

Overview

Fortunately, it shouldn’t be too much work to alter the directive decorator to support arbitrary expressions just like the directives it’s decorating. The major difference this time around is that instead of passing the original directive linking function a new child scope that inherits from the original directive scope, we’re always going to pass it a new isolate scope containing a single, simple property for the decorated directive to watch. We’ll watch the original expression provided to the directive until it sends a non-undefined, non-null value; then if the value is an Observable, we’ll subscribe to it in order to keep the isolate scope up-to-date. Otherwise, we’ll invoke the original directive linking function with all of its original arguments for it to do everything.

TL;DR: Here’s a diff of the changes to the gist.

Changes

The first third of the code is the same as before.

function rxDecorateDirective($provide, directiveName) {
    function isObservable(obj) {
        return obj && typeof obj.subscribe === 'function';
    }

    $provide.decorator(directiveName + 'Directive', ['$delegate', 'rx', function($delegate, rx) {
        var directiveConfig = $delegate[0];
        var originalCompileFn = directiveConfig.compile;

        directiveConfig.compile = function() {
            var originalLinkFn = originalCompileFn.apply(directiveConfig, arguments);

First, we’ll make a reference to the original linking function arguments, but this time we’ll defer modifying them until we know we’re bound to an Observable.

return function postLink(scope, iElement, iAttrs) {
    var originalScope = scope;
    var linkArgs = arguments;

Next we’ll obtain the expression we need to watch by looking up the directive name key in the attributes object.

var attrExpression = iAttrs[directiveName];

That’s the expression we’ll need to watch. We’ll use rx-angular’s Scope.$toObservable function to make a watcher into an Observable we can pluck, filter, and take only the first relevant value from.

originalScope.$toObservable(attrExpression)
    .pluck('newValue')
    .skipWhile(function isUndefinedOrNull(value) {
        return typeof value === 'undefined' || value === null;
    })
    .first()
    .subscribe(function onFirstUsefulValue(firstValue) { /*...*/ });

If the first useful value is an Observable, we need to create a new isolate scope to replace the original scope in the linking function arguments. We also have to replace the value of the normalized directive name attribute value in the attributes object with a new private name we made up: $$rxValue. This will cause the original directive code to set a watcher on the expression $$rxValue instead of the original expression that evaluated to an Observable. Then we can invoke the original linking function with the modified arguments.

.subscribe(function onFirstUsefulValue(firstValue) {
    if (isObservable(firstValue)) {
        var RX_VALUE_SCOPE_PROPERTY = '$$rxValue';

        var valueStream = firstValue.share();

        var isolateScope = originalScope.$new(true);

        linkArgs[0] = isolateScope;

        iAttrs.$set(directiveName, RX_VALUE_SCOPE_PROPERTY);

        originalLinkFn.apply(directiveConfig, linkArgs);
// ...

Next we subscribe to the Observable. Anytime we get a new value, we update the property named $$rxValue on the isolate scope that the original directive is watching.

var valueStreamDisposable = valueStream
    .safeApply(isolateScope, function onNextValue(value) {
        isolateScope[RX_VALUE_SCOPE_PROPERTY] = value;
    })
    .subscribe();

Finally, if the first useful value is some other object besides an Observable, we’ll call the original linking function with all the original arguments so it can watch normally.

    } else {
        originalLinkFn.apply(directiveConfig, linkArgs);
    }
});

This is a bit simpler and shorter than the original version of the decorator, and it should cover many more common usage scenarios than before.

The original completed gist has been updated here.