Angular Element in an Angular app creates an infinite reloading issue when optimized
Asked Answered
C

1

10

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.

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 ^__^

Catalogue answered 28/3, 2019 at 12:54 Comment(3)
I had the exact same problem - the workaround solution you proposed worked for me. Thanks! Would be interested to know if you made any other progress on fixing the original problem.Kosygin
I'm glad to be of help. I wish I could say I have a better solution, but quite the opposite, our application added a new plugin and it uses the same ""workaround"". I'll keep searching and if I find anything I'll make sure to update this postCatalogue
I am having the exact same problem! After much troubleshooting I have decided to submit a bug report to Angular using your code here as an example. Track it here: github.com/angular/angular/issues/29788Ranit
R
8

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!

Ranit answered 10/6, 2019 at 20:39 Comment(1)
First of all, I'm SO sorry for not finding the time to check this solution, more so when it's a solution that work perfectly. I even created a branch in the repository to reflect this: github.com/daemonraco/angular-as-plugins/tree/solution/zakk_l I actually took so much time that I had to be careful with that package version and use an older one as suggested here: https://mcmap.net/q/1166599/-builders-39-browser-39-should-have-required-property-39-class-39 I appreciate you taking the time to write and to send this solution. Thank you very much.Catalogue

© 2022 - 2024 — McMap. All rights reserved.