5
votes

I wrote an Angular directive that is exhibiting some strange behavior. The directive adds a function to $parsers to limit what the user types based on a regex pattern. If the current text doesn't match the patter, the parser reverts the text back to the previous value of the field.

As a result, when the text is reverted, Angular detects this as a change in the field value and enters the parser again. This is normally fine since the value passed into the parser is now valid but I am experiencing one very strange quirk.

After I got this directive working I decided to change its name. I did this and suddenly the validation was failing. My error-handler was reporting too much recursion. When I debugged the code I found that the 2nd call into the parser after entering an invalid character showed the field value parameter as 'undefined'. As a result, my code treated the value as invalid and tried to revert back again, which caused another call into the parser with an 'undefined' value, etc, etc until a stack overflow occurred.

I changed the directive name back, debugged again, and everything suddenly started working fine! The second call into the parser had the correct value instead of 'undefined'.

I did a little playing around and discovered that I could recreate this bug by altering the first character of the directive name. Directive names that began with the characters 'a' through 'm' worked fine, but names beginning with 'n' through 'z' broke (OK, I confess, I didn't try all 26 characters, but a sampling of characters showed that all the names in the sample where the first letter of the directive name was in the first half of the alphabet worked and all runs where the first letter was in the 2nd half of the alphabet failed).

I put together a plunker with my code to demonstrate it:

http://plnkr.co/edit/k8Hpk2jsMCS6xjOKiES5

var app = angular.module('plunker', []);

app.controller('MainCtrl', function($scope) {
    $scope.someNumber;
});

app.directive('formattedWithPattern', function () {
    // Formats an input field as a positive integer.
    // Usage:
    //  <input type="text" data-integer-format>
    // Creates:
    //  <input type="text" data-integer-format>
    return {
        require: 'ngModel',
        link: function (scope, element, attr, ctrl) {
            if (!ctrl) return;

            var pattern = attr.ngPattern;
            // get the pattern between the slashes and any modifiers
            var r = new RegExp("^/(.*)/(.*)$");
            var matches = r.exec(pattern);
            var regex;
            if (matches) {
                regex = new RegExp('^' + matches[1] + '$', matches[2]);
            }
            var lastText = '';
            var reverted = false;

            function fromUser(text) {
                var m = regex.exec(text);
                if (m) {
                    // join matches together into a single string
                    lastText = m[0];
                    if (lastText != text) {
                        // the original text contained some invalid characters
                        ctrl.$setViewValue(lastText);
                        ctrl.$render();
                    }
                }
                else {
                    // nothing in the text matched the regular expression...
                    // revert to the last good value
                    if (text != lastText) {
                        ctrl.$setViewValue(lastText);
                        ctrl.$render();
                    }
                }
                return lastText;
            }

            ctrl.$parsers.unshift(fromUser);
        }
    };
});

Here's an example of it in use (also from the plunker):

<body ng-controller="MainCtrl">
    <input type="text" name="testNumber" id="testNumber" 
       data-ng-model="someNumber" data-ng-required="true"
       ng-pattern="/[\+\-]?[0-9]*(\.[0-9]*)?/" 
       formatted-with-pattern />
    {{zip}}
</body>

For some reason, the plunker behaves SLIGHTLY differently than what I saw when testing on my computer. All failures are still in the upper range of the alphabet, but the failures start at 'o' instead of 'n'.

If you change the name of the directive in the app.js and index.html to begin with any character 'o' through 'z' and rerun the plunker you can easily see the behavior. The above directive is using a numeric pattern, so when the directive name is "valid" the directive doesn't allow any characters other than 0-9, ., +, and -. When the name is "invalid" the directive also allows characters because the recursive call into the parser is breaking out without actually changing the input field value.

This strikes me as VERY bizarre behavior. I didn't find any other mentions of this online so I thought I'd throw it out here. Has anyone else ever encountered anything like this? Is this a bug in AngularJS? Does anyone know a work-around other than just making sure my directive name begins with a character a through m?

1
My oormattedWithPattern at plnkr.co/edit/4b7tHuNUd2bMCJ0DSDxB?p=preview seems to be doing fine, unless there's something I didn't understand.ivarni
My guess: what you get as an input of your parser is the output of the previous parser. And the ng-pattern directive also adds a parser to the chain, which outputs undefined if the entered text doesn't match the pattern. I guess thet depending on the name, your parser is put before or after ngPattern's parser in the chain.JB Nizet
The plunker seems to work for me as well in Chrome 36 (with z as the first letter). What browser are you using? Anyway, I suspect JB Nizet to be right. With f as the first letter, your parser is placed in the first position ($parsers[0]), before ngRequired and ngPattern. With z, it goes in the second position ($parsers[1]), between the two. Maybe an implementation detail of your browser is making it manage arrays differently and your parser shows up in the third position, after ngPattern, which would explain the behavior you're experiencing.Hugo Wood
I'm able to reproduce the issue in Chrome 36. The issue will occur only when typing an invalid character as the first character in the input box. And yes, what @JBNizet guess is correct.runTarm
I see. The regexp also matches anything that contains a number. So typing '4a' does not raise the issue, since the ngPattern parser doesn't return undefined. Another problem with the original code is that once you enter a digit, you cannot erase it to go back to a blank input.Hugo Wood

1 Answers

3
votes

You could raise a directive priority: to ensure the execution order like this:

return {
  require: 'ngModel',
  priority: 1, // default is 0
  link: function (scope, element, attr, ctrl) {
    ...
  }
};

It will ensure that your directive postLink function will be run after ng-required and ng-pattern.

Example Plunker: http://plnkr.co/edit/dy1zCq9F8EejYWo1m4Oe?p=preview

You could also use ctrl.$viewValue directly to avoid the directive execution order problem.

Actually, IMHO, it make more sense to use the ctrl.$viewValue since you would like to re-render the view if it is invalid, so you need a real view value, not a value that already passed other parsers.

To do so, you could change your parser from:

function fromUser(text) {
  ...
}

to this instead:

function fromUser() {
  var text = ctrl.$viewValue;
  ...
}

Example Plunker: http://plnkr.co/edit/FA2Fq4aNlUrcdUMoopuX?p=preview

Hope this helps.