SignalR Auto Disconnect when Page Changed in AngularJS
- by Shaun
Originally posted on: http://geekswithblogs.net/shaunxu/archive/2014/05/30/signalr-auto-disconnect-when-page-changed-in-angularjs.aspxIf we are using SignalR, the connection lifecycle was handled by itself very well. For example when we connect to SignalR service from browser through SignalR JavaScript Client the connection will be established. And if we refresh the page, close the tab or browser, or navigate to another URL then the connection will be closed automatically. This information had been well documented here. In a browser, SignalR client code that maintains a SignalR connection runs in the JavaScript context of a web page. That's why the SignalR connection has to end when you navigate from one page to another, and that's why you have multiple connections with multiple connection IDs if you connect from multiple browser windows or tabs. When the user closes a browser window or tab, or navigates to a new page or refreshes the page, the SignalR connection immediately ends because SignalR client code handles that browser event for you and calls the "Stop" method. But unfortunately this behavior doesn't work if we are using SignalR with AngularJS. AngularJS is a single page application (SPA) framework created by Google. It hijacks browser's address change event, based on the route table user defined, launch proper view and controller. Hence in AngularJS we address was changed but the web page still there. All changes of the page content are triggered by Ajax. So there's no page unload and load events. This is the reason why SignalR cannot handle disconnect correctly when works with AngularJS. If we dig into the source code of SignalR JavaScript Client source code we will find something below. It monitors the browser page "unload" and "beforeunload" event and send the "stop" message to server to terminate connection. But in AngularJS page change events were hijacked, so SignalR will not receive them and will not stop the connection. 1: // wire the stop handler for when the user leaves the page
2: _pageWindow.bind("unload", function () {
3: connection.log("Window unloading, stopping the connection.");
4:
5: connection.stop(asyncAbort);
6: });
7:
8: if (isFirefox11OrGreater) {
9: // Firefox does not fire cross-domain XHRs in the normal unload handler on tab close.
10: // #2400
11: _pageWindow.bind("beforeunload", function () {
12: // If connection.stop() runs runs in beforeunload and fails, it will also fail
13: // in unload unless connection.stop() runs after a timeout.
14: window.setTimeout(function () {
15: connection.stop(asyncAbort);
16: }, 0);
17: });
18: }
Problem Reproduce
In the codes below I created a very simple example to demonstrate this issue. Here is the SignalR server side code.
1: public class GreetingHub : Hub
2: {
3: public override Task OnConnected()
4: {
5: Debug.WriteLine(string.Format("Connected: {0}", Context.ConnectionId));
6: return base.OnConnected();
7: }
8:
9: public override Task OnDisconnected()
10: {
11: Debug.WriteLine(string.Format("Disconnected: {0}", Context.ConnectionId));
12: return base.OnDisconnected();
13: }
14:
15: public void Hello(string user)
16: {
17: Clients.All.hello(string.Format("Hello, {0}!", user));
18: }
19: }
Below is the configuration code which hosts SignalR hub in an ASP.NET WebAPI project with IIS Express.
1: public class Startup
2: {
3: public void Configuration(IAppBuilder app)
4: {
5: app.Map("/signalr", map =>
6: {
7: map.UseCors(CorsOptions.AllowAll);
8: map.RunSignalR(new HubConfiguration()
9: {
10: EnableJavaScriptProxies = false
11: });
12: });
13: }
14: }
Since we will host AngularJS application in Node.js in another process and port, the SignalR connection will be cross domain. So I need to enable CORS above.
In client side I have a Node.js file to host AngularJS application as a web server. You can use any web server you like such as IIS, Apache, etc..
Below is the "index.html" page which contains a navigation bar so that I can change the page/state. As you can see I added jQuery, AngularJS, SignalR JavaScript Client Library as well as my AngularJS entry source file "app.js".
1: <html data-ng-app="demo">
2: <head>
3: <script type="text/javascript" src="jquery-2.1.0.js"></script> 1: 2: <script type="text/javascript" src="angular.js"> 1: </script> 2: <script type="text/javascript" src="angular-ui-router.js"> 1: </script> 2: <script type="text/javascript" src="jquery.signalR-2.0.3.js"> 1: </script> 2: <script type="text/javascript" src="app.js"></script>
4: </head>
5: <body>
6: <h1>SignalR Auto Disconnect with AngularJS by Shaun</h1>
7: <div>
8: <a href="javascript:void(0)" data-ui-sref="view1">View 1</a> |
9: <a href="javascript:void(0)" data-ui-sref="view2">View 2</a>
10: </div>
11: <div data-ui-view></div>
12: </body>
13: </html>
Below is the "app.js". My SignalR logic was in the "View1" page and it will connect to server once the controller was executed. User can specify a user name and send to server, all clients that located in this page will receive the server side greeting message through SignalR.
1: 'use strict';
2:
3: var app = angular.module('demo', ['ui.router']);
4:
5: app.config(['$stateProvider', '$locationProvider', function ($stateProvider, $locationProvider) {
6: $stateProvider.state('view1', {
7: url: '/view1',
8: templateUrl: 'view1.html',
9: controller: 'View1Ctrl' });
10:
11: $stateProvider.state('view2', {
12: url: '/view2',
13: templateUrl: 'view2.html',
14: controller: 'View2Ctrl' });
15:
16: $locationProvider.html5Mode(true);
17: }]);
18:
19: app.value('$', $);
20: app.value('endpoint', 'http://localhost:60448');
21: app.value('hub', 'GreetingHub');
22:
23: app.controller('View1Ctrl', function ($scope, $, endpoint, hub) {
24: $scope.user = '';
25: $scope.response = '';
26:
27: $scope.greeting = function () {
28: proxy.invoke('Hello', $scope.user)
29: .done(function () {})
30: .fail(function (error) {
31: console.log(error);
32: });
33: };
34:
35: var connection = $.hubConnection(endpoint);
36: var proxy = connection.createHubProxy(hub);
37: proxy.on('hello', function (response) {
38: $scope.$apply(function () {
39: $scope.response = response;
40: });
41: });
42: connection.start()
43: .done(function () {
44: console.log('signlar connection established');
45: })
46: .fail(function (error) {
47: console.log(error);
48: });
49: });
50:
51: app.controller('View2Ctrl', function ($scope, $) {
52: });
When we went to View1 the server side "OnConnect" method will be invoked as below.
And in any page we send the message to server, all clients will got the response.
If we close one of the client, the server side "OnDisconnect" method will be invoked which is correct.
But is we click "View 2" link in the page "OnDisconnect" method will not be invoked even though the content and browser address had been changed. This might cause many SignalR connections remain between the client and server. Below is what happened after I clicked "View 1" and "View 2" links four times. As you can see there are 4 live connections.
Solution
Since the reason of this issue is because, AngularJS hijacks the page event that SignalR need to stop the connection, we can handle AngularJS route or state change event and stop SignalR connect manually. In the code below I moved the "connection" variant to global scope, added a handler to "$stateChangeStart" and invoked "stop" method of "connection" if its state was not "disconnected".
1: var connection;
2: app.run(['$rootScope', function ($rootScope) {
3: $rootScope.$on('$stateChangeStart', function () {
4: if (connection && connection.state && connection.state !== 4 /* disconnected */) {
5: console.log('signlar connection abort');
6: connection.stop();
7: }
8: });
9: }]);
Now if we refresh the page and navigated to View 1, the connection will be opened. At this state if we clicked "View 2" link the content will be changed and the SignalR connection will be closed automatically.
Summary
In this post I demonstrated an issue when we are using SignalR with AngularJS. The connection cannot be closed automatically when we navigate to other page/state in AngularJS. And the solution I mentioned below is to move the SignalR connection as a global variant and close it manually when AngularJS route/state changed. You can download the full sample code here.
Moving the SignalR connection as a global variant might not be a best solution. It's just for easy to demo here. In production code I suggest wrapping all SignalR operations into an AngularJS factory. Since AngularJS factory is a singleton object, we can safely put the connection variant in the factory function scope.
Hope this helps,
Shaun
All documents and related graphics, codes are provided "AS IS" without warranty of any kind.
Copyright © Shaun Ziyan Xu. This work is licensed under the Creative Commons License.