46

I want to prevent some flickering that happens when rails devise timeout occurs, but angular doesn't know until the next authorization error from a resource.

What happens is that the template is rendered, some ajax calls for resources happen and then we are redirected to rails devise to login. I would rather do a ping to rails on every state change and if rails session has expired then I will immediately redirect BEFORE the template is rendered.

ui-router has resolve that can be put on every route but that doesn't seem DRY at all.

What I have is this. But the promise is not resolved until the state is already transitioned.

$rootScope.$on('$stateChangeStart', function(event, toState, toParams, fromState, fromParams){
        //check that user is logged in
        $http.get('/api/ping').success(function(data){
          if (data.signed_in) {
            $scope.signedIn = true;
          } else {
            window.location.href = '/rails/devise/login_path'
          }
        })

    });

How can I interrupt the state transition, before the new template is rendered, based on the result of a promise?

T J
  • 42,762
  • 13
  • 83
  • 138
Homan
  • 25,618
  • 22
  • 70
  • 107
  • You might want to create a service through which you can make your promise call and call this service in your controller. – Aditya Sethi Nov 20 '13 at 11:42
  • 1
    can use `resolve` in route(state) config. Controllers and templates won't load until it is complete – charlietfl Nov 20 '13 at 13:52
  • @AdityaSethi, executing code in the controller would be too late as the ui-router state has changed, the template is rendered and there is no telling when the promise is fulfilled. – Homan Nov 23 '13 at 01:56
  • 1
    @charlietfl, yes I mentioned resolve in my original question. That would work, but to me its not DRY (Do Not Repeat Yourself) at all. I would have to put resolve on every single one of my routes which bloats the file. – Homan Nov 23 '13 at 01:59
  • 2
    Well, you could have a parent abstract route with one `resolve` on that one. It will resolve before child states are initiated and mantain DRYability. – Alejandro García Iglesias Sep 16 '14 at 04:34

10 Answers10

46

I know this is extremely late to the game, but I wanted to throw my opinion out there and discuss what I believe is an excellent way to "pause" a state change. Per the documentation of angular-ui-router, any member of the "resolve" object of the state that is a promise must be resolved before the state is finished loading. So my functional (albeit not yet cleaned and perfected) solution, is to add a promise to the resolve object of the "toState" on "$stateChangeStart":

for example:

$rootScope.$on('$stateChangeStart', function (event, toState, toParams) {
    toState.resolve.promise = [
        '$q',
        function($q) {
            var defer = $q.defer();
            $http.makeSomeAPICallOrWhatever().then(function (resp) {
                if(resp = thisOrThat) {
                    doSomeThingsHere();
                    defer.resolve();
                } else {
                    doOtherThingsHere();
                    defer.resolve();
                }
            });
            return defer.promise;
        }
    ]
});

This will ensure that the state-change holds for the promise to be resolved which is done when the API call finishes and all the decisions based on the return from the API are made. I've used this to check login statuses on the server-side before allowing a new page to be navigated to. When the API call resolves I either use "event.preventDefault()" to stop the original navigation and then route to the login page (surrounding the whole block of code with an if state.name != "login") or allow the user to continue by simply resolving the deferred promise instead of trying to using bypass booleans and preventDefault().

Although I'm sure the original poster has long since figured out their issue, I really hope this helps someone else out there.

EDIT

I figured I didn't want to mislead people. Here's what the code should look like if you are not sure if your states have resolve objects:

$rootScope.$on('$stateChangeStart', function (event, toState, toParams) {
    if (!toState.resolve) { toState.resolve = {} };
    toState.resolve.pauseStateChange = [
        '$q',
        function($q) {
            var defer = $q.defer();
            $http.makeSomeAPICallOrWhatever().then(function (resp) {
                if(resp = thisOrThat) {
                    doSomeThingsHere();
                    defer.resolve();
                } else {
                    doOtherThingsHere();
                    defer.resolve();
                }
            });
            return defer.promise;
        }
    ]
});

EDIT 2

in order to get this working for states that don't have a resolve definition you need to add this in the app.config:

   var $delegate = $stateProvider.state;
        $stateProvider.state = function(name, definition) {
            if (!definition.resolve) {
                definition.resolve = {};
            }

            return $delegate.apply(this, arguments);
        };

doing if (!toState.resolve) { toState.resolve = {} }; in stateChangeStart doesn't seem to work, i think ui-router doesn't accept a resolve dict after it has been initialised.

Foo Bar User
  • 2,401
  • 3
  • 20
  • 26
Joe.Flanigan
  • 736
  • 6
  • 18
  • 2
    This of course depends on the fact that all of your states have resolve objects. My application is setup that way. This is quite easy to do, but it is also very simple to nest an if/else that checks for the resolve object inside the code above. – Joe.Flanigan Oct 22 '15 at 11:36
  • 1
    This was a great starting point for me. In my case, I either resolve or reject the promise. If it's resolved, the state change continues as normal. If it's rejected, I continue my logic in `$stateChangeError`. DRY and very elegant, thank you. – adamdport Nov 24 '15 at 16:15
  • 1
    Also, `toState.resolve.promise` can be `toState.resolve.myStateChangeCheck` or whatever you want. `.promise` threw me off as it's generally reserved for promise objects. – adamdport Nov 25 '15 at 14:33
  • Glad I could get you started! I haven't looked at it in a few weeks, but my login redirect is functioning great based on this concept. I think I've changed the code a little since my first iteration, as often happens in our industry. – Joe.Flanigan Nov 25 '15 at 16:51
  • Oh, and great point about toState.resolve.promise. It was more for example, but definitely best not to confuse the situation. – Joe.Flanigan Nov 25 '15 at 16:53
  • do you have a good way of redirecting to a nested state from this resolve, such that it doesn't fall into an infinite loop? – morgs32 Jan 03 '16 at 17:22
  • @morgs32, can you be more specific? I have a conditional that either calls "preventDefault" on the state and redirects to a login page or simply resolves the promise and allows the transition to continue. – Joe.Flanigan Jan 04 '16 at 17:58
  • well I was trying to do something in the resolve for 'home' state that might $state.go('home.login'). looks like you can't redirect from inside the resolve because it breaks the transition and will hit the 'home' resolve forever. and since you don't know what state you're going to from a resolve, it's best to just use 'onstatechange'. which is what i ended up doing. – morgs32 Jan 05 '16 at 00:04
  • 3
    This only seems to work for me if the toState has other resolves. If it does not have other resolves, the one I added is never called. – Ryan Helmoski Mar 26 '16 at 08:06
  • Same here, if the toState doesn't have a resolve, it's not run – alex88 Sep 01 '16 at 02:47
  • 6
    you must add a at least a `resolve: {}` to each state, to make this work. `if (!toState.resolve) { toState.resolve = {} };` is not working – orszaczky Sep 08 '16 at 18:29
  • I haven't had a chance to test this since I originally used this in my previous company's code. I never had any trouble with the check adding the resolve on the fly. Obviously there are many factors that could come into play here, such as ui-router and angular versions. For me, it would be worth it to include a resolve in every state so this method works. Hopefully no one feels misled! – Joe.Flanigan Sep 09 '16 at 15:26
  • added an edit for states that have no resolve, tested and works for me. – Foo Bar User Oct 25 '16 at 14:03
  • You sir, are a gentleman and a scholar. Edit 2 finalized and fixed this issue for me. thank you. – trudesign Feb 15 '17 at 17:41
27

I believe you are looking for event.preventDefault()

Note: Use event.preventDefault() to prevent the transition from happening.

$scope.$on('$stateChangeStart', 
function(event, toState, toParams, fromState, fromParams){ 
        event.preventDefault(); 
        // transitionTo() promise will be rejected with 
        // a 'transition prevented' error
})

Although I would probably use resolve in state config as @charlietfl suggested

EDIT:

so I had a chance to use preventDefault() in state change event, and here is what I did:

.run(function($rootScope,$state,$timeout) {

$rootScope.$on('$stateChangeStart',
    function(event, toState, toParams, fromState, fromParams){

        // check if user is set
        if(!$rootScope.u_id && toState.name !== 'signin'){  
            event.preventDefault();

            // if not delayed you will get race conditions as $apply is in progress
            $timeout(function(){
                event.currentScope.$apply(function() {
                    $state.go("signin")
                });
            },300)
        } else {
            // do smth else
        }
    }
)

}

EDIT

Newer documentation includes an example of how one should user sync() to continue after preventDefault was invoked, but exaple provided there uses $locationChangeSuccess event which for me and commenters does not work, instead use $stateChangeStart as in the example below, taken from docs with an updated event:

angular.module('app', ['ui.router'])
    .run(function($rootScope, $urlRouter) {
        $rootScope.$on('$stateChangeStart', function(evt) {
            // Halt state change from even starting
            evt.preventDefault();
            // Perform custom logic
            var meetsRequirement = ...
            // Continue with the update and state transition if logic allows
            if (meetsRequirement) $urlRouter.sync();
        });
    });
Ivar
  • 4,350
  • 2
  • 27
  • 29
  • event.preventDefault does indeed stop the $stateChangeStart, but then how do you restart it based on the $http success condition? – Homan Nov 23 '13 at 02:00
  • `$state.go('your-state-name')` but then it will emit `$stateChangeStart` (I think) so one could add boolean flag to `$rootScope` to bypass authentication check, that boolean could be time constrained. – Ivar Nov 25 '13 at 12:25
  • @sketchfemme just had the chance to use it myself, added code to answer – Ivar Nov 26 '13 at 11:53
  • This all depends on how you set up your authentication. This answer helped me find a similar way – Deminetix Mar 04 '14 at 01:07
  • 3
    The last edit in this question that involves the documentation does not point out that it is using $locationChangeSuccess instead of what the question asked for, $stateChangeStart. The former does not have access to toParams and toState, which could be vital for authentication logic. Also, $locationChangeSuccess with urlRouter.sync() did not work for me, $locationChangeStart did, but still does not use $state. – CarbonDry Aug 15 '14 at 14:53
  • 2
    @CarbonDry I did update last edit, yes that is right, example in docs actually does not work for me either, I did copy it blindly previously. I do actually use `$stateChangeStart`. – Ivar Aug 19 '14 at 16:57
  • 4
    @ivarPrudnikov thanks for the update. BUT, are you sure that $urlRouter.sync() resumes the event.preventDefault() applied to $stateChangeStart? Surely urlRouter works on the location level, and not state provided by ui-router. I tried this myself and experienced some weird behaviour, and it did not work. – CarbonDry Aug 20 '14 at 12:37
  • @CarbonDry route resumes, but if you implement some sort of authentication and it starts when api was hit then you have to make sure to retry http requests after auth is complete, for that reason you could push some interceptors to $httpProvider – Ivar Aug 22 '14 at 12:42
  • `$stateChangeStart` may be a better option. I did however get this to work with `$locationChangeSuccess` - the docs don't mention that you need to set deferred interception on in a `config` block: `app.config(function($urlRouterProvider) { $urlRouterProvider.deferIntercept(); })` – Rhumborl Mar 21 '16 at 12:28
  • 1
    `$urlRouter.sync()` probably syncs with the current hash fragment, hence the docs using `$locationChangeSuccess` which I believe is fired after successfully updating the hash. Trying to use it with `$stateChangeStart` doesn't work since `evt.preventDefault();` prevents the of hash update. – T J Jun 22 '16 at 12:34
25

Here is my solution to this issue. It works well, and is in the spirit of some of the other answers here. It is just cleaned up a little. I'm setting a custom variable called 'stateChangeBypass' on the root scope to prevent infinite looping. I'm also checking to see if the state is 'login' and if so, that is always allowed.

function ($rootScope, $state, Auth) {

    $rootScope.$on('$stateChangeStart', function (event, toState, toParams) {

        if($rootScope.stateChangeBypass || toState.name === 'login') {
            $rootScope.stateChangeBypass = false;
            return;
        }

        event.preventDefault();

        Auth.getCurrentUser().then(function(user) {
            if (user) {
                $rootScope.stateChangeBypass = true;
                $state.go(toState, toParams);
            } else {
                $state.go('login');
            }
        });

    });
}
rmberg
  • 373
  • 4
  • 6
  • Note that the "stateChangeBypass" flag is very important otherwise you get an infinite loop as $stateChangeStart will get called over and over again. It doesn't have to be on root scope. Can use a local variable inside the outer function as the guard. – GameSalutes Oct 07 '16 at 20:24
  • Also I ran into some oddball infinite loop conditions using this method, so ended up doing this: – GameSalutes Oct 13 '16 at 18:32
  • this solution works, but the url doesn't update when back button is pressed due to the preventDefault(); – Rjk Nov 04 '16 at 01:34
15

as $urlRouter.sync() doesn't work with stateChangeStart, here's an alternative:

    var bypass;
    $rootScope.$on('$stateChangeStart', function(event,toState,toParams) {
        if (bypass) return;
        event.preventDefault(); // Halt state change from even starting
        var meetsRequirement = ... // Perform custom logic
        if (meetsRequirement) {  // Continue with the update and state transition if logic allows
            bypass = true;  // bypass next call
            $state.go(toState, toParams); // Continue with the initial state change
        }
    });
oori
  • 5,533
  • 1
  • 30
  • 37
  • 2
    For this to work you need to reset bypass to false otherwise the *next* state transition will also bypass this validation. – Ben Foster Mar 27 '15 at 12:58
  • @BenFoster Usually this situation/code runs at the controller, which gets **destroyed** after "meetsRequirement". so there is no need to specifically reset the bypass. otherwise - you're right, bypass reset is needed: `if (bypass) { bypass = false; return; }` – oori Apr 01 '15 at 12:06
4

To add to the existing answers here, I had the exact same issue; we were using an event handler on the root scope to listen for $stateChangeStart for my permission handling. Unfortunately this had a nasty side effect of occasionally causing infinite digests (no idea why, the code was not written by me).

The solution I came up with, which is rather lacking, is to always prevent the transition with event.preventDefault(), then determine whether or not the user is logged in via an asynchronous call. After verifying this, then use $state.go to transition to a new state. The important bit, though, is that you set the notify property on the options in $state.go to false. This will prevent the state transitions from triggering another $stateChangeStart.

 event.preventDefault();
 return authSvc.hasPermissionAsync(toState.data.permission)
    .then(function () {
      // notify: false prevents the event from being rebroadcast, this will prevent us
      // from having an infinite loop
      $state.go(toState, toParams, { notify: false });
    })
    .catch(function () {
      $state.go('login', {}, { notify: false });
    });

This is not very desirable though, but it's necessary for me due to the way that the permissions in this system are loaded; had I used a synchronous hasPermission, the permissions might not have been loaded at the time of the request to the page. :( Maybe we could ask ui-router for a continueTransition method on the event?

authSvc.hasPermissionAsync(toState.data.permission).then(continueTransition).catch(function() {
  cancelTransition();
  return $state.go('login', {}, { notify: false });
});
Dan
  • 10,282
  • 2
  • 37
  • 64
  • 2
    Addendum: as of the current build of ui-router, `{notify: false}` appears to prevent the state transition entirely. :\ – Dan Jun 23 '15 at 07:44
4

The on method returns a deregistration function for this listener.

So here is what you can do:

var unbindStateChangeEvent = $scope.$on('$stateChangeStart', 
  function(event, toState, toParams) { 
    event.preventDefault(); 

    waitForSomething(function (everythingIsFine) {
      if(everythingIsFine) {
        unbindStateChangeEvent();
        $state.go(toState, toParams);
      }
    });
});
Yann Bertrand
  • 3,084
  • 1
  • 22
  • 38
2

I really like the suggested solution by TheRyBerg, since you can do all in one place and without too much weird tricks. I have found that there is a way to improve it even further, so that you don't need the stateChangeBypass in the rootscope. The main idea is that you want to have something initialized in your code before your application can "run". Then if you just remember if it's initialized or not you can do it this way:

rootScope.$on("$stateChangeStart", function (event, toState, toParams, fromState) {

    if (dataService.isInitialized()) {
        proceedAsUsual(); // Do the required checks and redirects here based on the data that you can expect ready from the dataService
    } 
    else {

        event.preventDefault();

        dataService.intialize().success(function () {
                $state.go(toState, toParams);
        });
    }
});

Then you can just remember that your data is already initialized in the service the way you like, e.g.:

function dataService() {

    var initialized = false;

    return {
        initialize: initialize,
        isInitialized: isInitialized
    }

    function intialize() {

        return $http.get(...)
                    .success(function(response) {
                            initialized=true;
                    });

    }

    function isInitialized() {
        return initialized;
    }
};
Community
  • 1
  • 1
Ilya Chernomordik
  • 27,817
  • 27
  • 121
  • 207
1

You can grab the transition parameters from $stateChangeStart and stash them in a service, then reinitiate the transition after you've dealt with the login. You could also look at https://github.com/witoldsz/angular-http-auth if your security comes from the server as http 401 errors.

laurelnaiad
  • 4,558
  • 4
  • 20
  • 18
0

I ran in to the same issue Solved it by using this.

angular.module('app', ['ui.router']).run(function($rootScope, $state) {
    yourpromise.then(function(resolvedVal){
        $rootScope.$on('$stateChangeStart', function(event){
           if(!resolvedVal.allow){
               event.preventDefault();
               $state.go('unauthState');
           }
        })
    }).catch(function(){
        $rootScope.$on('$stateChangeStart', function(event){
           event.preventDefault();
           $state.go('unauthState');
           //DO Something ELSE
        })

    });
  • 3
    Surely this would not work because your promise is async and could be captured after stateChangeStart? – CarbonDry Aug 19 '14 at 13:45
0
        var lastTransition = null;
        $rootScope.$on('$stateChangeStart',
            function(event, toState, toParams, fromState, fromParams, options)  {
                // state change listener will keep getting fired while waiting for promise so if detect another call to same transition then just return immediately
                if(lastTransition === toState.name) {
                    return;
                }

                lastTransition = toState.name;

                // Don't do transition until after promise resolved
                event.preventDefault();
                return executeFunctionThatReturnsPromise(fromParams, toParams).then(function(result) {
                    $state.go(toState,toParams,options);
                });
        });

I had some issues using a boolean guard for avoiding infinite loop during stateChangeStart so took this approach of just checking if the same transition was attempted again and returning immediately if so since for that case the promise has still not resolved.

GameSalutes
  • 1,762
  • 2
  • 18
  • 24