aurelia/aurelia

View on GitHub
packages/router/src/routing-scope.ts

Summary

Maintainability
F
1 wk
Test Coverage
/* eslint-disable prefer-template */
/* eslint-disable max-lines-per-function */
import { NavigationCoordinator } from './navigation-coordinator';
import { IViewportScopeOptions, ViewportScope } from './endpoints/viewport-scope';
import { CustomElement, ICustomElementController, ICustomElementViewModel } from '@aurelia/runtime-html';
import { FoundRoute } from './found-route';
import { IRouter } from './router';
import { RoutingInstruction } from './instructions/routing-instruction';
import { Viewport } from './endpoints/viewport';
import { IViewportOptions } from './endpoints/viewport-options';
import { IConfigurableRoute, RouteRecognizer } from './route-recognizer';
import { Runner, Step } from './utilities/runner';
import { IRoute, Route } from './route';
import { Endpoint, EndpointTypeName, IConnectedCustomElement, IEndpoint } from './endpoints/endpoint';
import { EndpointMatcher } from './endpoint-matcher';
import { EndpointContent, Navigation, Router, RoutingHook, ViewportCustomElement } from './index';
import { IContainer } from '@aurelia/kernel';
import { arrayRemove, arrayUnique } from './utilities/utils';
import { Parameters } from './instructions/instruction-parameters';
import { Separators } from './router-options';

export type TransitionAction = 'skip' | 'reload' | 'swap' | '';

/**
 * The router uses routing scopes to organize all endpoints (viewports and viewport
 * scopes) into two hierarchical structures. Each routing scope belongs to a parent/child
 * hierarchy, that follows the DOM and is used when routing scopes are added and removed,
 * and an owner/owning hierarchy that's used when finding endpoints. Every routing scope
 * has a routing scope that owns it (except the root) and can in turn have several
 * routing scopes that it owns. A routing scope always has a connected endpoint content
 * and an endpoint content always has a connected routing scope.
 *
 * Every navigtion/load instruction that the router processes is first tied to a
 * routing scope, either a specified scope or the root scope. That routing scope is
 * then asked to
 * 1a) find routes (and their routing instructions) in the load instruction based on
 * the endpoint and its content (configured routes), and/or
 * 1b) find (direct) routing instructions in the load instruction.
 *
 * After that, the routing scope is used to
 * 2) match each of its routing instructions to an endpoint (viewport or viewport scope), and
 * 3) set the scope of the instruction to the next routing scopes ("children") and pass
 * the instructions on for matching in their new routing scopes.
 *
 * Once (component) transitions start in endpoints, the routing scopes assist by
 * 4) propagating routing hooks vertically through the hierarchy and disabling and
 * enabling endpoint contents and their routing data (routes) during transitions.
 *
 * Finally, when a navigation is complete, the routing scopes helps
 * 5) structure all existing routing instructions into a description of the complete
 * state of all the current endpoints and their contents.
 *
 * The hierarchy of the owner/owning routing scopes often follows the parent/child DOM
 * hierarchy, but it's not a necessity; it's possible to have routing scopes that doesn't
 * create their own "owning capable scope", and thus placing all their "children" under the
 * same "parent" as themselves or for a routing scope to hoist itself up or down in the
 * hierarchy and, for example, place itself as a "child" to a DOM sibling endpoint.
 * (Scope self-hoisting will not be available for early-on alpha.)
 */

export class RoutingScope {
  /** @internal */
  private static lastId = 0;

  public id = ++RoutingScope.lastId;

  /**
   * The parent of the routing scope (parent/child hierarchy)
   */
  public parent: RoutingScope | null = null;
  /**
   * The children of the routing scope (parent/child hierarchy)
   */
  public children: RoutingScope[] = [];

  public readonly router: IRouter;
  /**
   * Whether the routing scope has a scope and can own other scopes
   */
  public readonly hasScope: boolean;

  /**
   * The routing scope that owns this routing scope (owner/owning hierarchy)
   */
  public owningScope: RoutingScope | null;

  /**
   * The endpoint content the routing scope is connected to
   */
  public endpointContent: EndpointContent;

  public constructor(
    router: IRouter,
    /**
     * Whether the routing scope has a scope and can own other scopes
     */
    hasScope: boolean,

    /**
     * The routing scope that owns this routing scope (owner/owning hierarchy)
     */
    owningScope: RoutingScope | null,

    /**
     * The endpoint content the routing scope is connected to
     */
    endpointContent: EndpointContent,
  ) {
    this.router = router;
    this.hasScope = hasScope;
    this.owningScope = owningScope ?? this;
    this.endpointContent = endpointContent;
  }

  public static for(
    origin: Element | ICustomElementViewModel | Viewport | ViewportScope | RoutingScope | ICustomElementController | IContainer | null,
    instruction?: string
  ): { scope: RoutingScope | null; instruction: string | undefined } {

    if (origin == null) {
      return { scope: null, instruction };
    }
    if (origin instanceof RoutingScope || origin instanceof Viewport || origin instanceof ViewportScope) {
      return { scope: origin.scope, instruction };
    }
    // return this.getClosestScope(origin) || this.rootScope!.scope;
    let container: IContainer | null | undefined;

    // res is a private prop of IContainer impl
    // TODO: should use a different way to detect if something is a container
    // or move this to the bottom if this else-if
    if ('res' in origin) {
      container = origin as IContainer;
    } else {
      if ('container' in origin) {
        container = origin.container;
      } else if ('$controller' in origin) {
        container = origin.$controller!.container;
      } else {
        const controller = CustomElement.for(origin as Node, { searchParents: true });
        container = controller?.container;
      }
    }
    if (container == null) {
      if (__DEV__) {
        // eslint-disable-next-line no-console
        console.warn("RoutingScope failed to find a container for provided origin", origin);
      }
      return { scope: null, instruction };
    }
    const closestEndpoint = (container.has(Router.closestEndpointKey, true)
      ? container.get(Router.closestEndpointKey)
      : null) as Endpoint | null;

    let scope = closestEndpoint?.scope ?? null;

    if (scope === null || instruction === undefined) {
      const safeInstruction = instruction ?? '';
      return { scope, instruction: safeInstruction.startsWith('/') ? safeInstruction.slice(1) : instruction };
    }

    // Instruction specifies from root scope
    if (instruction.startsWith('/')) {
      return { scope: null, instruction: instruction.slice(1) };
    }
    // Instruction specifies scope traversals
    while (instruction.startsWith('.')) {
      // The same as no scope modification
      if (instruction.startsWith('./')) {
        instruction = instruction.slice(2);
      } else if (instruction.startsWith('../')) { // Traverse upwards
        scope = scope.parent ?? scope;
        instruction = instruction.slice(3);
      } else { // Bad traverse instruction
        break;
      }
    }
    // Testing without this since it seems to be removed
    // if (scope?.path != null) {
    //   instruction = `${scope.path}/${instruction}`;
    //   scope = null; // scope.root;
    // }
    return { scope, instruction };
  }

  /**
   * The routing scope children to this scope are added to. If this routing
   * scope has scope, this scope property equals this scope itself. If it
   * doesn't have scope this property equals the owning scope. Using this
   * ensures that routing scopes that don't have a their own scope aren't
   * part of the owner/owning hierarchy.
   */
  public get scope(): RoutingScope {
    return this.hasScope ? this : this.owningScope!.scope;
  }
  public get endpoint(): Endpoint {
    return this.endpointContent.endpoint;
  }
  public get isViewport(): boolean {
    return this.endpoint instanceof Viewport;
  }
  public get isViewportScope(): boolean {
    return this.endpoint instanceof ViewportScope;
  }

  public get type(): string {
    return this.isViewport ? 'Viewport' : 'ViewportScope';
  }

  public get enabled(): boolean {
    return this.endpointContent.isActive;
  }

  public get passThroughScope(): boolean {
    return this.isViewportScope && (this.endpoint as ViewportScope).passThroughScope;
  }

  public get pathname(): string {
    return `${this.owningScope !== this ? this.owningScope!.pathname : ''}/${this.endpoint.name}`;
  }

  public get path(): string {
    const parentPath = this.parent?.path ?? '';
    const path = this.routingInstruction?.stringify(this.router, false, true, true) ?? '';
    const sep = this.routingInstruction ? Separators.for(this.router).scope : '';
    return `${parentPath}${path}${sep}`;
  }

  public toString(recurse = false): string {
    return `${this.owningScope !== this ? this.owningScope!.toString() : ''}/${!this.enabled ? '(' : ''}${this.endpoint.toString()}#${this.id}${!this.enabled ? ')' : ''}` +
      `${recurse ? `\n` + this.children.map(child => child.toString(true)).join('') : ''}`;
  }

  public toStringOwning(recurse = false): string {
    return `${this.owningScope !== this ? this.owningScope!.toString() : ''}/${!this.enabled ? '(' : ''}${this.endpoint.toString()}#${this.id}${!this.enabled ? ')' : ''}` +
      `${recurse ? `\n` + this.ownedScopes.map(child => child.toStringOwning(true)).join('') : ''}`;
  }

  public get enabledChildren(): RoutingScope[] {
    return this.children.filter(scope => scope.enabled);
  }
  public get hoistedChildren(): RoutingScope[] {
    const scopes = this.enabledChildren;
    while (scopes.some(scope => scope.passThroughScope)) {
      for (const scope of scopes.slice()) {
        if (scope.passThroughScope) {
          const index = scopes.indexOf(scope);
          scopes.splice(index, 1, ...scope.enabledChildren);
        }
      }
    }
    return scopes;
  }
  public get ownedScopes(): RoutingScope[] {
    return this.getOwnedScopes();
  }

  public get routingInstruction(): RoutingInstruction | null {
    if (this.endpoint.isViewportScope) {
      return (this.endpoint as ViewportScope).instruction;
    }
    if (this.isViewport) {
      return (this.endpoint as Viewport).activeContent.instruction;
    }
    return null;
  }

  public getOwnedScopes(includeDisabled: boolean = false): RoutingScope[] {
    const scopes = this.allScopes(includeDisabled).filter(scope => scope.owningScope === this);
    // Hoist children to pass through scopes
    for (const scope of scopes.slice()) {
      if (scope.passThroughScope) {
        const index = scopes.indexOf(scope);
        scopes.splice(index, 1, ...scope.getOwnedScopes());
      }
    }
    return scopes;
  }

  public async processInstructions(instructions: RoutingInstruction[], earlierMatchedInstructions: RoutingInstruction[], navigation: Navigation, coordinator: NavigationCoordinator, configuredRoutePath = ''): Promise<Endpoint[]> {
    const router = this.router;
    const options = router.configuration.options;

    // If there are instructions that aren't part of an already found configured route...
    const nonRouteInstructions = instructions.filter(instruction => !(instruction.route instanceof Route));
    if (nonRouteInstructions.length > 0) {
      // ...find the routing instructions for them. The result will be either that there's a configured
      // route (which in turn contains routing instructions) or a list of routing instructions
      // TODO(return): This needs to be updated
      const foundRoute = this.findInstructions(nonRouteInstructions, options.useDirectRouting, options.useConfiguredRoutes);

      // Make sure we got routing instructions...
      if (nonRouteInstructions.some(instr => !instr.component.none || instr.route != null)
        && !foundRoute.foundConfiguration
        && !foundRoute.foundInstructions) {
        // ...call unknownRoute hook if we didn't...
        // TODO: Add unknownRoute hook here and put possible result in instructions
        throw this.createUnknownRouteError(nonRouteInstructions);
      }
      // ...and use any already found and the newly found routing instructions.
      instructions = [...instructions.filter(instruction => instruction.route instanceof Route), ...foundRoute.instructions];

      if (instructions.some(instr => instr.scope !== this)) {
        // eslint-disable-next-line no-console
        console.warn('Not the current scope for instruction(s)!', this, instructions);
      }

      // If it's a configured route...
      if (foundRoute.foundConfiguration) {
        // // ...trim leading slash and ...
        // navigation.path = (navigation.instruction as string).replace(/^\//, '');
        // ...store the matching route.
        configuredRoutePath = (configuredRoutePath ?? '') + foundRoute.matching;
      }
    }
    // TODO: Used to have an early exit if no instructions. Restore it?

    // If there are any unresolved components (functions or promises), resolve into components
    const unresolvedPromise = RoutingInstruction.resolve(instructions);
    if (unresolvedPromise instanceof Promise) {
      await unresolvedPromise;
    }

    // If router options defaults to navigations being full state navigation (containing the
    // complete set of routing instructions rather than just the ones that change), ensure
    // that there's an instruction to clear all non-specified viewports in the same scope as
    // the first routing instruction.
    // TODO: There should be a clear all instruction in all the scopes of the top instructions
    if (!options.additiveInstructionDefault) {
      instructions = this.ensureClearStateInstruction(instructions);
    }

    // Get all endpoints affected by any clear all routing instructions and then remove those
    // routing instructions.
    let clearEndpoints: Endpoint[] = [];
    ({ clearEndpoints, instructions } = this.getClearAllEndpoints(instructions));

    // Make sure "add all" instructions have the correct name and scope
    for (const addInstruction of instructions.filter(instr => instr.isAddAll(router))) {
      addInstruction.endpoint.set(addInstruction.scope!.endpoint.name);
      addInstruction.scope = addInstruction.scope!.owningScope!;
    }

    let allChangedEndpoints: IEndpoint[] = [];

    // Match the instructions to available endpoints within, and with the help of, their scope
    // TODO(return): This needs to be updated
    let { matchedInstructions, remainingInstructions } = this.matchEndpoints(instructions, earlierMatchedInstructions);
    let guard = 100;
    do {
      if (!guard--) { // Guard against endless loop
        router.unresolvedInstructionsError(navigation, remainingInstructions);
      }
      const changedEndpoints: IEndpoint[] = [];

      // Get all the endpoints of matched instructions...
      const matchedEndpoints = matchedInstructions.map(instr => instr.endpoint.instance);
      // ...and create and add clear instructions for all endpoints that
      // aren't already in an instruction.
      matchedInstructions.push(...clearEndpoints
        .filter(endpoint => !matchedEndpoints.includes(endpoint))
        .map(endpoint => RoutingInstruction.createClear(router, endpoint)));

      // TODO: Review whether this await poses a problem (it's currently necessary for new viewports to load)
      // eslint-disable-next-line no-await-in-loop
      const hooked = await RoutingHook.invokeBeforeNavigation(matchedInstructions, navigation);
      if (hooked === false) {
        router.cancelNavigation(navigation, coordinator);
        return [];
      } else if (hooked !== true && hooked !== matchedInstructions) {
        // TODO(return): Do a full findInstructions again with a new FoundRoute so that this
        // hook can return other values as well
        const skipped = RoutingInstruction.flat(matchedInstructions);
        remainingInstructions = remainingInstructions.filter(instr => !skipped.includes(instr));
        matchedInstructions = hooked;
      }

      for (const matchedInstruction of matchedInstructions) {
        const endpoint = matchedInstruction.endpoint.instance;
        if (endpoint !== null) {
          // Set endpoint path to the configured route path so that it knows it's part
          // of a configured route.
          // Inform endpoint of new content and retrieve the action it'll take
          const action = endpoint.setNextContent(matchedInstruction, navigation);
          if (action !== 'skip') {
            // Add endpoint to changed endpoints this iteration and to the coordinator's purview
            changedEndpoints.push(endpoint);
            coordinator.addEndpoint(endpoint);
          }
          // We're doing something, so don't clear this endpoint...
          const dontClear = [endpoint];
          if (action === 'swap') {
            // ...and none of it's _current_ children if we're swapping them out.
            dontClear.push(...endpoint.getContent().connectedScope.allScopes(true).map(scope => scope.endpoint));
          }
          // Exclude the endpoints to not clear from the ones to be cleared...
          arrayRemove(clearEndpoints, clear => dontClear.includes(clear));
          // ...as well as already matched clear instructions (but not itself).
          arrayRemove(matchedInstructions, matched => matched !== matchedInstruction
            && matched.isClear(router) && dontClear.includes(matched.endpoint.instance!));
          // And also exclude the routing instruction's parent viewport scope...
          if (!matchedInstruction.isClear(router) && matchedInstruction.scope?.parent?.isViewportScope) {
            // ...from clears...
            arrayRemove(clearEndpoints, clear => clear === matchedInstruction.scope!.parent!.endpoint);
            // ...and already matched clears.
            arrayRemove(matchedInstructions, matched => matched !== matchedInstruction
              && matched.isClear(router) && matched.endpoint.instance === matchedInstruction.scope!.parent!.endpoint);
          }
          // If the endpoint has been changed/swapped the next scope instructions
          // need to be moved into the new endpoint content scope and the endpoint
          // instance needs to be cleared
          if (action !== 'skip' && matchedInstruction.hasNextScopeInstructions) {
            for (const nextScopeInstruction of matchedInstruction.nextScopeInstructions!) {
              nextScopeInstruction.scope = endpoint.scope;
              nextScopeInstruction.endpoint.instance = null;
            }
          }
          // If the endpoint has not been changed/swapped and there are no next scope
          // instructions the endpoint's scope (its children) needs to be cleared
          if (action === 'skip' && !matchedInstruction.hasNextScopeInstructions) {
            // eslint-disable-next-line no-await-in-loop
            allChangedEndpoints.push(...(await endpoint.scope.processInstructions([], earlierMatchedInstructions, navigation, coordinator, configuredRoutePath)));
          }
        }
      }

      // In order to make sure all relevant canUnload are run on the first run iteration
      // we only run once all (top) instructions are doing something/there are no skip
      // action instructions.
      // If all first iteration instructions now do something the transitions can start
      const skipping = matchedInstructions.filter(instr => instr.endpoint.instance?.transitionAction === 'skip');
      const skippingWithMore = skipping.filter(instr => instr.hasNextScopeInstructions);
      if (skipping.length === 0 || (skippingWithMore.length === 0)) { // TODO: !!!!!!  && !foundRoute.hasRemaining)) {
        // If navigation is unrestricted (no other syncing done than on canUnload) we can tell
        // the navigation coordinator to instruct endpoints to transition
        if (!router.isRestrictedNavigation) {
          coordinator.finalEndpoint();
        }
        coordinator.run();

        // Wait for ("blocking") canUnload to finish
        if (coordinator.hasAllEndpoints) {
          const guardedUnload = coordinator.waitForSyncState('guardedUnload');
          if (guardedUnload instanceof Promise) {
            // eslint-disable-next-line no-await-in-loop
            await guardedUnload;
          }
        }
      }

      // If, for whatever reason, this navigation got cancelled, stop processing
      if (coordinator.cancelled) {
        router.cancelNavigation(navigation, coordinator);
        return [];
      }

      // Add this iteration's changed endpoints (inside the loop) to the total of all
      // updated endpoints (outside the loop)
      for (const changedEndpoint of changedEndpoints) {
        if (allChangedEndpoints.every(endpoint => endpoint !== changedEndpoint)) {
          allChangedEndpoints.push(changedEndpoint);
        }
      }

      // Make sure these endpoints in these instructions stays unavailable
      earlierMatchedInstructions.push(...matchedInstructions.splice(0));

      // Endpoints have now (possibly) been added or removed, so try and match
      // any remaining instructions
      if (remainingInstructions.length > 0) {
        ({ matchedInstructions, remainingInstructions } = this.matchEndpoints(remainingInstructions, earlierMatchedInstructions));
      }

      // If this isn't a restricted ("static") navigation everything will run as soon as possible
      // and then we need to wait for new viewports to be loaded before continuing here (but of
      // course only if we're running)
      // TODO: Use a better solution here (by checking and waiting for relevant viewports)
      if (!router.isRestrictedNavigation &&
        (matchedInstructions.length > 0 || remainingInstructions.length > 0) &&
        coordinator.running) {
        const waitForSwapped = coordinator.waitForSyncState('swapped');
        if (waitForSwapped instanceof Promise) {
          // eslint-disable-next-line no-await-in-loop
          await waitForSwapped;
        }
      }

      // Look for child routes (configured) and instructions (once we've loaded everything so far?)
      if (matchedInstructions.length === 0 && remainingInstructions.length === 0) {
        // Get child route (configured) and instructions (if any)
        const nextProcesses = [];
        for (const instruction of instructions) {
          if (!instruction.hasNextScopeInstructions) {
            continue;
          }
          const nextScope = instruction.endpoint.instance?.scope ?? instruction.endpoint.scope as RoutingScope;
          nextProcesses.push(nextScope.processInstructions(instruction.nextScopeInstructions!, earlierMatchedInstructions, navigation, coordinator, configuredRoutePath));
        }
        // eslint-disable-next-line no-await-in-loop
        allChangedEndpoints.push(...(await Promise.all(nextProcesses)).flat());
      }

      // // Don't add defaults when it's a full state navigation (since it's complete state)
      // if (navigation.useFullStateInstruction) {
      //   coordinator.appendedInstructions = coordinator.appendedInstructions.filter(instr => !instr.default);
      // }

      // Dequeue any instructions appended to the coordinator and add to either matched or
      // remaining. Default instructions aren't added if there's already a non-default
      ({ matchedInstructions, remainingInstructions } =
        coordinator.dequeueAppendedInstructions(matchedInstructions, earlierMatchedInstructions, remainingInstructions));

      // Once done with all explicit instructions...
      if (matchedInstructions.length === 0 && remainingInstructions.length === 0) {
        // ...check if we've got pending children (defaults that hasn't connected yet)...
        const pendingEndpoints = earlierMatchedInstructions
          .map(instr => (instr.endpoint.instance?.connectedCE as ViewportCustomElement).pendingPromise?.promise)
          .filter(promise => promise != null);
        // ...and await first one...
        if (pendingEndpoints.length > 0) {
          // eslint-disable-next-line no-await-in-loop
          await Promise.any(pendingEndpoints);
          // ...and dequeue them.
          ({ matchedInstructions, remainingInstructions } =
            coordinator.dequeueAppendedInstructions(matchedInstructions, earlierMatchedInstructions, remainingInstructions));
        } else {
          // ...or create the (remaining) implicit clear instructions (if any).
          matchedInstructions = clearEndpoints.map(endpoint => RoutingInstruction.createClear(router, endpoint));
        }
      }
      // If there are any unresolved components (functions or promises) to be appended, resolve them
      const unresolvedPromise = RoutingInstruction.resolve(matchedInstructions);
      if (unresolvedPromise instanceof Promise) {
        // eslint-disable-next-line no-await-in-loop
        await unresolvedPromise;
      }

      // Remove cancelled endpoints from changed endpoints (last instruction is cancelled)
      allChangedEndpoints = allChangedEndpoints.filter(endpoint => !([...earlierMatchedInstructions]
        .reverse()
        .find(instruction => instruction.endpoint.instance === endpoint)
        ?.cancelled ?? false)
      );
    } while (matchedInstructions.length > 0 || remainingInstructions.length > 0);

    return allChangedEndpoints;
  }

  /**
   * Deal with/throw an unknown route error.
   *
   * @param instructions - The failing instructions
   */
  private createUnknownRouteError(instructions: RoutingInstruction[]) {
    const options = this.router.configuration.options;
    const route = RoutingInstruction.stringify(this.router, instructions);
    // TODO: Add missing/unknown route handling
    //       shouldn't this check all routes, instead of only the first one?
    if (instructions[0].route != null) {
      if (!options.useConfiguredRoutes) {
        return new Error("Can not match '" + route + "' since the router is configured to not use configured routes.");
      } else {
        return new Error("No matching configured route found for '" + route + "'.");
      }
    } else if (options.useConfiguredRoutes && options.useDirectRouting) {
      return new Error("No matching configured route or component found for '" + route + "'.");
    } else if (options.useConfiguredRoutes) {
      return new Error("No matching configured route found for '" + route + "'.");
    } else {
      return new Error("No matching route/component found for '" + route + "'.");
    }
  }

  /**
   * Ensure that there's a clear all instruction present in instructions.
   */
  private ensureClearStateInstruction(instructions: RoutingInstruction[]): RoutingInstruction[] {
    const router = this.router;
    if (!instructions.some(instruction => instruction.isClearAll(router))) {
      const clearAll = RoutingInstruction.create(RoutingInstruction.clear(router)) as RoutingInstruction;
      clearAll.scope = this;
      return [clearAll, ...instructions];
    }
    return instructions;

  }

  /**
   * Get all endpoints affected by any clear all routing instructions and then remove those
   * routing instructions.
   *
   * @param instructions - The instructions to process
   */
  private getClearAllEndpoints(instructions: RoutingInstruction[]): { clearEndpoints: Endpoint[]; instructions: RoutingInstruction[] } {
    const router = this.router;
    let clearEndpoints: Endpoint[] = [];

    // If there's any clear all routing instruction...
    if (instructions.some(instruction => (instruction.scope ?? this) === this && instruction.isClearAll(router))) {
      // ...get all the endpoints to be cleared...
      clearEndpoints = this.enabledChildren  // TODO(alpha): Verfiy the need for rootScope check below
        .filter(scope => !scope.endpoint.isEmpty) // && scope !== this.router.rootScope?.connectedScope)
        .map(scope => scope.endpoint);
      // ...and remove the clear all instructions
      instructions = instructions.filter(instruction => !((instruction.scope ?? this) === this && instruction.isClearAll(router)));
    }
    return { clearEndpoints, instructions };
  }

  public findInstructions(instructions: RoutingInstruction[], useDirectRouting: boolean, useConfiguredRoutes: boolean): FoundRoute {
    const router = this.router;
    let route = new FoundRoute();

    if (useConfiguredRoutes && !RoutingInstruction.containsSiblings(router, instructions)) {
      let clearInstructions = instructions.filter(instruction => instruction.isClear(router) || instruction.isClearAll(router));
      const nonClearInstructions = instructions.filter(instruction => !instruction.isClear(router) && !instruction.isClearAll(router));

      // As long as the sibling constraint (above) is in, this will only be at most one instruction
      if (nonClearInstructions.length > 0) {
        for (const instruction of nonClearInstructions) {
          const idOrPath = typeof instruction.route === 'string'
            ? instruction.route
            : instruction.unparsed ?? RoutingInstruction.stringify(router, [instruction]);
          const foundRoute = this.findMatchingRoute(idOrPath, instruction.parameters.parametersRecord ?? {});
          if (foundRoute.foundConfiguration) {
            route = foundRoute!;
            route.instructions = [...clearInstructions, ...route.instructions];
            clearInstructions = [];
          } else if (useDirectRouting) {
            route.instructions = [...clearInstructions, ...route.instructions, instruction];
            clearInstructions = [];
            route.remaining = RoutingInstruction.stringify(router, instruction.nextScopeInstructions ?? []);
          } else {
            throw new Error(`No route found for: ${RoutingInstruction.stringify(router, instructions)}!`);
          }
        }
      } else {
        route.instructions = [...clearInstructions];
      }
    } else if (useDirectRouting) {
      route.instructions.push(...instructions);
    } else {
      throw new Error(`No way to process sibling viewport routes with direct routing disabled: ${RoutingInstruction.stringify(router, instructions)}!`);
    }

    // Remove empty instructions so that default can be used
    route.instructions = route.instructions.filter(instr => instr.component.name !== '');

    for (const instruction of route.instructions) {
      if (instruction.scope === null) {
        instruction.scope = this;
      }
    }

    return route;
  }

  // Note: This can't change state other than the instructions!
  /**
   * Match the instructions to available endpoints within, and with the help of, their scope.
   *
   * @param instructions - The instructions to matched
   * @param alreadyFound - The already found matches
   * @param disregardViewports - Whether viewports should be ignored when matching
   */
  public matchEndpoints(instructions: RoutingInstruction[], alreadyFound: RoutingInstruction[], disregardViewports: boolean = false): { matchedInstructions: RoutingInstruction[]; remainingInstructions: RoutingInstruction[] } {
    const allMatchedInstructions: RoutingInstruction[] = [];
    const scopeInstructions = instructions.filter(instruction => (instruction.scope ?? this) === this);
    const allRemainingInstructions = instructions.filter(instruction => (instruction.scope ?? this) !== this);

    const { matchedInstructions, remainingInstructions } = EndpointMatcher.matchEndpoints(this, scopeInstructions, alreadyFound, disregardViewports);
    allMatchedInstructions.push(...matchedInstructions);
    allRemainingInstructions.push(...remainingInstructions);

    return { matchedInstructions: allMatchedInstructions, remainingInstructions: allRemainingInstructions };
  }

  public addEndpoint(type: EndpointTypeName, name: string, connectedCE: IConnectedCustomElement | null, options: IViewportOptions | IViewportScopeOptions = {}): Viewport | ViewportScope {
    let endpoint: Endpoint | null = this.getOwnedScopes()
      .find(scope => scope.type === type &&
        scope.endpoint.name === name)?.endpoint ?? null;

    // Each endpoint element has its own Endpoint
    if (connectedCE != null && endpoint?.connectedCE != null && endpoint.connectedCE !== connectedCE) {
      endpoint = this.getOwnedScopes(true)
        .find(scope => scope.type === type &&
          scope.endpoint.name === name &&
          scope.endpoint.connectedCE === connectedCE)?.endpoint
        ?? null;
    }

    if (endpoint == null) {
      endpoint = type === 'Viewport'
        ? new Viewport(this.router, name, connectedCE, this.scope, !!(options as IViewportOptions).scope, options)
        : new ViewportScope(this.router, name, connectedCE, this.scope, true, null, options);
      this.addChild(endpoint.connectedScope);
    }
    if (connectedCE != null) {
      endpoint.setConnectedCE(connectedCE, options);
    }
    return endpoint as Viewport | ViewportScope;
  }

  public removeEndpoint(step: Step | null, endpoint: Endpoint, connectedCE: IConnectedCustomElement | null): boolean {
    if (((connectedCE ?? null) !== null) || endpoint.removeEndpoint(step, connectedCE)) {
      this.removeChild(endpoint.connectedScope);
      return true;
    }
    return false;
  }

  public addChild(scope: RoutingScope): void {
    if (!this.children.some(vp => vp === scope)) {
      if (scope.parent !== null) {
        scope.parent.removeChild(scope);
      }
      this.children.push(scope);
      scope.parent = this;
    }
  }
  public removeChild(scope: RoutingScope): void {
    const index = this.children.indexOf(scope);
    if (index >= 0) {
      this.children.splice(index, 1);
      scope.parent = null;
    }
  }

  public allScopes(includeDisabled: boolean = false): RoutingScope[] {
    const scopes: RoutingScope[] = includeDisabled ? this.children.slice() : this.enabledChildren;
    for (const scope of scopes.slice()) {
      scopes.push(...scope.allScopes(includeDisabled));
    }
    return scopes;
  }

  public reparentRoutingInstructions(): RoutingInstruction[] | null {
    const scopes = this.hoistedChildren
      .filter(scope => scope.routingInstruction !== null && scope.routingInstruction.component.name);
    if (!scopes.length) {
      return null;
    }
    for (const scope of scopes) {
      const childInstructions = scope.reparentRoutingInstructions();
      scope.routingInstruction!.nextScopeInstructions =
        childInstructions !== null && childInstructions.length > 0 ? childInstructions : null;
    }
    return scopes.map(scope => scope.routingInstruction!);
  }

  public getChildren(timestamp: number): RoutingScope[] {
    const contents = this.children
      .map(scope => scope.endpoint.getTimeContent(timestamp))
      .filter(content => content !== null) as EndpointContent[];
    return contents.map(content => content.connectedScope);
  }

  public getAllRoutingScopes(timestamp: number): RoutingScope[] {
    const scopes = this.getChildren(timestamp);
    for (const scope of scopes.slice()) {
      scopes.push(...scope.getAllRoutingScopes(timestamp));
    }
    return scopes;
  }

  public getOwnedRoutingScopes(timestamp: number): RoutingScope[] {
    const scopes = this.getAllRoutingScopes(timestamp)
      .filter(scope => scope.owningScope === this);
    // Hoist children to pass through scopes
    for (const scope of scopes.slice()) {
      if (scope.passThroughScope) {
        const passThrough = scopes.indexOf(scope);
        scopes.splice(passThrough, 1, ...scope.getOwnedRoutingScopes(timestamp));
      }
    }
    return arrayUnique(scopes);
  }

  public getRoutingInstructions(timestamp: number): RoutingInstruction[] | null {
    const contents = arrayUnique(
      this.getOwnedRoutingScopes(timestamp) // hoistedChildren
        .map(scope => scope.endpoint)
    )
      .map(endpoint => endpoint.getTimeContent(timestamp))
      .filter(content => content !== null) as EndpointContent[];
    const instructions = [];

    for (const content of contents) {
      const instruction = content.instruction.clone(true, false, false);
      if ((instruction.component.name ?? '') !== '') {
        instruction.nextScopeInstructions = content.connectedScope.getRoutingInstructions(timestamp);
        instructions.push(instruction);
      }
    }
    return instructions;
  }

  public canUnload(coordinator: NavigationCoordinator, step: Step<boolean> | null): boolean | Promise<boolean> {
    return Runner.run(step,
      (stepParallel: Step<boolean>) => {
        return Runner.runParallel(stepParallel,
          ...this.children.map(child => child.endpoint !== null
            ? (childStep: Step<boolean>) => child.endpoint.canUnload(coordinator, childStep)
            : (childStep: Step<boolean>) => child.canUnload(coordinator, childStep)
          ));
      },
      (step: Step<boolean>) => (step.previousValue as boolean[]).every(result => result)) as boolean | Promise<boolean>;
  }

  public unload(coordinator: NavigationCoordinator, step: Step<void> | null): Step<void> {
    return Runner.runParallel(step,
      ...this.children.map(child => child.endpoint !== null
        ? (childStep: Step<void>) => child.endpoint.unload(coordinator, childStep)
        : (childStep: Step<void>) => child.unload(coordinator, childStep)
      )) as Step<void>;
  }

  public matchScope(instructions: RoutingInstruction[], deep = false): RoutingInstruction[] {
    const matching: RoutingInstruction[] = [];

    for (const instruction of instructions) {
      if (instruction.scope === this) {
        matching.push(instruction);
      } else if (deep && instruction.hasNextScopeInstructions) {
        matching.push(...this.matchScope(instruction.nextScopeInstructions!, deep));
      }
    }
    return matching;
  }

  public findMatchingRoute(path: string, parameters: Parameters): FoundRoute {
    let found: FoundRoute = new FoundRoute();
    if (this.isViewportScope && !this.passThroughScope) {
      found = this.findMatchingRouteInRoutes(path, this.endpoint.getRoutes(), parameters);
    } else if (this.isViewport) {
      found = this.findMatchingRouteInRoutes(path, this.endpoint.getRoutes(), parameters);
    } else {
      for (const child of this.enabledChildren) {
        found = child.findMatchingRoute(path, parameters);
        if (found.foundConfiguration) {
          break;
        }
      }
    }

    if (found.foundConfiguration) {
      return found;
    }

    if (this.parent != null) {
      return this.parent.findMatchingRoute(path, parameters);
    }

    return found;
  }

  private findMatchingRouteInRoutes(path: string, routes: Route[], parameters: Parameters): FoundRoute {
    const found = new FoundRoute();
    if (routes.length === 0) {
      return found;
    }

    routes = routes.map(route => this.ensureProperRoute(route));

    const cRoutes: IConfigurableRoute[] = [];
    for (const route of routes) {
      const paths = (Array.isArray(route.path) ? route.path : [route.path]);
      for (const path of paths) {
        cRoutes.push({
          ...route,
          path,
          handler: route,
        });
        if (path !== '') {
          cRoutes.push({
            ...route,
            path: `${path}/*remainingPath`,
            handler: route,
          });
        }
      }
    }

    if (path.startsWith('/') || path.startsWith('+')) {
      path = path.slice(1);
    }

    const idRoute = routes.find(route => route.id === path);
    let result = { params: {}, endpoint: {} } as any;
    if (idRoute != null) {
      result.endpoint = { route: { handler: idRoute } };
      path = Array.isArray(idRoute.path) ? idRoute.path[0] : idRoute.path;
      const segments = path.split('/').map(segment => {
        if (segment.startsWith(':')) {
          const name = segment.slice(1).replace(/\?$/, '');
          const param = parameters[name];
          result.params[name] = param;
          return param;
        } else {
          return segment;
        }
      });
      path = segments.join('/');
    } else {
      const recognizer = new RouteRecognizer();

      recognizer.add(cRoutes);
      result = recognizer.recognize(path);
    }
    if (result != null) {
      found.match = result.endpoint.route.handler;
      found.matching = path;
      const $params = { ...result.params };
      if ($params.remainingPath != null) {
        found.remaining = $params.remainingPath;
        Reflect.deleteProperty($params, 'remainingPath');
        found.matching = found.matching.slice(0, found.matching.indexOf(found.remaining));
      }
      found.params = $params;
      if (found.match?.redirectTo != null) {
        let redirectedTo = found.match?.redirectTo;
        if ((found.remaining ?? '').length > 0) {
          redirectedTo += `/${found.remaining}`;
        }
        return this.findMatchingRouteInRoutes(redirectedTo, routes, parameters);
      }
    }
    if (found.foundConfiguration) {
      // clone it so config doesn't get modified
      found.instructions = RoutingInstruction.clone(found.match!.instructions as RoutingInstruction[], false, true);
      const instructions = found.instructions.slice();
      while (instructions.length > 0) {
        const instruction = instructions.shift()!;
        instruction.parameters.addParameters(found.params);
        instruction.route = found;
        if (instruction.hasNextScopeInstructions) {
          instructions.unshift(...instruction.nextScopeInstructions!);
        }
      }
      if (found.instructions.length > 0) {
        found.instructions[0].routeStart = true;
      }

      const remaining = RoutingInstruction.parse(this.router, found.remaining);
      if (remaining.length > 0) {
        let lastInstruction = found.instructions[0];
        while (lastInstruction.hasNextScopeInstructions) {
          lastInstruction = lastInstruction.nextScopeInstructions![0];
        }
        lastInstruction.nextScopeInstructions = remaining;
      }
    }
    return found;
  }

  private ensureProperRoute(route: IRoute): Route {
    if (route.id === void 0) {
      route.id = Array.isArray(route.path) ? route.path.join(',') : route.path;
    }
    if (route.instructions === void 0) {
      route.instructions = [{
        component: route.component!,
        viewport: route.viewport,
        parameters: route.parameters,
        children: route.children,
      }];
    }
    if (route.redirectTo === null) {
      route.instructions = RoutingInstruction.from(this.router, route.instructions!);
    }
    return route as Route;
  }
}