aurelia/aurelia

View on GitHub
packages/router/src/endpoints/viewport.ts

Summary

Maintainability
F
1 wk
Test Coverage
import { IContainer } from '@aurelia/kernel';
import { CustomElement, IHydratedController, IHydratedParentController, ICustomElementController } from '@aurelia/runtime-html';
import { ComponentAppellation, IRouteableComponent, RouteableComponentType, LoadInstruction } from '../interfaces';
import { IRouter } from '../router';
import { arrayRemove } from '../utilities/utils';
import { ViewportContent } from './viewport-content';
import { RoutingInstruction } from '../instructions/routing-instruction';
import { TransitionAction, RoutingScope } from '../routing-scope';
import { Navigation } from '../navigation';
import { NavigationCoordinator } from '../navigation-coordinator';
import { Runner, Step } from '../utilities/runner';
import { Routes } from '../decorators/routes';
import { Route } from '../route';
import { Endpoint, IConnectedCustomElement } from './endpoint';
import { IViewportOptions, ViewportOptions } from './viewport-options';

/**
 * The viewport is an endpoint that encapsulates an au-viewport custom element
 * instance. It always has at least one viewport content -- the current and also
 * the next when the viewport is in a transition -- even though the viewport
 * content can be empty.
 *
 * If a routing instruction is matched to a viewport during a navigation, the
 * router will ask the viewport if the navigation is approved (based on the state
 * of the current content, next content authorization and so on) and if it is,
 * instruct the navigation coordinator to start the viewport's transition when
 * appropriate. The viewport will then orchestrate, with coordination help from
 * the navigation coordinator, the transition between the current content and
 * the next, including calling relevant routing and lifecycle hooks.
 *
 * In addition to the above, the viewport also serves as the router's interface
 * to the loaded content/component and its configuration such as title and
 * configured routes.
 */

export class Viewport extends Endpoint {
  /**
   * The contents of the viewport. New contents are pushed to this, making
   * the last one the active one. It always holds at least one content, so
   * that there's always a current content.
   */
  public contents: ViewportContent[] = [];

  /**
   * Whether the viewport content should be cleared and removed,
   * regardless of statefulness (and hooks).
   */
  public forceRemove: boolean = false;

  /**
   * The viewport options.
   */
  public options: ViewportOptions = new ViewportOptions();

  /**
   * If set by viewport content, it's resolved when viewport has
   * been actived/started binding.
   */
  public activeResolve?: ((value?: void | PromiseLike<void>) => void) | null = null;

  /**
   * If set, it's resolved when viewport custom element has been
   * connected to the viewport endpoint/router.
   */
  private connectionResolve?: ((value?: void | PromiseLike<void>) => void) | null = null;

  /**
   * Whether the viewport is being cleared in the transaction.
   */
  private clear: boolean = false;

  /**
   * The coordinators that have transitions on the viewport.
   * Wheneve a new coordinator is pushed, any previous are
   * considered inactive and skips actual transition activities.
   */
  private readonly coordinators: NavigationCoordinator[] = [];

  /**
   * Stores the current state before navigation starts so that it can be restored
   * if navigation is cancelled/interrupted.
   * TODO(post-alpha): Look into using viewport content fully for this
   */
  private previousViewportState: Viewport | null = null;

  /**
   * The viewport content cache used for statefulness.
   */
  private cache: ViewportContent[] = [];

  /**
   * The viewport content cache used for history statefulness.
   */
  private historyCache: ViewportContent[] = [];

  public constructor(
    router: IRouter,

    /**
     * The name of the viewport
     */
    name: string,

    /**
     * The connected ViewportCustomElement (if any)
     */
    connectedCE: IConnectedCustomElement | null,

    /**
     * The routing scope the viewport belongs to/is owned by
     */
    owningScope: RoutingScope,

    /**
     * Whether the viewport has its own routing scope, containing
     * endpoints it owns
     */
    hasScope: boolean,

    /**
     * The viewport options.
     */
    options?: IViewportOptions,
  ) {
    super(router, name, connectedCE);
    this.contents.push(new ViewportContent(router, this, owningScope, hasScope));
    this.contents[0].completed = true;

    if (options !== void 0) {
      this.options.apply(options);
    }
  }

  /**
   * The current content of the endpoint
   */
  public getContent(): ViewportContent {
    // If there's only one content, it's always content
    if (this.contents.length === 1) {
      return this.contents[0];
    }
    let content!: ViewportContent;
    // Go through all contents looking for last completed
    for (let i = 0, ii = this.contents.length; i < ii; i++) {
      if (this.contents[i].completed ?? false) {
        content = this.contents[i];
      } else {
        break;
      }
    }
    return content;
  }

  /**
   * The next, to be transitioned in, content of the endpoint
   */
  public getNextContent(): ViewportContent | null {
    // If there's only one content, it's always content
    if (this.contents.length === 1) {
      return null;
    }
    const lastCompleted = this.contents.indexOf(this.getContent());
    return this.contents.length > lastCompleted ? this.contents[lastCompleted + 1] : null;
  }

  /**
   * The content of the viewport at a specific timestamp.
   *
   * @param timestamp - The timestamp
   */
  public getTimeContent(timestamp: number): ViewportContent | null {
    let content: ViewportContent | null = null;
    // Go through all contents looking for last completed
    for (let i = 0, ii = this.contents.length; i < ii; i++) {
      if (this.contents[i].navigation.timestamp > timestamp) {
        break;
      }
      content = this.contents[i];
    }
    return content;
  }

  /**
   * The content for a specific navigation (or coordinator)
   */
  public getNavigationContent(navigation: NavigationCoordinator | Navigation): ViewportContent | null {
    return super.getNavigationContent(navigation) as ViewportContent | null;
  }

  /**
   * The parent viewport.
   */
  public get parentViewport(): Viewport | null {
    let scope = this.connectedScope;
    while (scope?.parent != null) {
      scope = scope.parent;
      if (scope.endpoint.isViewport) {
        return scope.endpoint as Viewport;
      }
    }
    return null;
  }

  /**
   * Whether the viewport (content) is empty.
   */
  public get isEmpty(): boolean {
    return this.getContent().componentInstance === null;
  }

  /**
   * Whether the viewport content should be cleared and removed,
   * regardless of statefulness (and hooks). If a parent should
   * be removed, the viewport should as well.
   */
  public get doForceRemove(): boolean {
    let scope: RoutingScope | null = this.connectedScope;
    while (scope !== null) {
      if (scope.isViewport && (scope.endpoint as Viewport).forceRemove) {
        return true;
      }
      scope = scope.parent;
    }
    return false;
  }

  /**
   * Whether a coordinator handles the active navigation.
   *
   * @param coordinator - The coordinator to check
   */
  public isActiveNavigation(coordinator: NavigationCoordinator): boolean {
    return this.coordinators[this.coordinators.length - 1] === coordinator;
  }

  /**
   * For debug purposes.
   */
  public toString(): string {
    const contentName = this.getContent()?.instruction.component.name ?? '';
    const nextContentName = this.getNextContent()?.instruction.component.name ?? '';
    return `v:${this.name}[${contentName}->${nextContentName}]`;
  }

  /**
   * Set the next content for the viewport. Returns the action that the viewport
   * will take when the navigation coordinator starts the transition. Note that a
   * swap isn't guaranteed, current component configuration can result in a skipped
   * transition.
   *
   * @param instruction - The routing instruction describing the next content
   * @param navigation - The navigation that requests the content change
   */
  public setNextContent(instruction: RoutingInstruction, navigation: Navigation): TransitionAction {
    instruction.endpoint.set(this);
    this.clear = instruction.isClear(this.router);

    const content = this.getContent();
    // Can have a (resolved) type or a string (to be resolved later)
    const nextContent = new ViewportContent(this.router, this, this.owningScope, this.scope.hasScope, !this.clear ? instruction : void 0, navigation, this.connectedCE ?? null);
    this.contents.push(nextContent);

    nextContent.fromHistory = nextContent.componentInstance !== null && navigation.navigation != null
      ? !!navigation.navigation.back || !!navigation.navigation.forward
      : false;

    if (this.options.stateful) {
      // TODO: Add a parameter here to decide required equality
      const cached = this.cache.find((item) => nextContent.isCacheEqual(item));
      if (cached !== void 0) {
        this.contents.splice(this.contents.indexOf(nextContent), 1, cached);
        nextContent.fromCache = true;
      } else {
        this.cache.push(nextContent);
      }
    }

    // If we get the same _instance_, don't do anything (happens with cached and history)
    if (nextContent.componentInstance !== null && content.componentInstance === nextContent.componentInstance) {
      nextContent.delete();
      this.contents.splice(this.contents.indexOf(nextContent), 1);
      return this.transitionAction = 'skip';
    }

    if (!content.equalComponent(nextContent) ||
      navigation.navigation.refresh || // Navigation 'refresh' performed
      content.reloadBehavior === 'refresh' // ReloadBehavior 'refresh' takes precedence
    ) {
      return this.transitionAction = 'swap';
    }

    // If we got here, component is the same name/type

    // Explicitly don't allow navigation back to the same component again
    if (content.reloadBehavior === 'disallow') {
      nextContent.delete();
      this.contents.splice(this.contents.indexOf(nextContent), 1);
      return this.transitionAction = 'skip';
    }

    // Explicitly re-load same component again
    // TODO(alpha): NEED TO CHECK THIS TOWARDS activeContent REGARDING scope
    if (content.reloadBehavior === 'reload') {
      content.reload = true;

      nextContent.instruction.component.set(content.componentInstance);
      nextContent.contentStates = content.contentStates.clone();
      nextContent.reload = content.reload;
      return this.transitionAction = 'reload';
    }

    // ReloadBehavior is now 'default'

    // Requires updated parameters if viewport stateful
    if (this.options.stateful && content.equalParameters(nextContent)) {
      nextContent.delete();
      this.contents.splice(this.contents.indexOf(nextContent), 1);
      return this.transitionAction = 'skip';
    }

    if (!content.equalParameters(nextContent)) {
      // TODO: Fix a config option for this
      // eslint-disable-next-line no-constant-condition
      if (false) { // Re-use component, only reload with new parameters
        content.reload = true;
        nextContent.instruction.component.set(content.componentInstance);
        nextContent.contentStates = content.contentStates.clone();
        nextContent.reload = content.reload;
        return this.transitionAction = 'reload';
      } else { // Perform a full swap
        return this.transitionAction = 'swap';
      }
    }

    // Default is to do nothing
    nextContent.delete();
    this.contents.splice(this.contents.indexOf(nextContent), 1);
    return this.transitionAction = 'skip';
  }

  /**
   * Connect a ViewportCustomElement to this viewport endpoint, applying options
   * while doing so.
   *
   * @param connectedCE - The custom element to connect
   * @param options - The options to apply
   */
  public setConnectedCE(connectedCE: IConnectedCustomElement, options: IViewportOptions): void {
    options = options ?? {};
    if (this.connectedCE !== connectedCE) {
      // TODO: Restore this state on navigation cancel
      this.previousViewportState = { ...this };
      this.clearState();
      this.connectedCE = connectedCE;
      this.options.apply(options);
      this.connectionResolve?.();
    }

    const parentDefaultRoute = (this.scope.parent?.endpoint.getRoutes() ?? [])
      .filter(route => (Array.isArray(route.path) ? route.path : [route.path]).includes(''))
      .length > 0;

    if (this.getContent().componentInstance === null
      && this.getNextContent()?.componentInstance == null
      && (this.options.default || parentDefaultRoute)
    ) {
      const instructions = RoutingInstruction.parse(this.router, this.options.default ?? '');

      if (instructions.length === 0 && parentDefaultRoute) {
        const foundRoute = this.scope.parent?.findInstructions(
          [RoutingInstruction.create('') as RoutingInstruction],
          false,
          this.router.configuration.options.useConfiguredRoutes
        );

        if (foundRoute?.foundConfiguration) {
          instructions.push(...foundRoute.instructions);
        }
      }

      for (const instruction of instructions) {
        // Set to name to be delayed one turn (refactor: not sure why, so changed it)
        instruction.endpoint.set(this);
        instruction.scope = this.owningScope;
        instruction.default = true;
      }
      this.router.load(instructions, { append: true }).catch(error => { throw error; });
    }
  }

  // TODO(alpha): Look into this!
  public remove(step: Step | null, connectedCE: IConnectedCustomElement | null): boolean | Promise<boolean> {
    // TODO: Review this: should it go from promise to value somewhere?
    if (this.connectedCE === connectedCE) {
      return Runner.run(step,
        (innerStep: Step<void>) => {
          if (this.getContent().componentInstance !== null) {
            return this.getContent().freeContent(
              innerStep,
              this.connectedCE,
              (this.getNextContent()?.navigation ?? null),
              this.historyCache,
              this.doForceRemove ? false : this.router.statefulHistory || this.options.stateful
            ); // .catch(error => { throw error; });
          }
        },
        (innerStep: Step<void>) => {
          if (this.doForceRemove) {
            const removes = [];
            for (const content of this.historyCache) {
              removes.push((innerInnerStep: Step<void>) => content.freeContent(
                innerInnerStep,
                null,
                null,
                this.historyCache,
                false,
              ));
            }
            removes.push(() => { this.historyCache = []; });
            return Runner.run(innerStep,
              ...removes,
            );
          }
          return true;
        }
      ) as boolean | Promise<boolean>;
    }
    return false;
  }

  /**
   * Transition from current content to the next.
   *
   * @param coordinator - The coordinator of the navigation
   */
  public async transition(coordinator: NavigationCoordinator): Promise<void> {
    const navigatingPrefix = this.router.configuration.options.indicators.viewportNavigating;

    this.coordinators.push(coordinator);
    // If this isn't the first coordinator, a navigation is already in process...
    while (this.coordinators[0] !== coordinator) {
      // ...so first wait for it to finish.
      await this.coordinators[0].waitForSyncState('completed');
    }

    // Get the parent viewport...
    let actingParentViewport = this.parentViewport;
    // ...but not if it's not acting (reloading or swapping)
    if (actingParentViewport !== null
      && actingParentViewport.transitionAction !== 'reload'
      && actingParentViewport.transitionAction !== 'swap'
    ) {
      actingParentViewport = null;
    }
    // If actingParentViewport has a value, that viewport's routing
    // hooks needs to be awaited before starting this viewport's
    // corresponding routing hook.

    // First create a list with the steps that should run in the order
    // they should run and then, at the end, run them. Each hook step
    // registers its completeness with the navigation coordinator, which
    // keeps track of entity/endpoint transition states and restrictions
    // as well as pausing continuation if needed.

    // The transition guard hooks, canUnload and canLoad, both of which
    // can cancel the entire navigation
    const guardSteps = [
      (step: Step<boolean>) => {
        if (this.isActiveNavigation(coordinator)) {
          return this.canUnload(coordinator, step);
        }
      },

      (step: Step<boolean>) => {
        if (this.isActiveNavigation(coordinator)) {
          if (!(step.previousValue as boolean)) { // canUnloadResult: boolean
            // step.cancel();
            coordinator.cancel();
          } else {
            if (this.router.isRestrictedNavigation) { // Create the component early if restricted navigation
              const routerOptions = this.router.configuration.options;
              this.getNavigationContent(coordinator)!.createComponent(
                this.connectedCE!,
                this.options.fallback || routerOptions.fallback,
                this.options.fallbackAction || routerOptions.fallbackAction);
            }
          }
        }
        coordinator.addEndpointState(this, 'guardedUnload');
      },
      () => coordinator.waitForSyncState('guardedUnload', this), // Awaits all `canUnload` hooks
      () => actingParentViewport !== null ? coordinator.waitForEndpointState(actingParentViewport, 'guardedLoad') : void 0, // Awaits parent `canLoad`

      (step: Step<boolean>) => {
        if (this.isActiveNavigation(coordinator)) {
          return this.canLoad(coordinator, step) as boolean | LoadInstruction | LoadInstruction[];
        }
      },

      (step: Step) => {
        if (this.isActiveNavigation(coordinator)) {
          let canLoadResult = step.previousValue as boolean | LoadInstruction | LoadInstruction[];
          if (typeof canLoadResult === 'boolean') { // canLoadResult: boolean | LoadInstruction | LoadInstruction[],
            if (!canLoadResult) {
              step.cancel();
              coordinator.cancel();
              this.getNavigationContent(coordinator)!.instruction.nextScopeInstructions = null;
              return;
            }
          } else { // Denied and (probably) redirected
            this.getNavigationContent(coordinator)!.instruction.nextScopeInstructions = null;
            if (typeof canLoadResult === 'string') {
              const scope = this.scope;
              const options = this.router.configuration.options;
              let instructions = RoutingInstruction.parse(this.router, canLoadResult);
              const foundRoute = scope.parent?.findInstructions(instructions, options.useDirectRouting, options.useConfiguredRoutes);
              if (foundRoute?.foundConfiguration || foundRoute?.foundInstructions) {
                instructions = foundRoute.instructions;
              }
              for (const instruction of instructions) {
                instruction.endpoint.set(this);
                instruction.scope = scope.owningScope;
              }
              canLoadResult = instructions;
            }
            return Runner.run(step,
              (innerStep: Step<void>) => this.cancelContentChange(coordinator, innerStep),
              (innerStep: Step<void>) => {
                void this.router.load(canLoadResult, { append: true });
                return innerStep.exit();
              },
            );
          }
        }
        coordinator.addEndpointState(this, 'guardedLoad');
        coordinator.addEndpointState(this, 'guarded');
      },
    ];

    // The transition routing hooks, unloading and loading
    const routingSteps = [
      () => coordinator.waitForSyncState('guarded', this),
      (step: Step<void>) => {
        if (this.isActiveNavigation(coordinator)) {
          return this.unload(coordinator, step);
        }
      },
      () => coordinator.addEndpointState(this, 'unloaded'),

      () => coordinator.waitForSyncState('unloaded', this),
      () => actingParentViewport !== null ? coordinator.waitForEndpointState(actingParentViewport, 'loaded') : void 0,
      (step: Step<void>) => {
        if (this.isActiveNavigation(coordinator)) {
          return this.load(coordinator, step);
        }
      },
      () => coordinator.addEndpointState(this, 'loaded'),
      () => coordinator.addEndpointState(this, 'routed'),
    ];

    // The lifecycle hooks, with order and parallelism based on configuration
    const lifecycleSteps: ((step: Step<void | void[]>) => Step<void> | Promise<void> | void)[] = [
      () => coordinator.waitForSyncState('routed', this),
      () => coordinator.waitForEndpointState(this, 'routed'),
    ];

    const swapOrder = this.router.configuration.options.swapOrder;
    switch (swapOrder) {
      case 'detach-current-attach-next':
        lifecycleSteps.push(
          (step) => { if (this.isActiveNavigation(coordinator)) { return this.removeContent(step as Step<void>, coordinator); } },
          (step) => { if (this.isActiveNavigation(coordinator)) { return this.addContent(step as Step<void>, coordinator); } },
        );
        break;
      case 'attach-next-detach-current':
        lifecycleSteps.push(
          (step) => { if (this.isActiveNavigation(coordinator)) { return this.addContent(step as Step<void>, coordinator); } },
          (step) => { if (this.isActiveNavigation(coordinator)) { return this.removeContent(step as Step<void>, coordinator); } },
        );
        break;
      case 'detach-attach-simultaneously':
        lifecycleSteps.push((step): Step<void> =>
          Runner.runParallel(step,
            (innerStep: Step<void>) => { if (this.isActiveNavigation(coordinator)) { return this.removeContent(innerStep, coordinator); } },
            (innerStep: Step<void>) => { if (this.isActiveNavigation(coordinator)) { return this.addContent(innerStep, coordinator); } },
          ) as Step<void>,
        );
        break;
      case 'attach-detach-simultaneously':
        lifecycleSteps.push((step): Step<void> =>
          Runner.runParallel(step,
            (innerStep: Step<void>) => { if (this.isActiveNavigation(coordinator)) { return this.addContent(innerStep, coordinator); } },
            (innerStep: Step<void>) => { if (this.isActiveNavigation(coordinator)) { return this.removeContent(innerStep, coordinator); } },
          ) as Step<void>,
        );
        break;
    }

    lifecycleSteps.push(() => coordinator.addEndpointState(this, 'swapped'));

    // Set activity indicator (class) on the connected custom element
    this.connectedCE?.setActivity?.(navigatingPrefix, true);
    this.connectedCE?.setActivity?.(coordinator.navigation.navigation, true);

    // Run the steps and do the transition
    const result = Runner.run(null,
      (step: Step<void>) => coordinator.setEndpointStep(this, step.root),
      ...guardSteps,
      ...routingSteps,
      ...lifecycleSteps,
      () => coordinator.addEndpointState(this, 'completed'),
      () => coordinator.waitForSyncState('bound'),
      () => {
        this.connectedCE?.setActivity?.(navigatingPrefix, false);
        this.connectedCE?.setActivity?.(coordinator.navigation.navigation, false);
      },
    );

    if (result instanceof Promise) {
      result.catch(_err => { /* Happens when unload or load is prevented. TODO: React? */ });
    }
  }

  /**
   * Check if the current content can be unloaded.
   *
   * @param step - The previous step in this transition Run
   */
  public canUnload(coordinator: NavigationCoordinator, step: Step<boolean> | null): boolean | Promise<boolean> {
    return Runner.run(step,
      (innerStep: Step<boolean>) => {
        return this.getContent().connectedScope.canUnload(coordinator, innerStep);
      },
      (innerStep: Step<boolean>) => {
        if (!(innerStep.previousValue as boolean)) { // canUnloadChildren
          return false;
        }
        return this.getContent().canUnload(coordinator.navigation);
      },
    ) as boolean | Promise<boolean>;
  }

  /**
   * Check if the next content can be loaded.
   *
   * @param step - The previous step in this transition Run
   */
  public canLoad(coordinator: NavigationCoordinator, step: Step<boolean>): boolean | LoadInstruction | LoadInstruction[] | Promise<boolean | LoadInstruction | LoadInstruction[]> {
    if (this.clear) {
      return true;
    }

    return Runner.run(step,
      () => this.waitForConnected(),
      () => {
        const routerOptions = this.router.configuration.options;
        const navigationContent = this.getNavigationContent(coordinator)!;
        navigationContent.createComponent(
          this.connectedCE!,
          this.options.fallback || routerOptions.fallback,
          this.options.fallbackAction || routerOptions.fallbackAction);

        return navigationContent.canLoad();
      },
    ) as boolean | LoadInstruction | LoadInstruction[] | Promise<boolean | LoadInstruction | LoadInstruction[]>;
  }

  /**
   * Load the next content.
   *
   * @param step - The previous step in this transition Run
   */
  public load(coordinator: NavigationCoordinator, step: Step<void>): Step<void> | void {
    if (this.clear) {
      return;
    }

    return this.getNavigationContent(coordinator)!.load(step);
  }

  /**
   * Add (activate) the next content.
   *
   * @param step - The previous step in this transition Run
   * @param coordinator - The navigation coordinator
   */
  public addContent(step: Step<void>, coordinator: NavigationCoordinator): void | Step<void> {
    return this.activate(step, null, this.connectedController, coordinator);
  }

  /**
   * Remove (deactivate) the current content.
   *
   * @param step - The previous step in this transition Run
   * @param coordinator - The navigation coordinator
   */
  public removeContent(step: Step<void> | null, coordinator: NavigationCoordinator): void | Step<void> {
    if (this.isEmpty) {
      return;
    }

    const manualDispose = this.router.statefulHistory || (this.options.stateful ?? false);
    return Runner.run(step,
      // TODO: This also needs to be added when coordinator isn't active (and
      // this method isn't called)
      () => coordinator.addEndpointState(this, 'bound'),
      () => coordinator.waitForSyncState('bound'),
      (innerStep: Step<void>) => this.deactivate(
        innerStep,
        null,
        this.connectedController,
      ),
      () => manualDispose ? this.dispose() : void 0,
    ) as Step<void>;
  }

  /**
   * Activate the next content component, running `load` first. (But it only
   * runs if it's not already run.) Called both when transitioning and when
   * the custom element triggers it.
   *
   * @param step - The previous step in this transition Run
   * @param initiator - The controller that initiates the activate
   * @param parent - The parent controller
   * @param flags - The lifecycle flags for `activate`
   * @param coordinator - The navigation coordinator
   */
  public activate(step: Step<void> | null, initiator: IHydratedController | null, parent: IHydratedParentController | null, coordinator: NavigationCoordinator | undefined): void | Step<void> {
    if ((this.activeContent as ViewportContent).componentInstance !== null) {
      return Runner.run(step,
        () => (this.activeContent as ViewportContent).canLoad(), // Only acts if not already checked
        (innerStep: Step<void>) => (this.activeContent as ViewportContent).load(innerStep), // Only acts if not already loaded
        (innerStep: Step<void>) => (this.activeContent as ViewportContent).activateComponent(
          innerStep,
          initiator,
          parent as ICustomElementController,
          this.connectedCE!,
          // TODO: This also needs to be added when coordinator isn't active (and
          // this method isn't called)
          () => coordinator?.addEndpointState(this, 'bound'),
          coordinator?.waitForSyncState('bound'),
        ),
      ) as Step<void>;
    }
  }

  /**
   * Deactivate the current content component. Called both when
   * transitioning and when the custom element triggers it.
   *
   * @param initiator - The controller that initiates the deactivate
   * @param parent - The parent controller
   * @param flags - The lifecycle flags for `deactivate`
   */
  public deactivate(step: Step<void> | null, initiator: IHydratedController | null, parent: IHydratedParentController | null): void | Promise<void> {
    const content = this.getContent();
    if (content?.componentInstance != null &&
      !content.reload &&
      content.componentInstance !== this.getNextContent()?.componentInstance) {

      return content.deactivateComponent(
        step,
        initiator,
        parent as ICustomElementController,
        this.connectedCE!,
        this.router.statefulHistory || this.options.stateful
      ) as Promise<void>;
    }
  }

  /**
   * Unload the current content.
   *
   * @param step - The previous step in this transition Run
   */
  public unload(coordinator: NavigationCoordinator, step: Step<void> | null): void | Step<void> {
    return Runner.run(step,
      (unloadStep: Step<void>) => this.getContent().connectedScope.unload(coordinator, unloadStep),
      () => this.getContent().componentInstance != null ? this.getContent().unload(coordinator.navigation ?? null) : void 0,
    ) as Step<void>;
  }

  /**
   * Dispose the current content.
   */
  public dispose(): void {
    if (this.getContent().componentInstance !== null &&
      !this.getContent().reload &&
      this.getContent().componentInstance !== this.getNextContent()?.componentInstance) {
      this.getContent().disposeComponent(
        this.connectedCE!,
        this.historyCache,
        this.router.statefulHistory || this.options.stateful
      );
    }
  }

  /**
   * Finalize the change of content by making the next content the current
   * content. The previously current content is deleted.
   */
  public finalizeContentChange(coordinator: NavigationCoordinator, step: Step<void> | null): void | Step<void> {
    const nextContentIndex = this.contents.findIndex(content => content.navigation === coordinator.navigation);
    let nextContent = this.contents[nextContentIndex];
    const previousContent = this.contents[nextContentIndex - 1];
    // const previousContents = this.contents.slice(0, nextContentIndex);

    if (this.clear) {
      const emptyContent = new ViewportContent(this.router, this, this.owningScope, this.scope.hasScope, void 0, nextContent.navigation);
      this.contents.splice(nextContentIndex, 1, emptyContent);
      nextContent.delete();
      nextContent = emptyContent;
    } else {
      nextContent.reload = false;
    }

    previousContent.delete();

    // TODO: Fix this so that multiple removes work!
    // const freeSteps = [];
    // for (const previousContent of previousContents) {
    //   freeSteps.push(
    //     (innerStep: Step<void>) => {
    //       // return previousContent.freeContent(
    //       //   innerStep,
    //       //   this.connectedCE,
    //       //   previousContent.navigation,
    //       //   this.historyCache,
    //       //   this.router.statefulHistory || this.options.stateful)
    //     },
    //     () => previousContent.delete(),
    //   );
    // }

    // return Runner.run(step,
    //   ...freeSteps,
    //   () => {
    // if (nextContent !== null) {
    nextContent.completed = true;
    // }
    this.transitionAction = '';

    nextContent.contentStates.delete('checkedUnload');
    nextContent.contentStates.delete('checkedLoad');

    this.previousViewportState = null;

    const navigatingPrefix = this.router.configuration.options.indicators.viewportNavigating;
    this.connectedCE?.setActivity?.(navigatingPrefix, false);
    this.connectedCE?.setActivity?.(coordinator.navigation.navigation, false);

    let removeable = 0;
    for (let i = 0, ii = nextContentIndex; i < ii; i++) {
      if (!(this.contents[0].navigation.completed ?? false)) {
        break;
      }
      removeable++;
    }
    this.contents.splice(0, removeable);

    arrayRemove(this.coordinators, (coord => coord === coordinator));
    //   }
    // ) as Step<void>;
  }

  /**
   * Cancel the change of content. The next content is freed/discarded.
   *
   * @param step - The previous step in this transition Run
   */
  public cancelContentChange(coordinator: NavigationCoordinator, noExitStep: Step<void> | null = null): void | Step<void> {
    // First cancel content change in all children
    [...new Set(this.scope.children.map(scope => scope.endpoint))].forEach(child => child.cancelContentChange(coordinator, noExitStep));

    const nextContentIndex = this.contents.findIndex(content => content.navigation === coordinator.navigation);
    if (nextContentIndex < 0) {
      return;
    }

    const step = coordinator.getEndpointStep(this)?.current ?? null;
    const nextContent = this.contents[nextContentIndex];
    const previousContent = this.contents[nextContentIndex - 1];

    nextContent.instruction.cancelled = true;

    return Runner.run(step,
      (innerStep: Step<void>) => {
        return nextContent.freeContent(
          innerStep,
          this.connectedCE,
          nextContent.navigation,
          this.historyCache,
          this.router.statefulHistory || this.options.stateful);
      },
      () => {
        if (this.previousViewportState) {
          Object.assign(this, this.previousViewportState);
        }
        nextContent?.delete();
        if (nextContent !== null) {
          this.contents.splice(this.contents.indexOf(nextContent), 1);
        }
        this.transitionAction = '';

        previousContent?.contentStates.delete('checkedUnload');
        previousContent?.contentStates.delete('checkedLoad');

        const navigatingPrefix = this.router.configuration.options.indicators.viewportNavigating;
        this.connectedCE?.setActivity?.(navigatingPrefix, false);
        this.connectedCE?.setActivity?.(coordinator.navigation.navigation, false);

        coordinator.removeEndpoint(this);

        arrayRemove(this.coordinators, (coord => coord === coordinator));
      },
      () => {
        if (step !== noExitStep) {
          return step?.exit();
        }
      }
    ) as Step<void>;
  }

  /**
   * Whether the viewport wants a specific component. Used when
   * matching routing instructions to viewports.
   *
   * @param component - The component to check
   *
   * TODO: Deal with non-string components
   */
  public wantComponent(component: ComponentAppellation): boolean {
    return this.options.usedBy.includes(component as string);
  }

  /**
   * Whether the viewport accepts a specific component. Used when
   * matching routing instructions to viewports.
   *
   * @param component - The component to check
   *
   * TODO: Deal with non-string components
   */
  public acceptComponent(component: ComponentAppellation): boolean {
    if (component === '-' || component === null) {
      return true;
    }
    const usedBy = this.options.usedBy;
    if (usedBy.length === 0) {
      return true;
    }
    if (usedBy.includes(component as string)) {
      return true;
    }
    if (usedBy.filter((value) => value.includes('*')).length) {
      return true;
    }
    return false;
  }

  /**
   * Free/discard a history cached content containing a specific component.
   *
   * @param step - The previous step in this transition Run
   * @param component - The component to look for
   *
   * TODO: Deal with multiple contents containing the component
   */
  public freeContent(step: Step<void> | null, component: IRouteableComponent): void | Promise<void> | Step<void> {
    const content = this.historyCache.find(cached => cached.componentInstance === component);
    if (content !== void 0) {
      return Runner.run(step,
        (innerStep: Step<void>) => {
          this.forceRemove = true;
          return content.freeContent(
            innerStep,
            null,
            null,
            this.historyCache,
            false,
          );
        },
        () => {
          this.forceRemove = false;
          arrayRemove(this.historyCache, (cached => cached === content));
        },
      );
    }
  }

  /**
   * Get any configured routes in the relevant content's component type.
   */
  public getRoutes(): Route[] {
    const routes = [];
    let componentType = this.getComponentType();
    if (componentType != null) {
      componentType = componentType.constructor === componentType.constructor.constructor
        ? componentType
        : componentType.constructor as RouteableComponentType;

      routes.push(...(Routes.getConfiguration(componentType) ?? []));
    }
    return routes;
  }

  /**
   * Get the title for the content.
   *
   * @param navigation - The navigation that requests the content change
   */
  public getTitle(navigation: Navigation): string {
    if (this.options.noTitle) {
      return '';
    }
    const componentType = this.getComponentType();
    if (componentType === null) {
      return '';
    }
    let title = '';
    const typeTitle = componentType.title;
    if (typeTitle !== void 0) {
      if (typeof typeTitle === 'string') {
        title = typeTitle;
      } else {
        const component = this.getComponentInstance();
        title = typeTitle.call(component, component!, navigation);
      }
    } else if (this.router.configuration.options.title.useComponentNames) {
      let name = this.getContentInstruction()!.component.name ?? '';
      // TODO(alpha): Allow list of component prefixes
      const prefix = (this.router.configuration.options.title.componentPrefix ?? '') as string;
      if (name.startsWith(prefix)) {
        name = name.slice(prefix.length);
      }
      name = name.replace('-', ' ');
      title = name.slice(0, 1).toLocaleUpperCase() + name.slice(1);
    }
    return title;
  }

  /**
   * Get component type of the relevant, current or next, content.
   */
  private getComponentType(): RouteableComponentType | null {
    let componentType = this.getContentInstruction()!.component.type ?? null;
    if (componentType === null) {
      const controller = CustomElement.for(this.connectedCE!.element);
      componentType = (controller.container as
        IContainer & { componentType: RouteableComponentType })
        .componentType;
    }
    return componentType ?? null;
  }

  /**
   * Get component instance of the relevant, current or next, content.
   */
  private getComponentInstance(): IRouteableComponent | null {
    return this.getContentInstruction()!.component.instance ?? null;
  }

  /**
   * Get routing instruction of the relevant, current or next, content.
   */
  private getContentInstruction(): RoutingInstruction | null {
    return this.getNextContent()?.instruction ?? this.getContent().instruction ?? null;
  }

  /**
   * Clear the viewport state.
   *
   * TODO: Investigate the need.
   */
  private clearState(): void {
    this.options = ViewportOptions.create();

    const owningScope = this.owningScope;
    const hasScope = this.scope.hasScope;
    this.getContent().delete();
    this.contents.shift();

    if (this.contents.length < 1) {
      throw new Error('no content!');
    }

    this.contents.push(new ViewportContent(this.router, this, owningScope, hasScope));
    this.cache = [];
  }

  /**
   * If necessary, get a promise to await until a custom element connects.
   */
  private waitForConnected(): void | Promise<void> {
    if (this.connectedCE === null) {
      return new Promise((resolve) => {
        this.connectionResolve = resolve;
      });
    }
  }
}