feat(core): Add a injectAsync helper function

The commit introduces a new function to assist users who want to lazy load services and use the DI system to create them.

Example:

```ts
import {injectAsync} from 'angular/core';

class MyCmp {
  someSvc = injectAsync(() => import('..'));

  async onClick() {
    (await this.someSvc()).handleClick();
  }
}
 ```
This commit is contained in:
Matthieu Riegler
2026-04-16 18:01:14 +02:00
committed by Alon Mishne
parent 0c56679049
commit 444b024d49
12 changed files with 528 additions and 159 deletions
@@ -1,94 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {
DestroyRef,
ENVIRONMENT_INITIALIZER,
EnvironmentInjector,
Injector,
Provider,
ProviderToken,
Type,
createEnvironmentInjector,
inject,
Service,
} from '@angular/core';
/**
* inject a service asynchronously
*
* @param: injector. If the injector is a NodeInjector the loaded module will be destroyed alonside its injector
*/
export async function injectAsync<T>(
injector: Injector,
providerLoader: () => Promise<ProviderToken<T>>,
): Promise<T> {
const injectImpl = injector.get(InjectAsyncImpl);
return injectImpl.get(injector, providerLoader);
}
@Service()
class InjectAsyncImpl<T> {
private overrides = new WeakMap(); // no need to cleanup
override<T>(type: Type<T>, mock: Type<unknown>) {
this.overrides.set(type, mock);
}
async get(injector: Injector, providerLoader: () => Promise<ProviderToken<T>>): Promise<T> {
const type = await providerLoader();
// Check if we have overrides, O(1), low overhead
if (this.overrides.has(type)) {
const override = this.overrides.get(type);
return new override();
}
if (!(injector instanceof EnvironmentInjector)) {
// this is the DestroyRef of the component
const destroyRef = injector.get(DestroyRef);
// This is the parent injector of the injector we're creating
const environmentInjector = injector.get(EnvironmentInjector);
// Creating an environment injector to destroy it afterwards
const newInjector = createEnvironmentInjector([type as Provider], environmentInjector);
// Destroy the injector to trigger DestroyRef.onDestroy on our service
destroyRef.onDestroy(() => {
newInjector.destroy();
});
// We want to create the new instance of our service with our new injector
injector = newInjector;
}
return injector.get(type)!;
}
}
/**
* Helper function to mock the lazy loaded module in `injectAsync`
*
* @usage
* TestBed.configureTestingModule({
* providers: [
* mockAsyncProvider(SandboxService, fakeSandboxService)
* ]
* });
*/
export function mockAsyncProvider<T>(type: Type<T>, mock: Type<unknown>) {
return [
{
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useValue: () => {
inject(InjectAsyncImpl).override(type, mock);
},
},
];
}
@@ -6,12 +6,12 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {EnvironmentInjector} from '@angular/core';
import {injectAsync} from '../core/services/inject-async';
import {EnvironmentInjector, injectAsync, runInInjectionContext} from '@angular/core';
export function injectEmbeddedTutorialManager(injector: EnvironmentInjector) {
return injectAsync(injector, () =>
import('./embedded-tutorial-manager.service').then((c) => c.EmbeddedTutorialManager),
return runInInjectionContext(injector, () =>
injectAsync(() =>
import('./embedded-tutorial-manager.service').then((c) => c.EmbeddedTutorialManager),
)(),
);
}
@@ -6,12 +6,10 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {EnvironmentInjector} from '@angular/core';
import {injectAsync} from '../core/services/inject-async';
import {EnvironmentInjector, injectAsync, runInInjectionContext} from '@angular/core';
export function injectNodeRuntimeSandbox(injector: EnvironmentInjector) {
return injectAsync(injector, () =>
import('./node-runtime-sandbox.service').then((c) => c.NodeRuntimeSandbox),
return runInInjectionContext(injector, () =>
injectAsync(() => import('./node-runtime-sandbox.service').then((c) => c.NodeRuntimeSandbox))(),
);
}
@@ -12,9 +12,8 @@ import {WINDOW} from '@angular/docs';
import {EmbeddedTutorialManager} from '../../editor';
import {NodeRuntimeSandbox} from '../../editor/node-runtime-sandbox.service';
import TutorialPlayground from './playground.component';
import {provideRouter} from '@angular/router';
import {mockAsyncProvider} from '../../core/services/inject-async';
import TutorialPlayground from './playground.component';
describe('TutorialPlayground', () => {
let component: TutorialPlayground;
@@ -44,8 +43,8 @@ describe('TutorialPlayground', () => {
provide: WINDOW,
useValue: fakeWindow,
},
mockAsyncProvider(NodeRuntimeSandbox, FakeNodeRuntimeSandbox),
mockAsyncProvider(EmbeddedTutorialManager, FakeEmbeddedTutorialManager),
{provide: NodeRuntimeSandbox, useClass: FakeNodeRuntimeSandbox},
{provide: EmbeddedTutorialManager, useClass: FakeEmbeddedTutorialManager},
],
});
@@ -15,6 +15,7 @@ import {
effect,
EnvironmentInjector,
inject,
injectAsync,
input,
PLATFORM_ID,
signal,
@@ -24,7 +25,6 @@ import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {IconComponent, PlaygroundTemplate} from '@angular/docs';
import {forkJoin, switchMap, tap} from 'rxjs';
import {injectAsync} from '../../core/services/inject-async';
import {injectNodeRuntimeSandbox} from '../../editor/index';
import type {NodeRuntimeSandbox} from '../../editor/node-runtime-sandbox.service';
@@ -47,6 +47,10 @@ export default class PlaygroundComponent {
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private editorTutorialManager = injectAsync(() =>
import('../../editor/index').then((c) => c.EmbeddedTutorialManager),
);
readonly templates: PlaygroundTemplate[] = PLAYGROUND_ROUTE_DATA_JSON.templates;
readonly defaultTemplate = PLAYGROUND_ROUTE_DATA_JSON.defaultTemplate;
readonly starterTemplate = PLAYGROUND_ROUTE_DATA_JSON.starterTemplate;
@@ -106,10 +110,11 @@ export default class PlaygroundComponent {
}
private async loadTemplate(tutorialPath: string) {
const embeddedTutorialManager = await injectAsync(this.environmentInjector, () =>
import('../../editor/index').then((c) => c.EmbeddedTutorialManager),
);
await embeddedTutorialManager.fetchAndSetTutorialFiles(tutorialPath);
try {
const embeddedTutorialManager = await this.editorTutorialManager();
await embeddedTutorialManager.fetchAndSetTutorialFiles(tutorialPath);
} catch (err) {
console.error('Failed to load tutorial files', err);
}
}
}
@@ -6,9 +6,9 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {DOCS_VIEWER_SELECTOR, DocViewer, WINDOW, TutorialConfig, TutorialType} from '@angular/docs';
import {DOCS_VIEWER_SELECTOR, DocViewer, TutorialConfig, TutorialType, WINDOW} from '@angular/docs';
import {Component, input, Input, signal} from '@angular/core';
import {Component, input, signal} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {provideRouter} from '@angular/router';
import {of} from 'rxjs';
@@ -16,7 +16,6 @@ import {of} from 'rxjs';
import {EMBEDDED_EDITOR_SELECTOR, EmbeddedEditor, EmbeddedTutorialManager} from '../../editor';
import {NodeRuntimeSandbox} from '../../editor/node-runtime-sandbox.service';
import {mockAsyncProvider} from '../../core/services/inject-async';
import Tutorial from './tutorial.component';
@Component({
@@ -99,7 +98,7 @@ describe('Tutorial', () => {
provide: EmbeddedTutorialManager,
useValue: fakeEmbeddedTutorialManager,
},
mockAsyncProvider(NodeRuntimeSandbox, FakeNodeRuntimeSandbox),
{provide: NodeRuntimeSandbox, useClass: FakeNodeRuntimeSandbox},
],
});
TestBed.overrideComponent(Tutorial, {
@@ -150,15 +150,18 @@ export default class Tutorial {
this.embeddedTutorialManager.revealAnswer();
const nodeRuntimeSandbox = await injectNodeRuntimeSandbox(this.environmentInjector);
try {
const nodeRuntimeSandbox = await injectNodeRuntimeSandbox(this.environmentInjector);
await Promise.all(
Object.entries(this.embeddedTutorialManager.answerFiles()).map(([path, contents]) =>
nodeRuntimeSandbox.writeFile(path, contents as string | Uint8Array),
),
);
await Promise.all(
Object.entries(this.embeddedTutorialManager.answerFiles()).map(([path, contents]) =>
nodeRuntimeSandbox.writeFile(path, contents as string | Uint8Array),
),
);
this.answerRevealed.set(true);
this.answerRevealed.set(true);
} catch (err) {
console.error('Failed to reveal answer', err);
}
}
async handleResetAnswer() {
@@ -166,13 +169,16 @@ export default class Tutorial {
this.embeddedTutorialManager.resetRevealAnswer();
const nodeRuntimeSandbox = await injectNodeRuntimeSandbox(this.environmentInjector);
await Promise.all(
Object.entries(this.embeddedTutorialManager.tutorialFiles()).map(([path, contents]) =>
nodeRuntimeSandbox.writeFile(path, contents as string | Uint8Array),
),
);
try {
const nodeRuntimeSandbox = await injectNodeRuntimeSandbox(this.environmentInjector);
await Promise.all(
Object.entries(this.embeddedTutorialManager.tutorialFiles()).map(([path, contents]) =>
nodeRuntimeSandbox.writeFile(path, contents as string | Uint8Array),
),
);
} catch (err) {
console.error('Failed to reset answer', err);
}
this.answerRevealed.set(false);
}
@@ -195,9 +201,13 @@ export default class Tutorial {
(routeData.type === TutorialType.EDITOR || routeData.type === TutorialType.CLI) &&
this.isBrowser
) {
await this.setEditorTutorialData(
tutorialNavigationItem.path.replace(`${PAGE_PREFIX.TUTORIALS}/`, ''),
);
try {
await this.setEditorTutorialData(
tutorialNavigationItem.path.replace(`${PAGE_PREFIX.TUTORIALS}/`, ''),
);
} catch (err) {
console.error('Failed to load embedded editor tutorial data', err);
}
}
}