Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Angular Element in an Angular app creates an infinite reloading issue when optimized

TL;DR: I’m trying to use Angular Elements as plugins for an Angular application. If I build the element with --prod it works with ng serve on my app (development setup), but it goes into infinite reloading when I use it with ng serve --prod on my app or after the ng build --prod of my app (production setup).

Though, if I build the element adding --optimization=false, works with my productive app, but not in my development setup.

The thing is, I was expecting that building an Angular Element with --prod would be fine for both cases.

Question: Is there a way to solve this?


Now, the long read.

At work we are trying to use configurable plugins in our Angular site where the server is the one that tells which plugin is active or not.

We tried to load Angular modules dynamically, but that's a totally different headache we pushed aside for another day.

So, the next thing we wanted to try was Angular Elements and it kinda works, unless we build everything the way it should.

First, I started following this tutorial https://scotch.io/tutorials/build-a-reusable-component-with-angular-elements/amp and ignored everything about okta because my functionality is something different.

Creation:

I created my core application using the next command, this will be the application that hosts plugins:

  • ng new core --routing --skip-git --style scss --skip-tests --minimal

Then I created a plugin/angular-element using this command:

  • ng new plugin --skip-git --style scss --skip-tests --minimal

Plugin:

After all the creation I went into my plugin and commented this line in polyfills.ts, I read somewhere in this site that it solve the problem of NgZone already loaded and it was true:

// import 'zone.js/dist/zone';  // Included with Angular CLI.

In tsconfig.json I changed "target": "es5" to "target": "es2015" in order to fix an issue with how Angular creates elements. Not so sure how this works, but stackoverflow suggested it and it did the trick.

I edited the app.module.ts into something like this following ideas from the tutorial and some other to which I lost the link:

import { BrowserModule } from '@angular/platform-browser';
import { CUSTOM_ELEMENTS_SCHEMA, Injector, NgModule } from '@angular/core';
import { createCustomElement } from '@angular/elements';

import { AppComponent } from './app.component';

@NgModule({
    declarations: [
        AppComponent,
    ],
    imports: [
        BrowserModule,
    ],
    providers: [
    ],
    schemas: [
        CUSTOM_ELEMENTS_SCHEMA,
    ],
    entryComponents: [
        AppComponent,
    ],
})
export class AppModule {
    constructor(private injector: Injector) {
        const elem = createCustomElement(AppComponent, { injector: this.injector });
        customElements.define('my-plugin', elem);
    }

    ngDoBootstrap() {
    }
}

Note: I added CUSTOM_ELEMENTS_SCHEMA 'cause I found it somewhere, but it didn't solve it (also, I'm not sure what it does).

In the app.component.ts I did this to have some property to show in its template:

import { Component } from '@angular/core';

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
})
export class AppComponent {
    public content: any = {
        a: 10,
        b: '20',
    }
}

And the app.component.html looks like this:

Some content:
<pre>{{content | json}}</pre>

In the package.json file I have three script to build all:

{
    "scripts": {
        "build": "npm run build:opt && npm run build:noopt",
        "build:opt": "ng build --prod --output-hashing none && node build-elements.js",
        "build:noopt": "ng build --prod --output-hashing none --optimization=false && node build-elements.noopt.js"
    }
}

The file build-elements.js looks like this (build-elements.noopt.js is the same with a different destination name):

'use strict';

const concat = require('concat');
const fs = require('fs-extra');
const path = require('path');

(async function build() {
    const files = [
        './dist/plugin/runtime.js',
        './dist/plugin/polyfills.js',
        './dist/plugin/scripts.js',
        './dist/plugin/main.js',
    ];

    const destinationDir = path.join(__dirname, '../core/src/assets');
    await fs.ensureDir(destinationDir);
    await concat(files, path.join(destinationDir, 'my-plugin.js'));
})();

Core:

For the host app I added a component called embedded and the default route goes to it.

Then I changed the embedded.component.html into something like this using some Bootstrap classes:

<div id="embedded-container" class="container border border-primary rounded mt-5 p-3" #container>
    <pre>Loading...</pre>
</div>

Finally, the embedded.component.ts ended up like this to show the actual loading mechanism:

import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import { Router, ActivatedRoute, Params } from '@angular/router';

import { environment } from '../../environments/environment';

@Component({
    selector: 'app-embedded',
    templateUrl: './embedded.component.html',
})
export class EmbeddedComponent implements OnInit {
    @ViewChild('container') public container: ElementRef;

    constructor(protected activatedRoute: ActivatedRoute) {
    }

    ngOnInit() {
        this.activatedRoute.queryParams.subscribe((params: Params) => {
            const script = document.createElement('script');
            if (params['how-it-should-be'] !== undefined) {
                script.src = environment.production ? '/assets/my-plugin.js' : '/assets/my-plugin-no-optimization.js';
            } else {
                script.src = environment.production ? '/assets/my-plugin-no-optimization.js' : '/assets/my-plugin.js';
            }
            document.body.appendChild(script);

            const div = document.createElement('div');
            div.innerHTML = '<my-plugin></my-plugin>';

            this.container.nativeElement.innerHTML = '';
            this.container.nativeElement.appendChild(div);
        });
    }
}

Running:

If I run ng serve and browse to http://localhost:4200, the page loads without problem, injects the plugin, adds the new element to the DOM and displays the message from my plugin. And if you debug the application you'll see that it loads /assets/my-plugin.js, the one built for production. This wouldn't be a problem except maybe for debugging.

Then, If I run ng serve --prod (or build it for production) it also works fine, but it loads /assets/my-plugin-no-optimization.js, the one built for "debugging".

This is the solution I ended up using on our real application, but as you can see, I'm not using optimized code in my plugin for production and that's not nice... at all.

To prove my point, if I browse to http://localhost:4200/?how-it-should-be, it will try to load the optimized plugin for ng serve --prod and the debugging one for ng serve. Be aware that this will put you into infinite reload, open you browser developer tools to see it.


The final product we are using is much more complex, but these examples have the basic logic that's not really working.

I also created a GitHub repository with this where you can see these codes, and try the problem yourselves, or use as example for your own ideas.

  • https://github.com/daemonraco/angular-as-plugins

If you're wondering how I found out that using --optimization=false kinda fixed it, well, I was trying to debug this problem (which proved to be impossible) and suddenly it loaded.

I looked at the time and I was two hours too late for a production deployment, so I added that ugly mechanism to load different build depending on the environment. It's working, both in development and production, but I don't feel proud about it.


Sorry if my English is bad... no no no, my English IS bad, sorry ^__^

like image 631
DAEMonRaco Avatar asked Mar 28 '19 12:03

DAEMonRaco


1 Answers

I found the solution!

It turns out the problem lies in having multiple webpack projects running in the same context. My understanding of the problem is essentially when you build projects with webpack they do some webpack bootstrapping at runtime and rely on a certain function defined in the global context (webpackJsonp). When more than one webpack configuration tries to do this bootstrapping in the same DOM context it creates the symptoms defined here. (More detailed explanation found here - https://github.com/angular/angular/issues/23732#issuecomment-388670907)

This GitHub comment describes a solution but not the how-to - https://github.com/angular/angular/issues/30028#issuecomment-489758172. Below I will show how I specifically solved it.

We can use webpack configuration to rename the webpackJsonp for our web component such that both Angular projects (or anything built with webpack) do not interfere with each other.

Solution

First we install the @angular-builders/custom-webpack package to enable us to modify the built-in webpack configuration within the Angular CLI.

npm install --save-dev @angular-builders/custom-webpack

Next we update our angular.json file to both use our new builder and a new property value within options called customWebpackConfig. This includes a path to a new file which we are about to create and a mergeStrategy. This merge strategy says that we want to append configuration to the output section of webpack configuration.

angular.json
...
      "architect": {
        "build": {
          "builder": "@angular-builders/custom-webpack:browser",
          "options": {
            "customWebpackConfig": {
              "path": "./extra-webpack.config.js",
              "mergeStrategies": { "output": "append"}
            },
...

Last we simply add our extra-webpack.config.js file to the same directory that contains angular.json. The file just contains the following:

module.exports = {
    output: {
        jsonpFunction: '<webcomponent-prefix>-webpackJsonp'
    }
}

The value of jsonpFunction defaults to webpackJsonp, if you change it to anything else than it will work. I decided to maintain the function name but add a prefix for my web component application. In theory you could have N webpack configurations running in the same DOM context as long as each has a unique jsonpFunction

Build your web component again and voila, it works!

like image 74
Zakk L. Avatar answered Sep 18 '22 11:09

Zakk L.