Skip to content

Proposal: caller-scoped lazy provisioning for injectAsync ({ scope: 'self' }) #69934

Description

@nigrosimone

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:

  1. 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.
  2. 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":

  1. Probe up the chain with a sentinel default (no throw).
  2. If found -> return the ancestor/component/TestBed instance (mocking & overrides preserved).
  3. 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

  1. providers: [Svc] in the component requires a static import -> breaks code-splitting. This is exactly what the proposal avoids.
  2. providedIn:'root' / @Service() root singleton; cannot be component-scoped, cannot see component providers, wrong lifecycle.
  3. 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.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreIssues related to the framework runtimefeatureLabel used to distinguish feature request from other issues

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions