javascriptangularjsangular-ui-routerangular-component-router

angular 1.5 component, ui-router resolve, $onChanges lifecycle hook


In the following example (plunker), a ui-router state routes to an app component that has a data object and a replace method that replaces this object with a new one using the given value. In its template, it has:

Everything works fine and as expected :-)

angular
.module('app', ['ui.router'])

.config(($urlRouterProvider, $stateProvider) => {
    $urlRouterProvider.otherwise('/');

    $stateProvider
    .state('root', {
      url: '/',
      component: 'app'
  });
})

.component('app', {
  controller: function () {
    this.data = { name: 'initial' };
    this.replace = (value) => { this.data = { name: value }; };
  },
  template: `
    <editor on-replace="$ctrl.replace(value)"></editor>
    <display data="$ctrl.data"></display>
  `
})

.component('editor', {
  bindings: {
    onReplace: '&'
  },
  controller: function () {
    this.replace = (value) => this.onReplace({value});
  },
  template: `
    <input type="text" ng-model="value">
    <button ng-click="$ctrl.replace(value)"> replace object </button>
  `
})

.component('display', {
  bindings: {
    data: '<'
  },
  controller: function () {
    this.$onChanges = (changes) => {
      if (changes.data) {
        this.data = Object.assign({}, this.data);
      }
    };
  },
  template: `
    <p> value : {{ $ctrl.data.name }} </p>
  `
});

I have a problem with the following second example (plunker). It is the exact same setting, except that the app component no longer manages the data itself, but receives a data object through a 1-way binding ('<') that is defined as a resolve of the ui-router state (as can be seen in the comments, I tested both using a global object and method, and interacting through a service). When this resolved object is reassigned, I was expecting the $onChanges hook of the app component to be triggered (as it is for the display component when the data object of the app component is reassigned), but this is not the case. Does anyone has an explanation?

let data = { name: 'initial' };
const replace = (value) => { data = { name: value }; };

angular
.module('app', ['ui.router'])

/*.factory('DataService', function () {
  let data = { name: 'initial' };
  return {
    getData: () => data,
    replace: (value) => { data = { name: value }; }
  };
})*/

.config(($urlRouterProvider, $stateProvider) => {
    $urlRouterProvider.otherwise('/');

    $stateProvider
    .state('root', {
      url: '/',
      component: 'app',
      resolve: {
        data: () => data /*(DataService) => DataService.getData()*/
      }
  });
})

.component('app', {
  bindings: {
    data: '<'
  },
  controller: function (/*DataService*/) {
    this.$onChanges = (changes) => {
      if (changes.data) {
        this.data = Object.assign({}, this.data);
      }
    };
    this.replace = (value) => { replace(value); }; /*(value) => { DataService.replace(value); };*/
  },
  template: `
    <editor on-replace="$ctrl.replace(value)"></editor>
    <display data="$ctrl.data"></consumer>
  `
})

.component('editor', {
  bindings: {
    onReplace: '&'
  },
  controller: function () {
    this.replace = (value) => this.onReplace({value});
  },
  template: `
    <input type="text" ng-model="value">
    <button ng-click="$ctrl.replace(value)"> replace object </button>
  `
})

.component('display', {
  bindings: {
    data: '<'
  },
  controller: function () {
    this.$onChanges = (changes) => {
      if (changes.data) {
        this.data = Object.assign({}, this.data);
      }
    };
  },
  template: `
    <p> value : {{ $ctrl.data.name }} </p>
  `
});

Solution

  • Under the hood, UI-ROUTER creates HTML with a one-time binding to $resolve.data on the parent scope.

    <app data="::$resolve.data" class="ng-scope ng-isolate-scope">
      <editor on-replace="$ctrl.replace(value)" class="ng-isolate-scope">
        <input type="text" ng-model="value" class="ng-pristine ng-untouched ng-valid ng-empty">
        <button ng-click="$ctrl.replace(value)"> replace object </button>
      </editor>
      <display data="$ctrl.data" class="ng-isolate-scope">
        <p class="ng-binding"> value : initial </p>
      </display>
    </app>
    

    The watcher that invokes the $onChanges hook in the app component watches the $resolve.data property of the parent scope. It does not react to changes to the $ctrl.data propery of the isolate scope. And since it is a one-time binding, the $onChanges hook gets invoked only when the variable gets initialized and not on subsequent changes.


    Use a Service with RxJZ

    Instead of trying to bend $onChange to do something for which it was not designed, build a service with RxJS Extensions for Angular.

    app.factory("DataService", function(rx) {
      var subject = new rx.Subject(); 
      var data = "Initial";
    
      return {
          set: function set(d){
            data = d;
            subject.onNext(d);
          },
          get: function get() {
            return data;
          },
          subscribe: function (o) {
             return subject.subscribe(o);
          }
      };
    });
    

    Then simply subscribe to the changes.

    app.controller('displayCtrl', function(DataService) {
      var $ctrl = this;
    
      $ctrl.data = DataService.get();
      var subscription = DataService.subscribe(function onNext(d) {
          $ctrl.data = d;
      });
    
      this.$onDestroy = function() {
          subscription.dispose();
      };
    });
    

    The DEMO on PLNKR.