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 theA
model. What's nice about this is that code calling forA
doesn't need to know about the specifics ofB
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.