| /** |
| * @license |
| * Copyright 2021 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import {ReactiveController, ReactiveControllerHost} from 'lit'; |
| import {PolymerElement} from '@polymer/polymer/polymer-element'; |
| |
| /** |
| * This module provides the ability to do dependency injection in components. |
| * It provides 3 functions that are for the purpose of dependency injection. |
| * |
| * Definitions |
| * --- |
| * A component's "connected lifetime" consists of the span between |
| * `super.connectedCallback` and `super.disconnectedCallback`. |
| * |
| * Dependency Definition |
| * --- |
| * |
| * A token for a dependency of type FooService is defined as follows: |
| * |
| * const fooToken = define<FooService>('some name'); |
| * |
| * Dependency Resolution |
| * --- |
| * |
| * To get the value of a dependency, a component requests a resolved dependency |
| * |
| * ``` |
| * private readonly serviceRef = resolve(this, fooToken); |
| * ``` |
| * |
| * A resolved dependency is a function that when called will return the actual |
| * value for that dependency. |
| * |
| * A resolved dependency is guaranteed to be resolved during a components |
| * connected lifetime. If no ancestor provided a value for the dependency, then |
| * the resolved dependency will throw an error if the value is accessed. |
| * Therefore, the following is safe-by-construction as long as it happens |
| * within a components connected lifetime: |
| * |
| * ``` |
| * serviceRef().fooServiceMethod() |
| * ``` |
| * |
| * Dependency Injection |
| * --- |
| * |
| * Ancestor components will inject the dependencies that a child component |
| * requires by providing factories for those values. |
| * |
| * |
| * To provide a dependency, a component needs to specify the following prior |
| * to finishing its connectedCallback: |
| * |
| * ``` |
| * provide(this, fooToken, () => new FooImpl()) |
| * ``` |
| * Dependencies are injected as factories in case the construction of them |
| * depends on other dependencies further up the component chain. For instance, |
| * if the construction of FooImpl needed a BarService, then it could look |
| * something like this: |
| * |
| * ``` |
| * const barRef = resolve(this, barToken); |
| * provide(this, fooToken, () => new FooImpl(barRef())); |
| * ``` |
| * |
| * Lifetime guarantees |
| * --- |
| * A resolved dependency is valid for the duration of its component's connected |
| * lifetime. |
| * |
| * Internally, this is guaranteed by the following: |
| * |
| * - Dependency injection relies on using dom-events which work synchronously. |
| * - Dependency injection leverages ReactiveControllers whose lifetime |
| * mirror that of the component |
| * - Parent components' connected lifetime is guaranteed to include the |
| * connected lifetime of child components. |
| * - Dependency provider factories are only called during the lifetime of the |
| * component that provides the value. |
| * |
| * Best practices |
| * === |
| * - Provide dependencies in or before connectedCallback |
| * - Verify that isConnected is true when accessing a dependency after an |
| * await. |
| * |
| * Type Safety |
| * --- |
| * |
| * Dependency injection is guaranteed type-safe by construction due to the |
| * typing of the token used to tie together dependency providers and dependency |
| * consumers. |
| * |
| * Two tokens can never be equal because of how they are created. And both the |
| * consumer and provider logic of dependencies relies on the type of dependency |
| * token. |
| */ |
| |
| /** |
| * A dependency-token is a unique key. It's typed by the type of the value the |
| * dependency needs. |
| */ |
| export type DependencyToken<ValueType> = symbol & {__type__: ValueType}; |
| |
| /** |
| * Defines a unique dependency token for a given type. The string provided |
| * is purely for debugging and does not need to be unique. |
| * |
| * Example usage: |
| * const token = define<FooService>('foo-service'); |
| */ |
| export function define<ValueType>(name: string) { |
| return Symbol(name) as unknown as DependencyToken<ValueType>; |
| } |
| |
| /** |
| * A provider for a value. |
| */ |
| export type Provider<T> = () => T; |
| |
| // Symbols to cache the providers and resolvers to avoid duplicate registration. |
| const PROVIDERS_SYMBOL = Symbol('providers'); |
| const RESOLVERS_SYMBOL = Symbol('resolvers'); |
| |
| interface Registrations { |
| [PROVIDERS_SYMBOL]?: Map< |
| DependencyToken<unknown>, |
| DependencyProvider<unknown> |
| >; |
| [RESOLVERS_SYMBOL]?: Map<DependencyToken<unknown>, Provider<unknown>>; |
| } |
| /** |
| * A producer of a dependency expresses this as a need that results in a promise |
| * for the given dependency. |
| */ |
| export function provide<T>( |
| host: ReactiveControllerHost & HTMLElement & Registrations, |
| dependency: DependencyToken<T>, |
| provider: Provider<T> |
| ) { |
| const hostProviders = (host[PROVIDERS_SYMBOL] ||= new Map< |
| DependencyToken<unknown>, |
| DependencyProvider<unknown> |
| >()); |
| const oldController = hostProviders.get(dependency); |
| if (oldController) { |
| host.removeController(oldController); |
| oldController.hostDisconnected(); |
| } |
| const controller = new DependencyProvider<T>(host, dependency, provider); |
| hostProviders.set(dependency, controller); |
| host.addController(controller); |
| } |
| |
| /** |
| * A consumer of a service will resolve a given dependency token. The resolved |
| * dependency is returned as a simple function that can be called to access |
| * the injected value. |
| */ |
| export function resolve<T>( |
| host: ReactiveControllerHost & HTMLElement & Registrations, |
| dependency: DependencyToken<T> |
| ): Provider<T> { |
| const hostResolvers = (host[RESOLVERS_SYMBOL] ||= new Map< |
| DependencyToken<unknown>, |
| Provider<unknown> |
| >()); |
| let resolver = hostResolvers.get(dependency); |
| if (!resolver) { |
| const controller = new DependencySubscriber(host, dependency); |
| host.addController(controller); |
| resolver = () => controller.get(); |
| hostResolvers.set(dependency, resolver); |
| } |
| return resolver as Provider<T>; |
| } |
| |
| /** |
| * Because Polymer doesn't (yet) depend on ReactiveControllerHost, this adds a |
| * work-around base-class to make this work for Polymer. |
| */ |
| export class DIPolymerElement |
| extends PolymerElement |
| implements ReactiveControllerHost |
| { |
| private readonly ___controllers: ReactiveController[] = []; |
| |
| override connectedCallback() { |
| for (const c of this.___controllers) { |
| c.hostConnected?.(); |
| } |
| super.connectedCallback(); |
| } |
| |
| override disconnectedCallback() { |
| super.disconnectedCallback(); |
| for (const c of this.___controllers) { |
| c.hostDisconnected?.(); |
| } |
| } |
| |
| addController(controller: ReactiveController) { |
| this.___controllers.push(controller); |
| |
| if (this.isConnected) controller.hostConnected?.(); |
| } |
| |
| removeController(controller: ReactiveController) { |
| const idx = this.___controllers.indexOf(controller); |
| if (idx < 0) return; |
| this.___controllers?.splice(idx, 1); |
| } |
| |
| requestUpdate() {} |
| |
| get updateComplete(): Promise<boolean> { |
| return Promise.resolve(true); |
| } |
| } |
| |
| /** |
| * A callback for a value. |
| */ |
| type Callback<T> = (value: T) => void; |
| |
| /** |
| * A Dependency Request gets sent by an element to ask for a dependency. |
| */ |
| export interface DependencyRequest<T> { |
| readonly dependency: DependencyToken<T>; |
| readonly callback: Callback<T>; |
| } |
| |
| declare global { |
| interface HTMLElementEventMap { |
| /** |
| * An 'request-dependency' can be emitted by any element which desires a |
| * dependency to be injected by an external provider. |
| */ |
| 'request-dependency': DependencyRequestEvent<unknown>; |
| } |
| interface DocumentEventMap { |
| /** |
| * An 'request-dependency' can be emitted by any element which desires a |
| * dependency to be injected by an external provider. |
| */ |
| 'request-dependency': DependencyRequestEvent<unknown>; |
| } |
| } |
| |
| /** |
| * Dependency Consumers fire DependencyRequests in the form of |
| * DependencyRequestEvent |
| */ |
| export class DependencyRequestEvent<T> |
| extends Event |
| implements DependencyRequest<T> |
| { |
| public constructor( |
| public readonly dependency: DependencyToken<T>, |
| public readonly callback: Callback<T> |
| ) { |
| super('request-dependency', {bubbles: true, composed: true}); |
| } |
| } |
| |
| /** |
| * A resolved dependency is valid within the connected lifetime of a component, |
| * namely between connectedCallback and disconnectedCallback. |
| */ |
| interface ResolvedDependency<T> { |
| get(): T; |
| } |
| |
| export class DependencyError<T> extends Error { |
| constructor(public readonly dependency: DependencyToken<T>, message: string) { |
| super(message); |
| } |
| } |
| |
| class DependencySubscriber<T> |
| implements ReactiveController, ResolvedDependency<T> |
| { |
| private value?: T; |
| |
| private resolved = false; |
| |
| constructor( |
| private readonly host: ReactiveControllerHost & HTMLElement, |
| private readonly dependency: DependencyToken<T> |
| ) {} |
| |
| get() { |
| this.checkResolved(); |
| return this.value!; |
| } |
| |
| hostConnected() { |
| this.host.dispatchEvent( |
| new DependencyRequestEvent(this.dependency, (value: T) => { |
| this.resolved = true; |
| this.value = value; |
| }) |
| ); |
| this.checkResolved(); |
| } |
| |
| checkResolved() { |
| if (this.resolved) return; |
| const dep = this.dependency.description; |
| const tag = this.host.tagName; |
| const msg = `Could not resolve dependency '${dep}' in '${tag}'`; |
| throw new DependencyError(this.dependency, msg); |
| } |
| |
| hostDisconnected() { |
| this.value = undefined; |
| this.resolved = false; |
| } |
| } |
| |
| class DependencyProvider<T> implements ReactiveController { |
| private value?: T; |
| |
| constructor( |
| private readonly host: ReactiveControllerHost & HTMLElement, |
| private readonly dependency: DependencyToken<T>, |
| private readonly provider: Provider<T> |
| ) {} |
| |
| hostConnected() { |
| // Delay construction in case the provider has its own dependencies. |
| this.value = this.provider(); |
| this.host.addEventListener('request-dependency', this.fullfill); |
| } |
| |
| hostDisconnected() { |
| this.host.removeEventListener('request-dependency', this.fullfill); |
| this.value = undefined; |
| } |
| |
| private readonly fullfill = (ev: DependencyRequestEvent<unknown>) => { |
| if (ev.dependency !== this.dependency) return; |
| ev.stopPropagation(); |
| ev.preventDefault(); |
| ev.callback(this.value!); |
| }; |
| } |