Navigation guard using AngularJS: onbeforeunload

onbeforeunload

I have been working on an add-on for one of our products that includes some data entry components. As part of this, I wanted to make sure that any changes to the data were saved before the user lost interest and went searching the Internet for cat pictures. I quickly turned to the window.onbeforeunload event.

The onbeforeunload  event is the last chance for a website to get the user's attention before they have navigated away to something more shiny or sparkly. In AngularJS, since a real navigation is not going to occur in a single-page application, two events are provided, $locationChangeStart  and $locationChangeSuccess, that can be used to similarly intercept in-app navigations.

Through onbeforeunload and the subsequent browser-based dialog, or the AngularJS events and a subsequent app-based dialog, the user is given the opportunity to cancel the navigation and address something important like unsaved data or a cute cat photo that they almost missed.

So, with events available to intercept navigation, I started hooking them up in all the places that needed them. A directive here, a factory there; before long I was duplicating almost the same code in nearly 20 places. There had to be a better way, so I created the saNavigationGuard service1.

saNavigationGuard

The saNavigationGuard service provides consumers with the ability to register callbacks, known as guardians, that will be called if a navigation event is detected. If a guardian wishes to prevent that navigation, it returns a message explaining why. The saNavigationGuard then signs up to the relevant events and when they occur, asks each guardian if it should go ahead. Whichever guardian returns a string first, wins, and the guardian acts accordingly to prevent the navigation.

In the case of onbeforeunload , the browser continues the event handling by offering the user a choice to continue or cancel the navigation. In the case of the $locationChangeStart  event, this part must be handled by the application. We will come back to how in another post; for right now, let's just look at the onbeforeunload scenario.

Here is the saNavigationGuard factory declaration:

angular.module('somewhatabstract').factory('saNavigationGuard', ['$window', function($window) {
	var guardians = [];

	var onBeforeUnloadHandler = function(event) {
		var message;
		if (_.any(guardians, function(guardian) { return !!(message = guardian()); })) {
			(event || $window.event).returnValue = message;
			return message;
		} else {
			return undefined;
		}
	}

	var registerGuardian = function(guardianCallback) {
		guardians.unshift(guardianCallback);
		return function() {
			var index = guardians.indexOf(guardianCallback);
			if (index >= 0) {
				guardians.splice(index, 1);
			}
		};
	};

	if ($window.addEventListener) {
		$window.addEventListener('beforeunload', onBeforeUnloadHandler);
	} else {
		$window.onbeforeunload = onBeforeUnloadHandler;
	}

	return {
		registerGuardian: registerGuardian
	};
}]);

First thing to note is on line 6 where I have used the any method from underscore (or lodash). This calls a predicate for each element in an array until the predicate returns true. In this case, we are passing our array of guardians and the predicate is calling that guardian to see if it currently wants to stop navigation. If it does, it will return a message.

If one of the guardians returns a message, the message it returns is captured and the predicate returns true. The message is then passed to the event so that the browser will show its dialog. If no guardian returns a message, then the browser is allowed to continue with the navigation unhindered (at least by this code).

Because of how factories are instantiated in Angular, the initialization code that actually signs-up to the onbeforeunload event only occurs once per Angular application. So, injecting this factory into your directives, factories, etc. means the event handling will be initialized just once. This gives a central point to control interception of navigation while allowing individual components to prevent navigation for their own reasons.

Usage

Here is a simple example of how this might be used:

angular.module('somewhatabstract').controller('saEditorThingyCtrl', ['$scope', 'saNavigationGuard', function($scope, saNavigationGuard) {
    var editing = false;
    var navigationGuardian = function() {
        return editing ? "Edit in progress" : undefined;
    };
    
    saNavigationGuard.registerGuardian(navigationGuardian);

    $scope.startEdit = function(){
        if (editing) return;
        editing = true;
        // Stuff happens
    };
    $scope.endEdit = function() {
        if (!editing) return;
        editing = false; 
    };
}]);

Of course, this is a contrived example but it illustrates how easy it is to put the saNavigationGuard to work. In fact, if you didn't want your guardian to be called all the time because you knew it usually would not block navigation, you could only have it registered when needed.

    ...
    var unregisterGuardian;
    
    $scope.startEdit = function(){
        ...
        unregisterGuardian = saNavigationGuard.registerGuardian(navigationGuardian);
        ...
    };
    $scope.endEdit = function() {
        ...
        unregisterGuardian();
        unregisterGuardian = undefined;
        ...
    };
    ...

This ability to register and unregister guardians brings me to my second noteworthy aspect of saNavigationGuard. When registering a new guardian, it is added to the start of the array of guardians rather than the end. This is because, in my use case, the more recently registered guardians are often the most urgent. I suspect this may be common for most uses.

Finally…

This post is the first in a series covering some useful things I have created in my experiences with AngularJS. In the posts to follow, I will be covering how we can expand saNavigationGuard to cover in-app navigation and how we can intercept web requests to provide both navigation guards and busy indication.

I hope you find the saNavigationGuard factory as useful as I have on my current project where I have guardians for active edits and when there are pending POST, PUT or DELETE requests.

If you use this or anything else that I have written about in your project or want to suggest some tweaks or alternatives, please don't forget to leave a comment.

  1. It's really a factory in the AngularJS world, but it's providing a service…oh, I'm not getting into this discussion again, just look up service vs. factory and read about why it doesn't matter as long as you choose. []

19 thoughts on “Navigation guard using AngularJS: onbeforeunload”

    1. I'm sorry to hear that. Have you placed breakpoints in your code to check that the Angular module is being bootstrapped and the navigation guard methods are being called? Also, check that your call to `registerGuardian` is actually happening.

  1. Thnx for posting this. Its helpful. I have observed that it sometimes does not call the navigation guard method when some one types in a URL to redirect, Like google.com. Can you please look into that?

    1. That's an interesting issue that I haven't seen. Do you have any more details to reproduce the issue? What browser are you using? Are you sure that the Angular code has executed and registered a handler for `onbeforeunload`?

      1. Yes. I am debugging the code with a break point in my Navigation Callback function, which is not hit most of the times i redirect by typing in a URL. But I see that the same break point is hit when i refresh the screen. I am using Chrome.

        1. Yes, anything that causes the `onbeforeunload` event to fire will cause the callback to be hit, including a refresh. However, you mentioned that it is not being called on some occasions. Can you provide additional detail? I am not able to reproduce this.

          1. I tried putting a break point on my onbeforeUnload function and with that when i try to redirect to google.com, the browser hangs. But it does not hang when i refresh the browser, and it even displays the navigation guard callback message.

  2. To give you more details on what i am trying to achieve, I am using the Angular file upload to upload a large file. During which, when I type in a new URL, the $upload.Upload Success function calls back with a status 0, where I display an alert. Some times I see this alert instead of the navigation guardian callback message. Some times I don't even see this, but it just redirects to the new URL.

    If I add break points in the in my $upload.upload Success callback ( where I receive status 0), then the navigation guardian callback message is displayed!

    1. Sounds like a race condition between starting the upload and setting up the guardian. Could you not set up the guardian before you start the upload and clear it when the upload is successful?

  3. In saNavigationGuard, do you mean if(!$window.eventHandler){…}?

    Also, this was very useful, thank you

    1. Where do you think that is needed? As it stands, I don't see why that change would be needed.

    1. Yes, though not necessarily for this exact version. It has evolved a little since this original incarnation, but it has always had tests.

  4. Hi Jeff,
    I am using window.onbeforeunload on Chrome to get alert message when we navigate without saving data on current page. It seems like work well for Close Tab and Reload Tab but not for new URL. I am not getting alert message very few time (not exactly but like 2 times out of 10-15) whenever i try to navigate to any new URL. Can you please let me know where and what changes i can do? Here is function which i am calling to get it …..

    window.onbeforeunload = function (e) {
              e = e || window.event;
              if(CommonService.getFlag() == true) {
                return '';
              }
            };
    
    1. The way you have registered the `onbeforeunload` event can be usurped by anyone else assigning to that. Could be that sometimes, some other code is doing just that, leading to you not getting the event.

  5. I'm opening a local exe with custom protocol using $window.open() which is causing "Leave site" pop-up to come up. How can we suppress the pop-up as we are not changing the routing url at all.

    1. Hmm, I'm not sure. You may need to specify a target in the `open()` call to ensure it knows it's not intending to redirect. Quite often, the APIs deliberately make it clear to the user when code is doing something potentially dangerous to avoid bad actors from doing bad things, so it could be that you won't be able to prevent this.

Leave a Reply to Jeff Cancel reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.