Model Layer
Understand MobX
TerriaJS's model layer is based on MobX, so it is essential to understand MobX. In particular, the MobX Concepts and Principles is a short and enlightening read.
It is also helpful to understand what MobX reacts to. We recommend giving it a read before you start working on TerriaJS's model layer, and then read it again any time MobX isn't behaving the way you expect it to.
Types of classes in the Model layer
- Traits: Define the configurable properties of a each stratum of a model object. Traits classes have no logic or behavior, only properties. Note that traits classes are often built by mixing in several sets of traits. Example:
WebMapServiceCatalogItemTraits
. - Models: Concrete, usable model objects representing things like map items and groups in the catalog. They are composed of mix-ins plus some model-specific computed properties and logic. Example:
WebMapServiceCatalogItem
. - Mixins: Provide computed properties and behavior intended to be mixed into model objects to add capabilities to them. Example:
GetCapabilitiesMixin
. - Load Strata: (singular: Load Stratum) Strata that provide values for a subset of the properties defined on model's traits class, usually by loading them from an external source such as WMS GetCapabilities service. Example:
GetCapabilitiesStratum
.
Reactivity
The TerriaJS model layer generally looks and feels - at least on the surface - like a traditional object-oriented design. An item or group in the catalog, for example, is an instance of a class, such as WebMapServiceCatalogItem
. Model objects are mutable, meaning we can modify them in-place without the need to copy them first or anything of that sort. This is different from the approach used in Redux, which requires that the application's state be represented with immutable objects and that transitions to new states happen in a very controlled fashion.
Unlike most OO applications, though, the TerriaJS model layer is reactive. Virtually all properties in the model layer are declared @observable
. An observable property is just like a normal property; you can get and set its value as normal. On top of that, however, an observable property raises an event when it changes.
Many classes also define @computed
properties. Computed properties have a getter and optionally a setter. The getter is invoked once the first time the property is accessed and the value is memoized (cached). Further accesses of the property will get the memoized value.
The interesting part is that MobX automatically keeps track of the set of observables (including other computed observables) that were accessed during the execution of the getter. If any of those properties change, the computed observable's getter will be invoked again and the new value memoized. This new invocation may access different observable properties than it did the first time around, and it is those new properties that will trigger any future re-evaluations of the computed property. We can subscribe to change notification on computed properties in the same way we subscribe to regular observable properties.
Rather than directly subscribing to property changes, MobX applications typically use reactions. A reaction, such as one created with the autorun
function, is a bit of code that, when it runs, accesses some observable and computed properties and does something, such as modifying the non-reactive Cesium and Leaflet mapping layers. If any of the properties accessed change in the future, the reaction will run again.
Avoid reactions and other side effects
A reaction, as described above, is a type of side-effect. When a property changes, "something" happens. That "something" may be surprising to whoever wrote the code that modified the property, and so this is an extremely common source of bugs.
A good rule of thumb is this: all properties should either be simple data properties (i.e. no getter or setter), or they should have only a getter and that getter should be a pure function of other properties. A pure function is one that can be called any number of times with no observable side effects, and that returns the same value for the same inputs every time it is called.
Computed properties with setters should be avoided. In our old architecture, we frequently used computed properties with a setter to model properties that are configurable by the user, but that have a default value computed from other properties when there is no user-specified value. In the current architecture, this is better modeled by:
- Defining the configurable property on the
Traits
class. - Defining a computed property on the derived Model class that accesses
super.propertyName
(if mixins should be allowed to define the property) orthis.flattened.propertyName
(if only a directly-configured property value should be used) and, if that is undefined, computes and returns the default value from other properties.
If a property does have a setter, it should obey these laws:
- You get back what you put in: If you set the value of the property and then get it, you should get back the same value you set.
- Putting back what you got doesn't change anything: If you get property's value, and then set it with that same value, it doesn't change anything.
- Setting twice is the same as setting once: There should be no observable effect from setting a property to the same value twice.
Note: These are the Lens Laws from functional programming.
We need to be more precise about what we mean by "same value" in the laws above. For primitive types (strings, numbers, booleans, null, and undefined), "the same" means that comparing the values with ===
returns true
. ===
should also return true
for references to model objects and other mutable, reactive objects. Arrays should be MobX reactive arrays where each element in the array follows the rules stated here.
For other types of objects, such as Cesium's mutable JulianDate
, Cartographic
, and Cartesian3
types, "same value" means that the instances should conceptually represent the same value (e.g. using the equals
function on the two instances should return true), but the instance may be different. In particular, properties of these types should declare their type as Readonly<JulianDate>
, Readonly<Cartographic>
, or Readonly<Cartesian3>
. The objects should be copied on set. Returning a frozen object (e.g. Object.freeze
) from get may help to prevent bugs, particular if some clients of the property are expected to not be using TypeScript.
Scenarios for avoiding side effects
Value comes from loading metadata (e.g. GetCapabilities), but users should be able to override the loaded value by specifying it explicitly in the catalog file or UI.
Create a load stratum for the values loaded from metadata. Example: the rectangle
property on WebMapServiceCatalogItem
's GetCapabilitiesStratum
.
Value can be specified in the catalog file or UI, but if it's not, use a default.
The default can be applied by overriding the property in a mixin or model class, accessing super.propertyName
, and returning the default value if it is undefined. The default value may be computed from other properties if desired. Example: legendUrls
property of WebMapServiceCatalogItem
.
Single source of truth
Another good rule of thumb is that there should always be a single source of truth for any piece of information. We run into trouble when the same information is stored in multiple places, even if it's stored in slightly different ways. When one is updated, we'll either need to remember to update the other, or we'll need to set up a reaction or other side-effect to automatically keep the other in sync.
A much less error-prone approach is to make one of the two places the information is needed a @computed
property, computed from the other one. This is usually pretty straightforward, but it gets more difficult when one of the two places is part of Cesium, Leaflet, D3, or some other non-reactive part of our application. The solution here is to adapt it to be reactive. There are two broad approaches for this, depending on the direction of information flow:
MobX observable is the source of truth, a non-reactive part of the application must be kept in sync
This is perhaps the only place it is justified to use a MobX autorun
. The autorun
should scoop up all the information it needs from the reactive world and push it into Cesium, Leaflet, D3, etc. This should be done as close to the non-reactive world as possible, and it must consider performance. For example, an easy way to keep Cesium's list of imagery layers in sync with the TerriaJS workbench is to create an autorun
that removes all the layers and then re-adds the imagery layers for all catalog items on the workbench. This would "work", but even small changes would cause the globe's imagery layers to vanish and then reload.
Instead, Cesium's current state should be modified to match the desired state derived from the workbench. A useful illustration of an extreme version of this principle can be found in how React interacts with the browser DOM. React components produce a desired state of the DOM each time their render
method is called. The magic of React is that it efficiently updates the DOM to match that desired state. Fortunately, we are rarely trying to update state that is as complicated as the browser DOM, so we don't need to use techniques as sophisticated as React's. The result, though, should be similar.
Non-reactive property is the source of truth, reactive (MobX) world must be kept in sync
MobX provides Atoms, which can be used to create a reactive view of non-reactive properties. With this we could, for example, create a property that retrieves the current Cesium clock time and that will notify any interested parties when that time changes. Behind the scenes, we use an Atom and a subscription to Cesium's clock tick event to trigger reactions when the clock time changes.
In this scenario, consider whether you could instead let the reactive world take ownership of this property and be the source of truth. Events in the non-reactive world may still trigger changes in this property's value, but all clients - including the non-reactive ones - would query the reactive property whenever they need this piece of information.
Put as much logic as possible in the model layer
We want to keep the UI as small and simple as possible, because:
- The UI is the hardest part of TerriaJS to test with automated tests. If it has lots of complicated logic, we will need to spend a lot of time writing tests for it to ensure that it behaves correctly. A very simple UI is easier to test, and is perhaps so simple that automated tests for it are not needed at all.
- We would like everything that can be done by a user interacting with the UI to be doable programmatically as well. If the UI encodes complex rules or has its own states, this may not be possible, or doing so may require duplicating complicated "business logic" that already exists in the UI.
Therefore, whenever possible, TerriaJS logic should be in the Model layer instead of in the UI. The UI should be a pure function from Model state to React components, and actions that simply execute functions or change a small number of properties in the Model layer.
Evaluate observable properties as late as possible. In particular, avoid getting the value of an observable before starting an async operation and using it when it completes.
Defining Properties
A few notes on defining properties in Model classes:
- Set only traits: All settable properties of a Model should be in its Traits. Because Traits are the only properties that are serialized/deserialized for catalog configuration and for sharing, settable properties that are not part of Traits prevent us from being able to completely recover application state. The only exception to this rule is for highly transient properties, such as whether a load from a remote source is currently in progress.
- Covariance: If you override a gettable property in a derived class, its type must be covariant with the base class type. That is, it is fine of the derived class property returns
string
while the base class property returnsstring | undefined
. And it is fine if the derived class returnsDog
while the base class returnsAnimal
. But it is not ok if this relationship is reversed. You shouldn't really have any settable properties, but if you do, the types of such properties must be identical in base and derived classes. - Equals: Pay attention to the comparer/equals to use with observables to determine if a new value is equal to an old one. The default
equals
is usually fine for primitive types (e.g. string, number, boolean), observable arrays, and objects whose properties are themselves observable (e.g. Traits). But for other types, especially Cesium types like JulianDate, Cartographic, and Cartesian3, it is essential to specify anequals
. Typically this looks like this:@computed({ equals: JulianDate.equals })
.
Traits
Creating new Traits
Correct:
export class SomeNewTraits extends ModelTraits {
...
}
Extending existing Traits
classes
Never extend existing Traits
classes directly - instead use the mixTraits()
function.
For example
Correct:
export class SomeExtendingTraits extends mixTraits(DimensionTraits) {
...
}
Incorrect:
export class SomeExtendingTraits extends DimensionTraits {
...
}
If Traits
are extended directly, trait properties may leak into the super class. For more details see traits in depth.
Time
Time-varying Models have their own currentTime
, startTime
, stopTime
, etc. properties. The currentTime
is the property that the dataset should use to determine what to display (i.e. what time to show).
Datasets that are synchronized to the timeline clock are listed in terria.timelineStack
.
Note: In our old architecture there was a `useOwnClock` property, which no longer exists in the new architecture. Instead, datasets that would have been `useOwnClock=false` should now be added to the `timelineStack`.
On tick of the timeline clock, the TimelineStack
will the current time and paused state to all datasets it contains. It will copy changes to startTime
, stopTime
, and multiplier
to the "active" (top) dataset in the timeline stack.
When a dataset becomes the top of the timeline stack, or the top dataset's time-related properties change, the currentTime
, startTime
, stopTime
, and multiplier
properties are copied to the timeline's clock.
ReferenceMixin
ReferenceMixin
is used to create models that are references to other models. For example, a MagdaReference
is a model that points to a particular record in a Magda catalog. The type of catalog item we need to use to access it is not known until we load that record from the Magda registry and see what kind of record it is (e.g. a WMS, GeoJSON, etc.). In fact, it may not even be a catalog item, it might be a group.
Our roughly object-oriented approach makes it difficult for a MagdaReference
to become another type after the Magda registry is read. But even if that were possible, we need to preserve access to the original traits and behavior that told us how to access the registry, too. For one thing, we'll need this to reload from the registry later.
So, instead of trying to make a model change type upon load, ReferenceMixin
creates/references a separate model, called the target
, that is not resolved until the reference is loaded.
There are two types of references
- Strong references
- Weak references
Strong references
These are a one-to-one mapping from a reference to it's target. The target can only have one reference, and it is aware of it's reference (through the sourceReference
property).
Some rules
- The target model of a
ReferenceMixin
may be obtained from thetarget
property, but it may be undefined until the promise returned byloadReference
resolves. It may also be stale if a relevant trait of the reference has changed butloadReference
hasn't yet been called or hasn't yet finished. - For simplicity, a particular model may only be the target of a single reference. Multiple references cannot point to the same target. The reference that points to a particular model may be obtained from the model's
sourceReference
property. - The
uniqueId
of thetarget
model must be the same as theuniqueId
of the model withReferenceMixin
. - The model with
ReferenceMixin
may be interria.models
. - The
target
model must not be interria.models
. - The instance referred to by the
target
property should remain stable (the same instance) whenever possible. But if something drastic changes (e.g. we need an instance of a different model class), it's possible for thetarget
property to switch to pointing at an entirely new instance. So it's important to only hold on to references to theReferenceMixin
model and access thetarget
as needed, rather than holding a reference to thetarget
directly.
Weak References
These are treated more like a shortcut to a model.
Here it is possible to have many-to-one mapping from references to a target. The target is not aware of it's references (sourceReference
is not set).
Weak references are determined by weakReference = true
.
The main use-case for weak references is the CatalogIndex
- which provides for the complete tree of the catalog with a subset of model properties (eg id
, name
, memberKnownContainerUniqueIds
...). This can be used to search and locate catalog members which may be nested in multiple references/groups (for example MagdaReference
-> WebMapServiceCatalogGroup
-> WebMapServiceCatalogItem
).
AsyncLoader
The AsyncLoader class provides a way to memoize (of sorts) async requests.
AsyncLoader
accepts an async function which can be use to do the following:
- load data from an asynchronous service
- transform the data into something that can be stored in 1 or multiple observables
- set those observables.
It works by calling an async function forceLoadX()
in a @computed
called loadKeepAlive
. This @computed
will update if observables change that were used in forceLoadX()
. Because we are using a @computed
in this way - it is very important that no changes to observables
are made before an async call.
This means that we can call asyncLoader.load()
many times without worrying about
forceLoadX()
shouldn't be called directly - instead you should use asyncLoader.load()
method - for example in CatalogMemberMixin
we have
- the abstract method
forceLoadMetadata
loadMetadata()
which wrapsasyncLoader.load()
loadMetadata()
can be called as many times as needed- See CatalogMemberMixin example
A correct example:
async function forceLoadX() {
const url = this.someObservableUrl;
const someData = await loadText(url);
runInAction(() => (this.someOtherObservable = someData));
}
This function will only be called again when someObservableUrl
changes.
If there is any synchronous processing present it should be pulled out of forceLoadX and placed into 1 or multiple computeds.
An incorrect example:
async function forceLoadX() {
const arg = this.someObservable;
const someData = someSynchronousFn(arg);
runInAction(() => (this.someOtherObservable = someData));
}
Instead this should be in a @computed
:
@computed
get newComputed {
return someSynchronousFn(this.someObservable);
}
Other tips:
- You should not nest together
AsyncLoaders
. Eg.ts async function forceLoadX() { await this.forceLoadY(); }
For more info, see lib\Core\AsyncLoader.ts
CatalogMemberMixin example
CatalogMemberMixin
contains an AsyncLoader
, which uses the followingfunction which must be implemented by a class:
/** Calls AsyncLoader to load metadata. It is safe to call this as often as necessary.
* If metadata is already loaded or already loading, it will
* return the existing promise.
*
* This returns a Result object, it will contain errors if they occur - they will not be thrown.
* To throw errors, use `(await loadMetadata()).throwIfError()`
*
* {@see AsyncLoader}
*/
async loadMetadata(): Promise<Result<void>> {
return (await this._metadataLoader.load()).clone(
`Failed to load \`${getName(this)}\` metadata`
);
}
/**
* Forces load of the metadata. This method does _not_ need to consider
* whether the metadata is already loaded.
*
* You **can not** make changes to observables until **after** an asynchronous call {@see AsyncLoader}.
*
* Errors can be thrown here.
*
* {@see AsyncLoader}
*/
protected async forceLoadMetadata() {}