My $scope Is Not Your Dumping Ground

by
Tags: , ,

This little problem came up in my recent AngularJS/Ionic app.
It all started with an innocent modal dialog. There are several, but for the sake of simplicity, let’s look at the login dialog:
Login Dialog
The generic dialog plumbing is a div with a surrounding overlay that dims out the rest of the screen. You can click the X in the top right to dismiss it. Or if you fill out the fields the Log In link becomes enabled. When you click it, then you get a little spinner while it processes and it either disappears and you’re logged in or shows a message for you to try again. Not much to it, right?
Well, I found a third-party modal dialog directive for Angular — one small CSS and one small JS file and I’d be able to do it:

<modal-dialog show='loginVisible' on-close='loginClosed()'>
    <!-- A bunch of Ionic HTML here, including roughly: -->
    <input ng-model='email' type='text'>
    <input ng-model='password' type='password'>
    <a ng-click='login()'>Log In</a>
</modal-dialog>

Here’s how it works. In whatever scope you put this in, there should be a loginVisible field. If it’s set to true, the dialog shows up. When set to false, the dialog and overlay all disappear similar to the ng-show behavior (why doesn’t it just use ng-show? We’ll get to that). Finally, if you click the “X” in the top right corner, it evaluates whatever you assign to the on-close attribute — here calling a function on the scope. The meaty content of the dialog has two input fields bound to scope variables, and the login link bound to a scope function. All good.
The controller code is pretty simple too. Neglecting the field validation, it looks something like this:

angular.module('someapp').
    controller('LoginCtrl', function($scope, server) {
        $scope.loginVisible = true;
        $scope.login = function() {
            server.login($scope.email, $scope.password,
                function() {
                    $scope.loginVisible = false;
                    // You're logged in, now do stuff
                },
                function() {
                    alert("Login failed.  Please try again.");
                });
        };
        $scope.loginClosed = function() {
            // You canceled the login, do something else
        };
    });

All right. When you invoke this controller, it shows the dialog (line 3). If you click the ‘X’, the directive sets loginVisible=false for you, so you just have to take whatever action is appropriate (line 17) — maybe nothing, maybe send the user to an access denied page, whatever. And, if you proceed with the login, we pass the e-mail and password you entered to some service that knows how to talk to the server (line 6). We also provide a success callback that closes the dialog (line 8) and a failure callback that notifies you (line 12) but leaves the dialog open for you to try again.

There’s only one small problem. This doesn’t work.

Specifically, the login always fails, and some debugging leads you to the conclusion that $scope.email and $scope.password are always undefined. A dialog widget that somehow crippled ng-model? WTF?
All right, let’s look at that dialog directive in detail:

angular.module("dialog").
  directive('modalDialog', function() {
    return {
      restrict: 'E',
      scope: {
        show: '=',
        onClose: '&?'
      },
      replace: true,
      transclude: true,
      link: function(scope, element, attrs) {
        ...
      },
      template:
"<div class='ng-modal' ng-show='show'>
  <div class='ng-modal-overlay'></div>
  <div class='ng-modal-dialog' ng-style='dialogStyle'>
    <span class='ng-modal-title'
          ng-show='dialogTitle && dialogTitle.length'
          ng-bind='dialogTitle'></span>
    <div class='ng-modal-close' ng-click='hideModal()'>
      <div ng-bind-html='closeButtonHtml'></div>
    </div>
    <div class='ng-modal-dialog-content' ng-transclude></div>
  </div>
</div>"
    };
  });

The original link function is a mess, but that’s OK, the problem is already visible here.
Look at the template. Let’s list that garbage it needs in my scope:

  1. show (line 15)
  2. dialogStyle (line 17)
  3. dialogTitle (lines 19-20)
  4. hideModal() (line 21)
  5. closeButtonHtml (line 22)

For some reason, the template actually turns the show attribute into ng-show instead of just letting you use ng-show directly. That probably could have been avoided, but that’s the least of the issues.
The last key part of the template is that it uses Angular transclusion (line 24) to stuff all the content I put inside the <modal-dialog> element into the emitted HTML.
Now, to their credit, they actually agree that the surrounding scope should not be their dumping ground. They don’t spew all that stuff into my scope. They create their own instead (lines 5-8). With a private scope for this directive, all that junk they need will be hidden away from the rest of the app, you can have multiple copies of the dialog on a single page without interfering, and all that lovely isolate scope business.

BAM! Now my dialog is completely dysfunctional.

Oh, yeah. There’s one other side effect of the isolate scope. The widgets I put into the modal dialog? Their fields are now bound to properties on the isolate scope instead of the controller’s scope. Completely invisible to the controller. So my login dialog sets $scope.email and $scope.password on the scope that has all the modal-dialog crap instead of the one where the rest of my application lives. When the controller uses $scope.email and $scope.password, they come up undefined because the widgets are operating on a child scope.
Workarounds? Sure, there are workarounds. At least two:

  1. Put an object into the main scope, and bind the text input fields to properties of the object in the scope instead of properties directly on the scope.
  2. Don’t put a controller on or around the modal-dialog element, but attach it to an element inside the modal-dialog. That way, everything will be in the dialog’s isolate scope. Everything except the dialogVisible property. So that will pollute some parent scope. Also, you can’t refer to it as dialogVisible, because inside the dialog’s scope, it’s known as show, which you have to read the third-party code to know. So having a successful login close the dialog is not at all trivial. And if you use an on-close function, it also pollutes the parent scope and can’t live in your controller.

Yeah, both of these are pretty crappy workarounds. I’m not looking for a third-party widget that puts artificial constraints on how I code my dialog controller, especially if I have to read and understand the third-party code to even know what those constraints are.

The Solution: Scope-Free

Fortunately, there’s no reason the modal dialog widget needs to put anything in the scope at all. They were just being lazy. There’s a little more “JQuery Lite” code needed to lay out the dialog without scopes, but it means the entire dialog, contents and all, can live in the owning controller’s scope without polluting it at all.
Let’s look:

angular.module("dialog").
  directive('modalDialog', function() {
    return {
      restrict: 'E',
      replace: true,
      transclude: true,
      compile: function (element, attrs) {...},
      template:
"<div class='ng-modal'>
  <div class='ng-modal-overlay'></div>
  <div class='ng-modal-dialog'>
    <span class='ng-modal-title'></span>
    <div class='ng-modal-close'></div>
    <div class='ng-modal-dialog-content'></div>
  </div>
</div>"
    };
  });

Now the link function has become a compile function, and it does more things like this:

var i, spans = element.find("span");
for(i=0; i<spans.length; i++) {
    if(spans.eq(i).hasClass('ng-modal-title')) {
        if(attrs.dialogTitle)
            spans.eq(i).text(attrs.dialogTitle);
        else
            spans.eq(i).css({display: "none"});
        break;
    }
}

Given the JQuery Lite element representing the template, it looks through it and hides or customizes the title, close widget, content style (width and height), and so on. It can assign actual Angular directives like ng-click to the elements in the template. The whole dialog can even use a regular ng-show attribute now to control its visibility!
Overall, the compile function is probably a little more complicated than the old link function needed to be. But what a tradeoff — a slightly more complex directive you write once, in exchange for no compromises in your application code. I’ll take that.

Moral of the Story: Hands Off My Scope!

Isolate scopes aren’t good enough. They just create time bombs for the application developers. Anything you can do with a bunch of properties in an isolate scope, you can also do with slightly more clever directive code. It may feel retro to write JQuery code in an Angular app, but I’m getting the feeling that’s what directives are for — a singularity that sucks in all the badness, leaving the rest of your code looking nice.
(Well, until you try to cancel an AJAX request. But that’s a topic for another post.)