Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

feat(parse): ability to bind once an expressions #7486

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions docs/content/guide/expression.ngdoc
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,98 @@ expose a `$event` object within the scope of that expression.

Note in the example above how we can pass in `$event` to `clickMe`, but how it does not show up
in `{{$event}}`. This is because `$event` is outside the scope of that binding.


## One-time binding
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we are going to need more docs than this. I suggest reusing portions of the design doc that describes:

  • the reasoning for this feature (freeing up resources in cases when model, ones initialized, doesn't change)
  • algorithm used to determine when the value is stable
  • how to deal with optional model paths and benefit from this one-time binding


An expression that starts with `::` is considered a one-time expression. One-time expressions
will take the first non-undefined value assigned to them within a $digest

<example module="oneTimeBidingExampleApp">
<file name="index.html">
<div ng-controller="EventController">
<button ng-click="clickMe($event)">Click Me</button>
<p>One time binding {{::name}}</p>
<p>Normal binding {{name}}</p>
</div>
</file>
<file name="script.js">
angular.module('oneTimeBidingExampleApp', []).
controller('EventController', ['$scope', function($scope) {
var counter = 0;
var names = ['Igor', 'Misko', 'Chirayu', 'Lucas'];
/*
* expose the event object to the scope
*/
$scope.clickMe = function(clickEvent) {
$scope.name = names[counter % names.length];
counter++;
};
}]);
</file>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you also write an e2e test for this please?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a test for this already.

</example>


### Why this feature

The main purpose of one-time binding expression is to provide a way to create a binding
that gets deregistered and frees up resources once the binding is initialized.
Reducing the number of expressions being watched makes the digest
loop faster and allows more information to be displayed at the same time.


### Value stabilization algorithm

One-time binding expressions will retain the value of the expression at the end of the
digest cycle as long as that value is not undefined. If the value of the expression is set
within the digest loop and later, within the same digest loop, it is set to undefined,
then the expression is not fulfilled and will remain watched.

1. Given an expression that starts with `::` when a digest loop is entered and expression
is dirty-checked store the value as V
2. If V is not undefined mark the result of the expression as stable and schedule a task
to deregister the watch for this expression when we exit the digest loop
3. Process the digest loop as normal
4. When digest loop is done and all the values have settled process the queue of watch
deregistration tasks. For each watch to be deregistered check if it still evaluates
to value that is not `undefined`. If that's the case, deregister the watch. Otherwise
keep dirty-checking the watch in the future digest loops by following the same
algorithm starting from step 1


### How to benefit from one-time binding

When interpolating text or attributes. If the expression, once set, will not change
then it is a candidate for one-time expression.

```html
<div name="attr: {{::color}}">text: {{::name}}</div>
```

When using a directive with bidirectional binding and the parameters will not change

```js
someModule.directive('someDirective', function() {
return {
scope: {
name: '=',
color: '@'
},
template: '{{name}}: {{color}}'
};
});
```

```html
<div some-directive name=“::myName” color=“My color is {{::myColor}}”></div>
```


When using a directive that takes an expression

```html
<ul>
<li ng-repeat="item in ::items">{{item.name}};</li>
</ul>
```

3 changes: 3 additions & 0 deletions src/ng/compile.js
Original file line number Diff line number Diff line change
Expand Up @@ -1490,6 +1490,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
parentSet(scope, parentValue = isolateScope[scopeName]);
}
}
parentValueWatch.$$unwatch = parentGet.$$unwatch;
return lastValue = parentValue;
}, null, parentGet.literal);
break;
Expand Down Expand Up @@ -1818,6 +1819,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
compile: valueFn(function textInterpolateLinkFn(scope, node) {
var parent = node.parent(),
bindings = parent.data('$binding') || [];
// Need to interpolate again in case this is using one-time bindings
interpolateFn = $interpolate(text);
bindings.push(interpolateFn);
safeAddClass(parent.data('$binding', bindings), 'ng-binding');
scope.$watch(interpolateFn, function interpolateFnWatchAction(value) {
Expand Down
6 changes: 5 additions & 1 deletion src/ng/directive/ngBind.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,11 @@ var ngBindHtmlDirective = ['$sce', '$parse', function($sce, $parse) {
element.addClass('ng-binding').data('$binding', attr.ngBindHtml);

var parsed = $parse(attr.ngBindHtml);
function getStringValue() { return (parsed(scope) || '').toString(); }
function getStringValue() {
var value = parsed(scope);
getStringValue.$$unwatch = parsed.$$unwatch;
return (value || '').toString();
}

scope.$watch(getStringValue, function ngBindHtmlWatchAction(value) {
element.html($sce.getTrustedHtml(parsed(scope)) || '');
Expand Down
3 changes: 3 additions & 0 deletions src/ng/interpolate.js
Original file line number Diff line number Diff line change
Expand Up @@ -252,16 +252,19 @@ function $InterpolateProvider() {


try {
interpolationFn.$$unwatch = true;
for (; i < ii; i++) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is something wrong with interpolation. check out this plunker: http://plnkr.co/edit/rJOqa0oh35Y0xBhTYZ6w

the case with interpolation gets messed up on the digest after the watches are finalized.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There were two issues, one was with text interpolation and transclusion and the other was with $interpolate and the $$unwatch flag. Fixed them and add a new test for the first issue and some check for the second issue

val = getValue(parseFns[i](context));
if (allOrNothing && isUndefined(val)) {
interpolationFn.$$unwatch = undefined;
return;
}
val = stringify(val);
if (val !== lastValues[i]) {
inputsChanged = true;
}
values[i] = val;
interpolationFn.$$unwatch = interpolationFn.$$unwatch && parseFns[i].$$unwatch;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like how you simplified this!

}

if (inputsChanged) {
Expand Down
41 changes: 38 additions & 3 deletions src/ng/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -1246,13 +1246,19 @@ function $ParseProvider() {
};

return function(exp) {
var parsedExpression;
var parsedExpression,
oneTime;

switch (typeof exp) {
case 'string':

if (exp.charAt(0) === ':' && exp.charAt(1) === ':') {
oneTime = true;
exp = exp.substring(2);
}

if (cache.hasOwnProperty(exp)) {
return cache[exp];
return oneTime ? oneTimeWrapper(cache[exp]) : cache[exp];
}

var lexer = new Lexer($parseOptions);
Expand All @@ -1265,14 +1271,43 @@ function $ParseProvider() {
cache[exp] = parsedExpression;
}

return parsedExpression;
if (parsedExpression.constant) {
parsedExpression.$$unwatch = true;
}

return oneTime ? oneTimeWrapper(parsedExpression) : parsedExpression;

case 'function':
return exp;

default:
return noop;
}

function oneTimeWrapper(expression) {
var stable = false,
lastValue;
oneTimeParseFn.literal = expression.literal;
oneTimeParseFn.constant = expression.constant;
oneTimeParseFn.assign = expression.assign;
return oneTimeParseFn;

function oneTimeParseFn(self, locals) {
if (!stable) {
lastValue = expression(self, locals);
oneTimeParseFn.$$unwatch = isDefined(lastValue);
if (oneTimeParseFn.$$unwatch && self && self.$$postDigestQueue) {
self.$$postDigestQueue.push(function () {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it important that we freeze this function? our end goal is to unregister the watch and not make the parse function cachable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that $interpolate is only one $watch call, we need a mechanism for the expression itself to know that it is stable

// create a copy if the value is defined and it is not a $sce value
if ((stable = isDefined(lastValue)) && !lastValue.$$unwrapTrustedValue) {
lastValue = copy(lastValue);
}
});
}
}
return lastValue;
}
}
};
}];
}
35 changes: 25 additions & 10 deletions src/ng/rootScope.js
Original file line number Diff line number Diff line change
Expand Up @@ -338,14 +338,6 @@ function $RootScopeProvider(){
watcher.fn = function(newVal, oldVal, scope) {listenFn(scope);};
}

if (typeof watchExp == 'string' && get.constant) {
var originalFn = watcher.fn;
watcher.fn = function(newVal, oldVal, scope) {
originalFn.call(this, newVal, oldVal, scope);
arrayRemove(array, watcher);
};
}

if (!array) {
array = scope.$$watchers = [];
}
Expand Down Expand Up @@ -391,24 +383,37 @@ function $RootScopeProvider(){
var deregisterFns = [];
var changeCount = 0;
var self = this;
var unwatchFlags = new Array(watchExpressions.length);
var unwatchCount = watchExpressions.length;

forEach(watchExpressions, function (expr, i) {
deregisterFns.push(self.$watch(expr, function (value, oldValue) {
var exprFn = $parse(expr);
deregisterFns.push(self.$watch(exprFn, function (value, oldValue) {
newValues[i] = value;
oldValues[i] = oldValue;
changeCount++;
if (unwatchFlags[i] && !exprFn.$$unwatch) unwatchCount++;
if (!unwatchFlags[i] && exprFn.$$unwatch) unwatchCount--;
unwatchFlags[i] = exprFn.$$unwatch;
}));
}, this);

deregisterFns.push(self.$watch(function () {return changeCount;}, function () {
deregisterFns.push(self.$watch(watchGroupFn, function () {
listener(newValues, oldValues, self);
if (unwatchCount === 0) {
watchGroupFn.$$unwatch = true;
} else {
watchGroupFn.$$unwatch = false;
}
}));

return function deregisterWatchGroup() {
forEach(deregisterFns, function (fn) {
fn();
});
};

function watchGroupFn() {return changeCount;}
},


Expand Down Expand Up @@ -553,6 +558,7 @@ function $RootScopeProvider(){
}
}
}
$watchCollectionWatch.$$unwatch = objGetter.$$unwatch;
return changeDetected;
}

Expand Down Expand Up @@ -644,6 +650,7 @@ function $RootScopeProvider(){
dirty, ttl = TTL,
next, current, target = this,
watchLog = [],
stableWatchesCandidates = [],
logIdx, logMsg, asyncTask;

beginPhase('$digest');
Expand Down Expand Up @@ -694,6 +701,7 @@ function $RootScopeProvider(){
logMsg += '; newVal: ' + toJson(value) + '; oldVal: ' + toJson(last);
watchLog[logIdx].push(logMsg);
}
if (watch.get.$$unwatch) stableWatchesCandidates.push({watch: watch, array: watchers});
} else if (watch === lastDirtyWatch) {
// If the most recently dirty watcher is now clean, short circuit since the remaining watchers
// have already been tested.
Expand Down Expand Up @@ -740,6 +748,13 @@ function $RootScopeProvider(){
$exceptionHandler(e);
}
}

for (length = stableWatchesCandidates.length - 1; length >= 0; --length) {
var candidate = stableWatchesCandidates[length];
if (candidate.watch.get.$$unwatch) {
arrayRemove(candidate.array, candidate.watch);
}
}
},


Expand Down
4 changes: 3 additions & 1 deletion src/ng/sce.js
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,9 @@ function $SceProvider() {
return parsed;
} else {
return function sceParseAsTrusted(self, locals) {
return sce.getTrusted(type, parsed(self, locals));
var result = sce.getTrusted(type, parsed(self, locals));
sceParseAsTrusted.$$unwatch = parsed.$$unwatch;
return result;
};
}
};
Expand Down
Loading