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

View file

@ -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);
},
},
];
}

View file

@ -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),
)(),
);
}

View file

@ -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))(),
);
}

View file

@ -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},
],
});

View file

@ -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);
}
}
}

View file

@ -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, {

View file

@ -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);
}
}
}

View file

@ -907,6 +907,14 @@ export interface InjectableType<T> extends Type<T> {
ɵprov: unknown;
}
// @public
export function injectAsync<T>(loader: () => Promise<ProviderToken<T>>, options?: InjectAsyncOptions): () => Promise<T>;
// @public
export interface InjectAsyncOptions {
prefetch?: PrefetchTrigger;
}
// @public
export interface InjectDecorator {
(token: string): any;
@ -1315,6 +1323,11 @@ export interface OnDestroy {
ngOnDestroy(): void;
}
// @public
export function onIdle(options?: {
timeout?: number;
}): Promise<void>;
// @public
export interface OnInit {
ngOnInit(): void;
@ -1443,6 +1456,9 @@ export class PlatformRef {
// @public
export type Predicate<T> = (value: T) => boolean;
// @public
export type PrefetchTrigger = () => Promise<void>;
// @public
export interface PromiseResourceOptions<T, R> extends BaseResourceOptions<T, R> {
loader: ResourceLoader<T, R>;

View file

@ -12,37 +12,30 @@
* The `di` module provides dependency injection container services.
*/
export * from './metadata';
export {assertInInjectionContext, runInInjectionContext} from './contextual';
export {ɵɵdefineInjectable, ɵɵdefineInjector, InjectableType, InjectorType} from './interface/defs';
export {forwardRef, resolveForwardRef, ForwardRefFn} from './forward_ref';
export {Injectable, InjectableDecorator, InjectableProvider} from './injectable';
export {Service, ServiceDecorator} from './service';
export {ɵɵdefineService} from './interface/service';
export {Injector, DestroyableInjector} from './injector';
export {EnvironmentInjector} from './r3_injector';
export {
importProvidersFrom,
ImportProvidersSource,
makeEnvironmentProviders,
provideEnvironmentInitializer,
} from './provider_collection';
export {forwardRef, ForwardRefFn, resolveForwardRef} from './forward_ref';
export {HostAttributeToken} from './host_attribute_token';
export {HOST_TAG_NAME} from './host_tag_name_token';
export {ENVIRONMENT_INITIALIZER} from './initializer_token';
export {ProviderToken} from './provider_token';
export {ɵɵinject, inject, ɵɵinvalidFactoryDep} from './injector_compatibility';
export {InjectOptions} from './interface/injector';
export {injectAsync, InjectAsyncOptions, onIdle, PrefetchTrigger} from './inject_async';
export {Injectable, InjectableDecorator, InjectableProvider} from './injectable';
export {InjectionToken} from './injection_token';
export {DestroyableInjector, Injector} from './injector';
export {inject, ɵɵinject, ɵɵinvalidFactoryDep} from './injector_compatibility';
export {INJECTOR} from './injector_token';
export {InjectableType, InjectorType, ɵɵdefineInjectable, ɵɵdefineInjector} from './interface/defs';
export {InjectOptions} from './interface/injector';
export {
ClassProvider,
ModuleWithProviders,
ClassSansProvider,
ConstructorProvider,
EnvironmentProviders,
ConstructorSansProvider,
EnvironmentProviders,
ExistingProvider,
ExistingSansProvider,
FactoryProvider,
FactorySansProvider,
ModuleWithProviders,
Provider,
StaticClassProvider,
StaticClassSansProvider,
@ -51,7 +44,14 @@ export {
ValueProvider,
ValueSansProvider,
} from './interface/provider';
export {InjectionToken} from './injection_token';
export {HostAttributeToken} from './host_attribute_token';
export {HOST_TAG_NAME} from './host_tag_name_token';
export {R3Injector as ɵR3Injector} from './r3_injector';
export {ɵɵdefineService} from './interface/service';
export * from './metadata';
export {
importProvidersFrom,
ImportProvidersSource,
makeEnvironmentProviders,
provideEnvironmentInitializer,
} from './provider_collection';
export {ProviderToken} from './provider_token';
export {EnvironmentInjector, R3Injector as ɵR3Injector} from './r3_injector';
export {Service, ServiceDecorator} from './service';

View file

@ -0,0 +1,118 @@
/**
* @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 {IDLE_SERVICE} from '../defer/idle_service';
import {promiseWithResolvers} from '../util/promise_with_resolvers';
import {assertInInjectionContext} from './contextual';
import {Injector} from './injector';
import {inject} from './injector_compatibility';
import {ProviderToken} from './provider_token';
/**
* A helper function that allows to inject dependencies asynchronously,
* which can be useful in cases when the dependency is not needed immediately and can be loaded lazily.
*
* NOTE: To enable lazy loading, the injected service must be auto-provided. This means it should be decorated with either `@Injectable({providedIn: 'root'})` or `@Service()`.
*
* @param loader A function that returns a promise resolving to the injectable service
* @param options Configuration options for the async injection
*
* @returns A function that returns a promise resolving to the requested service instance.
*
* @usageNotes
*
* ```ts
* class MyCmp {
* someSvc = injectAsync(() => import('..'));
*
* async onClick() {
* (await this.someSvc()).handleClick();
* }
* }
*
* // we can also configure prefetching:
* injectAsync(.., {prefetch: onIdle})
* ```
*
* @publicApi 22.0
*/
export function injectAsync<T>(
loader: () => Promise<ProviderToken<T>>,
options?: InjectAsyncOptions,
): () => Promise<T> {
if (ngDevMode) {
assertInInjectionContext(injectAsync);
}
const injector = inject(Injector);
let loadedPromise: Promise<ProviderToken<T>> | null = null;
const load = () => {
if (!loadedPromise) {
loadedPromise = loader();
}
return loadedPromise;
};
if (options?.prefetch) {
options.prefetch().then(() => load());
}
// We can't use `inject` later on because of the async nature of the loader
return () => load().then((type) => injector.get(type)!);
}
/**
* Interface for `options` argument used within `injectAsync` call.
*
* @publicApi 22.0
*/
export interface InjectAsyncOptions {
/**
* A trigger to eagerly prefetch the lazy-loaded dependency before it is requested.
*
*/
prefetch?: PrefetchTrigger;
}
/**
* A function that returns a promise which, when resolved, will trigger the prefetching of
* the lazy-loaded dependency.
*
* @see {@link onIdle}
*
* @publicApi 22.0
*/
export type PrefetchTrigger = () => Promise<void>;
/**
* A `PrefetchTrigger` helper function to provide the logic of triggering dependency loading
* when the browser becomes idle.
*
* @usageNotes
*
* ```ts
* injectAsync(import(...), {prefetch: onIdle})
*
* // or with custom idle options:
* injectAsync(import(...), {prefetch: () => onIdle({timeout: 100})})
* ```
*
* @publicApi 22.0
*/
export function onIdle(options?: {timeout?: number}): Promise<void> {
if (ngDevMode) {
assertInInjectionContext(injectAsync);
}
const idleService = inject(IDLE_SERVICE);
const {promise, resolve} = promiseWithResolvers<void>();
idleService.requestOnIdle(() => resolve(), options);
return promise;
}

View file

@ -0,0 +1,22 @@
load("//tools:defaults.bzl", "ng_project", "zoneless_jasmine_test")
package(default_visibility = ["//visibility:private"])
ng_project(
name = "inject_async_lib",
testonly = True,
srcs = glob(
["*.ts"],
),
deps = [
"//packages/core",
"//packages/core/testing",
],
)
zoneless_jasmine_test(
name = "inject_async",
data = [
":inject_async_lib",
],
)

View file

@ -0,0 +1,296 @@
/**
* @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
*/
@Injectable({providedIn: 'root'})
class FooService {
foo = 0;
}
class MyMockedFooService implements FooService {
foo = 42;
}
import {
Component,
inject,
Injectable,
InjectionToken,
Injector,
onIdle,
ProviderToken,
runInInjectionContext,
} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {IDLE_SERVICE, IdleService} from '../../../src/defer/idle_service';
import {injectAsync} from '../../../src/di/inject_async';
describe('injectAsync', () => {
it('should inject asynchronously', async () => {
await TestBed.runInInjectionContext(async () => {
const foo = await injectAsync(() => Promise.resolve(FooService))();
expect(foo).toBeInstanceOf(FooService);
});
});
it('should inject asynchronously a mock', async () => {
TestBed.configureTestingModule({
providers: [{provide: FooService, useClass: MyMockedFooService}],
});
await TestBed.runInInjectionContext(async () => {
const foo = await injectAsync(() => Promise.resolve(FooService))();
expect(foo).toBeInstanceOf(MyMockedFooService);
});
});
it('should inject asynchronously with custom prefetch', async () => {
TestBed.configureTestingModule({
providers: [{provide: FooService, useClass: MyMockedFooService}],
});
await TestBed.runInInjectionContext(async () => {
let prefetchResolve!: () => void;
const prefetchPromise = new Promise<void>((resolve) => {
prefetchResolve = resolve;
});
let prefetchCalled = false;
const loader = () => {
prefetchCalled = true;
return Promise.resolve(FooService);
};
const fooPromise = injectAsync(loader, {
prefetch: () => prefetchPromise,
});
// Loader must NOT have been called yet — trigger hasn't fired
expect(prefetchCalled).toBe(false);
// Fire the trigger
prefetchResolve();
await Promise.resolve();
// The Promise hasn't been yet but the prefetch has fired.
expect(prefetchCalled).toBe(true);
const foo = await fooPromise();
expect(foo).toBeInstanceOf(MyMockedFooService);
});
});
it('should inject asynchronously with onIdle trigger', async () => {
await TestBed.runInInjectionContext(async () => {
const fooPromise = injectAsync(() => Promise.resolve(FooService), {prefetch: onIdle});
const foo = await fooPromise();
expect(foo).toBeInstanceOf(FooService);
});
});
it('Async injected service should have access to symbols available higher up in the DI tree', async () => {
const TOKEN = new InjectionToken<string>('TOKEN');
@Injectable({providedIn: 'root'})
class ServiceThatNeedsToken {
value = inject(TOKEN);
}
TestBed.configureTestingModule({
providers: [{provide: TOKEN, useValue: 'hello from parent'}],
});
await TestBed.runInInjectionContext(async () => {
const service = injectAsync(() => Promise.resolve(ServiceThatNeedsToken));
expect((await service()).value).toBe('hello from parent');
});
});
it('should not destroy the service if the component is destroyed (because its owned by the root injector', async () => {
let destroyed = false;
// providedIn: 'root' — owned by the root injector, not the component
@Injectable({providedIn: 'root'})
class RootService {
ngOnDestroy() {
destroyed = true;
}
}
@Component({template: ''})
class TestComponent {
service = injectAsync(() => Promise.resolve(RootService))();
}
TestBed.configureTestingModule({imports: [TestComponent]});
await TestBed.runInInjectionContext(async () => {
const fixture = TestBed.createComponent(TestComponent);
await fixture.whenStable();
expect(await fixture.componentInstance.service).toBeInstanceOf(RootService);
expect(destroyed).toBe(false);
fixture.destroy();
// Root-owned service must NOT be destroyed when the component is destroyed
expect(destroyed).toBe(false);
});
});
it("should throw if the service wasn't provided in root", async () => {
class UnprovidedService {}
await TestBed.runInInjectionContext(async () => {
let error!: Error;
try {
await injectAsync(() => Promise.resolve(UnprovidedService))();
} catch (e: any) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toContain('No provider found for `UnprovidedService`');
});
});
it('should load after prefetch timeout fires', async () => {
jasmine.clock().install();
jasmine.clock().autoTick();
let prefetchCalled = false;
const loader = () => {
prefetchCalled = true;
return Promise.resolve(FooService);
};
const prefetchTrigger = (timeout: number) =>
new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, timeout);
});
await TestBed.runInInjectionContext(async () => {
const fooPromise = injectAsync(loader, {prefetch: () => prefetchTrigger(100)});
jasmine.clock().tick(50);
await Promise.resolve(); // wait for the loader promise to resolve (if it was called)
expect(prefetchCalled).toBe(false);
jasmine.clock().tick(100);
await Promise.resolve(); // wait for the loader promise to resolve
expect(prefetchCalled).toBe(true);
return fooPromise().then((foo) => {
expect(foo).toBeInstanceOf(FooService);
});
});
jasmine.clock().uninstall();
});
it('should report failure correctly (eg on Injector destroyed)', async () => {
const myInjector = Injector.create({parent: TestBed.inject(Injector), providers: []});
await runInInjectionContext(myInjector, async () => {
const fooPromise = injectAsync(() => Promise.resolve(FooService));
myInjector.destroy();
let error!: Error;
try {
await fooPromise();
} catch (e: any) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toContain('Injector has already been destroyed.');
});
});
it('should not wait for the prefetch to complete if the service is requested before that', async () => {
let prefetchCalled = false;
const loader = () =>
new Promise<ProviderToken<FooService>>((resolve) => {
prefetchCalled = true;
resolve(FooService);
});
const prefetchTrigger = () =>
new Promise<void>((resolve) => {
// never resolve, to simulate a long-running prefetch
});
await TestBed.runInInjectionContext(async () => {
const fooPromise = injectAsync(loader, {prefetch: prefetchTrigger});
// Request the service before the prefetch trigger fires
const foo = await fooPromise();
expect(prefetchCalled).toBe(true);
expect(foo).toBeInstanceOf(FooService);
});
});
it('should fire on onIdle timeout', async () => {
jasmine.clock().install();
jasmine.clock().autoTick();
const idleService: IdleService = {
requestOnIdle(
callback: (deadline?: IdleDeadline) => void,
options?: IdleRequestOptions,
): number {
// Do not run idle callbacks eagerly in tests; only honor explicit timeout path.
if (options?.timeout == null) {
return -1;
}
return setTimeout(callback, options.timeout) as unknown as number;
},
cancelOnIdle(id: number): void {
if (id !== -1) {
clearTimeout(id);
}
},
};
TestBed.configureTestingModule({
providers: [{provide: IDLE_SERVICE, useValue: idleService}],
});
let loaderCalled = false;
const loader = () =>
new Promise<ProviderToken<FooService>>((resolve) => {
loaderCalled = true;
resolve(FooService);
});
await TestBed.runInInjectionContext(async () => {
const fooPromise = injectAsync(loader, {prefetch: () => onIdle({timeout: 500})});
jasmine.clock().tick(300);
await Promise.resolve(); // wait for the loader promise to resolve
expect(loaderCalled).toBe(false);
// Simulate the passage of time until the onIdle timeout fires
jasmine.clock().tick(600);
await Promise.resolve(); // wait for the loader promise to resolve
expect(loaderCalled).toBe(true);
const foo = await fooPromise();
expect(foo).toBeInstanceOf(FooService);
});
jasmine.clock().uninstall();
});
});