Core
Context

The Context Class

The Context class powers an ability to resolve and connect to controllers which are considered ambient for some other code. Mainly, it is exposed for use by adapters between MVC and another framework, such as @expressive/react. You will not normally use this class, but it can be helpful for building unit tests.

If shipping an adapter for MVC, it is recommended you do not re-export Context from @expressive/mvc, unlike Model. If your users really need this, they should import it directly.


How it works

A root Context is created using its constructor. Then on, it is built layer-by-layer, which each wrapping the same instance, via Object.create(this) internally.

Models and controllers (model instances) may be inputs to a given layer. If supplied a model, a controller is instantiated and used instead. As controllers are added, they are registered using their own Model types as keys.

However you resolve context, layers all share the same logic to fetch a controller from its associated Models. For a given context, the layer you have access to will contain all the controllers registered to that point.


Model Inheritance

When a controller is added, it is registered using its own and also inherited Model types. This means a controller can be found using any of it's subtypes. This enables, in a given context, code to seek a generic model and receive the nearest controller which satisfies it, however one which may have more info or features, specific to the situation.

class A extends Model {
  hello(){
    return 'hello world';
  }
}
 
class B extends A {
  name = "Bob";
 
  hello(){
    return `hello ${this.name}`;
  }
}
 
const context = new Context({ B });
 
context.get(A).hello(); // "hello Bob"

Here, the B controller is returned because it satisfies the A model. What's nice about this is that code calling for A doesn't need to know about the specifics of B in order to use it.


Implicit provision

Controllers, by default, are added automatically when direct children of one added to context. This makes composition easier, as separated concerns are accessible downstream, allowing for generic code to interact with a composed controller.

class B extends Model {
  hello = () => 'hello world';
}
 
class A extends Model {
  b = use(B);
}
 
const context = new Context({ A });
 
context.get(B).hello(); // "hello world"

Here, A will create a controller for B and is bound to it. If added to context, its child is also available to in context, so consumers can interact with B without needing to drill for it.


Conflicts

When inputs are added, they will often share one or Model type with one already added. This can cause issues because Models are the lookup keys. In this situation, the closest always wins on access.

However, a single layer can also have this problem. To account for this, the following rules apply when resolving one of multiple controllers defined by the same layer.

  • If there are multiple controllers, but only one is explicit, it is returned.
  • If multiple controllers are explicit, an error is thrown. You'll need a more specific key.
class Foo extends Model {}
class Bar extends Foo {}
 
const context = new Context({ Foo, Bar });
 
context.get(Bar) instanceof Bar; // true
context.get(Foo); // throws

API

Full Signature
declare namespace Context {
  type Inputs = {
    [key: string | number]: Model | Model.New;
  };
}
declare class Context {
  static get(from: Model, callback: (context: Context) => void): void;
  key: string;
  constructor(inputs?: Context.Inputs);
  protected table: WeakMap<Model.Type<Model>, symbol>;
  protected layer: Map<string | number, Model | Model.Type<Model>>;
  protected has(T: Model.Type): keyof this;
  protected add<T extends Model>(input: T | Model.New<T>, implicit?: boolean): T;
  get<T extends Model>(Type: Model.Type<T>): T | undefined;
  include(inputs: Context.Inputs): Map<Model, boolean>;
  push(): this;
  pop(): void;
}

Constructor

constructor(inputs?: Context.Inputs)

Create the root context, which may be further built upon. Optionally may be supplied with inputs.

type Inputs = {
  [key: string | number]: Model | Model.New
};

Inputs can be an object literal or array containing one or more controllers and/or models. If an object, the property names are arbitrary.


Context.get

static get(from: Model, callback: (context: Context) => void)

⚠️

This method always throws in vanilla @expressive/mvc - it is meant to be overridden by adapters. If you do not have one, you will need to define this behavior in order to resolve models.

This is a static method used by the get instruction, among other things, to locate a controller for a given model argument, relative to another.


context.push

public push(inputs?: Context.Inputs): Context

Add a new layer to the context. This creates a wrapper around the current layer, which may accept new inputs, allowing for multiple branches of context. Any inputs with keys found in an upstream layer will replace but only for that layer and below.

class A extends Model {}
 
const a = A.new();
const a2 = A.new();
 
const outer = new Context({ a });
const inner = outer.push({ a2 });
 
outer.get(A); // a1
inner.get(A); // a2

context.include

public include(inputs: Context.Inputs): Map<Model, boolean>

Define current layer with one or more inputs. Returned is a map of controllers made available. Also included are direct children of any controller added, recursively.

This method may be called multiple times per layer. Between invocations, if an input changes, the previous controller will be replaced with the new one. Property names, otherwise arbitrary, are used to detect where inputs have been replaced or removed.

class A extends Model {}
class B extends Model {}
class C extends Model {}
 
const context = new Context();
const c = C.new();
 
context.include({ A, B, c });
 
context.get(A) instanceof A; // true
context.get(B) instanceof B; // true
context.get(C) === c; // true
 
context.include({ A, B, c: C.new() });
 
context.get(C) === c // false

context.add

protected add<T extends Model>(input: T | Model.New<T>, implicit?: boolean): Model

Add a model to the current layer. If the input is a Constructor, it will be instantiated and added. If it is an instance, it will be made available directly.

The implicit flag is used to indicate that a controller is added automatically instead of by the user. This prevents a collision from throwing on lookup from that layer, as explicit inputs should always win.

It is recommended to use include is used instead. This method will not handle collisions, replace controllers or register children.


context.pop

public pop(): void

Destroy the current layer. This will garbage collect all controllers created for that layer (where Model class was input) by calling null event. Controllers which were added as instances will not be affected.