DEV Community

Cover image for Nx Module Federation bad Angular routing
Gianpiero Errigo for This is Angular

Posted on • Updated on

Nx Module Federation bad Angular routing

While puzzling around with Nx' Webpack Module Federation support, I stumbled upon an issue that strangely looks like none cared about before.
I'm talking about duplication of remote component in presence of a <router-outlet> in its template, when served as independent app.
Its poor traction could be due to the fact the bug shows up only using an Angular Standalone Components setup, at least for remotes.


The issue

I will take for granted the creation of a Module Federated Angular app under Nx.
After scaffolding, we should have an host app acting as shell of our multi-module project, and at least one remote app.
Both of them are Micro-Frontends, meaning they are capable of running on their own, even the remote one.
That comes clear issuing nx serve host_project_name, where they will be served independently, giving you the chance to look at their behaviour both when remote is accessed as child route of the host, both when it's booted as standalone app.

To make thing barely functional, our host app consists of a single dumb component with two lines template:

@Component({
  selector: 'testapp-root',
  template: `
             <a routerLink="remote_app">Remote</a>
             <router-outlet></router-outlet>
            `,
})
export class RemoteEntryComponent {}
Enter fullscreen mode Exit fullscreen mode

Just an anchor linking to the remote, and a router-outlet under which the child will be rendered.

Similarly, the remote will be just a visual aid to show our issue

@Component({
  selector: 'testapp-remote1-entry',
  template: `
              <div style="background-color: blue; height: 100px; width: 100px; margin: 5px"></div>
            `
})
export class RemoteEntryComponent {}
Enter fullscreen mode Exit fullscreen mode

Here we got merely a square div coloured in blue.

That's it.
Let's look at what gets served, then.
For our host app we'll get just the link, that once clicked will route to the remote, that will appear right under it (and under host's <router-outlet>)

working_host

If we browse to the standalone remote served app, by default reachable at host_port+1, thus on http://localhost:4201 for a classic setup, we'll see our remote content, the blue square, immediately rendered and no host's anchor element, as expected.

working_remote

So, where's the problem?
The issue we're talking about arises if we add a <router-outlet> element to our remote.
This is quite a common situation: many times our microfrontend will have its own internal routing, with child paths and obviously a router-outlet under which rendering them.
This is our modified remote

@Component({
  selector: 'testapp-remote1-entry',
  template: `
              <div style="background-color: blue; height: 100px; width: 100px; margin: 5px"></div>
              <router-outlet>
            `
})
export class RemoteEntryComponent {}
Enter fullscreen mode Exit fullscreen mode

In this short GIF we can see how it still works flawlessy when rendered as child of our host app, while acting really strangely when browsing to its standalone serving:

doubled_remote

Our remote is rendered twice!


The reason

To understand the reason behind this wrong behaviour, we got to dive a little deep into tech used by Nx plugins to manage Webpack Module Federation for Angular apps.
For the host app there's nothing too fancy: it gets served as standalone, and if needed is able to load external modules through a little twist of normal routing:

{
  path: 'remote_app',
  loadChildren: () =>
    loadRemoteModule('rem1', './Routes').then((m) => m.remoteRoutes),
}
Enter fullscreen mode Exit fullscreen mode

See how as callback of Angular Router's loadChildren, instead of classic import(path/to/lazy_loaded.module) we got a call to Nx provided loadRemoteModule('remote_module_name', './exposed_routes_path').

Inside our remote's module-federation.config.js there's the mapping for arguments passed to that call:

module.exports = {
  name: 'rem1',
  exposes: {
    './Routes': 'apps/rem1/src/app/remote-entry/entry.routes.ts',
  },
};
Enter fullscreen mode Exit fullscreen mode

So: the host's Router will look inside remote's entry.route.ts to know which child routes it could render.
This is default generated entry.route.ts

export const remoteRoutes: Route[] = [
  { path: '', component: RemoteEntryComponent },
];
Enter fullscreen mode Exit fullscreen mode

As we could imagine, there's just a root path '' pointing to default remote's root component, commonly referred as entry component.
This is nice for a situation where our remote is treated as accessory module of a main app.
All its structure is just a lazy-loaded branch inside host routing tree.

Things drastically change when our remote has to be served as an independent app.
Without a "parent" app already up and running, it needs to be bootstrapped as any other Angular application.
The flow Nx relies on starts with the usual serving of an index.html built upon a template like this:

<!DOCTYPE html>
<html lang="en">
  <head>...</head>
  <body>
    <testapp-remote1-entry></testapp-remote1-entry>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

In place of Angular-cli generated <app-root></app-root> selector assigned to AppComponent, body has been populated with our custom remote entry component's selector.
The code then sources main.ts as always, that delegates to bootstrap.ts file.
Here takes place the actual application bootstrapping, that for an Angular Standalone Components setup (concept unrelated to standalone serving of our remote, don't get confused) could be enough something like:

bootstrapApplication(RemoteEntryComponent)
Enter fullscreen mode Exit fullscreen mode

Nx remotes generator takes one step further providing to our Router eventual inner routes defined for this remote:

...
import { appRoutes } from './app/app.routes';

bootstrapApplication(RemoteEntryComponent, {
  providers: [
    importProvidersFrom(
      RouterModule.forRoot(appRoutes, { initialNavigation: 'enabledBlocking' })
    ),
  ],
});
Enter fullscreen mode Exit fullscreen mode

This ./app/app.routes simply lazy loads the same entry.routes file exposed to our host app.

export const appRoutes: Route[] = [
  {
    path: '',
    loadChildren: () =>
      import('./remote-entry/entry.routes').then((m) => m.remoteRoutes),
  },
];
Enter fullscreen mode Exit fullscreen mode

And here comes the problem!
We've already seen that file provides a route to root path '' rendering the root component of our remote.
But in our remote independent serving that component is already rendered due to its reference inside remote's index.html template.
This graph should be explanatory enough:

entry_rendering_graph

As the purple highlighted labels emphasize, the path to rendering of the entry component for independent remote serving encounters two different locations asking for it.
That entry component is actually both declared both routed.

Why the issue doesn't occur with old NgModule-based remotes setup instead of standalone components?

Because Nx remotes generator for classic ng-modular setup doesn't need to declare the entry component inside index.html.
It declares a separate root component (AppComponent) hosting a <router-outlet> and imports RouterModule inside root module (AppModule).
This way the same route referring to entry component will be correctly rendered just once both for Shell setup than for independent remote serving.


My solution

The problem can be probably solved in many ways, since every sane solution involves a partial rewriting of Nx generators for remotes built upon Angular standalone components.

Mi idea is to distinguish the purpose of remote's app.routes and entry.routes.
At the moment the former simply lazyloads the second, leading to an identical initial routing flow for both serving contexts.
Instead I thought of turning app.routes into the real route provider for remote, and leaving entry.routes just as a "plugin" invoked only in Shell serving context, keeping needed root path pointing to entry component, and importing app.routes to use defined routes as its children.

To do this, first we modify app.routes, removing its entry.routes lazy load, and adding our remote children routes to its array.
To keep entry.routes inside our compilation unit we move its import outside of routes definition, so it will be still compiled and exposed by our federation configuration, but ignored by our router during independent serving:

/* commented-out original default entry.routes import 
export const appRoutes: Route[] = [
  {
    path: '',
    loadChildren: () =>
      import('./remote-entry/entry.routes').then((m) => m.remoteRoutes),
  },
]; */

import('./remote-entry/entry.routes')

export const appRoutes: Route[] = [
  { path: 'first_child_route', component: FirstChildRouteComponent },
  { path: 'second_child_route', component: SecondChildRouteComponent },
  { path: 'third_child_route', component: ThirdChildRouteComponent }, 
]
Enter fullscreen mode Exit fullscreen mode

Now we can edit entry.routes, enriching its only route with an array of children, simply importing the one defined in app.routes

import { appRoutes } from '../app.routes';
import { RemoteEntryComponent } from './entry.component';

export const remoteRoutes: Route[] = [
  { 
    path: '',
    component: RemoteEntryComponent,
    children: appRoutes
  }
];
Enter fullscreen mode Exit fullscreen mode

In the end we can appreciate the result of our efforts, looking at our blue square rendered a single time for any serving methodology:

fixed_remote


Conclusions

I think Nx support for Angular standalone components federation is still a bit "unripe".
I found a couple oddities in its implementation I'll spend some words about in a future article, maybe.
It does a great job though, raising the coder from writing a lot of boilerplate and, considering I'm not exactly an expert in this field, there's the chance some of the things looking like "flaws" to me, were real solutions for cornercases or common use scenarios I didn't think of.
For the very same reason, the one I presented as a "solution" could be sub-optimal if not even wrong for other situations.
I didn't test it extensively, that's why as usual you're highly encouraged to leave a comment if anything doesn't sound right.

Anyway, I opened a ticket for this specific bug, and issued a PR with my suggested solution:

Duplicate entry component rendering for standalone ng component served as independent frontend #14551

Current Behavior

When generating angular standalone component as remote, the entry component is listed inside entry.routes for root path ''. That's fine when module is lazy loaded by shell app, but gives a problem when served as independent microfrontend. In that case the entry component is declared into index.html too, so if a router-outlet gets added somewhere in the tree, the component will be rendered twice.

Expected Behavior

When served as independent app, entry.routes should be ignored, and inner routes should be defined into remote's app.routes directly.

Github Repo

https://github.com/4javier/monotest

Steps to Reproduce

  1. nx serve shell
  2. on localhost:4200 click on "remote" link: a blue square is rendered
  3. on localhost:4201: two squares get rendered

Nx Report

Node : 14.20.0
   OS   : linux x64
   npm  : 6.14.17
   
   nx : 15.5.1
   @nrwl/angular : 15.5.1
   @nrwl/cypress : 15.5.1
   @nrwl/detox : Not Found
   @nrwl/devkit : 15.5.1
   @nrwl/esbuild : Not Found
   @nrwl/eslint-plugin-nx : 15.5.1
   @nrwl/expo : Not Found
   @nrwl/express : Not Found
   @nrwl/jest : 15.5.1
   @nrwl/js : 15.5.1
   @nrwl/linter : 15.5.1
   @nrwl/nest : Not Found
   @nrwl/next : Not Found
   @nrwl/node : Not Found
   @nrwl/nx-cloud : Not Found
   @nrwl/nx-plugin : Not Found
   @nrwl/react : Not Found
   @nrwl/react-native : Not Found
   @nrwl/rollup : Not Found
   @nrwl/schematics : Not Found
   @nrwl/storybook : Not Found
   @nrwl/web : Not Found
   @nrwl/webpack : 15.5.1
   @nrwl/workspace : 15.5.1
   @nrwl/vite : Not Found
   typescript : 4.8.4
   ---------------------------------------
   Local workspace plugins:
   ---------------------------------------
   Community plugins:
Enter fullscreen mode Exit fullscreen mode

Failure Logs

No response

Additional Information

I explained extensively the issue and my suggested solution here. https://dev.to/this-is-angular/nx-module-federation-bad-angular-routing-1ac9



Cheers.

Oldest comments (2)

Collapse
 
coly010 profile image
Colum Ferry
Collapse
 
gianpiero_errigo profile image
Gianpiero Errigo

Hi Whelme.
I'm not sure about what you mean with "usage increasing".
The article you linked compare performance among AngularJS, Angular 7 and Angular 9 (Ivy), showing how they become better in every aspect.