Add support for dependency injection to Gerrit
The API consists of 3 parts:
- Defining a DependencyToken. This happens in model or service files
to define a unique token that can be used to identify a desired
dependency.
- A function call `provide` to setup a provided value. This needs to be
called before children's connectedCallback is called.
- A function called `resolve` to resolve a dependency. It returns a
handle to the value that should be valid for the lifetime of a
component (between connectedCallback and disconnectedCallback)
Change-Id: Id02b1356ebba855e88445bc7e9da3304eeda8927
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 1038629..2f04539 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -121,6 +121,7 @@
"elements/gr-app-element_html.ts",
"elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts",
"elements/shared/gr-account-list/gr-account-list_html.ts",
+ "services/dependency.ts",
]
sources_for_template_checking = glob(
diff --git a/polygerrit-ui/app/services/dependency.ts b/polygerrit-ui/app/services/dependency.ts
new file mode 100644
index 0000000..c1a145d
--- /dev/null
+++ b/polygerrit-ui/app/services/dependency.ts
@@ -0,0 +1,324 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {ReactiveController, ReactiveControllerHost} from 'lit';
+import {PolymerElement} from '@polymer/polymer';
+
+/**
+ * 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 npmtype-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;
+
+/**
+ * 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,
+ dependency: DependencyToken<T>,
+ provider: Provider<T>
+) {
+ host.addController(new DependencyProvider<T>(host, dependency, provider));
+}
+
+/**
+ * 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,
+ dependency: DependencyToken<T>
+): Provider<T> {
+ const controller = new DependencySubscriber(host, dependency);
+ host.addController(controller);
+ return () => controller.get();
+}
+
+/**
+ * Because Polymer doesn't (yet) depend on ReactiveControllerHost, this adds a
+ * work-around base-class to make this work for Polymer.
+ */
+export abstract class DIPolymerElement
+ extends PolymerElement
+ implements ReactiveControllerHost
+{
+ private readonly ___controllers: ReactiveController[] = [];
+
+ override connectedCallback() {
+ super.connectedCallback();
+ for (const c of this.___controllers) {
+ c.hostConnected?.();
+ }
+ }
+
+ override disconnectedCallback() {
+ for (const c of this.___controllers) {
+ c.hostDisconnected?.();
+ }
+ super.disconnectedCallback();
+ }
+
+ 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.
+ */
+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>;
+ }
+}
+
+/**
+ * Dependency Consumers fire DependencyRequests in the form of
+ * DependencyRequestEvent
+ */
+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 econnectd lifetime of a component,
+ * namely between connectedCallback and disconnectedCallback.
+ */
+interface ResolvedDependency<T> {
+ get(): T;
+}
+
+class DependencyError<D extends DependencyToken<unknown>> extends Error {
+ constructor(public readonly dependency: D, 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() {
+ if (!this.resolved) {
+ throw new DependencyError(
+ this.dependency,
+ 'Could not resolve dependency'
+ );
+ }
+ return this.value!;
+ }
+
+ hostConnected() {
+ this.host.dispatchEvent(
+ new DependencyRequestEvent(this.dependency, (value: T) => {
+ this.resolved = true;
+ this.value = value;
+ })
+ );
+ if (!this.resolved) {
+ throw new DependencyError(
+ this.dependency,
+ 'Could not resolve dependency'
+ );
+ }
+ }
+
+ 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!);
+ };
+}
diff --git a/polygerrit-ui/app/services/dependency_test.ts b/polygerrit-ui/app/services/dependency_test.ts
new file mode 100644
index 0000000..969fa5c
--- /dev/null
+++ b/polygerrit-ui/app/services/dependency_test.ts
@@ -0,0 +1,175 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {define, provide, resolve, DIPolymerElement} from './dependency';
+import {html, LitElement} from 'lit';
+import {customElement as polyCustomElement} from '@polymer/decorators';
+import {html as polyHtml} from '@polymer/polymer/lib/utils/html-tag';
+import {customElement, property, query} from 'lit/decorators';
+import '../test/common-test-setup-karma.js';
+
+interface FooService {
+ value: string;
+}
+const fooToken = define<FooService>('foo');
+
+interface BarService {
+ value: string;
+}
+
+const barToken = define<BarService>('bar');
+
+class FooImpl implements FooService {
+ constructor(public readonly value: string) {}
+}
+
+class BarImpl implements BarService {
+ constructor(private readonly foo: FooService) {}
+
+ get value() {
+ return this.foo.value;
+ }
+}
+
+@customElement('foo-provider')
+export class FooProviderElement extends LitElement {
+ @query('bar-provider')
+ bar?: BarProviderElement;
+
+ @property({type: Boolean})
+ public showBarProvider = true;
+
+ constructor() {
+ super();
+ provide(this, fooToken, () => new FooImpl('foo'));
+ }
+
+ override render() {
+ if (this.showBarProvider) {
+ return html`<bar-provider></bar-provider>`;
+ } else {
+ return undefined;
+ }
+ }
+}
+
+@customElement('bar-provider')
+export class BarProviderElement extends LitElement {
+ @query('leaf-lit-element')
+ litChild?: LeafLitElement;
+
+ @query('leaf-polymer-element')
+ polymerChild?: LeafPolymerElement;
+
+ @property({type: Boolean})
+ public showLit = true;
+
+ override connectedCallback() {
+ super.connectedCallback();
+ provide(this, barToken, () => this.create());
+ }
+
+ private create() {
+ const fooRef = resolve(this, fooToken);
+ assert.isDefined(fooRef());
+ return new BarImpl(fooRef());
+ }
+
+ override render() {
+ if (this.showLit) {
+ return html`<leaf-lit-element></leaf-lit-element>`;
+ } else {
+ return html`<leaf-polymer-element></leaf-polymer-element>`;
+ }
+ }
+}
+
+@customElement('leaf-lit-element')
+export class LeafLitElement extends LitElement {
+ readonly barRef = resolve(this, barToken);
+
+ override connectedCallback() {
+ super.connectedCallback();
+ assert.isDefined(this.barRef());
+ }
+
+ override render() {
+ return html`${this.barRef().value}`;
+ }
+}
+
+@polyCustomElement('leaf-polymer-element')
+export class LeafPolymerElement extends DIPolymerElement {
+ readonly barRef = resolve(this, barToken);
+
+ override connectedCallback() {
+ super.connectedCallback();
+ assert.isDefined(this.barRef());
+ }
+
+ static get template() {
+ return polyHtml`Hello`;
+ }
+}
+
+suite('Dependency', () => {
+ test('It instantiates', async () => {
+ const fixture = fixtureFromElement('foo-provider');
+ const element = fixture.instantiate();
+ await element.updateComplete;
+ assert.isDefined(element.bar?.litChild?.barRef());
+ });
+
+ test('It works by connecting and reconnecting', async () => {
+ const fixture = fixtureFromElement('foo-provider');
+ const element = fixture.instantiate();
+ await element.updateComplete;
+ assert.isDefined(element.bar?.litChild?.barRef());
+
+ element.showBarProvider = false;
+ await element.updateComplete;
+ assert.isNull(element.bar);
+
+ element.showBarProvider = true;
+ await element.updateComplete;
+ assert.isDefined(element.bar?.litChild?.barRef());
+ });
+
+ test('It works by connecting and reconnecting Polymer', async () => {
+ const fixture = fixtureFromElement('foo-provider');
+ const element = fixture.instantiate();
+ await element.updateComplete;
+
+ const beta = element.bar;
+ assert.isDefined(beta);
+ assert.isNotNull(beta);
+ assert.isDefined(element.bar?.litChild?.barRef());
+
+ beta!.showLit = false;
+ await element.updateComplete;
+ assert.isDefined(element.bar?.polymerChild?.barRef());
+ });
+});
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'foo-provider': FooProviderElement;
+ 'bar-provider': BarProviderElement;
+ 'leaf-lit-element': LeafLitElement;
+ 'leaf-polymer-element': LeafPolymerElement;
+ }
+}