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;
+  }
+}