Architectural principles over APIs
Intro
Angular 8 came with a big changelog of features, deprecations, and fixes. A major change was the way Angular lazy loads its components. The standard method for that (loadChildren) stopped accepting a string parameter and switched to an import function. For a particular case we had, this change gave us some food for thought and opened a lot of possibilities.
Usecase
We had a very basic requirement to greet each new user with a consent form. Users can be one of several types, the consent form needs to reflect that (i.e. have a few more checkboxes and fields). Simple, right?
Easy solution 1
The straightforward solution is to get the user type from the backend and render the prebuilt customized form dynamically based on that. This can be hacked together in less than an hour and works fine at first glance but it has several issues:
- The js code containing all variants of the form will be visible to all user types regardless of what is rendered in the HTML. This might a security concern if you really don’t want user A to see what user B should be consenting to.
- The readability of the component is not great and can become overly complicated and bloated with the addition of new user types and customizations. So next time you need to quickly answer the question “What does the consent form of user C of type X contain” you will have to meticulously follow all the ifs and built-in variables you had to use to cover the business requirements.
- The testability of the solution is just as bad as the readability. This is not at all a standard case for Angular and you will have to write customized integration tests to cover it properly.
Easy solution 2
Have all the checkboxes/fields required served from the backend and render the form dynamically based on that. This might be the desired solution, depending on the case, but for our particular needs it turned out to be too problematic and cumbersome. Generating and rendering Angular forms dynamically has its own set of quirks with their own solutions and workarounds that we can one day explain in detail in another article. For now, it suffices to say that the readability and testability of the solution will suffer even more than in solution 1.
Back to the basics
The main disadvantages of the previous solutions were difficult to follow and hard to test logic. So we split the code into separate components for each user type to make them smaller, cleaner and well defined. In other words, we needed to separate each customized form into a separate component. But how do we serve and present them to the user?
Unimplemented idea?
A straightforward solution would be to use different Angular routes, serve the needed customized form component and upon submitting redirect back to the initial user route. For some cases that might be the perfect solution but for us, it just didn’t cut it. The URL will be changing for the user and so each form will be reachable for each user type with simple URL tinkering. On top of that, all forms will be served inside the JS bundle anyway, so any sensitive fields in them will be available to all users.
Proper solution
Using the router as intended did not work for us, but it did give us an idea. What if we can load a different version of the component after the user is logged in. This looked pretty much like component lazy loading, which the router is pretty much doing. If we could do that, the solution will also fit the framework default use-cases and so be well maintained and be easy to test. Based on research and brainstorming, there appeared to be two ways to achieve lazy loading inside an Angular app.
The first one was to send an uncompiled angular component to the browser and compile it runtime using the JIT compiler. This solution would require a fully functional angular compiler to be exported to the browser. Shipping this compiler increased our bundle size by 400KB. It may not sound too much but it took the browser some time to parse it. When you combine this with the compilation process it happened to be significantly slower.
Another approach that many articles proposed was to use NgModuleFactoryLoader. It looked perfect but unfortunately was deprecated in Angular 8. At that point, this part of angular documentation wasn’t available yet. But the lazy loading mechanism cannot be deprecated altogether from a big web framework, especially one such as Angular that relies on it heavily (as already mentioned, in its own router). So we continued to dig in, eventually going through the source code of the Angular router and the deprecated NgModuleFactoryLoader. There we noticed something useful.
Playing around with the concepts from the ElementsLoader snippet, we started adapting to our own basic case. We first need to import the module that we will be loaded lazily. Before Angular 8, we could just pass the path to the module to the SystemJsNgModuleLoader. However, as this was deprecated, we now need a loader function. This is a Webpack feature that Angular utilizes in version 8 – i.e. as expected, the module will be excluded from the served js package and will be served separately once the loader function is invoked:
let loader = () => import('./lazy-comp/lazy-mod.module').then(m => m.LazyModModule);
Code language: TypeScript (typescript)
This works perfectly fine with the new Ivy compiler, but the old View Engine had a bug and required the loader function to be an element of a module-level array:
let loaders = [
() => import('./lazy-comp/lazy-mod.module').then(m => m.LazyModModule)
];
Code language: TypeScript (typescript)
Depending on the type of compilation, angular evaluates the router import function to different types. When we start our app with JIT compilation it returns ngModule which we compile to module factory using Compiler from angular/core. Otherwise, it returns the module factory and we don’t have to compile it.
const ngModuleOrNgModuleFactory = await loaders[0];
if (ngModuleOrNgModuleFactory instanceof NgModuleFactory) {
moduleFactory = ngModuleOrNgModuleFactory; // AOT
} else {
moduleFactory = await this.compiler.compileModuleAsync(ngModuleOrNgModuleFactory); // JIT
}
Code language: TypeScript (typescript)
So far we imported the module and got a moduleFactory out of it. We want to create one or more instances of the component out of that module factory, which according to the docs we can easily do if we had a component factory. We just need a bit of magic to get a component factory out of our module factory, right:
const entryComponent = moduleFactory.moduleType['componentName'];
const moduleRef = moduleFactory.create(this.injector);
const compFactory = moduleRef.componentFactoryResolver.resolveComponentFactory(entryComponent);
Code language: TypeScript (typescript)
Now, all we have to do is attach our component somewhere in our views and render it. There are 2 possible ways to do this:
Render under specific view container
The easiest way is to create a new component and attach it to a parent. The parent needs to be of type ViewContainerRef and in the most basic case, it can be a preexisting ViewChild. The template of our dynamically created component is instantiated and appended to the template of that ViewChild.
const comp = referenceToAViewContainer.createComponent(compFactory);
Code language: TypeScript (typescript)
For specific cases, it is possible to attach to any other instance of existing or newly created ViewContainerRef.
Render the lazy-loaded template inside an ng-container
A much more versatile but complicated solution would be to create the component,
const comp = componentFactory.create(this.inject);
let outlet = comp.instance['templateRef'];
Code language: TypeScript (typescript)
fetch its template and inject it inside an existing ng-container. That relies on the feature of ng-containers to take template reference as parameter and inject it inside:
<ng-container *ngTemplateOutlet="outlet">
</ng-container>
Code language: HTML, XML (xml)
One of the use cases for this would be to pass variables from the parent inside the lazily loaded component through context.
Final solution
async load(componentName: string, loaded: () => Promise<NgModuleFactory<any> | Type<any>>, container: ViewContainerRef) {
const ngModuleOrNgModuleFactory = await loaded();
let moduleFactory;
if (ngModuleOrNgModuleFactory instanceof NgModuleFactory) {
moduleFactory = ngModuleOrNgModuleFactory; // AOT
} else {
moduleFactory = await this.compiler.compileModuleAsync(ngModuleOrNgModuleFactory); // JIT
}
const entryComponent = moduleFactory.moduleType['componentName'];
const moduleRef = moduleFactory.create(this.injector);
const compFactory = moduleRef.componentFactoryResolver.resolveComponentFactory(entryComponent);
const comp = container.createComponent(compFactory);
return comp;
}
Code language: TypeScript (typescript)
Conclusion
Modern-day development is usually based on a deep stack of services, frameworks, and libraries. In many cases, a feature is fully reliant on what is exposed and documented by them. In the odd one in which it seems the needed method is not exposed, not documented or (as in our case) deprecated, the developer is tempted to close the request as impossible to implement. However, when the developer understands well the end goal and architectural principles involved, putting together a working solution is often possible and might even lead to a deeper appreciation of the used framework or library.