Add an opt-in option to injectAsync that lets a lazily-imported, non-auto-provided class (eg. @Injectable(), @Service({ autoProvided: false })) be provided at the caller's injector scope instead of requiring it to be a root singleton:
// today: MUST be providedIn:'root' / @Service(), otherwise NG0201 at runtime
private dialog = injectAsync(() => import('./dialog.service').then(m => m.DialogService));
// proposed: lazy AND scoped to the calling component
private dialog = injectAsync(
() => import('./dialog.service').then(m => m.DialogService),
{ scope: 'self' },
);
The default behaviour is byte-for-byte unchanged; the new behaviour is reachable only through the explicit { scope: 'self' } flag.
Motivation - the gap
injectAsync today resolves the loaded token with a plain injector.get(token) (inject_async.ts:84). Its doc-comment states the constraint (line 23): "the injected service must be auto-provided … @Injectable({providedIn: 'root'}) or @Service()". Auto-provided means root singleton.
That leaves a combination that is impossible to express today:
|
eager |
lazy |
| root-scoped |
providedIn:'root' |
injectAsync ✅ (today) |
| component-scoped |
providers: [Svc] |
❌ no way |
The bottom-right cell is unreachable because:
- Putting the class in a component's
providers: [Svc] requires a static reference to Svc, which pulls it into the component's chunk -> defeats code-splitting. So component-scoping and lazy-loading are mutually exclusive with the current primitives.
providedIn:'root' / @Service() load lazily (the dynamic import() runs the class's ɵprov and wires it to root) but the result is a root singleton: it cannot see component-level providers, and it is not destroyed with the component.
A component-scoped lazy service is a real need when the service:
- depends on providers that live only on a component subtree (element-injector providers), or ...
- must share its lifecycle with the component (destroyed on
ngOnDestroy / DestroyRef).
This composes naturally with the newly added @Service({ autoProvided: false }): that decorator already declares "I am provided by the user, not automatically." Today the only way to satisfy that contract is an eager providers: [Svc]. injectAsync(loader, { scope: 'self' }) becomes the lazy way to satisfy exactly that contract.
Proposed API
Extend InjectAsyncOptions with an optional scope:
export interface InjectAsyncOptions {
prefetch?: PrefetchTrigger;
/**
* Controls what happens when the loaded token is not provided anywhere up the injector chain.
*
* - omitted (default): the token is resolved with `injector.get` and an `NG0201`
* (`PROVIDER_NOT_FOUND`) error is thrown if it is not provided - identical to current behaviour.
* - `'self'`: the token is first resolved up the chain (so an ancestor provider, a
* component-level provider, or a `TestBed` override still wins); only if it is not provided
* anywhere is the loaded class lazily provided at the **caller's** injector scope, with its
* lifecycle tied to the caller's `DestroyRef`. Requires the loader to resolve to a concrete
* class (`Type<T>`), because provisioning uses `useClass`.
*/
scope?: 'self';
}
Typed overloads keep the existing surface intact and constrain the loader to a concrete Type<T> only when scope: 'self' is requested:
// unchanged - accepts any ProviderToken, throws if unprovided
export function injectAsync<T>(
loader: () => Promise<ProviderToken<T>>,
options?: InjectAsyncOptions & { scope?: undefined },
): () => Promise<T>;
export function injectAsync<T>(
loader: () => Promise<DefaultExport<ProviderToken<T>>>,
options?: InjectAsyncOptions & { scope?: undefined },
): () => Promise<T>;
// new - serf-provisioning requires a concrete class
export function injectAsync<T>(
loader: () => Promise<Type<T>>,
options: InjectAsyncOptions & { scope: 'self' },
): () => Promise<T>;
export function injectAsync<T>(
loader: () => Promise<DefaultExport<Type<T>>>,
options: InjectAsyncOptions & { scope: 'self' },
): () => Promise<T>;
Semantics (the key design choice)
scope: 'self' is "resolve-first, fallback-self", not "always create at self":
- Probe up the chain with a sentinel default (no throw).
- If found -> return the ancestor/component/TestBed instance (mocking & overrides preserved).
- If not found anywhere -> create a child injector
{ provide: token, useClass: token } parented on the caller injector, tie scoped.destroy() to the caller's DestroyRef, and resolve from it.
"Always create at self" was rejected: it would shadow ancestor providers and, critically, break TestBed.overrideProvider / component-level mocks of the lazy token a non-starter for a framework that mocks through DI everywhere.
Alternatives considered
providers: [Svc] in the component requires a static import -> breaks code-splitting. This is exactly what the proposal avoids.
providedIn:'root' / @Service() root singleton; cannot be component-scoped, cannot see component providers, wrong lifecycle.
- Manual child injector in the component (
Injector.create in the async callback) this is the current userland workaround; the proposal packages it behind the existing primitive and ties the lifecycle correctly.
- A separate function (
injectLazy / injectAsyncScoped) instead of an option more API surface for one behavioural axis. Preference here is an option on the existing function; open to discussion.
Open design questions (for the issue thread)
- Naming.
scope: 'self' vs provideIfMissing: true vs ifMissing: 'self-provide'. 'self' risks confusion with @Self() DI semantics(which it is not). Bikeshed welcome.
- Dev-mode signal. Should self-provisioning emit a one-time
console.warn in dev, to catch a service that merely forgot its providedIn? (Trade-off: noise vs. safety.)
- Root-injector caller. When called outside a component (root
EnvironmentInjector), the instance lives for the app lifetime. Acceptable + documented, or disallow / warn?
- Abstract tokens.
useClass needs a concrete class; the constrained overload enforces Type<T>. Confirm that excluding InjectionToken / AbstractType from the scope:'self' overload is acceptable.
Add an opt-in option to
injectAsyncthat lets a lazily-imported, non-auto-provided class (eg.@Injectable(),@Service({ autoProvided: false })) be provided at the caller's injector scope instead of requiring it to be a root singleton:The default behaviour is byte-for-byte unchanged; the new behaviour is reachable only through the explicit
{ scope: 'self' }flag.Motivation - the gap
injectAsynctoday resolves the loaded token with a plaininjector.get(token)(inject_async.ts:84). Its doc-comment states the constraint (line 23): "the injected service must be auto-provided …@Injectable({providedIn: 'root'})or@Service()". Auto-provided means root singleton.That leaves a combination that is impossible to express today:
providedIn:'root'injectAsync✅ (today)providers: [Svc]The bottom-right cell is unreachable because:
providers: [Svc]requires a static reference toSvc, which pulls it into the component's chunk -> defeats code-splitting. So component-scoping and lazy-loading are mutually exclusive with the current primitives.providedIn:'root'/@Service()load lazily (the dynamicimport()runs the class'sɵprovand wires it to root) but the result is a root singleton: it cannot see component-level providers, and it is not destroyed with the component.A component-scoped lazy service is a real need when the service:
ngOnDestroy/DestroyRef).This composes naturally with the newly added
@Service({ autoProvided: false }): that decorator already declares "I am provided by the user, not automatically." Today the only way to satisfy that contract is an eagerproviders: [Svc].injectAsync(loader, { scope: 'self' })becomes the lazy way to satisfy exactly that contract.Proposed API
Extend
InjectAsyncOptionswith an optionalscope:Typed overloads keep the existing surface intact and constrain the loader to a concrete
Type<T>only whenscope: 'self'is requested:Semantics (the key design choice)
scope: 'self'is "resolve-first, fallback-self", not "always create at self":{ provide: token, useClass: token }parented on the caller injector, tiescoped.destroy()to the caller'sDestroyRef, and resolve from it."Always create at self" was rejected: it would shadow ancestor providers and, critically, break
TestBed.overrideProvider/ component-level mocks of the lazy token a non-starter for a framework that mocks through DI everywhere.Alternatives considered
providers: [Svc]in the component requires a static import -> breaks code-splitting. This is exactly what the proposal avoids.providedIn:'root'/@Service()root singleton; cannot be component-scoped, cannot see component providers, wrong lifecycle.Injector.createin the async callback) this is the current userland workaround; the proposal packages it behind the existing primitive and ties the lifecycle correctly.injectLazy/injectAsyncScoped) instead of an option more API surface for one behavioural axis. Preference here is an option on the existing function; open to discussion.Open design questions (for the issue thread)
scope: 'self'vsprovideIfMissing: truevsifMissing: 'self-provide'.'self'risks confusion with@Self()DI semantics(which it is not). Bikeshed welcome.console.warnin dev, to catch a service that merely forgot itsprovidedIn? (Trade-off: noise vs. safety.)EnvironmentInjector), the instance lives for the app lifetime. Acceptable + documented, or disallow / warn?useClassneeds a concrete class; the constrained overload enforcesType<T>. Confirm that excludingInjectionToken/AbstractTypefrom thescope:'self'overload is acceptable.