aurelia/aurelia

View on GitHub
packages/runtime-html/src/templating/controller.ts

Summary

Maintainability
F
2 wks
Test Coverage
B
87%
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
import {
  AnyFunction,
  IIndexable,
  ILogger,
  InstanceProvider,
  LogLevel,
  noop,
  onResolve,
  onResolveAll,
  optional,
  optionalResource,
} from '@aurelia/kernel';
import { isObject } from '@aurelia/metadata';
import { IExpressionParser, IsBindingBehavior, AccessScopeExpression } from '@aurelia/expression-parser';
import {
  ICoercionConfiguration,
  IObserverLocator,
} from '@aurelia/runtime';
import { Scope } from '../binding/scope';
import { convertToRenderLocation, setRef } from '../dom';
import { IPlatform } from '../platform';
import { CustomAttributeDefinition, getAttributeDefinition } from '../resources/custom-attribute';
import { CustomElementDefinition, elementBaseName, findElementControllerFor, getElementDefinition, isElementType } from '../resources/custom-element';
import { etIsProperty, getOwnPropertyNames, isFunction, isPromise, isString, objectFreeze } from '../utilities';
import { createInterface, registerResolver } from '../utilities-di';
import { LifecycleHooks, LifecycleHooksEntry } from './lifecycle-hooks';
import { IRendering } from './rendering';
import { IShadowDOMGlobalStyles, IShadowDOMStyles } from './styles';
import { ComputedWatcher, ExpressionWatcher } from './watchers';

import type {
  Constructable,
  IContainer,
  IDisposable,
  IServiceLocator,
  ResourceDefinition,
  Writable,
} from '@aurelia/kernel';
import type {
  IObservable,
} from '@aurelia/runtime';
import type { INode, INodeSequence, IRenderLocation } from '../dom';
import { ErrorNames, createMappedError } from '../errors';
import type { IInstruction, AttrSyntax } from '@aurelia/template-compiler';
import type { PartialCustomElementDefinition } from '../resources/custom-element';
import type { IWatchDefinition, IWatcherCallback } from '../watch';
import type { LifecycleHooksLookup } from './lifecycle-hooks';
import type { IViewFactory } from './view';
import { IBinding } from '../binding/interfaces-bindings';

export class Controller<C extends IViewModel = IViewModel> implements IController<C> {

  public head: IHydratedController | null = null;
  public tail: IHydratedController | null = null;
  public next: IHydratedController | null = null;

  public parent: IHydratedController | null = null;
  public bindings: IBinding[] | null = null;
  public children: Controller[] | null = null;

  public hasLockedScope: boolean = false;

  public scope: Scope | null = null;
  public isBound: boolean = false;
  /** @internal */
  private _isBindingDone: boolean = false;

  // If a host from another custom element was passed in, then this will be the controller for that custom element (could be `au-viewport` for example).
  // In that case, this controller will create a new host node (with the definition's name) and use that as the target host for the nodes instead.
  // That host node is separately mounted to the host controller's original host node.
  public hostController: Controller | null = null;
  public mountTarget: MountTarget = targetNone;
  public shadowRoot: ShadowRoot | null = null;
  public nodes: INodeSequence | null = null;
  public location: IRenderLocation | null = null;

  /** @internal */
  public _lifecycleHooks: LifecycleHooksLookup<ICompileHooks & IActivationHooks<IHydratedController>> | null = null;
  public get lifecycleHooks(): LifecycleHooksLookup<ICompileHooks & IActivationHooks<IHydratedController>> | null {
    return this._lifecycleHooks;
  }

  public state: State = none;

  public get isActive(): boolean {
    return (this.state & (activating | activated)) > 0 && (this.state & deactivating) === 0;
  }

  public get name(): string {
    if (this.parent === null) {
      switch (this.vmKind) {
        case vmkCa:
          return `[${this.definition!.name}]`;
        case vmkCe:
          return this.definition!.name;
        case vmkSynth:
          return this.viewFactory!.name;
      }
    }
    switch (this.vmKind) {
      case vmkCa:
        return `${this.parent.name}>[${this.definition!.name}]`;
      case vmkCe:
        return `${this.parent.name}>${this.definition!.name}`;
      case vmkSynth:
        return this.viewFactory!.name === this.parent.definition?.name
          ? `${this.parent.name}[view]`
          : `${this.parent.name}[view:${this.viewFactory!.name}]`;
    }
  }

  /** @internal */
  private _compiledDef: CustomElementDefinition | undefined;
  private logger!: ILogger;
  private debug!: boolean;
  /** @internal */
  private _fullyNamed: boolean = false;
  /** @internal */
  private readonly _rendering: IRendering;

  /** @internal */
  public _vmHooks: HooksDefinition;

  /** @internal */
  public _vm: ControllerBindingContext<C> | null;
  public get viewModel(): ControllerBindingContext<C> | null {
    return this._vm;
  }
  public set viewModel(v: ControllerBindingContext<C> | null) {
    this._vm = v;
    this._vmHooks = v == null || this.vmKind === vmkSynth ? HooksDefinition.none : new HooksDefinition(v);
  }

  public coercion: ICoercionConfiguration | undefined;

  public constructor(
    public container: IContainer,
    public readonly vmKind: ViewModelKind,
    public readonly definition: CustomElementDefinition | CustomAttributeDefinition | null,
    /**
     * The viewFactory. Only present for synthetic views.
     */
    public viewFactory: IViewFactory | null,
    /**
     * The backing viewModel. Only present for custom attributes and elements.
     */
    viewModel: ControllerBindingContext<C> | null,
    /**
     * The physical host dom node.
     *
     * For containerless elements, this node will be removed from the DOM and replaced by a comment, which is assigned to the `location` property.
     *
     * For ShadowDOM elements, this will be the original declaring element, NOT the shadow root (the shadow root is stored on the `shadowRoot` property)
     */
    public host: HTMLElement | null,
    /**
     * The render location replacement for the host on containerless elements
     */
    location: IRenderLocation | null,
  ) {
    this._vm = viewModel;
    this._vmHooks = vmKind === vmkSynth ? HooksDefinition.none : new HooksDefinition(viewModel!);
    if (__DEV__) {
      this.logger = null!;
      this.debug = false;
    }
    this.location = location;
    this._rendering = container.root.get(IRendering);
    this.coercion = vmKind === vmkSynth
      ? void 0
      : container.get(optionalCoercionConfigResolver);
  }

  public static getCached<C extends ICustomElementViewModel = ICustomElementViewModel>(viewModel: C): ICustomElementController<C> | undefined {
    return controllerLookup.get(viewModel) as ICustomElementController<C> | undefined;
  }

  public static getCachedOrThrow<C extends ICustomElementViewModel = ICustomElementViewModel>(viewModel: C): ICustomElementController<C> {
    const $el = Controller.getCached(viewModel);
    if ($el === void 0) {
      throw createMappedError(ErrorNames.controller_cached_not_found, viewModel);
    }
    return $el as ICustomElementController<C>;
  }

  /**
   * Create a controller for a custom element based on a given set of parameters
   *
   * @param ctn - The own container of the custom element
   * @param viewModel - The view model object (can be any object if a definition is specified)
   *
   * Semi private API
   */
  public static $el<C extends ICustomElementViewModel = ICustomElementViewModel>(
    ctn: IContainer,
    viewModel: C,
    host: HTMLElement,
    hydrationInst: IControllerElementHydrationInstruction | null,
    // Use this when `instance.constructor` is not a custom element type
    // to pass on the CustomElement definition
    definition: CustomElementDefinition | undefined = void 0,
    // the associated render location of the host
    // if the element is containerless
    location: IRenderLocation | null = null,
  ): ICustomElementController<C> {
    if (controllerLookup.has(viewModel)) {
      return controllerLookup.get(viewModel) as unknown as ICustomElementController<C>;
    }

    if (__DEV__) {
      if (definition == null) {
        try {
          definition = getElementDefinition(viewModel.constructor as Constructable);
        } catch (ex) {
          // eslint-disable-next-line no-console
          console.error(`[DEV:aurelia] Custom element definition not found for creating a controller with host: <${host.nodeName} /> and component ${JSON.stringify(viewModel)}`);
          throw ex;
        }
      }
    } else {
      definition = definition ?? getElementDefinition(viewModel.constructor as Constructable);
    }

    registerResolver(ctn, definition.Type, new InstanceProvider<typeof definition.Type>(definition.key, viewModel, definition.Type));
    const controller = new Controller<C>(
      /* container      */ctn,
      /* vmKind         */vmkCe,
      /* definition     */definition,
      /* viewFactory    */null,
      /* viewModel      */viewModel as ControllerBindingContext<C>,
      /* host           */host,
      /* location       */location,
    );
    // the hydration context this controller is provided with
    const hydrationContext = ctn.get(optional(IHydrationContext)) as IHydrationContext;

    if (definition.dependencies.length > 0) {
      ctn.register(...definition.dependencies);
    }
    // each CE controller provides its own hydration context for its internal template
    registerResolver(ctn, IHydrationContext, new InstanceProvider(
      'IHydrationContext',
      new HydrationContext(
        controller as ICustomElementController,
        hydrationInst,
        hydrationContext,
      )
    ));
    controllerLookup.set(viewModel, controller as Controller);

    if (hydrationInst == null || hydrationInst.hydrate !== false) {
      controller._hydrateCustomElement(hydrationInst, hydrationContext);
    }

    return controller as ICustomElementController<C>;
  }

  /**
   * Create a controller for a custom attribute based on a given set of parameters
   *
   * @param ctn - own container associated with the custom attribute object
   * @param viewModel - the view model object
   * @param host - host element where this custom attribute is used
   * @param flags - todo(comment)
   * @param definition - the definition of the custom attribute,
   * will be used to override the definition associated with the view model object contructor if given
   */
  public static $attr<C extends ICustomAttributeViewModel = ICustomAttributeViewModel>(
    ctn: IContainer,
    viewModel: C,
    host: HTMLElement,
    /**
     * The definition that will be used to hydrate the custom attribute view model
     *
     * If not given, will be the one associated with the constructor of the attribute view model given.
     */
    definition?: CustomAttributeDefinition,
  ): ICustomAttributeController<C> {
    if (controllerLookup.has(viewModel)) {
      return controllerLookup.get(viewModel) as unknown as ICustomAttributeController<C>;
    }

    definition = definition ?? getAttributeDefinition(viewModel.constructor as Constructable);
    registerResolver(ctn, definition.Type, new InstanceProvider<typeof definition.Type>(definition.key, viewModel, definition.Type));

    const controller = new Controller<C>(
      /* own ct         */ctn,
      /* vmKind         */vmkCa,
      /* definition     */definition,
      /* viewFactory    */null,
      /* viewModel      */viewModel as ControllerBindingContext<C>,
      /* host           */host,
      /* location       */null
    );

    if (definition.dependencies.length > 0) {
      ctn.register(...definition.dependencies);
    }

    controllerLookup.set(viewModel, controller as Controller);

    controller._hydrateCustomAttribute();

    return controller as unknown as ICustomAttributeController<C>;
  }

  /**
   * Create a synthetic view (controller) for a given factory
   *
   * @param viewFactory - todo(comment)
   * @param flags - todo(comment)
   * @param parentController - the parent controller to connect the created view with. Used in activation
   *
   * Semi private API
   */
  public static $view(
    viewFactory: IViewFactory,
    parentController: ISyntheticView | ICustomElementController | ICustomAttributeController | undefined = void 0,
  ): ISyntheticView {
    const controller = new Controller(
      /* container      */viewFactory.container,
      /* vmKind         */vmkSynth,
      /* definition     */null,
      /* viewFactory    */viewFactory,
      /* viewModel      */null,
      /* host           */null,
      /* location       */null
    );
    controller.parent = parentController ?? null;

    controller._hydrateSynthetic();

    return controller as unknown as ISyntheticView;
  }

  /** @internal */
  public _hydrateCustomElement(
    hydrationInst: IControllerElementHydrationInstruction | null,
    /**
     * The context where this custom element is hydrated.
     *
     * This is the context controller creating this this controller
     */
    _hydrationContext: IHydrationContext | null,
  ): void {
    if (__DEV__) {
      this.logger = this.container.get(ILogger).root;
      this.debug = this.logger.config.level <= LogLevel.debug;
      if (this.debug) {
        this.logger = this.logger.scopeTo(this.name);
      }
    }

    const container = this.container;
    const instance = this._vm!;
    const definition = this.definition as CustomElementDefinition;

    this.scope = Scope.create(instance, null, true);

    if (definition.watches.length > 0) {
      createWatchers(this, container, definition, instance);
    }
    createObservers(this, definition, instance as IIndexable<ICustomElementViewModel>);

    this._lifecycleHooks = LifecycleHooks.resolve(container);
    // Support Recursive Components by adding self to own context
    container.register(definition.Type);
    // definition.register(container);

    if (definition.injectable !== null) {
      registerResolver(
        container,
        definition.injectable,
        new InstanceProvider('definition.injectable', instance as ICustomElementViewModel),
      );
    }

    // If this is the root controller, then the AppRoot will invoke things in the following order:
    // - Controller.hydrateCustomElement
    // - runAppTasks('hydrating') // may return a promise
    // - Controller.compile
    // - runAppTasks('hydrated') // may return a promise
    // - Controller.compileChildren
    // This keeps hydration synchronous while still allowing the composition root compile hooks to do async work.
    if (hydrationInst == null || hydrationInst.hydrate !== false) {
      this._hydrate();
      this._hydrateChildren();
    }
  }

  /** @internal */
  public _hydrate(): void {
    if (this._lifecycleHooks!.hydrating != null) {
      this._lifecycleHooks!.hydrating.forEach(callHydratingHook, this);
    }
    if (this._vmHooks._hydrating) {
      /* istanbul ignore next */
      if (__DEV__ && this.debug) { this.logger!.trace(`invoking hydrating() hook`); }
      this._vm!.hydrating(this as ICustomElementController);
    }

    const definition = this.definition!;
    const compiledDef = this._compiledDef = this._rendering.compile(definition as CustomElementDefinition, this.container);
    const shadowOptions = compiledDef.shadowOptions;
    const hasSlots = compiledDef.hasSlots;
    const containerless = compiledDef.containerless;
    let host = this.host!;
    let location: IRenderLocation | null = this.location;

    if ((this.hostController = findElementControllerFor(host, optionalCeFind) as Controller | null) !== null) {
      host = this.host = this.container.root.get(IPlatform).document.createElement(definition.name);
      if (containerless && location == null) {
        location = this.location = convertToRenderLocation(host);
      }
    }

    setRef(host, elementBaseName, this as IHydratedController);
    setRef(host, definition.key, this as IHydratedController);
    if (shadowOptions !== null || hasSlots) {
      if (location != null) {
        throw createMappedError(ErrorNames.controller_no_shadow_on_containerless);
      }
      setRef(this.shadowRoot = host.attachShadow(shadowOptions ?? defaultShadowOptions), elementBaseName, this as IHydratedController);
      setRef(this.shadowRoot, definition.key, this as IHydratedController);
      this.mountTarget = targetShadowRoot;
    } else if (location != null) {
      setRef(location, elementBaseName, this as IHydratedController);
      setRef(location, definition.key, this as IHydratedController);
      this.mountTarget = targetLocation;
    } else {
      this.mountTarget = targetHost;
    }

    (this._vm as Writable<C>).$controller = this;
    this.nodes = this._rendering.createNodes(compiledDef);

    if (this._lifecycleHooks!.hydrated !== void 0) {
      this._lifecycleHooks!.hydrated.forEach(callHydratedHook, this);
    }

    if (this._vmHooks._hydrated) {
      /* istanbul ignore next */
      if (__DEV__ && this.debug) { this.logger!.trace(`invoking hydrated() hook`); }
      this._vm!.hydrated(this as ICustomElementController);
    }
  }

  /** @internal */
  public _hydrateChildren(): void {
    this._rendering.render(
      /* controller */this as ICustomElementController,
      /* targets    */this.nodes!.findTargets(),
      /* definition */this._compiledDef!,
      /* host       */this.host,
    );

    if (this._lifecycleHooks!.created !== void 0) {
      this._lifecycleHooks!.created.forEach(callCreatedHook, this);
    }
    if (this._vmHooks._created) {
      /* istanbul ignore next */
      if (__DEV__ && this.debug) { this.logger!.trace(`invoking created() hook`); }
      this._vm!.created(this as ICustomElementController);
    }
  }

  /** @internal */
  private _hydrateCustomAttribute(): void {
    const definition = this.definition as CustomAttributeDefinition;
    const instance = this._vm!;

    if (definition.watches.length > 0) {
      createWatchers(this, this.container, definition, instance);
    }
    createObservers(this, definition, instance as unknown as IIndexable<ICustomAttributeViewModel>);

    (instance as Writable<C>).$controller = this;
    this._lifecycleHooks = LifecycleHooks.resolve(this.container);

    if (this._lifecycleHooks!.created !== void 0) {
      this._lifecycleHooks!.created.forEach(callCreatedHook, this);
    }
    if (this._vmHooks._created) {
      /* istanbul ignore next */
      if (__DEV__ && this.debug) { this.logger!.trace(`invoking created() hook`); }
      this._vm!.created(this as ICustomAttributeController);
    }
  }

  /** @internal */
  private _hydrateSynthetic(): void {
    this._compiledDef = this._rendering.compile(this.viewFactory!.def, this.container);
    this._rendering.render(
      /* controller */this as ISyntheticView,
      /* targets    */(this.nodes = this._rendering.createNodes(this._compiledDef)).findTargets(),
      /* definition */this._compiledDef,
      /* host       */void 0,
    );
  }

  private $initiator: IHydratedController = null!;
  public activate(
    initiator: IHydratedController,
    parent: IHydratedController | null,
    scope?: Scope | null,
  ): void | Promise<void> {
    switch (this.state) {
      case none:
      case deactivated:
        if (!(parent === null || parent.isActive)) {
          // If this is not the root, and the parent is either:
          // 1. Not activated, or activating children OR
          // 2. Deactivating itself
          // abort.
          return;
        }
        // Otherwise, proceed normally.
        // 'deactivated' and 'none' are treated the same because, from an activation perspective, they mean the same thing.
        this.state = activating;
        break;
      case activated:
        // If we're already activated, no need to do anything.
        return;
      case disposed:
        throw createMappedError(ErrorNames.controller_activating_disposed, this.name);
      default:
        throw createMappedError(ErrorNames.controller_activation_unexpected_state, this.name, stringifyState(this.state));
    }

    this.parent = parent;
    if (__DEV__ && this.debug && !this._fullyNamed) {
      this._fullyNamed = true;
      (this.logger ??= this.container.get(ILogger).root.scopeTo(this.name)).trace(`activate()`);
    }

    switch (this.vmKind) {
      case vmkCe:
        // Custom element scope is created and assigned during hydration
        (this.scope as Writable<Scope>).parent = scope ?? null;
        break;
      case vmkCa:
        this.scope = scope ?? null;
        break;
      case vmkSynth:
        // maybe only check when there's not already a scope
        if (scope === void 0 || scope === null) {
          throw createMappedError(ErrorNames.controller_activation_synthetic_no_scope, this.name);
        }

        if (!this.hasLockedScope) {
          this.scope = scope;
        }
        break;
    }

    this.$initiator = initiator;

    // opposing leave is called in attach() (which will trigger attached())
    this._enterActivating();

    let ret: void | Promise<void> = void 0;
    if (this.vmKind !== vmkSynth && this._lifecycleHooks!.binding != null) {
      /* istanbul ignore next */
      if (__DEV__ && this.debug) { this.logger!.trace(`lifecycleHooks.binding()`); }

      ret = onResolveAll(...this._lifecycleHooks!.binding!.map(callBindingHook, this));
    }

    if (this._vmHooks._binding) {
      /* istanbul ignore next */
      if (__DEV__ && this.debug) { this.logger!.trace(`binding()`); }

      ret = onResolveAll(ret, this._vm!.binding(this.$initiator, this.parent));
    }

    if (isPromise(ret)) {
      this._ensurePromise();
      ret.then(() => {
        this._isBindingDone = true;
        if (this.state !== activating) {
          // because controller can be deactivated, during a long running promise in the binding phase
          this._leaveActivating();
        } else {
          this.bind();
        }
      }).catch((err: Error) => {
        this._reject(err);
      });
      return this.$promise;
    }

    this._isBindingDone = true;
    this.bind();
    return this.$promise;
  }

  private bind(): void {
    /* istanbul ignore next */
    if (__DEV__ && this.debug) { this.logger!.trace(`bind()`); }

    let i = 0;
    let ii = 0;
    let ret: void | Promise<void> = void 0;

    if (this.bindings !== null) {
      i = 0;
      ii = this.bindings.length;
      while (ii > i) {
        this.bindings[i].bind(this.scope!);
        ++i;
      }
    }

    if (this.vmKind !== vmkSynth && this._lifecycleHooks!.bound != null) {
      /* istanbul ignore next */
      if (__DEV__ && this.debug) { this.logger!.trace(`lifecycleHooks.bound()`); }

      ret = onResolveAll(...this._lifecycleHooks!.bound.map(callBoundHook, this));
    }

    if (this._vmHooks._bound) {
      /* istanbul ignore next */
      if (__DEV__ && this.debug) { this.logger!.trace(`bound()`); }

      ret = onResolveAll(ret, this._vm!.bound(this.$initiator, this.parent));
    }

    if (isPromise(ret)) {
      this._ensurePromise();
      ret.then(() => {
        this.isBound = true;
        // because controller can be deactivated, during a long running promise in the bound phase
        if (this.state !== activating) {
          this._leaveActivating();
        } else {
          this._attach();
        }
      }).catch((err: Error) => {
        this._reject(err);
      });
      return;
    }

    this.isBound = true;
    this._attach();
  }

  /** @internal */
  private _append(...nodes: Node[]): void {
    switch (this.mountTarget) {
      case targetHost:
        this.host!.append(...nodes);
        break;
      case targetShadowRoot:
        this.shadowRoot!.append(...nodes);
        break;
      case targetLocation: {
        let i = 0;
        for (; i < nodes.length; ++i) {
          this.location!.parentNode!.insertBefore(nodes[i], this.location);
        }
        break;
      }
    }
  }

  /** @internal */
  private _attach(): void {
    /* istanbul ignore next */
    if (__DEV__ && this.debug) { this.logger!.trace(`attach()`); }

    if (this.hostController !== null) {
      switch (this.mountTarget) {
        case targetHost:
        case targetShadowRoot:
          this.hostController._append(this.host!);
          break;
        case targetLocation:
          this.hostController._append(this.location!.$start!, this.location!);
          break;
      }
    }

    switch (this.mountTarget) {
      case targetHost:
        this.nodes!.appendTo(this.host!, this.definition != null && (this.definition as CustomElementDefinition).enhance);
        break;
      case targetShadowRoot: {
        const container = this.container;
        const styles = container.has(IShadowDOMStyles, false)
          ? container.get(IShadowDOMStyles)
          : container.get(IShadowDOMGlobalStyles);
        styles.applyTo(this.shadowRoot!);
        this.nodes!.appendTo(this.shadowRoot!);
        break;
      }
      case targetLocation:
        this.nodes!.insertBefore(this.location!);
        break;
    }

    let i = 0;
    let ret: Promise<void> | void = void 0;

    if (this.vmKind !== vmkSynth && this._lifecycleHooks!.attaching != null) {
      /* istanbul ignore next */
      if (__DEV__ && this.debug) { this.logger!.trace(`lifecycleHooks.attaching()`); }

      ret = onResolveAll(...this._lifecycleHooks!.attaching!.map(callAttachingHook, this));
    }

    if (this._vmHooks._attaching) {
      /* istanbul ignore next */
      if (__DEV__ && this.debug) { this.logger!.trace(`attaching()`); }

      ret = onResolveAll(ret, this._vm!.attaching(this.$initiator, this.parent));
    }

    if (isPromise(ret)) {
      this._ensurePromise();
      this._enterActivating();
      ret.then(() => {
        this._leaveActivating();
      }).catch((err: Error) => {
        this._reject(err);
      });
    }

    // attaching() and child activation run in parallel, and attached() is called when both are finished
    if (this.children !== null) {
      for (; i < this.children.length; ++i) {
        // Any promises returned from child activation are cumulatively awaited before this.$promise resolves
        void this.children[i].activate(this.$initiator, this as IHydratedController, this.scope);
      }
    }

    // attached() is invoked by Controller#leaveActivating when `activatingStack` reaches 0
    this._leaveActivating();
  }

  public deactivate(
    initiator: IHydratedController,
    _parent: IHydratedController | null,
  ): void | Promise<void> {
    let prevActivation: void | Promise<void> = void 0;
    switch ((this.state & ~released)) {
      case activated:
        this.state = deactivating;
        break;
      case activating:
        this.state = deactivating;
        // we are about to deactivate, the error from activation can be ignored
        prevActivation = this.$promise?.catch(__DEV__
          /* istanbul-ignore-next */
          ? err => {
            this.logger.warn('The activation error will be ignored, as the controller is already scheduled for deactivation. The activation was rejected with: %s', err);
          }
          : noop);
        break;
      case none:
      case deactivated:
      case disposed:
      case deactivated | disposed:
        // If we're already deactivated (or even disposed), or never activated in the first place, no need to do anything.
        return;
      default:
        throw createMappedError(ErrorNames.controller_deactivation_unexpected_state, this.name, this.state);
    }

    /* istanbul-ignore-next */
    if (__DEV__ && this.debug) { this.logger!.trace(`deactivate()`); }

    this.$initiator = initiator;

    if (initiator === this) {
      this._enterDetaching();
    }

    let i = 0;
    let ret: void | Promise<void>;

    if (this.children !== null) {
      for (i = 0; i < this.children.length; ++i) {
        // Child promise results are tracked by enter/leave combo's
        void this.children[i].deactivate(initiator, this as IHydratedController);
      }
    }

    return onResolve(prevActivation, () => {
      if (this.isBound) {
        if (this.vmKind !== vmkSynth && this._lifecycleHooks!.detaching != null) {
          if (__DEV__ && this.debug) { this.logger!.trace(`lifecycleHooks.detaching()`); }

          ret = onResolveAll(...this._lifecycleHooks!.detaching.map(callDetachingHook, this));
        }

        if (this._vmHooks._detaching) {
          if (__DEV__ && this.debug) { this.logger!.trace(`detaching()`); }

          ret = onResolveAll(ret, this._vm!.detaching(this.$initiator, this.parent));
        }
      }

      if (isPromise(ret)) {
        this._ensurePromise();
        (initiator as Controller)._enterDetaching();
        ret.then(() => {
          (initiator as Controller)._leaveDetaching();
        }).catch((err: Error) => {
          (initiator as Controller)._reject(err);
        });
      }

      // Note: if a 3rd party plugin happens to do any async stuff in a template controller before calling deactivate on its view,
      // then the linking will become out of order.
      // For framework components, this shouldn't cause issues.
      // We can only prevent that by linking up after awaiting the detaching promise, which would add an extra tick + a fair bit of
      // overhead on this hot path, so it's (for now) a deliberate choice to not account for such situation.
      // Just leaving the note here so that we know to look here if a weird detaching-related timing issue is ever reported.
      if (initiator.head === null) {
        initiator.head = this as IHydratedController;
      } else {
        initiator.tail!.next = this as IHydratedController;
      }
      initiator.tail = this as IHydratedController;

      if (initiator !== this) {
        // Only detaching is called + the linked list is built when any controller that is not the initiator, is deactivated.
        // The rest is handled by the initiator.
        // This means that descendant controllers have to make sure to await the initiator's promise before doing any subsequent
        // controller api calls, or race conditions might occur.
        return;
      }

      this._leaveDetaching();
      return this.$promise;
    });
  }

  private removeNodes(): void {
    switch (this.vmKind) {
      case vmkCe:
      case vmkSynth:
        this.nodes!.remove();
        this.nodes!.unlink();
    }

    if (this.hostController !== null) {
      switch (this.mountTarget) {
        case targetHost:
        case targetShadowRoot:
          this.host!.remove();
          break;
        case targetLocation:
          this.location!.$start!.remove();
          this.location!.remove();
          break;
      }
    }
  }

  private unbind(): void {
    /* istanbul ignore next */
    if (__DEV__ && this.debug) { this.logger!.trace(`unbind()`); }

    let i = 0;

    if (this.bindings !== null) {
      for (; i < this.bindings.length; ++i) {
        this.bindings[i].unbind();
      }
    }

    this.parent = null;

    switch (this.vmKind) {
      case vmkCa:
        this.scope = null;
        break;
      case vmkSynth:
        if (!this.hasLockedScope) {
          this.scope = null;
        }

        if (
          (this.state & released) === released &&
          !this.viewFactory!.tryReturnToCache(this as ISyntheticView) &&
          this.$initiator === this
        ) {
          this.dispose();
        }
        break;
      case vmkCe:
        (this.scope as Writable<Scope>).parent = null;
        break;
    }

    this.state = deactivated;
    this.$initiator = null!;
    this._resolve();
  }

  private $resolve: (() => void) | undefined = void 0;
  private $reject: ((err: unknown) => void) | undefined = void 0;
  private $promise: Promise<void> | undefined = void 0;

  /** @internal */
  private _ensurePromise(): void {
    if (this.$promise === void 0) {
      this.$promise = new Promise((resolve, reject) => {
        this.$resolve = resolve;
        this.$reject = reject;
      });
      if (this.$initiator !== this) {
        (this.parent as Controller)._ensurePromise();
      }
    }
  }

  /** @internal */
  private _resolve(): void {
    if (this.$promise !== void 0) {
      _resolve = this.$resolve!;
      this.$resolve = this.$reject = this.$promise = void 0;
      _resolve();
      _resolve = void 0;
    }
  }

  /** @internal */
  private _reject(err: Error): void {
    if (this.$promise !== void 0) {
      _reject = this.$reject!;
      this.$resolve = this.$reject = this.$promise = void 0;
      _reject(err);
      _reject = void 0;
    }
    if (this.$initiator !== this) {
      (this.parent as Controller)._reject(err);
    }
  }

  /** @internal */
  private _activatingStack: number = 0;
  /** @internal */
  private _enterActivating(): void {
    ++this._activatingStack;
    if (this.$initiator !== this) {
      (this.parent as Controller)._enterActivating();
    }
  }
  /** @internal */
  private _leaveActivating(): void {
    if (this.state !== activating) {
      --this._activatingStack;
      // skip doing rest of the work if the controller is deactivated.
      this._resolve();
      if (this.$initiator !== this) {
        (this.parent as Controller)._leaveActivating();
      }
      return;
    }
    if (--this._activatingStack === 0) {
      if (this.vmKind !== vmkSynth && this._lifecycleHooks!.attached != null) {
        _retPromise = onResolveAll(...this._lifecycleHooks!.attached.map(callAttachedHook, this));
      }

      if (this._vmHooks._attached) {
        /* istanbul ignore next */
        if (__DEV__ && this.debug) { this.logger!.trace(`attached()`); }

        _retPromise = onResolveAll(_retPromise, this._vm!.attached!(this.$initiator));
      }

      if (isPromise(_retPromise)) {
        this._ensurePromise();
        _retPromise.then(() => {
          this.state = activated;
          // Resolve this.$promise, signaling that activation is done (path 1 of 2)
          this._resolve();
          if (this.$initiator !== this) {
            (this.parent as Controller)._leaveActivating();
          }
        }).catch((err: Error) => {
          this._reject(err);
        });
        _retPromise = void 0;
        return;
      }
      _retPromise = void 0;

      this.state = activated;
      // Resolve this.$promise (if present), signaling that activation is done (path 2 of 2)
      this._resolve();
    }
    if (this.$initiator !== this) {
      (this.parent as Controller)._leaveActivating();
    }
  }

  /** @internal */
  private _detachingStack: number = 0;
  /** @internal */
  private _enterDetaching(): void {
    ++this._detachingStack;
  }
  /** @internal */
  private _leaveDetaching(): void {
    if (--this._detachingStack === 0) {
      // Note: this controller is the initiator (detach is only ever called on the initiator)
      /* istanbul ignore next */
      if (__DEV__ && this.debug) { this.logger!.trace(`detach()`); }

      this._enterUnbinding();
      this.removeNodes();

      let cur = this.$initiator.head as Controller | null;
      let ret: void | Promise<void> = void 0;

      while (cur !== null) {
        if (cur !== this) {
          /* istanbul ignore next */
          if (cur.debug) { cur.logger!.trace(`detach()`); }

          cur.removeNodes();
        }

        if (cur._isBindingDone) {
          if (cur.vmKind !== vmkSynth && cur._lifecycleHooks!.unbinding != null) {
            ret = onResolveAll(...cur._lifecycleHooks!.unbinding.map(callUnbindingHook, cur));
          }

          if (cur._vmHooks._unbinding) {
            if (cur.debug) { cur.logger!.trace('unbinding()'); }

            ret = onResolveAll(ret, cur.viewModel!.unbinding(cur.$initiator, cur.parent));
          }
        }

        if (isPromise(ret)) {
          this._ensurePromise();
          this._enterUnbinding();
          ret.then(() => {
            this._leaveUnbinding();
          }).catch((err: Error) => {
            this._reject(err);
          });
        }

        ret = void 0;

        cur = cur.next as Controller;
      }

      this._leaveUnbinding();
    }
  }

  /** @internal */
  private _unbindingStack: number = 0;
  /** @internal */
  private _enterUnbinding(): void {
    ++this._unbindingStack;
  }
  /** @internal */
  private _leaveUnbinding(): void {
    if (--this._unbindingStack === 0) {
      /* istanbul ignore next */
      if (__DEV__ && this.debug) { this.logger!.trace(`unbind()`); }

      let cur = this.$initiator.head as Controller | null;
      let next: Controller | null = null;
      while (cur !== null) {
        if (cur !== this) {
          cur._isBindingDone = false;
          cur.isBound = false;
          cur.unbind();
        }
        next = cur.next as Controller;
        cur.next = null;
        cur = next;
      }

      this.head = this.tail = null;
      this._isBindingDone = false;
      this.isBound = false;
      this.unbind();
    }
  }

  public addBinding(binding: IBinding): void {
    if (this.bindings === null) {
      this.bindings = [binding];
    } else {
      this.bindings[this.bindings.length] = binding;
    }
  }

  public addChild(controller: Controller): void {
    if (this.children === null) {
      this.children = [controller];
    } else {
      this.children[this.children.length] = controller;
    }
  }

  public is(name: string): boolean {
    switch (this.vmKind) {
      case vmkCa:
      case vmkCe: {
        return (this.definition as ResourceDefinition).name === name;
      }
      case vmkSynth:
        return this.viewFactory!.name === name;
    }
  }

  public lockScope(scope: Writable<Scope>): void {
    this.scope = scope;
    this.hasLockedScope = true;
  }

  public setHost(host: HTMLElement): this {
    if (this.vmKind === vmkCe) {
      setRef(host, elementBaseName, this as IHydratedController);
      setRef(host, this.definition!.key, this as IHydratedController);
    }
    this.host = host;
    this.mountTarget = targetHost;
    return this;
  }

  public setShadowRoot(shadowRoot: ShadowRoot): this {
    if (this.vmKind === vmkCe) {
      setRef(shadowRoot, elementBaseName, this as IHydratedController);
      setRef(shadowRoot, this.definition!.key, this as IHydratedController);
    }
    this.shadowRoot = shadowRoot;
    this.mountTarget = targetShadowRoot;
    return this;
  }

  public setLocation(location: IRenderLocation): this {
    if (this.vmKind === vmkCe) {
      setRef(location, elementBaseName, this as IHydratedController);
      setRef(location, this.definition!.key, this as IHydratedController);
    }
    this.location = location;
    this.mountTarget = targetLocation;
    return this;
  }

  public release(): void {
    this.state |= released;
  }

  public dispose(): void {
    /* istanbul ignore next */
    if (__DEV__ && this.debug) { this.logger!.trace(`dispose()`); }

    if ((this.state & disposed) === disposed) {
      return;
    }
    this.state |= disposed;

    if (this._vmHooks._dispose) {
      this._vm!.dispose();
    }

    if (this.children !== null) {
      this.children.forEach(callDispose);
      this.children = null;
    }

    this.hostController = null;
    this.scope = null;

    this.nodes = null;
    this.location = null;

    this.viewFactory = null;
    if (this._vm !== null) {
      controllerLookup.delete(this._vm);
      this._vm = null;
    }
    this._vm = null;
    this.host = null;
    this.shadowRoot = null;
    this.container.disposeResolvers();
  }

  public accept(visitor: ControllerVisitor): void | true {
    if (visitor(this as IHydratedController) === true) {
      return true;
    }

    if (this._vmHooks._accept && this._vm!.accept(visitor) === true) {
      return true;
    }

    if (this.children !== null) {
      const { children } = this;
      for (let i = 0, ii = children.length; i < ii; ++i) {
        if (children[i].accept(visitor) === true) {
          return true;
        }
      }
    }
  }
}

const controllerLookup: WeakMap<object, Controller> = new WeakMap();

export type ControllerBindingContext<C extends IViewModel> = Required<ICompileHooks> & Required<IActivationHooks<IHydratedController | null>> & C;

const targetNone = 0;
const targetHost = 1;
const targetShadowRoot = 2;
const targetLocation = 3;

/**
 * Describes the type of the host node/location of a controller
 * - `none` / 1:       no host
 * - `host` / 2:       an HTML element is the host of a controller
 * - `shadowRoot` / 3: a shadow root is the host of a controller
 * - `location` / 4:   a render location is the location of a controller, this is often used for template controllers
 */
export const MountTarget = objectFreeze({
  none: targetNone,
  host: targetHost,
  shadowRoot: targetShadowRoot,
  location: targetLocation,
});
export type MountTarget = typeof MountTarget[keyof typeof MountTarget];

const optionalCeFind = { optional: true } as const;
const optionalCoercionConfigResolver = optionalResource(ICoercionConfiguration);

function createObservers(
  controller: Controller,
  definition: CustomElementDefinition | CustomAttributeDefinition,
  instance: IIndexable<ICustomElementViewModel | ICustomAttributeViewModel>,
): void {
  const bindables = definition.bindables;
  const observableNames = getOwnPropertyNames(bindables);
  const length = observableNames.length;
  const locator = controller.container.get(IObserverLocator);
  if (length > 0) {
    for (let i = 0; i < length; ++i) {
      const name = observableNames[i];
      const bindable = bindables[name];
      const handler = bindable.callback;
      const obs = locator.getObserver(instance, name);

      if (bindable.set !== noop) {
        if (obs.useCoercer?.(bindable.set, controller.coercion) !== true) {
          throw createMappedError(ErrorNames.controller_property_not_coercible, name);
        }
      }
      if (instance[handler] != null || instance.propertyChanged != null) {
        const callback = (newValue: unknown, oldValue: unknown) => {
          if (controller.isBound) {
            (instance[handler] as AnyFunction)?.(newValue, oldValue);
            instance.propertyChanged?.(name, newValue, oldValue);
          }
        };
        if (obs.useCallback?.(callback) !== true) {
          throw createMappedError(ErrorNames.controller_property_no_change_handler, name);
        }
      }
    }
  }
}

const AccessScopeAstMap = new Map<PropertyKey, AccessScopeExpression>();
const getAccessScopeAst = (key: PropertyKey) => {
  let ast = AccessScopeAstMap.get(key);
  if (ast == null) {
    ast = new AccessScopeExpression(key as string, 0);
    AccessScopeAstMap.set(key, ast);
  }
  return ast;
};

function createWatchers(
  controller: Controller,
  context: IServiceLocator,
  definition: CustomElementDefinition | CustomAttributeDefinition,
  instance: object,
) {
  const observerLocator = context!.get(IObserverLocator);
  const expressionParser = context.get(IExpressionParser);
  const watches = definition.watches;
  const scope: Scope = controller.vmKind === vmkCe
    ? controller.scope!
    // custom attribute does not have own scope
    : Scope.create(instance, null, true);
  const ii = watches.length;
  let expression: IWatchDefinition['expression'];
  let callback: IWatchDefinition['callback'];
  let ast: IsBindingBehavior;
  let i = 0;

  for (; ii > i; ++i) {
    ({ expression, callback } = watches[i]);
    callback = isFunction(callback)
      ? callback
      : Reflect.get(instance, callback) as IWatcherCallback<object>;
    if (!isFunction(callback)) {
      throw createMappedError(ErrorNames.controller_watch_invalid_callback, callback);
    }
    if (isFunction(expression)) {
      controller.addBinding(new ComputedWatcher(
        instance as IObservable,
        observerLocator,
        expression,
        callback,
        true,
      ));
    } else {
      ast = isString(expression)
        ? expressionParser.parse(expression, etIsProperty)
        : getAccessScopeAst(expression);

      controller.addBinding(new ExpressionWatcher(
        scope,
        context,
        observerLocator,
        ast,
        callback
      ) as unknown as IBinding);
    }
  }
}

export function isCustomElementController<C extends ICustomElementViewModel = ICustomElementViewModel>(value: unknown): value is ICustomElementController<C> {
  return value instanceof Controller && value.vmKind === vmkCe;
}

export function isCustomElementViewModel(value: unknown): value is ICustomElementViewModel {
  return isObject(value) && isElementType(value.constructor);
}

class HooksDefinition {
  public static readonly none: Readonly<HooksDefinition> = new HooksDefinition({});

  public readonly _define: boolean;

  public readonly _hydrating: boolean;
  public readonly _hydrated: boolean;
  public readonly _created: boolean;

  public readonly _binding: boolean;
  public readonly _bound: boolean;
  public readonly _attaching: boolean;
  public readonly _attached: boolean;

  public readonly _detaching: boolean;
  public readonly _unbinding: boolean;

  public readonly _dispose: boolean;
  public readonly _accept: boolean;

  public constructor(target: object) {
    this._define = 'define' in target;

    this._hydrating = 'hydrating' in target;
    this._hydrated = 'hydrated' in target;
    this._created = 'created' in target;

    this._binding = 'binding' in target;
    this._bound = 'bound' in target;
    this._attaching = 'attaching' in target;
    this._attached = 'attached' in target;

    this._detaching = 'detaching' in target;
    this._unbinding = 'unbinding' in target;

    this._dispose = 'dispose' in target;
    this._accept = 'accept' in target;
  }
}

const defaultShadowOptions = {
  mode: 'open' as 'open' | 'closed'
};

/** @internal */ export const vmkCe = 'customElement' as const;
/** @internal */ export const vmkCa = 'customAttribute' as const;
const vmkSynth = 'synthetic' as const;
export type ViewModelKind = typeof vmkCe | typeof vmkCa | typeof vmkSynth;

/**
 * A controller that is ready for activation. It can be `ISyntheticView`, `ICustomElementController` or `ICustomAttributeController`.
 *
 * In terms of specificity this is identical to `IController`. The only difference is that this
 * type is further initialized and thus has more properties and APIs available.
 */
export type IHydratedController = ISyntheticView | ICustomElementController | ICustomAttributeController;
/**
 * A controller that is ready for activation. It can be `ICustomElementController` or `ICustomAttributeController`.
 *
 * This type of controller is backed by a real component (hence the name) and therefore has ViewModel and may have lifecycle hooks.
 *
 * In contrast, `ISyntheticView` has neither a view model nor lifecycle hooks (but its child controllers, if any, may).
 */
export type IHydratedComponentController = ICustomElementController | ICustomAttributeController;
/**
 * A controller that is ready for activation. It can be `ISyntheticView` or `ICustomElementController`.
 *
 * This type of controller may have child controllers (hence the name) and bindings directly placed on it during hydration.
 *
 * In contrast, `ICustomAttributeController` has neither child controllers nor bindings directly placed on it (but the backing component may).
 *
 * Note: the parent of a `ISyntheticView` is always a `IHydratedComponentController` because views cannot directly own other views. Views may own components, and components may own views or components.
 */
export type IHydratedParentController = ISyntheticView | ICustomElementController;

/**
 * A callback that is invoked on each controller in the component tree.
 *
 * Return `true` to stop traversal.
 */
export type ControllerVisitor = (controller: IHydratedController) => void | true;

/**
 * The base type for all controller types.
 *
 * Every controller, regardless of their type and state, will have at least the properties/methods in this interface.
 */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export interface IController<C extends IViewModel = IViewModel> extends IDisposable {
  /**
   * The container associated with this controller.
   * By default, CE should have their own container while custom attribute & synthetic view
   * will use the parent container one, since they do not need to manage one
   */
  readonly name: string;
  readonly container: IContainer;
  readonly vmKind: ViewModelKind;
  readonly definition: CustomElementDefinition | CustomAttributeDefinition | null;
  readonly host: HTMLElement | null;
  readonly state: State;
  readonly isActive: boolean;
  readonly parent: IHydratedController | null;
  readonly isBound: boolean;
  readonly bindings: readonly IBinding[] | null;

  addBinding(binding: IBinding): void;

  /** @internal */head: IHydratedController | null;
  /** @internal */tail: IHydratedController | null;
  /** @internal */next: IHydratedController | null;

  /**
   * Return `true` to stop traversal.
   */
  accept(visitor: ControllerVisitor): void | true;
}

/**
 * The base type for `ICustomAttributeController` and `ICustomElementController`.
 *
 * Both of those types have the `viewModel` property which represent the user instance containing the bound properties and hooks for this component.
 */
export interface IComponentController<C extends IViewModel = IViewModel> extends IController<C> {
  readonly vmKind: 'customAttribute' | 'customElement';
  readonly definition: CustomElementDefinition | CustomAttributeDefinition;

  /**
   * The user instance containing the bound properties. This is always an instance of a class, which may either be user-defined, or generated by a view locator.
   */
  readonly viewModel: C;

  /**
   * Coercion configuration associated with a component (attribute/element) or an application
   */
  readonly coercion: ICoercionConfiguration | undefined;
}

/**
 * The base type for `ISyntheticView` and `ICustomElementController`.
 *
 * Both of those types can:
 * - Have `children` which are populated during hydration (hence, 'Hydratable').
 * - Have physical DOM nodes that can be mounted.
 */
export interface IHydratableController<C extends IViewModel = IViewModel> extends IController<C> {
  readonly vmKind: 'customElement' | 'synthetic';
  readonly mountTarget: MountTarget;
  readonly definition: CustomElementDefinition | null;

  readonly children: readonly IHydratedController[] | null;

  addChild(controller: IController): void;
}

/** @internal */ export const none         = 0b00_00_00;
/** @internal */ export const activating   = 0b00_00_01;
/** @internal */ export const activated    = 0b00_00_10;
/** @internal */ export const deactivating = 0b00_01_00;
/** @internal */ export const deactivated  = 0b00_10_00;
/** @internal */ export const released     = 0b01_00_00;
/** @internal */ export const disposed     = 0b10_00_00;

export const State = /*@__PURE__*/ objectFreeze({
  none,
  activating,
  activated,
  deactivating,
  deactivated,
  released,
  disposed,
});
export type State = typeof State[keyof typeof State];

export function stringifyState(state: State): string {
  const names: string[] = [];

  if ((state & activating) === activating) { names.push('activating'); }
  if ((state & activated) === activated) { names.push('activated'); }
  if ((state & deactivating) === deactivating) { names.push('deactivating'); }
  if ((state & deactivated) === deactivated) { names.push('deactivated'); }
  if ((state & released) === released) { names.push('released'); }
  if ((state & disposed) === disposed) { names.push('disposed'); }

  return names.length === 0 ? 'none' : names.join('|');
}

/**
 * The controller for a synthetic view, that is, a controller created by an `IViewFactory`.
 *
 * A synthetic view, typically created when composing a template controller (`if`, `repeat`, etc), is a hydratable component with mountable DOM nodes that has no user view model.
 *
 * It has either its own synthetic binding context or is locked to some externally sourced scope (in the case of `au-compose`)
 */
export interface ISyntheticView extends IHydratableController {
  readonly vmKind: 'synthetic';
  readonly definition: null;
  readonly viewModel: null;
  /**
   * The physical DOM nodes that will be appended during the attach operation.
   */
  readonly nodes: INodeSequence;

  activate(
    initiator: IHydratedController,
    parent: IHydratedController,
    scope: Scope,
  ): void | Promise<void>;
  deactivate(
    initiator: IHydratedController,
    parent: IHydratedController,
  ): void | Promise<void>;
  /**
   * Lock this view's scope to the provided `Scope`. The scope, which is normally set during `activate()`, will then not change anymore.
   *
   * This is used by `au-render` to set the binding context of a view to a particular component instance.
   *
   * @param scope - The scope to lock this view to.
   */
  lockScope(scope: Scope): void;
  /**
   * The scope that belongs to this view. This property will always be defined when the `state` property of this view indicates that the view is currently bound.
   *
   * The `scope` may be set during `activate()` and unset during `deactivate()`, or it may be statically set during composing with `lockScope()`.
   */
  readonly scope: Scope;

  /**
   * Set the render location that this view will be inserted before.
   */
  setLocation(location: IRenderLocation): this;
  /**
   * The DOM node that this view will be inserted before (if set).
   */
  readonly location: IRenderLocation | null;

  /**
   * Set the host that this view will be appended to.
   */
  setHost(host: Node & ParentNode): this;
  /**
   * The DOM node that this view will be appended to (if set).
   */
  readonly host: HTMLElement | null;

  /**
   * Set the `ShadowRoot` that this view will be appended to.
   */
  setShadowRoot(shadowRoot: ShadowRoot): this;
  /**
   * The ShadowRoot that this view will be appended to (if set).
   */
  readonly shadowRoot: ShadowRoot | null;

  /**
   * Mark this view as not-in-use, so that it can either be disposed or returned to cache after finishing the deactivate lifecycle.
   *
   * If this view is cached and later retrieved from the cache, it will be marked as in-use again before starting the activate lifecycle, so this method must be called each time.
   *
   * If this method is *not* called before `deactivate()`, this view will neither be cached nor disposed.
   */
  release(): void;
}

export interface ICustomAttributeController<C extends ICustomAttributeViewModel = ICustomAttributeViewModel> extends IComponentController<C> {
  readonly vmKind: 'customAttribute';
  readonly definition: CustomAttributeDefinition;
  /**
   * @inheritdoc
   */
  readonly viewModel: C;
  readonly lifecycleHooks: LifecycleHooksLookup;
  /**
   * The scope that belongs to this custom attribute. This property will always be defined when the `state` property of this view indicates that the view is currently bound.
   *
   * The `scope` will be set during `activate()` and unset during `deactivate()`.
   *
   * The scope's `bindingContext` will be the same instance as this controller's `viewModel` property.
   */
  readonly scope: Scope;
  readonly children: null;
  readonly bindings: null;
  activate(
    initiator: IHydratedController,
    parent: IHydratedController,
    scope: Scope,
  ): void | Promise<void>;
  deactivate(
    initiator: IHydratedController,
    parent: IHydratedController,
  ): void | Promise<void>;
}

/**
 * A representation of `IController` specific to a custom element whose `create` hook is about to be invoked (if present).
 *
 * It is not yet hydrated (hence 'dry') with any render-specific information.
 */
export interface IDryCustomElementController<C extends IViewModel = IViewModel> extends IComponentController<C>, IHydratableController<C> {
  readonly vmKind: 'customElement';
  readonly definition: CustomElementDefinition;
  /**
   * The scope that belongs to this custom element. This property is set immediately after the controller is created and is always guaranteed to be available.
   *
   * It may be overwritten by end user during the `create()` hook.
   *
   * By default, the scope's `bindingContext` will be the same instance as this controller's `viewModel` property.
   */
  scope: Scope;
  /**
   * The original host dom node.
   *
   * For containerless elements, this node will be removed from the DOM and replaced by a comment, which is assigned to the `location` property.
   *
   * For ShadowDOM elements, this will be the original declaring element, NOT the shadow root (the shadow root is stored on the `shadowRoot` property)
   */
  readonly host: HTMLElement;
}

/**
 * A representation of `IController` specific to a custom element whose `hydrating` hook is about to be invoked (if present).
 *
 * It has the same properties as `IDryCustomElementController`, as well as a render context (hence 'contextual').
 */
export interface IContextualCustomElementController<C extends IViewModel = IViewModel> extends IDryCustomElementController<C> {

}

/**
 * A representation of `IController` specific to a custom element whose `hydrated` hook is about to be invoked (if present).
 *
 * It has the same properties as `IContextualCustomElementController`, except the context is now compiled (hence 'compiled'), as well as the nodes, and projector.
 */
export interface ICompiledCustomElementController<C extends IViewModel = IViewModel> extends IContextualCustomElementController<C> {
  /**
   * The ShadowRoot, if this custom element uses ShadowDOM.
   */
  readonly shadowRoot: ShadowRoot | null;
  /**
   * The renderLocation, if this is a `containerless` custom element.
   */
  readonly location: IRenderLocation | null;
  /**
   * The physical DOM nodes that will be appended during the `mount()` operation.
   */
  readonly nodes: INodeSequence;
}

/**
 * A fully hydrated custom element controller.
 */
export interface ICustomElementController<C extends ICustomElementViewModel = ICustomElementViewModel> extends ICompiledCustomElementController<C> {
  /**
   * @inheritdoc
   */
  readonly viewModel: C;
  readonly lifecycleHooks: LifecycleHooksLookup;

  activate(
    initiator: IHydratedController,
    parent: IHydratedController | null,
    scope?: Scope,
  ): void | Promise<void>;
  deactivate(
    initiator: IHydratedController,
    parent: IHydratedController | null,
  ): void | Promise<void>;
}

export const IController = /*@__PURE__*/createInterface<IController>('IController');

export const IHydrationContext = /*@__PURE__*/createInterface<IHydrationContext>('IHydrationContext');
export interface IHydrationContext<T extends ICustomElementViewModel = ICustomElementViewModel> {
  readonly controller: ICustomElementController<T>;
  readonly instruction: IControllerElementHydrationInstruction | null;
  readonly parent: IHydrationContext | undefined;
}

/** @internal */
export class HydrationContext<T extends ICustomElementViewModel> implements IHydrationContext<T> {
  public readonly controller: ICustomElementController<T>;
  public constructor(
    controller: ICustomElementController,
    public readonly instruction: IControllerElementHydrationInstruction | null,
    public readonly parent: IHydrationContext | undefined,
  ) {
    this.controller = controller as ICustomElementController<T>;
  }
}

export interface IActivationHooks<TParent> {
  binding?(
    initiator: IHydratedController,
    parent: TParent,
  ): void | Promise<void>;
  bound?(
    initiator: IHydratedController,
    parent: TParent,
  ): void | Promise<void>;
  attaching?(
    initiator: IHydratedController,
    parent: TParent,
  ): void | Promise<void>;
  attached?(
    initiator: IHydratedController,
  ): void | Promise<void>;

  detaching?(
    initiator: IHydratedController,
    parent: TParent,
  ): void | Promise<void>;
  unbinding?(
    initiator: IHydratedController,
    parent: TParent,
  ): void | Promise<void>;

  dispose?(): void;
  /**
   * If this component controls the instantiation and lifecycles of one or more controllers,
   * implement this hook to enable component tree traversal for plugins that use it (such as the router).
   *
   * Return `true` to stop traversal.
   */
  accept?(visitor: ControllerVisitor): void | true;
}

export interface ICompileHooks {
  define?(
    controller: IDryCustomElementController<this>,
    /**
     * The context where this element is hydrated.
     *
     * This is created by the controller associated with the CE creating this this controller
     */
    hydrationContext: IHydrationContext | null,
    definition: CustomElementDefinition,
  ): PartialCustomElementDefinition | void;
  hydrating?(
    controller: IContextualCustomElementController<this>,
  ): void;
  hydrated?(
    controller: ICompiledCustomElementController<this>,
  ): void;
  created?(
    controller: ICustomElementController<this> | ICustomAttributeController<this>,
  ): void;
}

/**
 * Defines optional lifecycle hooks that will be called only when they are implemented.
 */
export interface IViewModel {
  // eslint-disable-next-line @typescript-eslint/ban-types
  constructor: Function;
  readonly $controller?: IController<this>;
}

export interface ICustomElementViewModel extends IViewModel, IActivationHooks<IHydratedController | null>, ICompileHooks {
  readonly $controller?: ICustomElementController<this>;
  created?(
    controller: ICustomElementController<this>,
  ): void;
  propertyChanged?(key: PropertyKey, newValue: unknown, oldValue: unknown): void;
}

export interface ICustomAttributeViewModel extends IViewModel, IActivationHooks<IHydratedController> {
  readonly $controller?: ICustomAttributeController<this>;
  link?(
    controller: IHydratableController,
    childController: ICustomAttributeController,
    target: INode,
    instruction: IInstruction,
  ): void;
  created?(
    controller: ICustomAttributeController<this>,
  ): void;
  propertyChanged?(key: PropertyKey, newValue: unknown, oldValue: unknown): void;
}

export interface IHydratedCustomElementViewModel extends ICustomElementViewModel {
  readonly $controller: ICustomElementController<this>;
}

export interface IHydratedCustomAttributeViewModel extends ICustomAttributeViewModel {
  readonly $controller: ICustomAttributeController<this>;
}

export interface IControllerElementHydrationInstruction {
  /**
   * An internal mechanism to manually halt + resume hydration process
   *
   * - 0: no hydration
   * - 1: hydrate until define() lifecycle
   *
   * @internal
   */
  readonly hydrate?: boolean;
  readonly projections: Record<string, PartialCustomElementDefinition> | null;
  /**
   * A list of captured attributes/binding in raw format
   */
  readonly captures?: AttrSyntax[];
  /**
   * Indicates whether the custom element was used with "containerless" attribute
   */
  readonly containerless?: boolean;
}

function callDispose(disposable: IDisposable): void {
  disposable.dispose();
}

function callCreatedHook(this: Controller, l: LifecycleHooksEntry<ICompileHooks, 'created'>) {
  l.instance.created(this._vm!, this as IHydratedComponentController);
}

function callHydratingHook(this: Controller, l: LifecycleHooksEntry<ICompileHooks, 'hydrating'>) {
  l.instance.hydrating(this._vm!, this as IContextualCustomElementController<ICompileHooks>);
}

function callHydratedHook(this: Controller, l: LifecycleHooksEntry<ICompileHooks, 'hydrated'>) {
  l.instance.hydrated(this._vm!, this as ICompiledCustomElementController<ICompileHooks>);
}

function callBindingHook(this: Controller, l: LifecycleHooksEntry<IActivationHooks<IHydratedController>, 'binding'>) {
  return l.instance.binding(this._vm!, this['$initiator'], this.parent!);
}

function callBoundHook(this: Controller, l: LifecycleHooksEntry<IActivationHooks<IHydratedController>, 'bound'>) {
  return l.instance.bound(this._vm!, this['$initiator'], this.parent!);
}

function callAttachingHook(this: Controller, l: LifecycleHooksEntry<IActivationHooks<IHydratedController>, 'attaching'>) {
  return l.instance.attaching(this._vm!, this['$initiator'], this.parent!);
}

function callAttachedHook(this: Controller, l: LifecycleHooksEntry<IActivationHooks<IHydratedController>, 'attached'>) {
  return l.instance.attached(this._vm!, this['$initiator']);
}

function callDetachingHook(this: Controller, l: LifecycleHooksEntry<IActivationHooks<IHydratedController>, 'detaching'>) {
  return l.instance.detaching(this._vm!, this['$initiator'], this.parent!);
}

function callUnbindingHook(this: Controller, l: LifecycleHooksEntry<IActivationHooks<IHydratedController>, 'unbinding'>) {
  return l.instance.unbinding(this._vm!, this['$initiator'], this.parent!);
}

// some reuseable variables to avoid creating nested blocks inside hot paths of controllers
let _resolve: undefined | (() => unknown);
let _reject: undefined | ((err: unknown) => unknown);
let _retPromise: void | Promise<void>;