From 444b024d49725afc8b40aec67cfdb63a1f7f23ea Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Thu, 16 Apr 2026 18:01:14 +0200 Subject: [PATCH] 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(); } } ``` --- adev/src/app/core/services/inject-async.ts | 94 ------ .../inject-embedded-tutorial-manager.ts | 10 +- .../app/editor/inject-node-runtime-sandbox.ts | 8 +- .../playground/playground.component.spec.ts | 7 +- .../playground/playground.component.ts | 17 +- .../tutorial/tutorial.component.spec.ts | 7 +- .../features/tutorial/tutorial.component.ts | 46 +-- goldens/public-api/core/index.api.md | 16 + packages/core/src/di/index.ts | 46 +-- packages/core/src/di/inject_async.ts | 118 +++++++ .../core/test/di/inject_async/BUILD.bazel | 22 ++ .../test/di/inject_async/inject_async_spec.ts | 296 ++++++++++++++++++ 12 files changed, 528 insertions(+), 159 deletions(-) delete mode 100644 adev/src/app/core/services/inject-async.ts create mode 100644 packages/core/src/di/inject_async.ts create mode 100644 packages/core/test/di/inject_async/BUILD.bazel create mode 100644 packages/core/test/di/inject_async/inject_async_spec.ts diff --git a/adev/src/app/core/services/inject-async.ts b/adev/src/app/core/services/inject-async.ts deleted file mode 100644 index 8b2a43aa852..00000000000 --- a/adev/src/app/core/services/inject-async.ts +++ /dev/null @@ -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( - injector: Injector, - providerLoader: () => Promise>, -): Promise { - const injectImpl = injector.get(InjectAsyncImpl); - return injectImpl.get(injector, providerLoader); -} - -@Service() -class InjectAsyncImpl { - private overrides = new WeakMap(); // no need to cleanup - override(type: Type, mock: Type) { - this.overrides.set(type, mock); - } - - async get(injector: Injector, providerLoader: () => Promise>): Promise { - 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(type: Type, mock: Type) { - return [ - { - provide: ENVIRONMENT_INITIALIZER, - multi: true, - useValue: () => { - inject(InjectAsyncImpl).override(type, mock); - }, - }, - ]; -} diff --git a/adev/src/app/editor/inject-embedded-tutorial-manager.ts b/adev/src/app/editor/inject-embedded-tutorial-manager.ts index d1f79d19b41..414647a3a0b 100644 --- a/adev/src/app/editor/inject-embedded-tutorial-manager.ts +++ b/adev/src/app/editor/inject-embedded-tutorial-manager.ts @@ -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), + )(), ); } diff --git a/adev/src/app/editor/inject-node-runtime-sandbox.ts b/adev/src/app/editor/inject-node-runtime-sandbox.ts index aafdcf4dd7d..a765b44c0ee 100644 --- a/adev/src/app/editor/inject-node-runtime-sandbox.ts +++ b/adev/src/app/editor/inject-node-runtime-sandbox.ts @@ -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))(), ); } diff --git a/adev/src/app/features/playground/playground.component.spec.ts b/adev/src/app/features/playground/playground.component.spec.ts index 77a0555d96b..59fbfe3437b 100644 --- a/adev/src/app/features/playground/playground.component.spec.ts +++ b/adev/src/app/features/playground/playground.component.spec.ts @@ -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}, ], }); diff --git a/adev/src/app/features/playground/playground.component.ts b/adev/src/app/features/playground/playground.component.ts index 3de494c54f7..e921cc603f8 100644 --- a/adev/src/app/features/playground/playground.component.ts +++ b/adev/src/app/features/playground/playground.component.ts @@ -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); + } } } diff --git a/adev/src/app/features/tutorial/tutorial.component.spec.ts b/adev/src/app/features/tutorial/tutorial.component.spec.ts index 90f7f51a545..c6848dccbed 100644 --- a/adev/src/app/features/tutorial/tutorial.component.spec.ts +++ b/adev/src/app/features/tutorial/tutorial.component.spec.ts @@ -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, { diff --git a/adev/src/app/features/tutorial/tutorial.component.ts b/adev/src/app/features/tutorial/tutorial.component.ts index 2583550b171..c32612781fa 100644 --- a/adev/src/app/features/tutorial/tutorial.component.ts +++ b/adev/src/app/features/tutorial/tutorial.component.ts @@ -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); + } } } diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index c440ad9f433..94bcbe3da45 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -907,6 +907,14 @@ export interface InjectableType extends Type { ɵprov: unknown; } +// @public +export function injectAsync(loader: () => Promise>, options?: InjectAsyncOptions): () => Promise; + +// @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; + // @public export interface OnInit { ngOnInit(): void; @@ -1443,6 +1456,9 @@ export class PlatformRef { // @public export type Predicate = (value: T) => boolean; +// @public +export type PrefetchTrigger = () => Promise; + // @public export interface PromiseResourceOptions extends BaseResourceOptions { loader: ResourceLoader; diff --git a/packages/core/src/di/index.ts b/packages/core/src/di/index.ts index b074c0da62a..20731343726 100644 --- a/packages/core/src/di/index.ts +++ b/packages/core/src/di/index.ts @@ -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'; diff --git a/packages/core/src/di/inject_async.ts b/packages/core/src/di/inject_async.ts new file mode 100644 index 00000000000..fd6a8ffb48f --- /dev/null +++ b/packages/core/src/di/inject_async.ts @@ -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( + loader: () => Promise>, + options?: InjectAsyncOptions, +): () => Promise { + if (ngDevMode) { + assertInInjectionContext(injectAsync); + } + + const injector = inject(Injector); + + let loadedPromise: Promise> | 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; + +/** + * 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 { + if (ngDevMode) { + assertInInjectionContext(injectAsync); + } + + const idleService = inject(IDLE_SERVICE); + const {promise, resolve} = promiseWithResolvers(); + idleService.requestOnIdle(() => resolve(), options); + + return promise; +} diff --git a/packages/core/test/di/inject_async/BUILD.bazel b/packages/core/test/di/inject_async/BUILD.bazel new file mode 100644 index 00000000000..356e0f985ba --- /dev/null +++ b/packages/core/test/di/inject_async/BUILD.bazel @@ -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", + ], +) diff --git a/packages/core/test/di/inject_async/inject_async_spec.ts b/packages/core/test/di/inject_async/inject_async_spec.ts new file mode 100644 index 00000000000..c21924f58b5 --- /dev/null +++ b/packages/core/test/di/inject_async/inject_async_spec.ts @@ -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((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('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((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>((resolve) => { + prefetchCalled = true; + resolve(FooService); + }); + + const prefetchTrigger = () => + new Promise((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>((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(); + }); +});