feat(ngRoute): allow ngView to be included in an asynchronously loaded template

During its linking phase, `ngView` relies on the info provided in `$route.current` for
instantiating the initial view. `$route.current` is set in the callback of a listener to
`$locationChangeSuccess`, which is registered during the instantiation of the `$route` service.

Thus, it is crucial that the `$route` service is instantiated _before_ the initial
`$locationChangeSuccess` event is fired. Since `ngView` declares `$route` as a dependency, the
service is instantiated in time, if `ngView` is present during the initial load of the page.

Yet, in cases where `ngView` is included in a template that is loaded asynchronously (e.g. in
another directive's template), the directive factory might not be called soon enough for `$route`
to be instantiated before the initial `$locationChangeSuccess` event is fired.

This commit fixes it, by enabling eager instantiation of `$route` (during the initialization phase).
Eager instantiation can be disabled (restoring the old behavior), but is on by default.

Fixes #1213
Closes #14893

BREAKING CHANGE:

In cases where `ngView` was loaded asynchronously, `$route` (and its dependencies; e.g. `$location`)
might also have been instantiated asynchronously. After this change, `$route` (and its dependencies)
will - by default - be instantiated early on.

Although this is not expected to have unwanted side-effects in normal application bebavior, it may
affect your unit tests: When testing a module that (directly or indirectly) depends on `ngRoute`, a
request will be made for the default route's template. If not properly "trained", `$httpBackend`
will complain about this unexpected request.

You can restore the previous behavior (and avoid unexpected requests in tests), by using
`$routeProvider.eagerInstantiationEnabled(false)`.
This commit is contained in:
Georgios Kalpakas
2016-07-10 21:16:26 +03:00
parent 47583d9800
commit c13c666728
3 changed files with 147 additions and 5 deletions
+63 -5
View File
@@ -2,10 +2,11 @@
/* global shallowCopy: false */
// There are necessary for `shallowCopy()` (included via `src/shallowCopy.js`).
// `isArray` and `isObject` are necessary for `shallowCopy()` (included via `src/shallowCopy.js`).
// They are initialized inside the `$RouteProvider`, to ensure `window.angular` is available.
var isArray;
var isObject;
var isDefined;
/**
* @ngdoc module
@@ -22,10 +23,17 @@ var isObject;
*
* <div doc-module-components="ngRoute"></div>
*/
/* global -ngRouteModule */
var ngRouteModule = angular.module('ngRoute', ['ng']).
provider('$route', $RouteProvider),
$routeMinErr = angular.$$minErr('ngRoute');
/* global -ngRouteModule */
var ngRouteModule = angular.
module('ngRoute', []).
provider('$route', $RouteProvider).
// Ensure `$route` will be instantiated in time to capture the initial `$locationChangeSuccess`
// event (unless explicitly disabled). This is necessary in case `ngView` is included in an
// asynchronously loaded template.
run(instantiateRoute);
var $routeMinErr = angular.$$minErr('ngRoute');
var isEagerInstantiationEnabled;
/**
* @ngdoc provider
@@ -44,6 +52,7 @@ var ngRouteModule = angular.module('ngRoute', ['ng']).
function $RouteProvider() {
isArray = angular.isArray;
isObject = angular.isObject;
isDefined = angular.isDefined;
function inherit(parent, extra) {
return angular.extend(Object.create(parent), extra);
@@ -287,6 +296,47 @@ function $RouteProvider() {
return this;
};
/**
* @ngdoc method
* @name $routeProvider#eagerInstantiationEnabled
* @kind function
*
* @description
* Call this method as a setter to enable/disable eager instantiation of the
* {@link ngRoute.$route $route} service upon application bootstrap. You can also call it as a
* getter (i.e. without any arguments) to get the current value of the
* `eagerInstantiationEnabled` flag.
*
* Instantiating `$route` early is necessary for capturing the initial
* {@link ng.$location#$locationChangeStart $locationChangeStart} event and navigating to the
* appropriate route. Usually, `$route` is instantiated in time by the
* {@link ngRoute.ngView ngView} directive. Yet, in cases where `ngView` is included in an
* asynchronously loaded template (e.g. in another directive's template), the directive factory
* might not be called soon enough for `$route` to be instantiated _before_ the initial
* `$locationChangeSuccess` event is fired. Eager instantiation ensures that `$route` is always
* instantiated in time, regardless of when `ngView` will be loaded.
*
* The default value is true.
*
* **Note**:<br />
* You may want to disable the default behavior when unit-testing modules that depend on
* `ngRoute`, in order to avoid an unexpected request for the default route's template.
*
* @param {boolean=} enabled - If provided, update the internal `eagerInstantiationEnabled` flag.
*
* @returns {*} The current value of the `eagerInstantiationEnabled` flag if used as a getter or
* itself (for chaining) if used as a setter.
*/
isEagerInstantiationEnabled = true;
this.eagerInstantiationEnabled = function eagerInstantiationEnabled(enabled) {
if (isDefined(enabled)) {
isEagerInstantiationEnabled = enabled;
return this;
}
return isEagerInstantiationEnabled;
};
this.$get = ['$rootScope',
'$location',
@@ -791,3 +841,11 @@ function $RouteProvider() {
}
}];
}
instantiateRoute.$inject = ['$injector'];
function instantiateRoute($injector) {
if (isEagerInstantiationEnabled) {
// Instantiate `$route`
$injector.get('$route');
}
}
+31
View File
@@ -1029,4 +1029,35 @@ describe('ngView', function() {
));
});
});
describe('in async template', function() {
beforeEach(module('ngRoute'));
beforeEach(module(function($compileProvider, $provide, $routeProvider) {
$compileProvider.directive('asyncView', function() {
return {templateUrl: 'async-view.html'};
});
$provide.decorator('$templateRequest', function($timeout) {
return function() {
return $timeout(angular.identity, 500, false, '<ng-view></ng-view>');
};
});
$routeProvider.when('/', {template: 'Hello, world!'});
}));
it('should work correctly upon initial page load',
// Injecting `$location` here is necessary, so that it gets instantiated early
inject(function($compile, $location, $rootScope, $timeout) {
var elem = $compile('<async-view></async-view>')($rootScope);
$rootScope.$digest();
$timeout.flush(500);
expect(elem.text()).toBe('Hello, world!');
dealoc(elem);
})
);
});
});
+53
View File
@@ -1,5 +1,58 @@
'use strict';
describe('$routeProvider', function() {
var $routeProvider;
beforeEach(module('ngRoute'));
beforeEach(module(function(_$routeProvider_) {
$routeProvider = _$routeProvider_;
$routeProvider.when('/foo', {template: 'Hello, world!'});
}));
it('should support enabling/disabling automatic instantiation upon initial load',
inject(function() {
expect($routeProvider.eagerInstantiationEnabled(true)).toBe($routeProvider);
expect($routeProvider.eagerInstantiationEnabled()).toBe(true);
expect($routeProvider.eagerInstantiationEnabled(false)).toBe($routeProvider);
expect($routeProvider.eagerInstantiationEnabled()).toBe(false);
expect($routeProvider.eagerInstantiationEnabled(true)).toBe($routeProvider);
expect($routeProvider.eagerInstantiationEnabled()).toBe(true);
})
);
it('should automatically instantiate `$route` upon initial load', function() {
inject(function($location, $rootScope) {
$location.path('/foo');
$rootScope.$digest();
});
inject(function($route) {
expect($route.current).toBeDefined();
});
});
it('should not automatically instantiate `$route` if disabled', function() {
module(function($routeProvider) {
$routeProvider.eagerInstantiationEnabled(false);
});
inject(function($location, $rootScope) {
$location.path('/foo');
$rootScope.$digest();
});
inject(function($route) {
expect($route.current).toBeUndefined();
});
});
});
describe('$route', function() {
var $httpBackend,
element;