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

fix($parse): always re-evaluate filters within literals when an input is an object #15990

Merged
merged 2 commits into from
May 25, 2017

Conversation

jbedard
Copy link
Collaborator

@jbedard jbedard commented May 15, 2017

Fixes #15964

This changes the compareObjectIdentity flag from being per-expression to per-input. This allows inputs to filters to not compare object entity, but other inputs such as - x or {x: x} to only compare object entity. This also expands this compareObjectIdentity = true beyond just object literals.

src/ng/parse.js Outdated
@@ -622,15 +622,51 @@ function isStateless($filter, filterName) {
return !fn.$stateful;
}

function findConstantAndWatchExpressions(ast, $filter) {
// Detect nodes which could depend on non-shallow state of objects
function isShallowState(stack) {
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 could probably use a better name/terminology...

Copy link
Contributor

Choose a reason for hiding this comment

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

isStackPure?

src/ng/parse.js Outdated
@@ -781,7 +819,7 @@ ASTCompiler.prototype = {
filters: {},
fn: {vars: [], body: [], own: {}},
assign: {vars: [], body: [], own: {}},
inputs: []
inputs: {}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Probably a nicer way of doing this...

Copy link
Contributor

Choose a reason for hiding this comment

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

By doing this (and later Object.keys) you are abusing the fact that Object.keys returns the keys in the order that were added to assert that expressions as foo.bar();foo.baz() will be evaluated in the right order. Even when this is somehow true, this is not part of the spec and we should not depend on this out-of-spec fact.

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 forgot the order even mattered, good point 👍

expect(watcherCalls).toBe(2);
}));

it('should always reevaluate filters with literal input containing non-primitives',
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

One way to test this better (which I'll add) would be something like [(obj | filterThatConvertsToPrimitive)]

src/ng/parse.js Outdated
function findConstantAndWatchExpressions(ast, $filter) {
// Detect nodes which could depend on non-shallow state of objects
function isShallowState(stack) {
for (var i = 0; i < stack.length; i++) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need to walk the stack every time? It seems that we are either pushing or popping items on and off this stack and then re-evaluating the shallowStateness of the stack. Can we not cache the state of each stack and then we only need to evaluate the pushed item?

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 that's probably true. Probably store the result of the switch statement in the stack? I'll try it out...

src/ng/parse.js Outdated
return true;
}

function findConstantAndWatchExpressions(ast, $filter, stack) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I am not following completely the logic here, but by just looking at the code it looks like stack can be replace with a simple boolean (that the first called call with true and the rest do ast.isShallowState = parentShallowState && isShallowState([ast]); (and of course this can be simplified).

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 I think that's possible. I guess the idea of the stack was that it could also potentially be used for other purposes, but I'll try your suggestion.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I think this is what I was getting at with #15990 (comment)

@Narretz Narretz added this to the 1.6.5 milestone May 16, 2017
@jbedard jbedard force-pushed the 15964 branch 2 times, most recently from 437c9af to 3d07e75 Compare May 18, 2017 05:32
@jbedard
Copy link
Collaborator Author

jbedard commented May 20, 2017

I think I've addressed all the comments. Also added a ng-class test for the exact issue someone reported.

I'm also open to other ideas around this and similar issues such as #15905 if anyone has alternative ideas.

Note that if the $filter solution in this PR is extended to interceptors it will also fix #15905 except one-time literals containing objects. That one failure is caused by this condition which I really wish I could find an alternative solution for, especially since that condition forces workarounds in multiple places where interceports are used. The other thing that would fix that one ng-class test is dropping nested object support in ng-class 😒

Copy link
Contributor

@petebacondarwin petebacondarwin left a comment

Choose a reason for hiding this comment

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

This looks pretty good; a few minor comments.
My main question is about the purity of UnaryExpressions.
Otherwise happy to land this.

src/ng/parse.js Outdated
var allConstants;
var argsToWatch;
var isStatelessFilter;

ast.isPure = parentIsPure = isPure(ast, (undefined === parentIsPure) || parentIsPure);
Copy link
Contributor

Choose a reason for hiding this comment

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

(undefined === parentIsPure) || parentIsPure - I would move this logic into the isPure() function.

src/ng/parse.js Outdated
break;

case AST.UnaryExpression:
return true;
Copy link
Contributor

Choose a reason for hiding this comment

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

In this case would you not defer to the parentPureness also?

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 idea is to allow shallow-watching the input in things like fn(!input) where !parentIsPure, but the child is.

I've tried to exclude case which can invoke toString like "" + input because those could be non-pure (when toString reads inner state).

forEach(fns, function(name) {
result.push('var ' + name + '=' + self.generateFunction(name, 's'));
forEach(inputs, function(input) {
result.push('var ' + input.name + '=' + self.generateFunction(input.name, 's'));
Copy link
Contributor

Choose a reason for hiding this comment

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

is there any performance/code-size gain in setting and using var name = input.name; here?
Or does the minification process deal with that automatically?

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 think performance/code-size has no (noticeable) gain or loss either way, so I'd vote for whatever is more readable/prettier. My preference is how it is (no local var) but I can change it if others would prefer that.

@@ -2973,6 +2973,172 @@ describe('parser', function() {
expect(watcherCalls).toBe(1);
}));

it('should not reevaluate filters in literals with non-primitive input that does support valueOf()',
Copy link
Contributor

Choose a reason for hiding this comment

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

can we use some describe blocks to simplify the test descriptions here:

describe('containing literals', function() {
  describe('with non-primitive inputs', function() {
    describe('that support valueOf()', function() {
      it('should not reevaluate filters, inputs do not change', ...);
      it('should reevaluate filters, when an input valueOf() result changes', ...);
      it('should not reevaluate filters, when the input changes but valueOf() does not ', ...);
    });
  });
  describe('with non-primitive inputs', function() {
  });
});

you get the idea...

@jbedard jbedard force-pushed the 15964 branch 2 times, most recently from 0e2296e to fdf7a2a Compare May 24, 2017 07:27
@jbedard
Copy link
Collaborator Author

jbedard commented May 24, 2017

Updated based on PR feedback. Also added more tests and added a separate commit to reorganize the tests. How those tests are organized is debatable but I think what I did is similar to the suggestion in the comment.

Copy link
Contributor

@petebacondarwin petebacondarwin left a comment

Choose a reason for hiding this comment

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

LGTM!
Thanks @jbedard - please squash and merge (assuming Travis is happy).

@jbedard
Copy link
Collaborator Author

jbedard commented May 24, 2017

@lgalfaso have a chance to look at the latest update?

Copy link
Contributor

@lgalfaso lgalfaso left a comment

Choose a reason for hiding this comment

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

LGTM!

Just one minor comment.

src/ng/parse.js Outdated
var allConstants;
var argsToWatch;
var isStatelessFilter;

ast.isPure = parentIsPure = isPure(ast, parentIsPure);
Copy link
Contributor

Choose a reason for hiding this comment

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

[nit] I find it slightly odd to change the value of the argument. I would just use ast.isPure below and keep the argument as is.

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 think I agree with that. Could also change the parentIsPure param to parentAst, but I'll go with your suggestion unless someone says otherwise.

@jbedard jbedard force-pushed the 15964 branch 2 times, most recently from e056dd9 to f0df97d Compare May 25, 2017 03:56
@jbedard jbedard merged commit aef3ef7 into angular:master May 25, 2017
jbedard added a commit that referenced this pull request May 25, 2017
jbedard added a commit that referenced this pull request May 25, 2017
@jbedard
Copy link
Collaborator Author

jbedard commented May 25, 2017

Made the change and merged into master and v1.6.x


// Unary always convert to primative
case AST.UnaryExpression:
return true;
Copy link
Member

Choose a reason for hiding this comment

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

Can't unary expressions invoke a stateful valueOf()?

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 I think you're right.

Seems like any numeric operator (everything except !, && and ||?) will use valueOf(). However this will be handled so I don't think it will lead to bugs, but this method is still wrong.

Now that I type that... && and || also don't convert to primitives and might return an object so those are also incorrect, but we watch the full logical expression so it won't lead to bugs, but this method is still wrong.

So I don't think there's a bug but it might be nice to make this function more correct?

Once again, always wait for @gkalpak to review your PRs...

Copy link
Member

Choose a reason for hiding this comment

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

First of all, I don't know what && and || have to do with this (since they are not unary operators) 😕

Regarding getValueOf(), I don't think it is enough to "save" us (in cases where valueOf() returns non-primitive values). I admit it is very unlikely that this will affect any real app, but from a theoretical point of view, I think expressions such as '+obj', where obj has a valueOf() method that returns an object will not be handled correctly (e.g. if the returned object stays the same by reference, but one of its properties changes).

Copy link
Member

Choose a reason for hiding this comment

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

Hm...OK maybe that is not true, because unary operators will always evaluate to the same thing for objects whose valueOf returns a non-primitve value (! will return false, +/- will return NaN), so this might actually work correctly 😁

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sorry I forgot that && and || are not BinaryExpressions (the next case statement) so never mind about that one!

I do still think that numeric unary/binary operators are non-pure because valueOf might be stateful. Which is a bug in isPure, but the use of getValueOf later on prevents a bug in $parse. I'll probably still correct the isPure function...

Copy link
Member

Choose a reason for hiding this comment

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

I do still think that numeric unary/binary operators are non-pure because valueOf might be stateful. Which is a bug in isPure, but the use of getValueOf later on prevents a bug in $parse. I'll probably still correct the isPure function...

That's also my conclusion 😃

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants