avatarRuss Carver

Summary

The provided content outlines a method for maintaining application state, including user login status, across multiple browser tabs in an Angular application using the Broadcast Channel API.

Abstract

The article discusses a technique for developers to ensure that when a user duplicates a browser tab in an Angular application, the new tab retains the same application state and login status as the original tab. This is achieved through the use of the Broadcast Channel API, which allows different instances of the web application running in separate tabs to communicate and synchronize data. The author provides a detailed guide on implementing this feature, including code examples for creating a Broadcast Channel service, handling message broadcasting and reception, and integrating with Angular's routing and authentication mechanisms. The solution ensures a consistent user experience by allowing tabs to share login sessions and application state, and it also includes a mechanism for logging out across all tabs simultaneously.

Opinions

  • The author emphasizes the frustration of encountering a login screen when duplicating a browser tab and presents the Broadcast Channel as a solution to this problem.
  • The use of Broadcast Channel is advocated for its ability to work across different browsers, versions, and even in WebWorkers and Node.js environments.
  • The author suggests that maintaining state across tabs is a feature of well-designed applications, implying that developers should strive to implement such functionality.
  • The article conveys that the described method of inter-tab communication is straightforward and effective, as it allows for the transfer of complex data structures, provided they are serializable.
  • The author expresses confidence in the robustness of the solution by stating that it can handle any data that needs to be shared between tabs, including JSON-serializable objects.
  • The author's enthusiasm for the technique is evident, as they encourage other developers to adopt similar practices to enhance the user experience in their applications.

How to Duplicate a Browser Tab and Maintain App State in Angular

Use a Broadcast Channel to maintain login, session data and app state when opening new browser tabs.

Have you ever developed an Angular app and used the web browser functionality to duplicate your active tab, only to be met with your app’s login screen? That’s so frustrating, right? But take heart! I’m here to tell you that all is not lost. There is a way we can maintain our state and/or session data upon opening a new tab and it all starts with Broadcast Channel!

Broadcast Channel

The overview of Broadcast Channel is that it allows you to send data between different browser tabs or nodejs processes.

With this ability, we can communicate between browser tabs to keep our app’s state, data, and user login status in sync between all browser tabs.

To start, install Broadcast Channel with npm install broadcast-channel

Here’s the Angular service that I use for the Broadcast Channel (broadcast-channel.service.ts):

import { Injectable, OnDestroy } from '@angular/core';
import { BroadcastMessageType } from 'app/shared/enums/broadcast-messages.enum';
import { BroadcastMessage } from 'app/shared/models/messages/message.model';
import { SessionLogoutService } from 'app/shared/services/session-logout.service';
import { SessionService } from 'app/shared/services/session.service';
import { TokenService } from 'app/shared/services/token.service';
import { BroadcastChannel } from 'broadcast-channel';
import { Subject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class BroadcastChannelService implements OnDestroy {
  private channel: BroadcastChannel<BroadcastMessage>;

  public constructor(
    private sessionLogoutService: SessionLogoutService,
    private sessionService: SessionService,
    private tokenService: TokenService
  ) {}

  public ngOnDestroy(): void {
    this.channel.close();
    this.refreshSignalRConnections$?.next();
    this.refreshSignalRConnections$?.complete();
  }

  public postMessage(message: BroadcastMessage): void {
    if (this.channel !== undefined) {
      this.channel.postMessage(message);
    }
  }

  // This is called in the ngOnInit method of your app's root component
  public init(): void {
    // Pick any random string for this for the channel's name
    this.channel = new BroadcastChannel('my-app-broadcast-channel');
    this.channel.onmessage = this.handleMessage.bind(this);
  }

  /* IMPORTANT: Sender will NOT receive their own message. */
  private handleMessage(message: BroadcastMessage): void {
    switch (message.type) {
      case BroadcastMessageType.IsAnotherTabLoggedIn:
        if (this.tokenService.isLoggedIn()) {
          this.postMessage({
            data: this.sessionService.getLoginSessionData(),
            type: BroadcastMessageType.Login
          });
        }
        break;
      case BroadcastMessageType.Login:
        if (!this.tokenService.isLoggedIn()) {
          this.sessionService.loginViaSession(message.data);
        }
        break;
      case BroadcastMessageType.Logout:
        if (this.tokenService.isLoggedIn()) {
          this.sessionLogoutService.logoutViaSession();
        }
        break;
      default:
        break;
    }
  }
}

And some supporting files: a) broadcast-messages.enum.ts

export enum BroadcastMessageType {
  IsAnotherTabLoggedIn,
  Login,
  Logout
}

b) message.model.ts

import { BroadcastMessageType } from 'app/shared/enums/broadcast-messages.enum';

export interface BroadcastMessage {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  data?: any; // Must be JSON.stringify'able
  type: BroadcastMessageType;
}

In the BroadcastChannelService, it’s the postMessage and handleMessagemethods that provide the core of how inter-tab communication works. It’s important to note that the sending browser tab will not receive its own message.

Not shown here is the TokenService which is mainly used to store the user’s JWT access token, refresh token, and other methods such as checking if the user is logged into the app.

Later, we will look at the methods in the SessionService.

Duplicating a Tab

Let’s now take a dive into how this all works when we duplicate a browser tab. When the new tab starts, it will attempt to enter your app at the same URL as the existing tab. Your app’s routing rules will determine how that works. But before that happens, your app’s main entry point will run. This is typically in your root component, commonly known as app.component.ts, and is tied to whatever component is put into the body of your index.html file:

  <body>
    <app-root></app-root>
  </body>

Inside of your root component’s ngOnInit function is where we will call the init method of the BroadcastChannelService.

import { BroadcastChannelService } from 'app/shared/services/broadcast-channel.service';

public ngOnInit(): void {
  this.broadcastChannelService.init();
}

During this initialization, the BroadcastChannelService creates the channel and begins listening to broadcast messages. Because this happens in both browser tabs, both have now created a channel with the same name which allows them to send messages to each other!

Next, in the root component’s ngAfterViewInit method, we begin the process to check if another browser tab is already logged in to your app. This is so that we can send over any state/data we need from the existing logged-in browser tab. Here are some of the related methods from app.component.ts:

import { BroadcastMessageType } from 'app/shared/enums/broadcast-messages.enum';
import { BroadcastChannelService } from 'app/shared/services/broadcast-channel.service';
import { RouterService } from 'app/shared/services/router.service';
import { SessionUtilityService } from 'app/shared/services/session-utility.service';
import { TokenService } from 'app/shared/services/token.service';

@Component({
  selector: 'app-root',
  styleUrls: ['app.component.scss'],
  templateUrl: 'app.component.html'
})
export class AppComponent implements AfterViewInit, OnDestroy, OnInit {
  public constructor(
    private broadcastChannelService: BroadcastChannelService,
    private routerService: RouterService,
    private sessionUtilityService: SessionUtilityService,
    private tokenService: TokenService
  ) {}

  public ngOnInit(): void {
    this.broadcastChannelService.init();
  }

  public ngAfterViewInit(): void {
    this.checkIfAnotherTabIsLoggedIn();
  }

  public ngOnDestroy(): void {
    this.broadcastChannelService?.destroy();
  }

  private async checkIfAnotherTabIsLoggedIn(): Promise<void> {
    if (!this.tokenService.isLoggedIn()) {
      this.sessionUtilityService.isSessionLogin = true;
      this.broadcastChannelService
        .postMessage({ type: BroadcastMessageType.IsAnotherTabLoggedIn });
    } else {
      this.tokenService.parseAndSaveTokenDetails();
    }
  }
}

In the method checkIfAnotherTabIsLoggedIn, we first check if the user is logged in. In this example app, for the first browser tab opened, we’re assuming that by the time the root component is created, the user is already logged in. This could happen via an outside process, such as Okta, which redirects a user back to your app after they have logged in.

If the user is logged in, we parse their JWT access token and store any relevant data needed about the user. Then we proceed as normal.

However, if the user is not logged in, we store (in the SessionUtilityService) that the user is logging in via session (aka tab) duplication.

Here is the code for the simple SessionUtilityService which contains only getters & setters. We use a service for this as the getter will be used in multiple places as we’ll see later on.

import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot } from '@angular/router';

@Injectable({ providedIn: 'root' })
export class SessionUtilityService {
  private _isSessionLogin: boolean = false;
  private _originalRoute: ActivatedRouteSnapshot;

  public get isSessionLogin(): boolean {
    return this._isSessionLogin;
  }

  public set isSessionLogin(value: boolean) {
    this._isSessionLogin = value;
  }

  public get originalRoute(): ActivatedRouteSnapshot {
    return this._originalRoute;
  }

  public set originalRoute(value: ActivatedRouteSnapshot) {
    this._originalRoute = value;
}

Asking if any other Browser Tab is Logged In

After we store the fact that we’re logging in via tab duplication, we send out a message over the broadcast channel to see if any other browser tabs of our app have a logged-in user:

this.broadcastChannelService
  .postMessage({ type: BroadcastMessageType.IsAnotherTabLoggedIn });

This will cause all other browser tabs to receive this message and will cause the following code of the BroadcastChannelService to be executed:

case BroadcastMessageType.IsAnotherTabLoggedIn:
  if (this.tokenService.isLoggedIn()) {
    this.postMessage({
      data: this.sessionService.getLoginSessionData(),
        type: BroadcastMessageType.Login
    });
  }
  break;

What we are doing here is:

1) Checking if the user is logged in, and if so:

2) Posting a “Login” broadcast message (in our broadcast channel) that contains any data we need the new tab to have so that it can have the full state that it needs. This data would include anything needed that you are storing in sessionStorage (since such data is not duplicated across browser tabs); any data that you may be storing in memory (eg. app state data in any services); the user’s access & refresh tokens (which indicate a valid login); and more! Feel free to send anything you want, even objects. The only limitation is that the data must be able to be JSON.stringify-able.

Log-in the User in the New Browser Tab

Now that we have broadcast our login data from a logged-in browser tab, the following code in BroadcastChannelService will handle the reception of the “Login” message:

case BroadcastMessageType.Login:
  if (!this.tokenService.isLoggedIn()) {
    this.sessionService.loginViaSession(message.data);
  }
  break;

In this, we first check if we are not logged in. This should only be true for the new browser tab. Next, we take all that state/session/memory/token data and wire up the new instance of the app (in the new browser tab) with it. Here is some example code in the SessionService:

import { Injectable } from '@angular/core';
import { LoginSessionData } from 'app/shared/models/session-data.model';
import { RouterService } from 'app/shared/services/router.service';
import { SessionUtilityService } from 'app/shared/services/session-utility.service';
import { sessionKeys, StorageService } from 'app/shared/services/storage.service';
import { TokenService } from 'app/shared/services/token.service';

@Injectable({ providedIn: 'root' })
export class SessionService {

  public constructor(
    private routerService: RouterService,
    private sessionUtilityService: SessionUtilityService,
    private storageService: StorageService,
    private tokenService: TokenService
  ) {}

  /* This is called when receiving the Broadcast Channel message IsAnotherTabLoggedIn
   * In here, you should retrieve all of your state/session/memory/token data. */
  public getLoginSessionData(): LoginSessionData {
    return {
      refreshToken: this.tokenService.getRefreshToken(),
      token: this.tokenService.getAccessToken()
    };
  }

  /***** Do NOT call this method directly. It should be called only by the BroadcastChannelService. *****/
  public async loginViaSession(broadCastData: LoginSessionData): Promise<void> {
    if (!this.tokenService.isLoggedIn()) {
      this.loadLoginSessionData(broadCastData);

      setTimeout((): void => {
        this.sessionUtilityService.isSessionLogin = false;
        this.routerService.routeToFirstPage();
      }, 500); // Timeout to allow route guards to do their work
    }
  }

  // In here is where you would load all of your state/session/memory/token data.
  public loadLoginSessionData(data: LoginSessionData): void {
    this.tokenService.storeTokens(data.token, data.refreshToken);
  }
}

The method loadLoginSessionData is where you will wire up the data for the new browser tab. How you do this will depend on the specifics of your app. We also pause a bit here (500ms) so that any needed route guards can execute. The route guard we are concerned about here is a new SessionLoginGuard.

import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate } from '@angular/router';
import { SessionUtilityService } from 'app/shared/services/session-utility.service';

@Injectable({
  providedIn: 'root'
})
export class SessionLoginGuard implements CanActivate {
  public constructor(
    private sessionUtilityService: SessionUtilityService
  ) {}

  public canActivate(route: ActivatedRouteSnapshot): boolean {
    if (this.sessionUtilityService.isSessionLogin) {
      this.sessionUtilityService.originalRoute = route;
      return false;
    }
    return true;
  }
}

This guard will run for any route we attach it to. You will want to attach it to all routes for which you want a user to be able to go during browser tab duplication. In this guard, we store the route the user is requesting so that we can later route a user to it. This is needed because you would usually have a Login route guard on such routes as well. Due to the asynchronous nature of the broadcast channel communication, we may be denied routing to the requested page (by the Login route guard) because the new tab hasn’t been wired up with the user’s login (token) data yet. Therefore, this new route guard must run first. Here is an example (eg. app-routing.module.ts) file:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from 'app/shared/guards/auth.guard';
import { SessionLoginGuard } from 'app/shared/guards/session-login.guard';

/* eslint-disable @typescript-eslint/no-explicit-any */
const routes: Routes = [
  {
    canActivate: [SessionLoginGuard, AuthGuard],
    loadChildren: (): any => import('./pages/products/products.module')
      .then((module: any) =>  module.ProductsModule),
    path: '/products',
    title: 'Products'
  }
];
/* eslint-enable @typescript-eslint/no-explicit-any */

@NgModule({
  exports: [RouterModule],
  imports: [RouterModule.forRoot(routes)]
})
export class AppRoutingModule {}

Because the SessionLoginGuard is listed before the AuthGuard(the Login route guard) it will run first.

You can see the code above in the SessionUtilityService example for storing the route.

Routing the User to the Requested Page

Let’s now take a look at how the user actually gets to their requested page (aka the URL from the original browser tab they duplicated). For this, let’s take a look into a method from a custom RouterService.

@Injectable({ providedIn: 'root' })
export class RouterService {
  public routeToFirstPage(): void {
    if (this.tokenService.isLoggedIn()) {
      if (this.sessionUtilityService.originalRoute !== undefined) {
        const url: string = this.sessionUtilityService.originalRoute.url[0].path;
        const queryParams: Params = this.sessionUtilityService.originalRoute.queryParams;
        this.sessionUtilityService.originalRoute = undefined;
        if (queryParams !== undefined && Object.keys(queryParams)?.length > 0) {
          this.router.navigate([url], { queryParams });
        } else {
          this.routeToUrl(`/${url}`);
        }
      } else {
        this.routeToUrl(`/defaultPage`);
      }
    } else {
      this.routeToUrl(`/loginPage`);
    }
  }

  public async routeToUrl(url: string): Promise<boolean> {
    if (url === undefined) {
      return undefined;
    }

    if (parameters !== undefined) {
      return await this.router.navigate([url, parameters], extras);
    }
    return await this.router.navigateByUrl(url);
  }
}

Here, in routeToFirstPage, we perform the following steps: 1) Make sure the user is logged in. 2) If there is a saved route from the SessionLoginGuard, parse the route and take the user there. 3) If there was no saved route, take the user to the default landing page of the app. 4) If the user was not logged in, take them to the login page.

Voila!

Logging Out all Browser Tabs

Every app will be different in how it performs a user logout, but once we have the paradigm of allowing multiple, logged-in browser tabs, we must ensure that all of them are logged out when the user logs out of one of them. Let’s pretend you have a LogoutService something like this:

import { Injectable } from '@angular/core';
import { BroadcastMessageType } from 'app/shared/enums/broadcast-messages.enum';
import { BroadcastChannelService } from 'app/shared/services/broadcast-channel.service';
import { RouterService } from 'app/shared/services/router.service';
import { SessionLogoutService } from 'app/shared/services/session-logout.service';

@Injectable({ providedIn: 'root' })
export class LogoutService {

  public constructor(
    private broadcastChannelService: BroadcastChannelService,
    private routerService: RouterService,
    private sessionLogoutService: SessionLogoutService,
  ) {}

  /***** NOTE: any code put in here should also be mirrored into SessionLogoutService.logoutViaSession ******/
  public logout(): void {
    this.broadcastChannelService.postMessage({
      type: BroadcastMessageType.Logout
    });

    // Do other logout & cleanup things
    this.sessionLogoutService.clearSessionStorage();

    this.routerService.routeToUrl(`/loginPage`);
  }
}

In the logout method, we are broadcasting the final of our broadcast channel messages: “Logout”. This will be handled in all other open browser tabs (of our app) in the handleMessage method of the BroadcastChannelService (shown near the top). As a refresher:

case BroadcastMessageType.Logout:
  if (this.tokenService.isLoggedIn()) {
    this.sessionLogoutService.logoutViaSession();
  }
  break;

We cannot call the same logoutmethod of the LogoutService. Otherwise, the “Logout” broadcast message would be broadcast again and each tab would be in an infinite loop. So instead, we create a separate SessionLogoutService.

import { Injectable } from '@angular/core';
import { RouterService } from 'app/shared/services/router.service';

@Injectable({ providedIn: 'root' })
export class SessionLogoutService {
  public constructor(private routerService: RouterService) {}

  public clearSessionStorage(): void {
    /* Clear things in memory, sessionStorage, app state etc that you don't
       want hanging around after a user logs out. */
  }

  /***** Do NOT call this method directly. It should be called only by the BroadcastChannelService. *****/
  public logoutViaSession(): void {
    // Do cleanup things:
    this.clearSessionStorage();

    this.routerService.routeToUrl(`/loginPage`);
  }
}

Logging In all Browser Tabs

One last thing that you may wish to consider is that if you have multiple browser tabs open for your app and they are all at the login page (maybe because one of them logged out and, subsequently, so did the other tabs) you might want to log them all back into your app once one of them has logged in. We have the plumbing done for that already, and it’s as simple as executing the following code after your user has successfully logged in:

// Tell other tabs to login
this.broadcastChannelService.postMessage({
  data: this.sessionService.getLoginSessionData(),
  type: BroadcastMessageType.Login
});

Magic!!

Conclusion

Being able to broadcast messages to all browser tabs running your app is very powerful. It can help you auto-login and auto-logout multiple tabs. It can also help you transfer data & state of your entire application. This makes opening (duplicating) a browser tab a seamless experience for your users. This is something all great apps do and I encourage you to do it for your app as well. Happy coding!

Angular
Typescript
Front End Development
Software Engineering
Browser Tabs
Recommended from ReadMedium