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

fix($compile): don't run unnecessary update to one-way bindings #14580

Merged
merged 1 commit into from May 10, 2016

Conversation

petebacondarwin
Copy link
Member

What kind of change does this PR introduce? (Bug fix, feature, docs update, ...)

Bug Fix

What is the current behavior? (You can also link to an open issue here)

The watch handler was triggering on its first invocation, even though
its change had already been dealt with when setting up the binding.

What is the new behavior (if this is a feature change)?

The handler does not trigger an update on its first invocation

Does this PR introduce a breaking change?

Nope - I don't think so. Although I had to modify a bunch of tests to ensure that a digest occurred between compilation and the first checks.

Please check if the PR fulfills these requirements

Other information:

Closes #14546

@@ -3756,6 +3760,7 @@ describe('$compile', function() {
module('my');
inject(function($compile, $rootScope) {
element = $compile('<c1 prop1="val"></c1>')($rootScope);
$rootScope.$apply();
Copy link
Member

Choose a reason for hiding this comment

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

Why is this needed ?

Copy link
Member Author

Choose a reason for hiding this comment

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

If we have no digest before changing the value of val then effectively the system thinks that val has not changed, so $onChanges is not called.

};
this.$onChanges = function(changes) {
if (changes.input) {
log.push(changes.input);
Copy link
Member

Choose a reason for hiding this comment

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

You are pushing to log, but never checking what was actually pushed.

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed.

@gkalpak
Copy link
Member

gkalpak commented May 10, 2016

One testcase I believe should work, but doesn't, is when the parent value changes after initializing the bindings but before the first call to the one-way binding watcher callback.
Here are a couple of unit tests demonstrating the issue. They are essentially equivalent, the first being more straight-forward and the latter being more "real-world-ish".

      it('should update isolate again after $onInit if outer has changed (before initial watchAction call)', function() {
        var log = [];
        var component;
        angular.module('owComponentTest', [])
          .component('owComponent', {
            bindings: { input: '<' },
            controller: function() {
              component = this;
              this.input = 'constructor';
              this.$onInit = function() {
                this.input = '$onInit';
              };
              this.$onChanges = function(changes) {
                if (changes.input) {
                  log.push(changes.input);
                }
              };
            }
          });
        module('owComponentTest');
        inject(function() {
          $rootScope.name = 'outer1';
          compile('<ow-component input="name"></ow-component>');

          expect(component.input).toEqual('$onInit');
          $rootScope.$apply('name = "outer2"');

          expect($rootScope.name).toEqual('outer2');
          expect(component.input).toEqual('outer2');
        });
      });

      it('should update isolate again after $onInit if outer has changed (before initial watchAction call)', function() {
        var log = [];
        var component;
        angular.module('owComponentTest', [])
          .directive('changeInput', function() {
            return function(scope, elem, attrs) {
              scope.name = 'outer2';
            };
          })
          .component('owComponent', {
            bindings: { input: '<' },
            controller: function() {
              component = this;
              this.input = 'constructor';
              this.$onInit = function() {
                this.input = '$onInit';
              };
              this.$onChanges = function(changes) {
                if (changes.input) {
                  log.push(changes.input);
                }
              };
            }
          });
        module('owComponentTest');
        inject(function() {
          $rootScope.name = 'outer1';
          compile('<ow-component input="name" change-input></ow-component>');

          expect(component.input).toEqual('$onInit');
          $rootScope.$digest();

          expect($rootScope.name).toEqual('outer2');
          expect(component.input).toEqual('outer2');
        });
      });

@gkalpak
Copy link
Member

gkalpak commented May 10, 2016

BTW, in order to support these usecases (and fix the original bug), we could change the code from:

destination[scopeName] = parentGet(scope);
...

removeWatch = scope.$watch(parentGet, function parentValueWatchAction(newValue, oldValue) {
  if (oldValue === newValue) return;
  ...

to:

var initialValue = destination[scopeName] = parentGet(scope);
...

removeWatch = scope.$watch(parentGet, function parentValueWatchAction(newValue, oldValue) {
  if (oldValue === newValue) {
    if (oldValue === initialValue) return;
    oldValue = initialValue;
  }
  ...

@petebacondarwin petebacondarwin force-pushed the issue-14546 branch 3 times, most recently from 60909c8 to 15de372 Compare May 10, 2016 14:19
'constructor',
['$onChanges', jasmine.objectContaining({ currentValue: 'outer' })],
'$onInit'
]);
Copy link
Member

Choose a reason for hiding this comment

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

Wouldn't this test pass even without the fix ? (I haven't tested it, but I think so.)
I think it's a good idea to add a $rootScope.$digest() and another round of expectation to make sure that nothing is overwritten when the watch callback is executed.

Copy link
Member Author

Choose a reason for hiding this comment

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

OK. It did fail before the fix. But then I might have changed it again since then.

Copy link
Member Author

Choose a reason for hiding this comment

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

It totally fails as it is without the fix. But I will add the extra digest and check

@gkalpak
Copy link
Member

gkalpak commented May 10, 2016

A couple of minor test-related comments.
Otherwise LGTM 👍

The watch handler was triggering on its first invocation, even though
its change had already been dealt with when setting up the binding.

Closes angular#14546
@petebacondarwin petebacondarwin merged commit f1f892d into angular:master May 10, 2016
petebacondarwin added a commit that referenced this pull request May 10, 2016
The watch handler was triggering on its first invocation, even though
its change had already been dealt with when setting up the binding.

Closes #14546
Closes #14580
@ghost
Copy link

ghost commented May 27, 2016

Hey, this just broke a whole bunch of our unit tests which I was hoping to get some clarification on.

So right now, we have something like this:

export someComponent = {
  ...

  controller: class SomeController {
    constructor(...) {
      this.state = { ... }
    }

    set config(config: Config) {
      if (!this.state) return
      ...
      this.state.config = config
    }
  }
}

angular.component('someComponent', someComponent)

The reason for the if (!this.state) return line is because prototype accessors are called before the constructor, and we need to do a number of things in the constructor (not least setting up this.state) before the config setter is called again.

We can't move the constructor code since we rely on injected dependencies to do some setup. Perhaps there's a better pattern for what we want to do but not sure how our use case can be accomplished. Do you have any ideas on what to do here?

@gkalpak
Copy link
Member

gkalpak commented May 27, 2016

TBH, I don't get what the problem is. Could you share some more code and/or tests.

@ghost
Copy link

ghost commented May 27, 2016

Hey @gkalpak the issue we're encountering is basically very similar to #13590 - I guess we just need to work around it.

@ghost
Copy link

ghost commented May 27, 2016

Just to give you some context, setters got called twice on startup (once before the component controller instantiates and once after). We relied on the behavior that setters got called after instantiation since dependencies are available at that point. Since this change returns early for matching values, our setter doesn't execute.

@ghost
Copy link

ghost commented May 28, 2016

OK I see the angular way of doing this is to hook into $onChanges 😃 - thx!

@gkalpak
Copy link
Member

gkalpak commented May 29, 2016

Glad you got it working with $onChanges 😃
I'm still not sure what your code does and how it is afected by this, but you might also want to look into $onInit.

@bcherny
Copy link
Contributor

bcherny commented Jun 6, 2016

@gkalpak I think what @khoomeister is saying is that in 1.5.5, the order of calls was setter-constructor-setter. So on the first fire this.state was undefined and the setter returned early, then the constructor defined this.state, then the setter fired again (successfully this time, because this.state was now defined).

In 1.5.6, that 2nd call to the setter never happens, so the setter is never fully executed.

This is a breaking change for anyone that relies on something being defined on this in a setter!

@gkalpak
Copy link
Member

gkalpak commented Jun 6, 2016

@bcherny, it will be easier to investigate with an actual example, but this is expected behavior as far as I understand. The fact that it worked differently in 1.5.5 (i.e. overwriting the inner value even though the outer vaue hadn't changed) was a bug. This commit is restoring the expacted/correct behavior and thus is not considered a breaking change (although it would break a usecase that relied on the buggy behavior).

The proper way to ensure something is run after the bindings have been initialized is using an appropriate hook (e.g. $onInit or $onChanges - depending on the usecase).

@bcherny
Copy link
Contributor

bcherny commented Jun 6, 2016

@gkalpak Demo here - http://codepen.io/anon/pen/EyVyzB

@gkalpak
Copy link
Member

gkalpak commented Jun 7, 2016

@bcherny, fwiw it is expected behavior. The actual "problem" with your usecase is not how one-way binding watches are triggered, but the fact that we are pre-assigning the binding values on the controller instance before actually instantiating it.

Although this is supposed to be a feature, this has also created its fair share of headaches as well, so it is scheduled for deprecation/removal in future versions. (We might even provide a way to opt-out in 1.5.x as well.)
Note, that the behavior will be different, depending on whether or not your controller is recognized as a class (vs plain old constructor function).

@bcherny
Copy link
Contributor

bcherny commented Jun 7, 2016

A backwards compatible way to make it work (at least in my case) would be to get rid of the 1st binding fire (caused by pre-assigning) rather than the 2nd binding fire -- is that what you mean?

So in 1.5.5 it was:

call setter - call constructor - call setter

In 1.5.6 it's:

call setter - call constructor

And what I'm proposing is:

call constructor - call setter

@petebacondarwin
Copy link
Member Author

call constructor - call setter is what we are aiming for in 1.6 but this would certainly break even more apps than the current change in 1.5.6

@bcherny
Copy link
Contributor

bcherny commented Jun 7, 2016

Good to hear - at least for me, that would be an improvement!

@gkalpak
Copy link
Member

gkalpak commented Jun 7, 2016

Note that this is the behavior you might get today if your controller is a class (a real class).
I think it would be nice to introduce the new behavior as an opt-in in 1.5.x, so people that are eager for this get a chance to try it out early and we gather some feedback before 1.6 is out.
Something like:

$compileProvider.enableBindingsPreAssignment(false);

The feature would be enabled by default on 1.5.x (so backwards compatible), but can be turned off.
Then on 1.6.x we will have it disabled by default (optionally allowing developers to turn it on).

@petebacondarwin
Copy link
Member Author

@gkalpak I agree. But I think it should be simply:

$compileProvider.preAssignBindings(false);

@RobbertWolfs
Copy link

RobbertWolfs commented Aug 29, 2016

@gkalpak @petebacondarwin Is there any progress on this?
Because we just upgraded from 1.5.5 to 1.5.8 and we also have binding issues..

We have a component that requires a boolean value but it is always set to the default value.. not the one we use in the binding..

Some code:

Se we pass true as a binding and if we don't have the binding the default value should be false. But now it is always set to false..

in our test:

it('it has a required binding', () => {
   $scope.required = true';
   element = createDirective('<image-upload required="required"></image-upload>', $scope);
   controller = element.controller('imageUpload'); 
   expect(controller.required).to.eql(true);
});

in our component

class ImageUploadController {
   // @ngInject
   constructor() {
      // DEFAULT BINDING
      this.required = false;
    }
}

export default {
   template: 'my template',
   controller: ImageUploadController,
   bindings: {
      required: '<?',
   },
};

petebacondarwin added a commit to petebacondarwin/angular.js that referenced this pull request Sep 5, 2016
A new flag to enable/disable whether directive controllers are assigned bindings before
calling the controller's constructor.

If enabled (true), the compiler assigns the value of each of the bindings to the
properties of the controller object before the constructor of this object is called.

If disabled (false), the compiler calls the constructor first before assigning bindings.

The default value is enabled (true) in Angular 1.5.x but will switch to false in Angular 1.6.x.

See angular#14580
petebacondarwin added a commit to petebacondarwin/angular.js that referenced this pull request Sep 6, 2016
A new option to enable/disable whether directive controllers are assigned bindings before
calling the controller's constructor.

If enabled (true), the compiler assigns the value of each of the bindings to the
properties of the controller object before the constructor of this object is called.

If disabled (false), the compiler calls the constructor first before assigning bindings.

The default value is enabled (true) in Angular 1.5.x but will switch to false in Angular 1.6.x.

See angular#14580
petebacondarwin added a commit to petebacondarwin/angular.js that referenced this pull request Sep 7, 2016
A new flag to enable/disable whether directive controllers are assigned bindings before
calling the controller's constructor.

If enabled (true), the compiler assigns the value of each of the bindings to the
properties of the controller object before the constructor of this object is called.

If disabled (false), the compiler calls the constructor first before assigning bindings.

The default value is enabled (true) in Angular 1.5.x but will switch to false in Angular 1.6.x.

See angular#14580
petebacondarwin added a commit to petebacondarwin/angular.js that referenced this pull request Sep 7, 2016
A new option to enable/disable whether directive controllers are assigned bindings before
calling the controller's constructor.

If enabled (true), the compiler assigns the value of each of the bindings to the
properties of the controller object before the constructor of this object is called.

If disabled (false), the compiler calls the constructor first before assigning bindings.

The default value is enabled (true) in Angular 1.5.x but will switch to false in Angular 1.6.x.

See angular#14580
petebacondarwin added a commit that referenced this pull request Sep 7, 2016
A new option to enable/disable whether directive controllers are assigned bindings before
calling the controller's constructor.

If enabled (true), the compiler assigns the value of each of the bindings to the
properties of the controller object before the constructor of this object is called.

If disabled (false), the compiler calls the constructor first before assigning bindings.

The default value is enabled (true) in Angular 1.5.x but will switch to false in Angular 1.6.x.

See #14580
Closes #15095
petebacondarwin added a commit that referenced this pull request Sep 7, 2016
A new option to enable/disable whether directive controllers are assigned bindings before
calling the controller's constructor.

If enabled (true), the compiler assigns the value of each of the bindings to the
properties of the controller object before the constructor of this object is called.

If disabled (false), the compiler calls the constructor first before assigning bindings.

The default value is enabled (true) in Angular 1.5.x but will switch to false in Angular 1.6.x.

See #14580
Closes #15095
@gkalpak
Copy link
Member

gkalpak commented Sep 8, 2016

@RobbertWolfs, in case you haven't noticed, the related PR has been merged and the preAssignBindingsEnabled configuration switch will be available in an upcoming 1.5.x release 🎉

petebacondarwin added a commit to petebacondarwin/angular.js that referenced this pull request Nov 21, 2016
A new option to enable/disable whether directive controllers are assigned bindings before
calling the controller's constructor.

If enabled (true), the compiler assigns the value of each of the bindings to the
properties of the controller object before the constructor of this object is called.

If disabled (false), the compiler calls the constructor first before assigning bindings.

The default value is enabled (true) in Angular 1.5.x but will switch to false in Angular 1.6.x.

See angular#14580
Closes angular#15095
petebacondarwin added a commit to petebacondarwin/angular.js that referenced this pull request Nov 21, 2016
A new option to enable/disable whether directive controllers are assigned bindings before
calling the controller's constructor.

If enabled (true), the compiler assigns the value of each of the bindings to the
properties of the controller object before the constructor of this object is called.

If disabled (false), the compiler calls the constructor first before assigning bindings.

The default value is enabled (true) in Angular 1.5.x but will switch to false in Angular 1.6.x.

See angular#14580
Closes angular#15095
petebacondarwin added a commit to petebacondarwin/angular.js that referenced this pull request Nov 21, 2016
A new option to enable/disable whether directive controllers are assigned bindings before
calling the controller's constructor.

If enabled (true), the compiler assigns the value of each of the bindings to the
properties of the controller object before the constructor of this object is called.

If disabled (false), the compiler calls the constructor first before assigning bindings.

The default value is enabled (true) in Angular 1.5.x but will switch to false in Angular 1.6.x.

See angular#14580
Closes angular#15095
petebacondarwin added a commit to petebacondarwin/angular.js that referenced this pull request Nov 21, 2016
A new option to enable/disable whether directive controllers are assigned bindings before
calling the controller's constructor.

If enabled (true), the compiler assigns the value of each of the bindings to the
properties of the controller object before the constructor of this object is called.

If disabled (false), the compiler calls the constructor first before assigning bindings.

The default value is enabled (true) in Angular 1.5.x but will switch to false in Angular 1.6.x.

See angular#14580
Closes angular#15095
petebacondarwin added a commit to petebacondarwin/angular.js that referenced this pull request Nov 21, 2016
A new option to enable/disable whether directive controllers are assigned bindings before
calling the controller's constructor.

If enabled (true), the compiler assigns the value of each of the bindings to the
properties of the controller object before the constructor of this object is called.

If disabled (false), the compiler calls the constructor first before assigning bindings.

The default value is enabled (true) in Angular 1.5.x but will switch to false in Angular 1.6.x.

See angular#14580
Closes angular#15095
petebacondarwin added a commit that referenced this pull request Nov 23, 2016
A new option to enable/disable whether directive controllers are assigned bindings before
calling the controller's constructor.

If enabled (true), the compiler assigns the value of each of the bindings to the
properties of the controller object before the constructor of this object is called.

If disabled (false), the compiler calls the constructor first before assigning bindings.

The default value is enabled (true) in Angular 1.5.x but will switch to false in Angular 1.6.x.

See #14580
Closes #15095
petebacondarwin added a commit that referenced this pull request Nov 24, 2016
A new option to enable/disable whether directive controllers are assigned bindings before
calling the controller's constructor.

If enabled (true), the compiler assigns the value of each of the bindings to the
properties of the controller object before the constructor of this object is called.

If disabled (false), the compiler calls the constructor first before assigning bindings.

The default value is enabled (true) in Angular 1.5.x but will switch to false in Angular 1.6.x.

See #14580
Closes #15095
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.

Outer change is stronger than the inner one when they are both changed and using one-way binding
5 participants