Creating a lazy-loading wrapper component in Angular

A couple of weeks ago, one of my colleagues was tasked with implementing multiple customizable reports for our software. So, he did some research and found ActiveReportsJS by Grapecity. It allows us to create templates for reports, which could then be edited by our users to fully customize the appearance of those reports. Not only that, but it allows for PDF previewing on the one hand and headless exporting the PDFs on the other.  

To my surprise, the PDF rendering takes place in the user’s browser, which was great news to me because it means no additional server infrastructure on our side. Not to mention we don’t have to worry about our client data being transferred to a Grapecity server.  

On top of that, Grapecity seems to have a dedicated and helpful customer service which helped create a POC by quickly integrating their component into our app. 
So, I was convinced my co-worker did a great job finding a suitable solution for all our reporting needs, and therefore, I gave my ok to finalize the integration (creating the reports, etc.)  

Sounds like a success story, eh? Well, then I probably wouldn’t take the time to write a blog post about it…

As it turned out, when I reviewed the code of my colleague, the build time on our Azure DevOps agent increased from 5 minutes total (loading the repo, npm install, build and create a zip of the dist directory) to a whopping 17 minutes! Not only that, but the total size of our app went from 7MB to 23MB. Yikes!  

Ok, I convinced myself the size growth wasn’t that big of a deal because we lazy-load our Angular modules, and the reporting module isn’t being loaded right from the start. So, the end-user probably won’t even notice the app being larger.  

But the tripling of the build time is a big issue for me. Not only will every build on the pipeline take more than three times longer than before, but it will also slow us down developing the app (locally building the app). That’s unacceptable, so I had to find a way to mitigate the problem. 

What’s happening?

First, I had to find out what was behind the increased build time. After all, the ActiveReportsJS angular module is already built, so why does it take so long to build our app after we imported Grapecity’s module? It’s not so hard to find out that when having all optimization flags set to true, the angular CLI will perform extensive tree-shaking when building your app. That means it will go through your imports and try to find every bit of unnecessary code (code you don’t use) and strip it away. The larger the imported module (and ActiveReportsJS is quite large), the longer it takes to tree-shake it.  

So, you might think, “why don’t you just disable all those optimization flags, then?”. Well, this, of course, further increases the size of your app. And unfortunately, not only the parts where the large external module is being imported but all of your modules. To the best of my knowledge, there is no way to selectively disable tree-shaking (and other optimizations) on a single import.  

How to solve it? 

As expected, Grapecity’s ActiveReportsJS angular module is only a wrapper around a native JavaScript library. So, I decided to create my own wrapper that would not take a hit at the build time of our application.  

The main idea behind my plan is quite simple: don’t import the JavaScript library via TypeScript’s import mechanism, but instead load the pre-built JavaScript file during runtime:

const scriptElement = document.createElement("script");
scriptElement.type = "text/javascript";
scriptElement.src = "path/to/script.js";
document.head.appendChild(scriptElement);

As you can see, we’re creating a script element, setting the path to the script we want to load, and then adding this element to the head DOM element of our HTML document. That’s more or less all, but I took some extra steps to further optimize the script-loading:  

First, I wanted to be informed when the script has been loaded. There’s a handy onload attribute on the script element that takes a callback function that gets invoked after the script is fully loaded. However, I prefer working with promises nowadays, so I transformed the onload into a promise:

@Injectable()
export class GrapeCityLibraryService {

    constructor(
        @Inject(DOCUMENT) private doc: Document
    ) {
    }

    private loadScript(path: string) {
        return new Promise<HTMLScriptElement>(
            resolve => {
                const scriptElement = this.doc.createElement("script");
                scriptElement.type = "text/javascript";
                scriptElement.src = path;
                scriptElement.onload = () => {
                    resolve(scriptElement);
                };
                this.doc.head.appendChild(scriptElement);
            },
        );
    }
}

Next up are dependencies: The native ActiveReportsJS library consists of multiple JavaScript files. There is a core file that needs to be loaded first, and then there are different files for every use case or view you want to use.  

Because I want to create an angular module, I think the best way to implement such a thing is using RXJS observables

@Injectable()
export class GrapeCityLibraryService {
    private coreLib: Observable<HTMLScriptElement>;
    public pdfExportLib: Observable<HTMLScriptElement>;
    public xlsxExportLib: Observable<HTMLScriptElement>;
    public designerLib: Observable<HTMLScriptElement>;
    public viewerLib: Observable<HTMLScriptElement>;

    constructor(
        @Inject(DOCUMENT) private doc: Document
    ) {
        this.coreLib = of(`/assets/grapecity/ar-js-core.js`).pipe(
            mergeMap(path => this.loadScript(path)),
            tap(_ => GC.ActiveReports.Core.setLicenseKey(license)),
            shareReplay()
        );

        const load = (path: string) => this.coreLib.pipe(
            mergeMap(_ => this.loadScript(path)),
            shareReplay()
        );

        this.designerLib = load(`/assets/grapecity/ar-js-designer.js`);
        this.viewerLib = load(`/assets/grapecity/ar-js-viewer.js`);
        this.pdfExportLib = load(`/assets/grapecity/ar-js-pdf.js`);
        this.xlsxExportLib = load(`/assets/grapecity/ar-js-xlsx.js`);
    }

    ...
}

This results in the following behavior: The JS file only gets loaded if there is at least one subscriber for each specific file. Because I’m using shareReplay, each file gets loaded once at most. So, we have a “cold cached” behavior. 

Third-Party library as assets?

Reading the code above, you might have wondered, “the files get loaded from the assets directory. Am I supposed to copy the JS files from the node_module directory and commit these files into my git repo? Isn’t this bad practice?”  

Yes and no. In the end, you’re more or less copying the files into your assets directory, that’s correct. But you can elegantly do that: First, you create an npm install script that gets executed every time npm install is performed. And on top of that, you add the additional library folder in your assets directory to your .gitignore file. Following this practice, you can have your cake and eat it, too: Having the JS files available for everyone without actually storing them in your git repository.

{
    ...
    "scripts": {
        "postinstall": "ngcc && node ./postInstall"
    },
    ...
}
const fs = require('fs');
const fsExtra = require('fs-extra');
const path = require('path');

const copyGrapeCityAssets = () => {
    const sourceDir = path.join(__dirname, "./node_modules/@grapecity/activereports/dist");
    const destinationDir = path.join(__dirname, "./src/assets/grapecity");
    if (!fs.existsSync(destinationDir)){
        fs.mkdirSync(destinationDir, { recursive: true });
    }
    fsExtra.copy(sourceDir, destinationDir, error => {
        if (error) {
            throw error;
        }
    });
}

copyGrapeCityAssets();

Building a wrapper component around a native JS control

Last but not least, we now want to create angular components providing us with the control in an angular idiomatic way.  

When we look at the official ActiveReportsJS angular designer component, for example, we see that it doesn’t use angular best practices: The event emitting parts aren’t implemented as output attributes but rather as input attributes taking a callback function.  

So, let’s take a look at how we can create a wrapper component: 

@Component({
    selector: 'gc-designer',
    template: `<div id="designer-host-{{ instanceId }}"></div>`,
    changeDetection: ChangeDetectionStrategy.OnPush,
    styleUrls: ["./grapeCity.import.scss"],
    encapsulation: ViewEncapsulation.None
})
export class GrapeCityDesignerComponent implements AfterViewInit, OnChanges, OnDestroy {
    @Input() definition: ReportDefinition;
    @Input() title: string;
    @Output() ready = new EventEmitter<GcDesigner>();
    @Output() render = new EventEmitter<GcRdlReport>();
    @Output() saved = new EventEmitter<SaveContext>();

    instanceId = generateUuid();
    
    ...
}

Line 4 disables the auto change detection of angular. As a result, angular will only render the component’s content when something is passed to the element via an @Input() attribute. If angular should render the component’s content at any other point in time, we have to manually tell angular to do so. This is a performance optimization which I encourage everyone to use wherever possible. 

Line 5 and 6 belong together: We @import the ActiveReportsJS stylesheets in grapeCity.import.scss, but because the native ActiveReportsJS control is being rendered outside of the realm of our component, we have to tell angular that our stylesheet applies to the whole application. That’s what the encapsulation property is for.

@Input() definition: ReportDefinition;
private definitionChanged = new ReplaySubject<ReportDefinition>();

ngOnChanges(changes: SimpleChanges): void {
    const definitionChanges = changes["definition"];

    if (definitionChanges?.currentValue != undefined) {
        this.definitionChanged.next(this.definition);
    }
}

Implementing the OnChanges interface together with @Input() fields is a common practice I often use: The idea behind it is that we can have an Observable stream which allows us to appropriately react to every change on a specific field while simultaneously providing this field in an angular-idiomatic manner via @Input().  

Now we come to the bread and butter of every wrapper component, the AfterViewInit interface implementation: This method gets invoked after the content of our template is being loaded into the applications DOM tree. So, that’s when we want to instantiate our designer object and apply it to the relative DOM element. Now we can make use of our lazy-loading code we created before, provided by our angular service:  

@Output() ready = new EventEmitter<GcDesigner>();
@Output() render = new EventEmitter<GcRdlReport>();
@Output() saved = new EventEmitter<SaveContext>();

ngAfterViewInit() {
    const designerInstance = this.libService.designerLib
        .pipe(
            map(_ => new GC.ActiveReports.ReportDesigner.Designer(`#designer-host-${this.instanceId}`)),
            shareReplay()
        );

    this.elementSubscription = designerInstance.subscribe(async designer => {
        await designer.setActionHandlers({
            onSave: (info: GcSaveReportInfo) => {
                this.saved.next(info);

                const result: GcSaveResult = { displayName: info.displayName };
                return Promise.resolve(result);
            },
            onRender: async (r: any) => {
                const report = <GcRdlReport>r.definition;
                this.render.next(report);
                this.appRef.tick();
            },
        });

        this.ready.next(designer);
        this.changeRef.detectChanges();
    });

    ...
}

In the first subscription, we listen to when the JS files are fully loaded. We then create an instance of the designer. We also listen to the events provided by the designer object and forward them to the relative @Output() field so that a consumer of our component can react to these events. In the last line, we inform angular about the DOM manipulation in order for angular to properly re-render the component.  

private elementSubscription: Subscription;
ngAfterViewInit() {
    this.elementSubscription = designerInstance.subscribe(async designer => {
        ...
    });
}

ngOnDestroy() {
    this.elementSubscription?.unsubscribe();
}

One thing that developers often forget, and I, therefore, want to highlight, is taking the return value of the subscribe method and later unsubscribe() in ngOnDestroy().  

Similar to the script subscription, we also listen to a definitionChanges: As soon as there is a new definition and the script is loaded (that’s what the combineLatest(…) is for), we call the setReport method on our designer object. I also had to include an appRef.tick(). This means bringing out the big guns, I know. To be honest, I don’t know why it isn’t enough to call changeRef.detectChanges() instead of appRef.tick(), which re-renders the whole application, but I had to do it. Otherwise, the designer’s UI didn’t update. 

ngAfterViewInit() {
    ...

    this.reportSubscription = combineLatest([designerInstance, this.definitionChanged])
        .subscribe(async ([designer, definition]) => {
            await designer.setReport({
                id: definition.reportId,
                definition: definition,
                displayName: this.title
            });
            this.appRef.tick();
        });
}

Export a PDF without loading the report first into a designer/viewer  

One thing we can do with our architecture is export a PDF without loading it first into a viewer. The idea is the same as with the component: Just load the JS files needed to render the PDF first and then instantiate an exporter object, respectively invoke its export method:

@Injectable()
export class GrapeCityLibraryService {
    public pdfExportLib: Observable<HTMLScriptElement>;

    public exportPdf(report: Report, title: string, fileName: string) {
        return this.pdfExportLib.pipe(
            mergeMap(async _ => {
                const pdfExportSettings: GcPdfSettings = {
                    info: {
                        title: title,
                        author: "Some Author"
                    },
                    autoPrint: false,
                    pdfVersion: "1.7",
                };

                const reportGeneration = <GcPageReport>new GC.ActiveReports.Core.PageReport();
                await reportGeneration.load(report);
                const doc = await reportGeneration.run();
                const result = await GC.ActiveReports.PdfExport.exportDocument(doc, pdfExportSettings);

                const sanitizedFileName = fileName.replace(".pdf", "");
                result.download(sanitizedFileName);
            })
        ).toPromise();
    }
}

That’s all I’ve got for today. Who knows: Maybe Grapecity adopts some of our concepts, and we can switch back to the official ActiveReportsJS angular module. In the meantime, we now have a working application that builds as fast as before introducing ActiveReportsJS.  

I hope you have learned something from reading this blog post. Also, if you have any suggestions on improving what we’ve done, please feel free to drop a comment below. 

Big thanks to Calitime for letting me take the time to write this blog post.

(Photo taken by Giorgio Tomassetti)

About the author

Domenic Helfenstein

1 comment

Recent Posts