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

fix(ngRoute): make route.resolve count as a pending request #14159

Closed
wants to merge 1 commit into from

Conversation

sjelin
Copy link
Contributor

@sjelin sjelin commented Mar 2, 2016

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

Make angular count pending resolve values as pending request, for the purposes of making whenStable more accurate.

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

Currently, if someone uses promises to asynchronously set a resolve, this is not counted as a pending request. The result is that callbacks to whenStable can be invoked before these variables are available. This was causing protractor users problems (see angular/protractor#789 (comment) for details)

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

Waiting for these variables now counts as a pending request.

  • Does this PR introduce a breaking change?

I guess you could imagine a breaking change where someone wanted a whenStable callback to be invoked before the variables resolved.

Not sure tests are necessary for such a small change. And since this brings behavior in line with user expectations not sure about the docs either.

  • Other information:

@gkalpak
Copy link
Member

gkalpak commented Mar 5, 2016

(Note to self: Related to #13782.)

@gkalpak
Copy link
Member

gkalpak commented Jul 15, 2016

@sjelin, this seems reasonable. It needs rebasing and definitely some tests (ideally both unit and e2e).
(I wouldn't consider it a breaking change, but this is debatable 😄)

Would you be interested in moving this forward?

@sjelin
Copy link
Contributor Author

sjelin commented Sep 8, 2016

Hi, sorry, yeah - I'll take a look.

@sjelin sjelin force-pushed the routeResolvePending branch from 11956ba to d4114be Compare December 21, 2016 21:12
@sjelin
Copy link
Contributor Author

sjelin commented Dec 21, 2016

Update: I'm back on this. Just rebased, I'll add some tests after lunch

@sjelin sjelin force-pushed the routeResolvePending branch 2 times, most recently from 6a73d5b to 7d94d0a Compare January 3, 2017 23:09
@sjelin
Copy link
Contributor Author

sjelin commented Jan 3, 2017

@gkalpak I finally added tests! Sorry for not prioritizing this

Copy link
Member

@gkalpak gkalpak left a comment

Choose a reason for hiding this comment

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

I left some comments. We also miss an e2e test.
(@sjelin, feel free to ping me if you need any help.)

return $q.all(locals).then(function(locals) {
$browser.$$completeOutstandingRequest(function() {});
return locals;
});
Copy link
Member

Choose a reason for hiding this comment

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

This should be $.all(locals).finally(function() { $browser.$$completeOutstandingRequest(function() {}); });.
Because now you will never reach 0 outstanding requests once a resolve fails in the app.

@@ -786,7 +788,10 @@ function $RouteProvider() {
if (angular.isDefined(template)) {
locals['$template'] = template;
}
return $q.all(locals);
return $q.all(locals).then(function(locals) {
$browser.$$completeOutstandingRequest(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 would use angular.noop here. Actually, I would initialize noop = angular.noop (e.g. the same as isArray) above.

@@ -194,5 +194,33 @@ describe('$$testability', function() {
$$testability.whenStable(callback);
expect(callback).toHaveBeenCalled();
}));

it('should wait for route promises before calling callbacks', function() {
Copy link
Member

Choose a reason for hiding this comment

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

This test does not belong here. It should be moved to routeSpec.js.

Copy link
Member

Choose a reason for hiding this comment

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

You should also add a test for where a resolve fails/throws.

@sjelin sjelin force-pushed the routeResolvePending branch from 7d94d0a to 3fa796f Compare January 4, 2017 22:13
@sjelin
Copy link
Contributor Author

sjelin commented Jan 4, 2017

@gkalpak all comments addressed

Copy link
Member

@gkalpak gkalpak left a comment

Choose a reason for hiding this comment

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

While taking another look, I realized that ngRoute has one more async feature (that might not be there when this PR was first created) which is worth taking into account: resolveRedirectTo

Basically, it is possible to decide about a redirection asynchronously (possibly after some delay). It sounds reasonable to take that delay into account too.

Thus, I suggest expanding the outstanding request increment/decrement "scope" to this block, instead of just around resolveLocals. Maybe something like:

function decrementRequestCount() {
  $browser.$$completeOutstandingRequest(noop);
}

...

$browser.$$incOutstandingRequestCount();

nextRoutePromise.
  then(getRedirectionData).
  then(handlePossibleRedirection).
  then(function(keepProcessingRoute) {
    return keepProcessingRoute && nextRoutePromise.
      then(resolveLocals).
      then(function(locals) {
        // after route change
        if (nextRoute === $route.current) {
          if (nextRoute) {
            nextRoute.locals = locals;
            angular.copy(nextRoute.params, $routeParams);
          }

          $rootScope.$broadcast('$routeChangeSuccess', nextRoute, lastRoute);
        }
      });
  }).
  catch(function(error) {
    if (nextRoute === $route.current) {
      $rootScope.$broadcast('$routeChangeError', nextRoute, lastRoute, error);
    }
  }).
  finally(decrementRequestCount);

WDYT, @sjelin?

@@ -786,7 +790,9 @@ function $RouteProvider() {
if (angular.isDefined(template)) {
locals['$template'] = template;
}
return $q.all(locals);
return $q.all(locals).finally(function(locals) {
Copy link
Member

Choose a reason for hiding this comment

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

finally callbacks don't take any arguments.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

woops 😅

expect(element.all(by.tagName('li')).count()).toBe(5);
});
it('should time out if the promise takes long enough', function() {
// Don't try this at home kids, I'm a protractor dev
Copy link
Member

Choose a reason for hiding this comment

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

😆

expect(error.message).toContain('Timed out waiting for asynchronous Angular tasks to finish after 0.001 seconds.');
});
});
afterAll(function() {
Copy link
Member

@gkalpak gkalpak Jan 5, 2017

Choose a reason for hiding this comment

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

It doesn't make much difference atm, but I believe it is safer to do this afterEach spec (just in case someone adds another test after the second it block).

beforeEach(function() {
loadFixture('ng-route-promise');
});
it('should wait for promises in resolve blocks', function() {
Copy link
Member

Choose a reason for hiding this comment

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

Add newlines before it blocks.

<script src="angular.js"></script>
<script src="angular-route.js"></script>
<script src="script.js"></script>
</head>
Copy link
Member

Choose a reason for hiding this comment

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

Any particular reason for the <head> element? Why not just put the scripts in <body> (for brevity and consistency with other tests)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just habit - I'll remove it

}
}
}).
otherwise({
Copy link
Member

Choose a reason for hiding this comment

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

Nit: otherwise('/') is shorter 😁

$routeProvider.
when('/', {
template: '<ul><li ng-repeat="letter in letters">{{letter}}</li><ul>',
controller: 'LettersCtrl',
Copy link
Member

Choose a reason for hiding this comment

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

Nit: You can get rid of the controller altogether and use $resolve.letters directly in the template.

Copy link
Member

Choose a reason for hiding this comment

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

You don't have to (unless you really want to 😉), but this script could be simply written as:

'use strict';

angular
  .module('lettersApp', ['ngRoute'])
  .config(function($routeProvider) {
    $routeProvider
      .when('/', {
        template: '<ul><li ng-repeat="letter in $resolve.letters">{{ letter }}</li></ul>',
        resolve: {
          letters: function($q) {
            return $q(function(resolve) { setTimeout(resolve, 1000, ['a', 'b', 'c', 'd', 'e']); });
          }
        }
        // Sigh :( - Missing arrow functions already.
        // resolve: {letters: $q => $q(resolve => setTimeout(resolve, 1000, [...]))}
      })
      .otherwise('/');
  });

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will do! An yeah, not having arrow functions is the worse 😢

@gkalpak
Copy link
Member

gkalpak commented Jan 5, 2017

(Also, the Travis failures seem legit.)

Copy link
Contributor Author

@sjelin sjelin left a comment

Choose a reason for hiding this comment

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

I like the idea of expending the scope of this change to include resolveRedirectTo, but I'm not sure how to handle the following race condition:

  1. A route uses resolveRedirectTo, causing $$incOutstandingRequestCount to be called
  2. Protractor queries for stability, getting enqueued by whenStable
  3. resolveRedirectTo resolves to a new route, thereby calling $$completeOutstandingRequest, and thereby calling the whenStable callbacks. Protractor will get the signal in a few milliseconds, since it takes time for things to go through the selenium server
  4. The new route uses resolve
  5. Protractor, thinking the page is stable, executes some webdriver commands commands even though resolve has not yet resolved
  6. resolve resolves, but too late

@@ -786,7 +790,9 @@ function $RouteProvider() {
if (angular.isDefined(template)) {
locals['$template'] = template;
}
return $q.all(locals);
return $q.all(locals).finally(function(locals) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

woops 😅

<script src="angular.js"></script>
<script src="angular-route.js"></script>
<script src="script.js"></script>
</head>
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just habit - I'll remove it

$routeProvider.
when('/', {
template: '<ul><li ng-repeat="letter in letters">{{letter}}</li><ul>',
controller: 'LettersCtrl',
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will do! An yeah, not having arrow functions is the worse 😢

@gkalpak
Copy link
Member

gkalpak commented Jan 9, 2017

I like the idea of expending the scope of this change to include resolveRedirectTo, but I'm not sure how to handle the following race condition: ...

This doesn't seem to be an issue, because of the relative timing of when things happen. The redirection is correctly taken into account (see the test in #15587).

I have incorporated the proposed changes into #15587 (along with a fix in the second test). Feel free cherry-pick the changes into this PR.

@sjelin
Copy link
Contributor Author

sjelin commented Jan 9, 2017

Oh, because javascript is synchronous huh? I get it now

@sjelin sjelin force-pushed the routeResolvePending branch from 05b2cc7 to aa05cde Compare January 10, 2017 04:32
$$testability.whenStable(callback);
expect(callback).not.toHaveBeenCalled();

// ngRoute code is wrapped in a $browser.defer call (via $rootScope.evalAsync), which in
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@gkalpak this comment is the result of my investigation

Copy link
Member

Choose a reason for hiding this comment

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

Where does the $rootScope.$evalAsync() come from?

Copy link
Member

Choose a reason for hiding this comment

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

Answering my own question: It comes from $location (see here).
So, you were spot on 👍 (which is a good thing, because it confirms that the feature works out-of-the-box outside of unit tests).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll mention $location too 😄

@sjelin sjelin force-pushed the routeResolvePending branch from aa05cde to 785c987 Compare January 10, 2017 04:59
}, function(error) {
var errorRe = new RegExp(
'Timed out waiting for asynchronous Angular tasks to finish after ' +
'(?:1\\.5 seconds|15\\d\\d ?ms)\\.', '');
Copy link
Member

Choose a reason for hiding this comment

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

I know you took this from my PR, but this RegExp is testing more than is necessary (and might give false negatives after a future update). I think it suffixes to have:

expect(error.message).toContain('Timed out waiting for asynchronous Angular tasks to finish');

// Restore old timeout limit
browser.getProcessedConfig().then(function(config) {
return browser.manage().timeouts().setScriptTimeout(config.allScriptsTimeout);
}).then(function() { done(); });
Copy link
Member

Choose a reason for hiding this comment

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

Couldn't this just be .then(done)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Probably. In some jasmine setups, passing parameters to the done callback indicates a failed test. This isn't an issue anymore, but I guess it's just a habit of mine now.

$$testability.whenStable(callback);
expect(callback).not.toHaveBeenCalled();

// ngRoute code is wrapped in a $browser.defer call (via $rootScope.evalAsync), which in
Copy link
Member

Choose a reason for hiding this comment

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

Where does the $rootScope.$evalAsync() come from?

@gkalpak
Copy link
Member

gkalpak commented Jan 10, 2017

So, this generally LGTM (now that we have confirmed that the failures where due to unit-test mocks).

Ideally, I would apply some more changes (mainly to tests): 927f355
(I don't feel too strongly about it though - feel free to ignore.)

@googlebot
Copy link

So there's good news and bad news.

👍 The good news is that everyone that needs to sign a CLA (the pull request submitter and all commit authors) have done so. Everything is all good there.

😕 The bad news is that it appears that one or more commits were authored by someone other than the pull request submitter. We need to confirm that they're okay with their commits being contributed to this project. Please have them confirm that here in the pull request.

Note to project maintainer: This is a terminal state, meaning the cla/google commit status will not change from this state. It's up to you to confirm consent of the commit author(s) and merge this pull request when appropriate.

1 similar comment
@googlebot
Copy link

So there's good news and bad news.

👍 The good news is that everyone that needs to sign a CLA (the pull request submitter and all commit authors) have done so. Everything is all good there.

😕 The bad news is that it appears that one or more commits were authored by someone other than the pull request submitter. We need to confirm that they're okay with their commits being contributed to this project. Please have them confirm that here in the pull request.

Note to project maintainer: This is a terminal state, meaning the cla/google commit status will not change from this state. It's up to you to confirm consent of the commit author(s) and merge this pull request when appropriate.

@sjelin sjelin force-pushed the routeResolvePending branch 2 times, most recently from 3b106ae to cb55b32 Compare January 10, 2017 19:50
@sjelin
Copy link
Contributor Author

sjelin commented Jan 10, 2017

@gkalpak I adopted your changes but then made some extremely minor ones of my own: 3b106ae

  1. I wrapped the callback in $$completeOutstandingRequest in a try block, which is how it's done in production
  2. I moved some comments around. I wanted some of the explanation to be in src/ngRoute/route.js so that no one looked at that code and wondered "Why is this never causing outstandingRequestCount to hit zero?"
  3. In production, I routed all modifications to outstandingRequestCount through $$incOutstandingRequestCount or $$completeOutstandingRequest. This doesn't fix anything but it just seemed like a good refactor

@@ -321,7 +321,7 @@ function Browser(window, document, $log, $sniffer) {
*/
self.defer = function(fn, delay) {
var timeoutId;
outstandingRequestCount++;
self.$$incOutstandingRequestCount();
Copy link
Member

Choose a reason for hiding this comment

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

I don't think this buys us much and is inconsistent with the rest of the file (which either accesses/modifies outstandRequestCount directly or through (the private) completeOutstandingRequest() function).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I disagree but I'll revert this change anyway 😛

@gkalpak
Copy link
Member

gkalpak commented Jan 11, 2017

LGTM (Nit: I don't particularly like the 3rd change 😁)

@googlebot, I confirm that I am okay with my commits being contributed to this fine project 😃

@sjelin sjelin force-pushed the routeResolvePending branch from cb55b32 to 4567b9d Compare January 11, 2017 18:57
@googlebot
Copy link

CLAs look good, thanks!

1 similar comment
@googlebot
Copy link

CLAs look good, thanks!

@sjelin
Copy link
Contributor Author

sjelin commented Jan 11, 2017

@gkalpak alright, should be good to go! I don't have permission to actually do the merge though

Protractor users were having a problem where if they had asynchonous code in a route.resolve
variable, Protractor was not waiting for that code to complete before continuing. See
angular/protractor#789 (comment) for details

Also enhanced ngMock to wait for pending requests before calling callbacks from
`$browser.notifyWhenNoOutstandingRequests`

Potentially breaking change if someone was relying on route.resolve not blocking
`$browser.notifyWhenNoOutstandingRequests`
@sjelin sjelin force-pushed the routeResolvePending branch 2 times, most recently from 26d10a0 to 6aca56b Compare January 11, 2017 19:35
@gkalpak
Copy link
Member

gkalpak commented Jan 11, 2017

Awesome! I can take it from here. Thx for bearing with me till the end 😃

@gkalpak gkalpak closed this in c522a43 Jan 11, 2017
gkalpak pushed a commit that referenced this pull request Jan 11, 2017
Protractor users were having a problem where if they had asynchonous code in a
`route.resolve` or `route.resolveRedirectTo` variable, Protractor was not
waiting for that code to complete before continuing. See
angular/protractor#789 (comment) for
details.

This commit fixes it by ensuring that `$browser#outstandingRequestCount` is
properly increased/decreased while `$route` (asynchronously) processes a route.

Also, enhanced `ngMock` to wait for pending requests, before calling callbacks
from `$browser.notifyWhenNoOutstandingRequests()`.

Related to angular/protractor#789.

Closes #14159
ellimist pushed a commit to ellimist/angular.js that referenced this pull request Mar 15, 2017
Protractor users were having a problem where if they had asynchonous code in a
`route.resolve` or `route.resolveRedirectTo` variable, Protractor was not
waiting for that code to complete before continuing. See
angular/protractor#789 (comment) for
details.

This commit fixes it by ensuring that `$browser#outstandingRequestCount` is
properly increased/decreased while `$route` (asynchronously) processes a route.

Also, enhanced `ngMock` to wait for pending requests, before calling callbacks
from `$browser.notifyWhenNoOutstandingRequests()`.

Related to angular/protractor#789.

Closes angular#14159
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.

3 participants