URL out of sync with ng-view after a $routeChangeError #2100

Open
butchpeters opened this Issue Mar 5, 2013 · 28 comments
@butchpeters
Contributor
  1. Configure a route (e.g. '/causesError') containing a resolve property
  2. Load a good state in your application (e.g. the route '/goodRoute')
  3. User performs an action to load the route '/causesError'
  4. The resolve gets rejected, which causes $routeChangeError to be broadcast rather than $routeChangeSuccess.

Because there was no $routeChangeSuccess, the ng-view still contains content for '/goodRoute', but now the URL is changed to the failed route. They are out of sync.

Any attempt to change the URL back to '/goodRoute' in a $routeChangeError event handler would cause the '/goodRoute' to reload, which is not desirable.

One problem this causes is now the user can't retry the action that caused the error. In a mobile application, for example, they may have been experiencing intermittent connection loss. If they retry the same action, the location change events won't fire because the URL isn't changing, and therefore the route won't be reattempted.

@fozzle
fozzle commented Jul 22, 2013

Does anyone have a decent solution for this problem?

@xMysteriox

It would be great if this could be fixed somewhere along the line...

@marcalj
marcalj commented Dec 16, 2013

+1

@dogancelik

I have the same issue. My current solution is:

$rootScope.$on "$routeChangeError", (event, current, previous, rejection) ->
  $location.path(if not angular.isUndefined(previous) then previous.originalPath else "/")

There are two options here:

  1. If user goes to /causesError without a referrer, user will be redirected to / (home page).
  2. If user goes to /causesError from a link, user will be redirected to the last route (in this case: /goodRoute).
@austingreco
Contributor

Just a note: the above code won't work for routes with parameters, originalPath would contain the literal /goodRoute/:param

@dogancelik

Oh I didn't notice that, thank you. Here's a little fix, I don't know how to do this the Angular way:

previous.originalPath.replace(/:(\w+)/g, (match, param) -> previous.pathParams[param])
@bbshopadmin

+1 need a clean solution too

@bbshopadmin

In case of rejected resolve on route change: there is a needless entry in the history and a wrong location is shown. Any ideas?

@bbshopadmin

I can't believe not finding a solution for that issue. In my case i don't want to redirect to error pages, so i want to stay in the current view.

Is route resolve simply unusable? What i'm overlooking?

@just-boris
Contributor

What about to show route defined as otherwise case? It is intended to be shown when other routes can't do it.
I think, that is good solution, because usually there is described a fallback page for wrong route. It will be much better than loading previous path when I have to listen $routeChangeError and show error message manually.

@bbshopadmin

For now i redirect to an error view, though i'd like to handle route change errors messages via popup or something like that.

@just-boris
Contributor

The main reason, why I don't use pop-ups for error, it that is when opening page with error by direct link you will see the only small block instead of full-page message.
For example, my app has route '/project/:id' and if I request non-existing id and server returns an error I want to show 404 page for user, and this is common use case of my routes.
Could you provide your example where you need your behaviour?

P.S. Popups is only useful when you have a deal with request without route change, e.g. form submit or pagination. In this case current view may has an unsaved user data, which cannot be lost.

@bbshopadmin

@just-boris I use angular-http-auth and $route resolve. I tried to explain my issue here: witoldsz/angular-http-auth#55 .

@just-boris
Contributor

Very interesting. +1 for the route reload usage.
With authorization I see the following steps:

  1. User clicks on link, requiring authorization.
  2. Server respond 401 error
  3. We capture this error and show auth form.
  4. There two ways:
    • User successfully logged in: call $route.reload().
    • User clicks cancel: call $window.history.back(); to get previous page.

My scheme ideally covers this case

@marcghorayeb

👍

@ovmjm
ovmjm commented Mar 5, 2014

+1

@Narretz Narretz added this to the 1.3.0 milestone Jun 25, 2014
@btford btford removed the gh: issue label Aug 20, 2014
@sinelaw
Contributor
sinelaw commented Oct 26, 2014

Any update on this?

@bsr203
bsr203 commented Jan 6, 2015

severity: inconvenient !! is there a proper way to not having a wrong route in history, though resolve fails before route change?

@veritas1

👍

@amitport

Building on @just-boris and @dogancelik comments, I'm using this code and it works good:

$rootScope.$on("$routeChangeError", function(event, current, previous, rejection) {
  if (previous) {
    $window.history.back();
  } else {
    $location.path("/").replace();
  }
});

(assumes "/" is your default route)

@Tathanen
Tathanen commented Aug 6, 2015

This really is the worst! I'm having to write completely insane things to work around this. Can we get this in a 1.4.x milestone maybe?

@petebacondarwin
Member

This needs to be addressed in 1.4.x, I think.

@petebacondarwin
Member

I think it might be that this is a good use case for the "location change without route change" issue.

@litera
litera commented Oct 9, 2015

History state is manipulated by $location service. When you use routing events follow this sequence:

  1. $locationChangeStart
  2. $routeChangeStart (which also prevents location change event if any $routeChangeStart listeners prevented routing change
  3. $locationChangeSuccess
  4. $routeChangeSuccess or $routeChangeError

The problem is event ordering and how routing subscribes to location change events.

  1. When location change event fires, routing prepares next route and fires route change start event to all its listeners giving them the possibility to cancel it but then it exists, giving location service the possibility to change URL
  2. Location service changes URL
  3. Routing detects this change (by subscribing to $locationChangeSuccess) and starts preparing route locals (route resolves) and route template
  4. Routing then waits for all these promises to successfully resolve.
  5. It than just updates next route data and informs listeners that routing has successfully finished

The main problem as I see it is the synchronous nature of location service. Even if we moved route resolves from commitRoute to prepareRoute function. These resolves are still usually (or can be) promises. Location service would still continue with URL change.

The only way (without changing location service to async nature) would be to always cancel route change start event (which also cancels location change process).

So. on route change start we check any any route resolves and prepare queue them with $q.all and cancel event. When all resolves complete (however they do), we'd somehow have to initiate a location change to the original URL while somehow temporarily replacing the same specific route so it would have all promises already resolved which would then execute location+route change accordingly.

Maybe this is all just too complicated an we'd be better off changing location service.

Is there any particular reason why $location service is implemented in synchronous manner?

@Tathanen
Tathanen commented Oct 9, 2015

I'm actually using the current implementation to some good end now, rejecting certian routes and loading them as modal dialogs, and the changed URL allows those dialogs to function as history steps and bookmarkable targets. So if this behavior is changed at some point, I hope it's via a property passed into the routeProvider, or that the current functionality is maintained as an option in that way.

@litera
litera commented Oct 9, 2015

@Tathanen could you please show us some of your code. The important bits, that work for you? So we can maybe get some clues how to change this...

@Tathanen
Tathanen commented Oct 9, 2015

Welll, most of my stuff is labyrinthine application code that wouldn't help much, but the thrust is just this:

// resolve block on route checks various roles, if route is determined to be a dialog route, do this
deferred.reject();
ngDialog.closeAll();

var dialog = ngDialog.open( {
    template:     $route.current.templateUrl,
    controller:   $route.current.controller,
    controllerAs: $route.current.controllerAs
} );

There are basically only a couple reasons why I'd reject a route via resolve. One, you're trying to go somewhere you're not allowed to. I hide links you shouldn't be clicking based on your access roles so you'll probably be trying to go to it directly. In this case, I reject the route, which would leave the non-allowed route in the URL, but then I redirect immediately to someplace you're allowed to go, which changes the URL. So no problem here.

Two, I've decided that the route you're going to should (in certain circumstances) not load in ngView, but instead in a dialog created by ngDialog. I like doing it this way because it's all just normal anchor links, you can copy the url out of the link, you can right click and open it in a new tab, etc. It's not some weird fake JS link you can't act on in expected ways. So in this case I reject the route, but still have access to the route data, so I pass it into ngDialog (as seen in the example code above). So the dialog is up with its own unique route. I have my app close dialogs on $routeChangeSuccess so hitting the back button will close it, and I can open multiple dialogs in a row and they'll all have entries in the browser history. If I hit the dialog close button explicitly, I've got something like this going on:

dialog.closePromise.then( function ( params )
{
    window.history.go( 0 - count );
} );

Where count is a variable I've been iterating across sequential dialogs and storing via history.replaceState on each new history state that's created thanks to the URL having changed with each route rejection.

If Angular didn't change the URL and insert a history state when you reject a route I couldn't do this exactly the same. I could probably still manage to make it work by manually using history.pushState when a route rejects, but as-is I don't have to do that.

Soooo in summary, rejected routes adding a history state with a URL change can be convenient in certain usecases, irrelevant in others, and irritating mainly only in ones where you're providing access to links someone shouldn't be able to act on. So the "easy" fix is to try and predict when a route change will be rejected ahead of time, and not provide links to that route in the first place. Obviously this doesn't work all of the time, particularly when the error is a failed loaded resource or something, but in these instances at the very least you can just throw a window.history.back() in your reject handler. And find creative times to use $location.replace() to suppress any additional history states being added where you don't want them.

@litera
litera commented Oct 9, 2015

@btford @Narretz this issue is tagged component: ngRoute but I suppose this should be tackled on two sides. ngRoute and $location. The latter should be changed in processing from synchronous, to asynchronous which is my opinion the main culprit why this specific bug still hasn't been resolved.

@Narretz Narretz modified the milestone: 1.4.x, 1.5.x May 27, 2016
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment