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

fix($parse): respect the interceptor.$stateful flag #16015

Merged
merged 3 commits into from
Jul 17, 2017
Merged
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
6 changes: 0 additions & 6 deletions src/ng/directive/ngClass.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,6 @@ function classDirective(name, selector) {
}

function ngClassWatchAction(newClassString) {
// When using a one-time binding the newClassString will return
// the pre-interceptor value until the one-time is complete
if (!isString(newClassString)) {
newClassString = toClassString(newClassString);
}

if (oldModulo === selector) {
updateClasses(oldClassString, newClassString);
}
Expand Down
123 changes: 72 additions & 51 deletions src/ng/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -1798,15 +1798,9 @@ function $ParseProvider() {
var lexer = new Lexer($parseOptions);
var parser = new Parser(lexer, $filter, $parseOptions);
parsedExpression = parser.parse(exp);
if (parsedExpression.constant) {
parsedExpression.$$watchDelegate = constantWatchDelegate;
} else if (oneTime) {
parsedExpression.oneTime = true;
parsedExpression.$$watchDelegate = oneTimeWatchDelegate;
} else if (parsedExpression.inputs) {
parsedExpression.$$watchDelegate = inputsWatchDelegate;
}
cache[cacheKey] = parsedExpression;
parsedExpression.oneTime = !!oneTime;

cache[cacheKey] = addWatchDelegate(parsedExpression);
}
return addInterceptor(parsedExpression, interceptorFn);

Expand Down Expand Up @@ -1890,28 +1884,37 @@ function $ParseProvider() {
function oneTimeWatchDelegate(scope, listener, objectEquality, parsedExpression, prettyPrintExpression) {
var isDone = parsedExpression.literal ? isAllDefined : isDefined;
var unwatch, lastValue;
if (parsedExpression.inputs) {
unwatch = inputsWatchDelegate(scope, oneTimeListener, objectEquality, parsedExpression, prettyPrintExpression);
} else {
unwatch = scope.$watch(oneTimeWatch, oneTimeListener, objectEquality);
}

var exp = parsedExpression.$$intercepted || parsedExpression;
var post = parsedExpression.$$interceptor || identity;

var useInputs = parsedExpression.inputs && !exp.inputs;
Copy link
Member

Choose a reason for hiding this comment

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

How could parsedExpresion.inputs be truthy and parsedException.$$intercepted.inputs be falsy?
Can you give an example scenario?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It's basically detecting this case. This is when the intercepted expression had no inputs, so the expression itself becomes the input.

For example

  • $parse("x", stringify): x has no inputs, but we don't want to execute stringify(x) per-digest so we set x itself as the input and useInputs = true to handle this
  • $parse("x | number", stringify): x | number already has x as an input so we just continue using that input (and useInputs = false)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The fact that this line basically "detects" the useInputs = true from addInterceptor is something I don't like about this PR atm. It already existed before, but I guess now it is spread across two methods which seems worse to me. But it's all internal and could always be changed...

Copy link
Member

Choose a reason for hiding this comment

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

I can't say I can fully wrap my head around this, but the tests pass, so... 😃

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Which part? Anything I can explain more/better? There's a couple weird things being changed in this PR, and I also get confused after not looking at it for a while ^_^

Copy link
Member

Choose a reason for hiding this comment

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

The useInputs bit still confuses me (but in tests we trust).
(At some point I need to grab my debugger and go through some usecases, but this doesn't have to hold this PR back.)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The useInputs is also the part I don't like.

Note that I think it should really be renamed to something such as theInputIsTheInterceptedExpression (where normally the input is the input to the intercepted expression).

It is also just a performance thing, dropping all useInputs == true logic would still function correctly.


// Propogate the literal/inputs/constant attributes
// ... but not oneTime since we are handling it
oneTimeWatch.literal = parsedExpression.literal;
oneTimeWatch.constant = parsedExpression.constant;
oneTimeWatch.inputs = parsedExpression.inputs;

// Allow other delegates to run on this wrapped expression
addWatchDelegate(oneTimeWatch);

unwatch = scope.$watch(oneTimeWatch, listener, objectEquality, prettyPrintExpression);

return unwatch;

function oneTimeWatch(scope) {
return parsedExpression(scope);
}
function oneTimeListener(value, old, scope) {
lastValue = value;
if (isFunction(listener)) {
listener(value, old, scope);
function unwatchIfDone() {
if (isDone(lastValue)) {
unwatch();
}
if (isDone(value)) {
scope.$$postDigest(function() {
if (isDone(lastValue)) {
unwatch();
}
});
}

function oneTimeWatch(scope, locals, assign, inputs) {
lastValue = useInputs && inputs ? inputs[0] : exp(scope, locals, assign, inputs);
if (isDone(lastValue)) {
scope.$$postDigest(unwatchIfDone);
}
return post(lastValue, scope, locals);
}
}

Expand All @@ -1931,40 +1934,58 @@ function $ParseProvider() {
return unwatch;
}

function addWatchDelegate(parsedExpression) {
if (parsedExpression.constant) {
parsedExpression.$$watchDelegate = constantWatchDelegate;
} else if (parsedExpression.oneTime) {
parsedExpression.$$watchDelegate = oneTimeWatchDelegate;
} else if (parsedExpression.inputs) {
parsedExpression.$$watchDelegate = inputsWatchDelegate;
}

return parsedExpression;
}

function chainInterceptors(first, second) {
function chainedInterceptor(value) {
return second(first(value));
}
chainedInterceptor.$stateful = first.$stateful || second.$stateful;

return chainedInterceptor;
}

function addInterceptor(parsedExpression, interceptorFn) {
if (!interceptorFn) return parsedExpression;
var watchDelegate = parsedExpression.$$watchDelegate;
var useInputs = false;

var isDone = parsedExpression.literal ? isAllDefined : isDefined;

function regularInterceptedExpression(scope, locals, assign, inputs) {
var value = useInputs && inputs ? inputs[0] : parsedExpression(scope, locals, assign, inputs);
return interceptorFn(value);
// Extract any existing interceptors out of the parsedExpression
// to ensure the original parsedExpression is always the $$intercepted
if (parsedExpression.$$interceptor) {
interceptorFn = chainInterceptors(parsedExpression.$$interceptor, interceptorFn);
parsedExpression = parsedExpression.$$intercepted;
}

function oneTimeInterceptedExpression(scope, locals, assign, inputs) {
var useInputs = false;

var fn = function interceptedExpression(scope, locals, assign, inputs) {
var value = useInputs && inputs ? inputs[0] : parsedExpression(scope, locals, assign, inputs);
var result = interceptorFn(value);
// we only return the interceptor's result if the
// initial value is defined (for bind-once)
return isDone(value) ? result : value;
}
return interceptorFn(value);
};

var fn = parsedExpression.oneTime ? oneTimeInterceptedExpression : regularInterceptedExpression;
// Maintain references to the interceptor/intercepted
fn.$$intercepted = parsedExpression;
fn.$$interceptor = interceptorFn;

// Propogate the literal/oneTime attributes
// Propogate the literal/oneTime/constant attributes
fn.literal = parsedExpression.literal;
fn.oneTime = parsedExpression.oneTime;
fn.constant = parsedExpression.constant;

// Propagate or create inputs / $$watchDelegates
useInputs = !parsedExpression.inputs;
if (watchDelegate && watchDelegate !== inputsWatchDelegate) {
Copy link
Member

Choose a reason for hiding this comment

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

If we remove this, what happens with "custom" $$watchDelegates (such as the ones added by $interpolate)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This actually fixes a bug because that lost $$watchDelegate knows nothing about the interceptor and is built specifically for that interpolate fn.

For example watching $parse($interpolate("{{foo}}"), function() { return "bar"; }) will always output the "foo" var even though it is wrapped in an interceptor that always returns "bar", yet executing that parsed-expression will always return "bar"...

Copy link
Member

Choose a reason for hiding this comment

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

Where is the test for this fix then? 😛

Copy link
Member

@gkalpak gkalpak Jun 6, 2017

Choose a reason for hiding this comment

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

FWIW, I am still confused about what happens to $$watchDelegates when combined with interceptors? Are they just ignored?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah they are just ignored, because the interceptor might have made that $$watchDelegate completely invalid. However the returned ("interecepted"?) fn might have some constant/oneTime/inputs properties that might cause a $$watchDelegate to be added.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Here's an example, I'll try to add a test for this...

$parse($interpolate("a{{b}}"), function(x) { return x + 'c'; })

$interpolate adds a special $$watchDelegate for an efficient way to watch the interpolated-expression, but that delegate of course knows nothing about the x + 'c' interceptor. Previously this line we're commenting on would blindly copy the delegate creating an expression which produces the correct value when executed (with the 'c' concatenated at the end) but the wrong value when watched due to the use of a $$watchDelegate that knows nothing about the x + 'c'...

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I've added a test for this: "should not propogate $$watchDelegate to the interceptor wrapped expression"

Copy link
Member

Choose a reason for hiding this comment

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

I see. And we are fine with that because we never use $$watchDelegate with interceptors in core, right?
I might add a comment (where?) about that, i.e. the you can't use $$watchDelegate with interceptors.

fn.$$watchDelegate = watchDelegate;
fn.inputs = parsedExpression.inputs;
} else if (!interceptorFn.$stateful) {
// Treat interceptor like filters - assume non-stateful by default and use the inputsWatchDelegate
fn.$$watchDelegate = inputsWatchDelegate;
// Treat the interceptor like filters.
// If it is not $stateful then only watch its inputs.
// If the expression itself has no inputs then use the full expression as an input.
if (!interceptorFn.$stateful) {
useInputs = !parsedExpression.inputs;
fn.inputs = (parsedExpression.inputs ? parsedExpression.inputs : [parsedExpression]).map(function(e) {
// Remove the isPure flag of inputs when it is not absolute because they are now wrapped in a
// potentially non-pure interceptor function.
Expand All @@ -1975,7 +1996,7 @@ function $ParseProvider() {
});
}

return fn;
return addWatchDelegate(fn);
}
}];
}
14 changes: 14 additions & 0 deletions test/ng/directive/ngClassSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,20 @@ describe('ngClass', function() {
})
);

it('should support a one-time mixed literal-array/object variable', inject(function($rootScope, $compile) {
element = $compile('<div ng-class="::[classVar1, classVar2]"></div>')($rootScope);

$rootScope.classVar1 = {orange: true};
$rootScope.$digest();
expect(element).toHaveClass('orange');

$rootScope.classVar1.orange = false;
$rootScope.$digest();

expect(element).not.toHaveClass('orange');
})
);


it('should do value stabilization as expected when one-time binding',
inject(function($rootScope, $compile) {
Expand Down
16 changes: 16 additions & 0 deletions test/ng/interpolateSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,22 @@ describe('$interpolate', function() {
expect($rootScope.$countWatchers()).toBe(0);
}));

it('should respect one-time bindings for literals', inject(function($interpolate, $rootScope) {
var calls = [];
$rootScope.$watch($interpolate('{{ ::{x: x} }}'), function(val) {
calls.push(val);
});

$rootScope.$apply();
expect(calls.pop()).toBe('{}');

$rootScope.$apply('x = 1');
expect(calls.pop()).toBe('{"x":1}');

$rootScope.$apply('x = 2');
expect(calls.pop()).toBeUndefined();
}));

it('should stop watching strings with no expressions after first execution',
inject(function($interpolate, $rootScope) {
var spy = jasmine.createSpy();
Expand Down
139 changes: 139 additions & 0 deletions test/ng/parseSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3324,6 +3324,71 @@ describe('parser', function() {
expect(called).toBe(true);
}));

it('should always be invoked if flagged as $stateful when wrapping one-time',
inject(function($parse) {

var interceptorCalls = 0;
function interceptor() {
interceptorCalls++;
return 123;
}
interceptor.$stateful = true;

scope.$watch($parse('::a', interceptor));

interceptorCalls = 0;
scope.$digest();
expect(interceptorCalls).not.toBe(0);

interceptorCalls = 0;
scope.$digest();
expect(interceptorCalls).not.toBe(0);
}));

it('should always be invoked if flagged as $stateful when wrapping one-time with inputs',
inject(function($parse) {

$filterProvider.register('identity', valueFn(identity));

var interceptorCalls = 0;
function interceptor() {
interceptorCalls++;
return 123;
}
interceptor.$stateful = true;

scope.$watch($parse('::a | identity', interceptor));

interceptorCalls = 0;
scope.$digest();
expect(interceptorCalls).not.toBe(0);

interceptorCalls = 0;
scope.$digest();
expect(interceptorCalls).not.toBe(0);
}));

it('should always be invoked if flagged as $stateful when wrapping one-time literal',
inject(function($parse) {

var interceptorCalls = 0;
function interceptor() {
interceptorCalls++;
return 123;
}
interceptor.$stateful = true;

scope.$watch($parse('::[a]', interceptor));

interceptorCalls = 0;
scope.$digest();
expect(interceptorCalls).not.toBe(0);

interceptorCalls = 0;
scope.$digest();
expect(interceptorCalls).not.toBe(0);
}));

it('should not be invoked unless the input changes', inject(function($parse) {
var called = false;
function interceptor(v) {
Expand Down Expand Up @@ -3434,6 +3499,80 @@ describe('parser', function() {
scope.$digest();
expect(scope.$$watchersCount).toBe(0);
}));

it('should watch the intercepted value of one-time bindings', inject(function($parse, log) {
scope.$watch($parse('::{x:x, y:y}', function(lit) { return lit.x; }), log);

scope.$apply();
expect(log.empty()).toEqual([undefined]);

scope.$apply('x = 1');
expect(log.empty()).toEqual([1]);

scope.$apply('x = 2; y=1');
expect(log.empty()).toEqual([2]);

scope.$apply('x = 1; y=2');
expect(log.empty()).toEqual([]);
}));

it('should watch the intercepted value of one-time bindings in nested interceptors', inject(function($parse, log) {
scope.$watch($parse($parse('::{x:x, y:y}', function(lit) { return lit.x; }), identity), log);

scope.$apply();
expect(log.empty()).toEqual([undefined]);

scope.$apply('x = 1');
expect(log.empty()).toEqual([1]);

scope.$apply('x = 2; y=1');
expect(log.empty()).toEqual([2]);

scope.$apply('x = 1; y=2');
expect(log.empty()).toEqual([]);
}));

it('should nest interceptors around eachother, not around the intercepted', inject(function($parse) {
function origin() { return 0; }

var fn = origin;
function addOne(n) { return n + 1; }

fn = $parse(fn, addOne);
expect(fn.$$intercepted).toBe(origin);
expect(fn()).toBe(1);

fn = $parse(fn, addOne);
expect(fn.$$intercepted).toBe(origin);
expect(fn()).toBe(2);

fn = $parse(fn, addOne);
expect(fn.$$intercepted).toBe(origin);
expect(fn()).toBe(3);
}));

it('should not propogate $$watchDelegate to the interceptor wrapped expression', inject(function($parse) {
function getter(s) {
return s.x;
}
getter.$$watchDelegate = getter;

function doubler(v) {
return 2 * v;
}

var lastValue;
function watcher(val) {
lastValue = val;
}
scope.$watch($parse(getter, doubler), watcher);

scope.$apply('x = 1');
expect(lastValue).toBe(2 * 1);

scope.$apply('x = 123');
expect(lastValue).toBe(2 * 123);
}));
});

describe('literals', function() {
Expand Down