angular: Elements lazy load make unable to use providers in modules

🐞 bug report

Affected Package

The issue is caused by package @angular/elements.

Is this a regression?

I’m not sure.

Description

When you export a Custom Element that contains a lazy load module, you aren’t able to create a service without the providedIn: ‘root’. If you create a service with a @Injectable() and than make the provider in a module under the lazy load module, it’ll break the application and throw a no provider error.

The service code

The providers in the module under a Lazy load route

🔬 Minimal Reproduction

https://richardlnnr.github.io/angular-lazy-load-injectable/

https://github.com/richardlnnr/angular-lazy-load-injectable

🔥 Exception or Error


core.js:6228 ERROR NullInjectorError: R3InjectorError(AppModule)[ContentService -> ContentService -> ContentService]: 
  NullInjectorError: No provider for ContentService!
    at NullInjector.get (http://localhost:4200/vendor.js:8310:27)
    at R3Injector.get (http://localhost:4200/vendor.js:22304:33)
    at R3Injector.get (http://localhost:4200/vendor.js:22304:33)
    at R3Injector.get (http://localhost:4200/vendor.js:22304:33)
    at NgModuleRef$1.get (http://localhost:4200/vendor.js:39605:33)
    at Object.get (http://localhost:4200/vendor.js:37339:35)
    at getOrCreateInjectable (http://localhost:4200/vendor.js:12112:39)
    at Module.ɵɵdirectiveInject (http://localhost:4200/vendor.js:26119:12)
    at NodeInjectorFactory.ContentComponent_Factory [as factory] (http://localhost:4200/feature-feature-module.js:26:162)
    at getNodeInjectable (http://localhost:4200/vendor.js:12257:44)

ERROR Error: Uncaught (in promise): NullInjectorError: R3InjectorError(AppModule)[ContentService -> ContentService -> ContentService]: 
  NullInjectorError: No provider for ContentService!
NullInjectorError: R3InjectorError(AppModule)[ContentService -> ContentService -> ContentService]: 
  NullInjectorError: No provider for ContentService!
    at NullInjector.get (core.js:1085)
    at R3Injector.get (core.js:16955)
    at R3Injector.get (core.js:16955)
    at R3Injector.get (core.js:16955)
    at NgModuleRef$1.get (core.js:36329)
    at Object.get (core.js:33972)
    at getOrCreateInjectable (core.js:5848)
    at Module.ɵɵdirectiveInject (core.js:21103)
    at NodeInjectorFactory.ContentComponent_Factory [as factory] (content.component.ts:9)
    at getNodeInjectable (core.js:5993)
    at resolvePromise (zone-evergreen.js:798)
    at resolvePromise (zone-evergreen.js:750)
    at zone-evergreen.js:860
    at ZoneDelegate.invokeTask (zone-evergreen.js:399)
    at Object.onInvokeTask (core.js:41632)
    at ZoneDelegate.invokeTask (zone-evergreen.js:398)
    at Zone.runTask (zone-evergreen.js:167)
    at drainMicroTaskQueue (zone-evergreen.js:569)

🌍 Your Environment

Angular Version:


Angular CLI: 9.1.7
Node: 10.18.0
OS: linux x64

Angular: 9.1.9
... animations, common, compiler, compiler-cli, core, elements
... forms, platform-browser, platform-browser-dynamic, router
Ivy Workspace: Yes

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.901.7
@angular-devkit/build-angular     0.901.7
@angular-devkit/build-optimizer   0.901.7
@angular-devkit/build-webpack     0.901.7
@angular-devkit/core              9.1.7
@angular-devkit/schematics        9.1.7
@angular/cli                      9.1.7
@ngtools/webpack                  9.1.7
@schematics/angular               9.1.7
@schematics/update                0.901.7
rxjs                              6.5.5
typescript                        3.8.3
webpack                           4.42.0

Anything else relevant?

About this issue

  • Original URL
  • State: open
  • Created 4 years ago
  • Reactions: 9
  • Comments: 18 (7 by maintainers)

Most upvoted comments

Below is a diagram of the injector tree.

Unknown

On the left, the typical scenario is outlined. In ComponentFactory.create, one can pass an Injector and NgModuleRef for which a “switch” injector is created (in purple). The numbers show the path that is followed when injecting NgModuleRef and the injector with a thicker outline is the NgModuleRef that is resolved. The numbers in red indicate that this request is “tainted” with a default value, which is interpreted in the purple injectors to instruct them to skip their NgModule injector. This is why the AppModule is not considered after step 5, but instead the resolution continues from the first purple injector. This allows the resolution to properly resolve NgModuleRef from LazyModule as desired.

From the above, it can be observed that the node injector tree and NgModule injector tree are only ever connected through the switch injector which is aware of the tainted resolutions, allowing for the system to work.

The scenario on the right is how things are setup with @angular/elements. When registering an NgElement, you pass it an Injector. This is typically the NgModule itself, or a derived injector using Injector.create({ parent: ngModuleInjector }). This injector will be considered to represent the “view injector”, hence a “tainted” resolution will first attempt to use this injector (step 2) before considered its NgModule injector (which is LazyMod in this case). However, no switch injector will be hit this time, so there is nothing to detect the magic default that should prevent resolution further up the tree. As such, AppModule will happily resolve its own NgModuleRef<AppModule> and that will be injected, whereas AppModuleRef<LazyModule> was expected.


The suggestion proposed by @gkalpak of changing childInjector into Injector.NULL does indeed prevent this particular issue, as doing so changes step 2 to end up in the null injector, allowing the switch injector to switch to the NgModule injector tree which starts at LazyMod as desired. However this approach has a major flaw: a custom injector created using Injector.create that provides additional tokens will be ignored altogether, which is most certainly an issue.


One could argue that ComponentFactory.create could inspect the injector to see if it corresponds with a module injector, or a view injector and configure the switch injector accordingly. Unfortunately, I don’t think this can work correctly in the presence of custom injectors, as there is no way to detect what would be the cut-off point between a custom injector and an NgModule injector (essentially the point where the switch injector would typically exist).

A possible resolution could be to move the recognition of the magic default value into NgModuleRef, so that module injectors themselves will identity the cut-off point instead of the switch injector created by ComponentFactory.create.


Errata: where I mention the switch injector the original design doc refers to it as merge injector.

It turns out my analysis in https://github.com/angular/angular/issues/37441#issuecomment-639737971 is completely wrong 😁 I didn’t realize that you are importing ContentModule in FeatureModule and therefore it should work.

The problem seems to be how we create the component in @angular/elements and in particular how we pass in the injector. This line seems to be the problem:

https://github.com/angular/angular/blob/1197965e69d658c92422d49b1f2a1ba90498af67/packages/elements/src/component-factory-strategy.ts#L153

Replacing that with Injector.NULL (which is how components are created outside of @angular/elements) seems to fix the problem. I am not an expert on Injectors, so I am not sure what is the correct way to fix this (and how much of a breaking change it will be).

I have the same problem in my project

@richardlnnr A silly workaround could be to create a “fake” element injector by instantiating a component and using its injector:

@Component({ template: '' })
export class Workaround37441Component {}

export function createInjector_workaround37441(ngModule: NgModuleRef<unknown>): Injector {
  const cf = ngModule.componentFactoryResolver.resolveComponentFactory(Workaround37441Component);
  return cf.create(Injector.NULL, undefined, undefined, ngModule).injector;
}

Then change AppModule to inject NgModuleRef<unknown> instead of Injector and manually create an injector using the above fn:

export class AppModule {
  constructor(module: NgModuleRef<AppModule>) {
    const injector = createInjector_workaround37441(module);
    const el = createCustomElement(AppComponent, { injector });
    customElements.define('element-injectable', el);
  }

  ngDoBootstrap() {}
}

The problem is that you never load ContentModule (where the ContentService is provided). In FeatureRoutingModule you load the ContentComponent directly, so ContentModule is never loaded into the app. _UPDATE: This is incorrect. See https://github.com/angular/angular/issues/37441#issuecomment-646151990._

For service provided in @NgModule.providers to be available you need to ensure that the corresponding module is loaded into the app. In your case, you have the following options:

  1. Use providedIn: 'root' (which is the recommended way in most cases, as it allows tree-shaking) 😁
  2. In FeatureRoutingModule, load the ContentModule (via loadChildren: () => import('./content/content.module').then(m => m.ContentModule)) and in ContentModule import RouterModule.forChild([{path: '', component: ContentComponent}]).
  3. Provide the ContentService in the FeatureModule (which is already loaded into the app).
  4. Provide ContentService on the ContentComponent (via @Component.providers).

Closing since everything works as expected, but feel free to continue the discussion below (in case I missed something or you need more info).

I have the same problem here

Minimum repro without @angular/elements nor @angular/router, as they complicate debugging significantly (it took me over an hour to figure out where the root cause was):

https://stackblitz.com/edit/angular-ivy-n9vavs?file=src%2Fapp%2Fapp.component.ts

The issue originates from to the fact that an Angular element is registered using a module injector, whereas dynamic components are typically created from element injectors. There’s a special design at play which only deals with element injectors, original design doc can be found here.

I tried to correct this project to have a working solution: https://stackblitz.com/edit/angular-ivy-jwxrj1

the trick is to get the new injector in the moduleFatcory obtained from lazyModule.create(). This injector has the LazyService instance to make it available for injection! Hope this could help