From 44909264383915e6b5642d9cbb6ef9a0927fe04a Mon Sep 17 00:00:00 2001 From: Georgios Kalpakas Date: Sun, 10 Jul 2016 21:16:26 +0300 Subject: [PATCH] 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 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)`. --- src/ngRoute/route.js | 68 ++++++++++++++++++++++++++-- test/ngRoute/directive/ngViewSpec.js | 31 +++++++++++++ test/ngRoute/routeSpec.js | 53 ++++++++++++++++++++++ 3 files changed, 147 insertions(+), 5 deletions(-) diff --git a/src/ngRoute/route.js b/src/ngRoute/route.js index 4ead323e8baf..8dcb2bd718de 100644 --- a/src/ngRoute/route.js +++ b/src/ngRoute/route.js @@ -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; * *
*/ - /* 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**:
+ * 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'); + } +} diff --git a/test/ngRoute/directive/ngViewSpec.js b/test/ngRoute/directive/ngViewSpec.js index daab5f26c987..66d2653108ce 100644 --- a/test/ngRoute/directive/ngViewSpec.js +++ b/test/ngRoute/directive/ngViewSpec.js @@ -1027,3 +1027,34 @@ describe('ngView animations', function() { )); }); }); + +describe('ngView 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, ''); + }; + }); + + $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('')($rootScope); + $rootScope.$digest(); + $timeout.flush(500); + + expect(elem.text()).toBe('Hello, world!'); + + dealoc(elem); + }) + ); +}); diff --git a/test/ngRoute/routeSpec.js b/test/ngRoute/routeSpec.js index 1d6037a84f9d..5a0780f5efbf 100644 --- a/test/ngRoute/routeSpec.js +++ b/test/ngRoute/routeSpec.js @@ -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;