mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
118
packages/core/src/di/inject_async.ts
Normal file
118
packages/core/src/di/inject_async.ts
Normal 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;
|
||||
}
|
||||
22
packages/core/test/di/inject_async/BUILD.bazel
Normal file
22
packages/core/test/di/inject_async/BUILD.bazel
Normal 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",
|
||||
],
|
||||
)
|
||||
296
packages/core/test/di/inject_async/inject_async_spec.ts
Normal file
296
packages/core/test/di/inject_async/inject_async_spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue