isPlatformBrowser Is Not Your Friend
This article is inspired by this comment by the eminent Jeff Whelpley
Stop me if this doesn’t seem familiar to you. You’ve built a fancy Angular application and you want to take it to the next level. So what do you do? You add a Service Worker. You add some fancy animations. And you add server-side rendering (SSR), maybe with the easy-to-use Angular Universal schematic.
Everything’s going great, and then all of a sudden… BAM! You get an error like this:
ReferenceError: HTMLTextAreaElement is not defined
“What’s going on here? I thought I could just flip a switch!” Ah the blissful realization that SSR isn’t a real browser engine. That’s a memory that will stick with you forever.
Now you may be tempted to wrap the offending code, be it from a library or something you wrote, with code provided by the Angular platform, like so:
if (isPlatformBrowser(this.platformId)) {
... browser stuff
} else {
... server stuff
}
This is where insanity brews. This is where complexity is born. This is where dreams come to die.
You see, dear reader, this takes a beautiful, innocent component, directive, or service, and turns it into a monster. Something that has conditional behavior based on the platform. It is now wholly dependent on that state, regardless of whether it will ever get rendered on the server.
So what’s the solution? How do we escape this madness? We return to the basics: Angular’s Dependency Injection (DI). With the power of DI, we can selectively replace APIs when we’re on the server. We can identify with a fine-tooth comb exactly which calls aren’t supported, and either shim or stub them out, without changing the underlying code.
Say we have ServiceA
, which on the client uses LibraryA
, and that library calls a browser API:
import {touchesBrowser} from 'libraryA';
import {Injectable, NgModule} from '@angular/core';@Injectable({providedIn: 'root')
export class ServiceA {
apiCall() {
return touchesBrowser(123);
}
}
On the server, we have another app module, by convention called AppServerModule
, that acts almost like a superset of the browser app module. And it is in this module that we can overwrite the instance of ServiceA
.
import {ServiceA} from './service-a';
import {Injectable, NgModule} from '@angular/core';@Injectable()
export class ServerServiceA {
apiCall() {
return 'Server value shim!';
}
}@NgModule({
providers: [{ provide: ServiceA, useClass: ServerServiceA }],
})
export class AppServerModule {}
Now, whenever a client calls ServiceA
on the server, without changing any of those underlying calls, we get the server value – no browser errors in sight.
Components
Thanks to the vitality and expansive nature of the Angular ecosystem, the likelihood is high that you’re using a third-party component library. If you’re lucky, this library is fully server-compatible, like Angular Material! If you’re not as lucky, the library exports components and services that almost exclusively use browser globals.
So now, the moment comes when your application won’t build, and it’s not even your fault! You want to use the shiny third-party components, but you don’t want to reinvent them just to make them server-compatible. Your next line of thought is probably to do something like this:
<fancy-component *ngIf="isBrowser"></fancy-component>
In this case,
isBrowser
is a component variable corresponding toisPlatformBrowser(PLATFORM_ID)
When this happens, you have two paths. The first path is to simply cordon off your component in a browser-only AppModule
, separate from your root AppModule
and your server module (e.g. AppBrowserModule
, AppServerModule
, and the base AppModule
). This will ensure that the Angular compiler skips over the element, and the DOM will parse it out as HTMLUnknownElement
, no muss, no fuss.
The second path is a continuation of the first path, but should be taken when the component has properties. When this is the case, create a shim component in your AppServerModule
that has an empty template and binds to the same inputs, but does not act on it.
If you’re scratching your head and saying to yourself: “this will never work in my app, I’ve got X/Y/Z!”, just know: you can and should switch to this model. It’s likely that you have some browser logic mixed in with your components. All you need to do is move that logic into a service that you can overwrite. Need an added bonus? It helps with testability!
isPlatformBrowser
is dead, long live overwriting services with server implementations!
Disclaimer: it is entirely possible that there are edge cases that I’ve missed with this assessment. Please feel free to add comments below, and I will amend the article accordingly.