Skip to main content
Ben Nadel at cf.Objective() 2010 (Minneapolis, MN) with: Pat Santora
Ben Nadel at cf.Objective() 2010 (Minneapolis, MN) with: Pat Santora

Creating A Pre-Bootstrap Loading Screen With window.postMessage() In Angular 6.1.2

By
Published in Comments (8)

Managing the UI (User Interface) within an Angular application is relatively easy. But, communicating UI changes to the greater browser context is not exactly straightforward. This challenge presents itself when creating a pre-bootstrap "loading" screen that only disappears once the Angular application has been bootstrapped (and, optionally, after data has been fetched). In the past, I've bridged the Angular/Browser divide through globally-scoped Promises and custom DOM (Document Object Model) events. The other day, however, I saw some of my teammates using window.postMessage() to pass information between windows; and, it occurred to me that .postMessage() might be a really easy way to bridge the gap between the Angular context and the browser context in a pre-bootstrap loading screen implementation.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

In my most recent look at creating a pre-bootstrap loading screen in Angular, I communicated the "app ready" event through a custom DOM Event object. Said custom DOM event would be triggered on the Document object where it could be observed from outside of the Angular app. This approach worked; but, it felt janky and required some cross-browser hoop-jumping when creating the actual Event object:

// I create and return a custom event with the given configuration.
private createEvent(
	eventType: string,
	bubbles: boolean,
	cancelable: boolean
	) : Event {

	// IE (shakes fist) uses some other kind of event initialization. As such,
	// we'll default to trying the "normal" event generation and then fallback to
	// using the IE version.
	try {

		var customEvent: any = new CustomEvent(
			eventType,
			{
				bubbles: bubbles,
				cancelable: cancelable
			}
		);

	} catch ( error ) {

		var customEvent: any = this.doc.createEvent( "CustomEvent" );

		customEvent.initCustomEvent( eventType, bubbles, cancelable );

	}

	return( customEvent );

}

Not only does this entail cross-browser complexity, it doesn't actually work. It requires a Polyfill in IE9+ - something that I missed in my previous exploration before Sam Storie pointed out my oversight. And, to add insult to injury, it includes information about bubbling and cancelability that doesn't even pertain to this type of interaction.

What I really want is something simple: a way for the Angular app to say, "I'm done loading". And, that's where window.postMessage() comes into the picture. The .postMessage() method was designed as a way to safely enable Cross-Origin communication between Window objects. But, there's nothing stopping us from using it in an intra-window communications workflow.

Our Angular application can use the window.postMessage() method to "emit" the "app ready" event on the Window object. Then, our greater browser context can add an event-listener on the Window object for said "app ready" event. This way, it still leverages the DOM-event system we're familiar with; but, it uses a much simpler API.

window.postMessage() provides a way for Angular applications to communicate with the outside world.

To implement this in an Angular application, I first created a "message" abstraction. I probably didn't need quite this level of indirection; but, it was a small-enough amount of code that I didn't mind:

// Import the core angular services.
import { Injectable } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@Injectable({
	providedIn: "root"
})
export class MessageService {

	// I initialize the message service.
	constructor() {
		// ....
	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I send messages outside of the current Angular application to the parent window.
	public send( message: any ) : void {

		window.postMessage( message, this.getOriginForSelf() );

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I calculate the postMessage() origin that will lock the message target down to
	// the current window (for tightest security).
	// --
	// NOTE: Technically, this isn't really necessary since we know that we're only
	// sending messages to the SELF window. But, it's a good practice to always provide
	// an explicit origin value.
	private getOriginForSelf() : string {

		// At this time, if the application is being loaded directly off disk (ie, not
		// being served-up as a web-app), then the origin has to be "*" or the message
		// will be denied by the browser. If you never expect to serve from disk, you can
		// omit this edge-case.
		if ( window.location.protocol === "file:" ) {

			return( "*" );

		// If the application is being served-up "proper", then let's lock it down to the
		// current origin.
		} else {

			return( `${ window.location.protocol }//${ window.location.host }` );

		}

	}

}

The MessageService just exposes one method, .send(), which encapsulates the window.postMessage() object, hiding the details of having to calculate the appropriate "origin" value. Since we're using this as an intra-window communications workflow, security becomes much less of a concern; but, providing an explicit origin is still a best practice.

This MessageService can then be consumed inside of the next abstraction - the AppReadyEvent. The AppReadyEvent is the service that the application can use to indicate that the Angular application has been bootstrapped and ready to receive interactions:

// Import the core angular services.
import { Injectable } from "@angular/core";

// Import the application components and services.
import { MessageService } from "./message.service";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@Injectable({
	providedIn: "root"
})
export class AppReadyEvent {

	private hasBeenTriggered: boolean;
	private messageService: MessageService;

	// I initialize the app ready event service.
	constructor( messageService: MessageService ) {

		this.messageService = messageService;
		this.hasBeenTriggered = false;

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I emit the "appready" event outside of the Angular application.
	public trigger() : void {

		if ( this.hasBeenTriggered ) {

			return;

		}

		this.hasBeenTriggered = true;
		this.messageService.send( "appready" );

	}

}

As you can see, when the "appready" event is triggered, the AppReadyEvent simply turns around and posts the event as a "message" to our MessageService.

This AppReadyEvent can then be consumed inside of our root Angular component:

// Import the core angular services.
import { Component } from "@angular/core";

// Import the application components and services.
import { AppReadyEvent } from "./app-ready-event";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@Component({
	selector: "my-app",
	styleUrls: [ "./app.component.less" ],
	template:
	`
		The app has loaded! Woot :D
	`
})
export class AppComponent {

	private appReadyEvent: AppReadyEvent;

	// I initialize the app-component.
	constructor( appReadyEvent: AppReadyEvent ) {

		this.appReadyEvent = appReadyEvent;

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I get called once after the inputs have been bound for the first time.
	public ngOnInit() : void {

		// FOR THE SAKE OF THE DEMO, let's add a small delay here so that the app can be
		// in a "loading" state for a "visible" amount of time.
		setTimeout(
			() => {

				this.appReadyEvent.trigger();

			},
			1000
		);

	}

}

Now, the concept of "ready" depends on the application. In one app, "ready" might just mean that the Angular code had been loaded and the services have all been wired-together. But, in another app, "ready" might mean that the application has been bootstrapped and subsequent user-data has also been fetched from the server. In the case of this demo, I'm triggering the "appready" event inside of a setTimeout() in order to simulate some additional "data load" above and beyond the basic bootstrapping.

Once the Angular application starts emitting the "appready" event through the .postMessage() workflow, we can start to listen for "message" events in the parent-page context by using .addEventListener(). All .postMessage() events come through as "message" event-types. The events can then be differentiated based on their embedded "data" payload.

For this demo, we're going to listen for the "message" event, filter-out the "appready" data payloads, and then fade our pre-bootstrap loading screen out-of-view, revealing the primed Angular app just below the surface:

<!doctype html>
<html lang="en">
<head>
	<meta charset="utf-8" />

	<title>
		Creating A Pre-Bootstrap Loading Screen With window.postMessage() In Angular 6.1.2
	</title>
</head>
<body>

	<style type="text/css" class="preloader">
		div.splash {
			background-color: #ffffff ;
			bottom: 0px ;
			display: flex ;
			left: 0px ;
			position: fixed ;
			right: 0px ;
			top: 0px ;
		}

		div.splash--loaded {
			opacity: 0.0 ;
			transition: opacity 500ms ease ;
		}

		div.splash__message {
			font-size: 26px ;
			line-height: 31px ;
			margin: auto auto auto auto ;
			text-decoration: underline ;
		}
	</style>

	<div class="preloader splash">
		<div class="splash__message">
			App is Loading
		</div>
	</div>

	<script type="text/javascript" class="preloader">
		(function listenForAppReadyEvent() {

			// Gather DOM references.
			var splash = document.querySelector( ".splash" );
			var preloaders = document.querySelectorAll( ".preloader" );

			// Listen for our "appready" message event.
			window.addEventListener( "message", handleMessage, false );

			// ----------------------------------------------------------------------- //
			// ----------------------------------------------------------------------- //

			// I handle "message" events on the current window.
			function handleMessage( event ) {

				if ( event.data === "appready" ) {

					console.log( "AppReady event received!" );
					window.removeEventListener( "message", handleMessage, false );
					removeSplashScreen();

				}

				// NOTE ON SECURITY: According to the documentation on postMessage(), you
				// should always test the Origin and [optionally] the Source attributes
				// of the "message" event in order to make sure the event is coming from
				// the expected place. However, for something as innocuous as the
				// "splash page", I'm omitting such a security check. Since this handler
				// does nothing but remove DOM elements from the static portion of the
				// page, there's no security risk here that I can see. Such a test would
				// add unnecessary complexity. That said, testing the origin is a best
				// practice that should be done with anything that involves passing
				// around sensitive data.

			}

			// I remove the splash screen and elements from the DOM, exposing the loaded
			// application beneath them.
			function removeSplashScreen() {

				splash.classList.add( "splash--loaded" );

				// Once the splash screen has been faded-out, let's rip all of the
				// preloader-related DOM elements out of the document.
				setTimeout(
					function removeDomElements() {

						for ( var i = 0 ; i < preloaders.length ; i++ ) {

							var item = preloaders[ i ];
							item.parentNode.removeChild( item );

						}

					},
					500
				);

			}

		})();
	</script>


	<h1>
		Creating A Pre-Bootstrap Loading Screen With window.postMessage() In Angular 6.1.2
	</h1>

	<my-app></my-app>

</body>
</html>

In this demo, I'm only looking for the "appready" event - I'm not doing any additional validation. With a .postMessage() workflow, it is normally a best practice to validate the event "origin" and, optionally, the "source" Window reference. However, since the "appready" event exists solely in the user experience (UX) domain, there really is no possible security threat. As such, any additional validation would - for this demo specifically - only bring complexity without any material benefit.

That said, if we load this Angular application in the browser, we get the following output:

Implementing a pre-bootstraping loading screen using window.postMessage() to communicate across the application boundary.

As you can see, the Angular application used the window.postMessage() method to emit the "appready" event (as a "message" event). The parent window context was then able to listen for that "appready" event; and, when observed, faded-out the "splash" page.

So far, this feels like the most elegant pre-bootstrap loading screen implementation that I've been able to create in an Angular context. It bypasses the cross-browser complexity of "custom events". And, it's still quite easy to consume externally to the Angular application, as it is still just an "event" to observe with an event-handler. If nothing else, this warrants a deeper exploration of the .postMessage() method - it seems like there's a lot of interesting potential there.

Want to use code from this post? Check out the license.

Reader Comments

448 Comments

I have been dealing with a similar issue. I have created an Angular app within an IFRAME, and I found that using MutationObserver to monitor changes within an iframe's custom attribute, like 'data-role-iframe', is another way to communicate between the iframe & its parent context. I pass URL Variables into Angular & send data out via the iframe' custom attribute. After the app has initialised I can also send data into the app via the same attribute. But, I am sure PostMessage would offer an alternative.

15,848 Comments

@Charles,

I've heard of the MutationObserver, but have not used it yet. It seems very cool. On a side-note, in the Chrome Dev Tools, you can add break-points to mutation events in the Elements tab. I don't use it very often; but, it can be helpful for debugging certain things (like trying to figure out what code is actually mutating an Element).

That said, I believe cross-Frame communication is one of the driving forces behind the .postMessage() implementation. It's pretty slick, and it sounds like it could help you with what you're trying to do.

Looking at MDN, I'm actually shocked at the browser-support for MutationObserver; perhaps it's time I take a look at it :D

448 Comments

Yes. PostMessage is great as well. Its just that PostMessage requires origin validation, whereas MutationObserver does not, so in certain situation, when using an IFRAME, the latter can come in handy. And you are correct, MutationObserver is one of the oldest observers, and is supported by almost every browser!

One word of warning, if you are ever using Angular 5, there is a bug in Angular's implementation of MutationObserver. Angular 6 has probably addressed this issue.

So, the workaround is:

import { Injectable, OnDestroy } from '@angular/core';
import { Subject, Observable, Subscription } from 'rxjs';

@Injectable()
export class mutationService implements OnDestroy {
  
  baseUrl: string = '';
  iframeId: string = '';
  iframe: any;
  iframeMutation = new Subject<any>();
  private iframeObserver: any;

  constructor() {
	  
	  this.baseUrl = decodeURIComponent(this.getUrlParameter('baseUrl'));
	  this.iframeId = this.getUrlParameter('iframeid');
	  const domain = this.extractRootDomain(this.baseUrl).split(':')[0];
	  
	  document.domain = domain;
	  this.iframe = parent.document.getElementById('iframe-' + this.iframeId) || window.document.getElementById('iframe-' + this.iframeId) || window.frameElement;

      // "@angular/cli": "1.5.0", "@angular/compiler-cli": "5.0.0"
      // this is a production bug fix to allow MutationObserver to register correctly
      const MutationObserver: new(callback) => MutationObserver = ((window as any).MutationObserver as any).__zone_symbol__OriginalDelegate;
      this.iframeObserver = new MutationObserver( (mutations: MutationRecord[]) => {
        mutations.forEach( (mutation: MutationRecord) =>  {
          const attributeValue = (mutation.target as HTMLInputElement).getAttribute(mutation.attributeName);
          return this.iframeMutation.next(attributeValue);
        });
      });
      this.iframeObserver.observe(this.iframe, {
        attributes: true,
        childList: true,
        characterData: true,
        attributeFilter:['data-role-iframe']
      });

  }
  
  getUrlParameter(sParam): any {
    return decodeURIComponent(window.location.search.substring(1)).split('&')
     .map((v) => { 
        return v.split('='); 
      })
     .filter((v) => { 
        return (v[0] === sParam) ? true : false; 
      })
     .reduce((acc:any,curr:any) => { 
        return curr[1]; 
      },0);
  };
  
  extractHostname(url): string {
    let hostname;
    // find & remove protocol (http, ftp, etc.) and get hostname
    if (url.indexOf('://') > -1) {
      hostname = url.split('/')[2];
    }
    else {
      hostname = url.split('/')[0];
    }
    // find & remove port number
    hostname = hostname.split(':')[0];
    // find & remove "?"
    hostname = hostname.split('?')[0];
    return hostname;
  }
    
  extractRootDomain(url): string {
    let domain = this.extractHostname(url);
    const splitArr = domain.split('.');
    const arrLen = splitArr.length;
    // extracting the root domain here
    // if there is a subdomain 
    if (arrLen > 2) {
      domain = splitArr[arrLen - 2] + '.' + splitArr[arrLen - 1];
      // check to see if it's using a Country Code Top Level Domain (ccTLD) (i.e. ".me.uk")
      if (splitArr[arrLen - 1].length === 2) {
      // this is using a ccTLD
      domain = splitArr[arrLen - 3] + '.' + domain;
      }
    }
    return domain;
  }
  
  ngOnDestroy() {
  }
  
}

448 Comments

The bug fix is only required once the Angular app is transpiled. But it also work in development code, so I just use it for both. The native Angular MutationObserver works in development code...

448 Comments

By thw way, this fix, took me about a week to resolve, so it could save someone, a lot of head scratching. I literally went into the deepest, darkest, most obscure areas of StackOverlow. These links are found, at least, 20 pages into a Google search...

In fact, I have used this general bug fix fo several Angular 5 production scenarios:

const MutationObserver: new(callback) => MutationObserver = ((window as any).MutationObserver as any).__zone_symbol__OriginalDelegate;

So, you can replace the 'MutationObserver' object for many other objects that cause trouble in production.

15,848 Comments

@Charles,

Whoa, that's a really crazy looking fix :D I have no idea how you even figured that out :P That will be good to have on record when I need to refer to it, thanks.

448 Comments

I found it in the deepest darkest recesses of the web. It took me about 5 days of StackOverflow intra linking, to find. It's amazing what you can find on the internet nowadays. It also works with certain FileReader solutions. As I said, hopefully this issue has been fixed in Angular 6, but I have quite a few legacy [probably the wrong word] Angular 5 projects still on the boil. Too scared to update them!??

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel