Sandboxes
Overview
The sandbox utility allows you to make changes to a copy of an original subtree without actually changing the original state that drives UI rendering. Changes made in the sandbox can be committed to the original subtree or rejected. A common use case is testing of "what-if" scenarios.
For example, consider this simple model:
@model("MyApp/EvenNumber")
class EvenNumber extends Model({
value: prop<number>().withSetter(),
}) {
@computed
get isValid(): number {
return value % 2 === 0
}
}
We can create a sandbox for an instance of this model:
const num = new EvenNumber({ value: 0 })
const numSandbox = sandbox(num)
The sandbox manager numSandbox
can now be used to test assigning a new value by performing the setValue
action on a copy of num
and validating the result using the computed property isValid
.
const isValid = numSandbox.withSandbox([num], (numCopy) => {
numCopy.setValue(2)
return { commit: false, return: numCopy.isValid }
})
The callback passed to withSandbox
supports two return types:
boolean
- Whentrue
any changes made to the sandbox copy are applied to the original subtree. Whenfalse
any changes made to the copy are rejected, i.e. rolled back.{ commit: boolean; return: R }
- Thecommit
property is equivalent to the boolean return value described above. The value of thereturn
property is also returned bywithSandbox
.
withSandbox
can be called with a tuple of nodes in order to retrieve sandbox copies of multiple nodes at the same time:
@model("NumberStore")
class NumberStore extends Model({
a: prop<EvenNumber>(),
b: prop<EvenNumber>(),
}) {}
const store = new NumberStore({
a: new EvenNumber({ value: 0 }),
b: new EvenNumber({ value: 2 }),
})
const storeSandbox = sandbox(store)
storeSandbox.withSandbox([store.a, store.b], (a, b) => {
// ...
})
withSandbox
calls can be nested:
const isValid = numSandbox.withSandbox([num], (numCopy1) => {
numCopy1.setValue(2)
const isValid1 = numCopy1.isValid
const isValid2 = numSandbox.withSandbox([numCopy1], (numCopy2) => {
numCopy2.setValue(numCopy2.value * 2)
return { commit: false, return: numCopy2.isValid }
})
return { commit: false, return: isValid1 && isValid2 }
})
When nesting withSandbox
calls, the node for which the corresponding sandbox node is obtained must be a sandbox node itself, e.g.:
// good:
numSandbox.withSandbox([num], (numCopy1) => {
numSandbox.withSandbox([numCopy1], (numCopy2) => {
// ...
})
// ...
})
// bad:
numSandbox.withSandbox([num], (numCopy1) => {
numSandbox.withSandbox([num], (numCopy2) => {
// ...
})
// ...
})
When changes made in nested withSandbox
calls are to be committed, only the outermost withSandbox
call commits changes to the original subtree. Changes made in any inner withSandbox
call are either retained or rolled back depending on the commit flag. E.g.:
numSandbox.withSandbox([num], (numCopy1) => {
// `numCopy1.value` => 0
numCopy1.setValue(1)
// `numCopy1.value` => 1
numSandbox.withSandbox([numCopy1], (numCopy2) => {
// `numCopy2.value` => 1
numCopy2.setValue(2)
// `numCopy2.value` => 2
numSandbox.withSandbox([numCopy2], (numCopy3) => {
// `numCopy3.value` => 2
numCopy3.setValue(3)
// `numCopy3.value` => 3
return true
})
// `numCopy2.value` => 3
return false
})
// `numCopy1.value` => 1
return true
})
// `num.value` => 1
The sandbox copy of a subtree root node tracks the root store state of the original subtree root node, i.e. when the original subtree root node is registered as a root store, its corresponding sandbox copy becomes a root store as well and vice versa.
The sandbox
function generates an instance with the following methods:
withSandbox<T extends [object, ...object[]], R = void>(nodes: T, fn: (...nodes: T) => boolean | { commit: boolean; return: R }): R
- Executesfn
with sandbox copies of the elements ofnodes
. Any changes made to the sandbox are applied to the original subtree whenfn
returnstrue
or{ commit: true, ... }
. Whenfn
returnsfalse
or{ commit: false, ... }
the changes made to the sandbox are rejected. Whenfn
returns an object of type{ commit: boolean; return: R }
thenwithSandbox
returns a value of typeR
.dispose()
- Disposes of the sandbox.
Checking if a node is sandboxed / getting a node sandbox manager
Sometimes it might be useful to know if a node is part of a sandbox or its related sandbox manager. To do so you can use the following functions:
isSandboxedNode(node: object): boolean
- Returns if a given node is a sandboxed node.getNodeSandboxManager(node: object): SandboxManager | undefined
- Returns the sandbox manager of a node, orundefined
if none.
These might be useful for example to filter out reactions that should only run on the "real" nodes, but not on the sandboxed ones.
Examples
Store of polymorphic items
Consider a store of polymorphic items which can generally co-exist in the same store, but each item type implements validation rules that determine whether the item is valid in the context of the other items currently present in the store.
Let all items implement the following interface:
interface Item {
error: string | undefined
}
Further, let the item store be a model which contains ...
- an array of items currently present in the store,
- a computed property which returns an array of errors accumulated from all items in the store,
- a method (action) to add a new item, and
- a method that assesses whether a new item can be added to the store without error.
const sandboxCtx = createContext<SandboxManager>()
@model("MyApp/ItemStore")
class ItemStore extends Model({
items: prop<Item[]>(() => []),
}) {
@computed
get errors(): string[] {
return this.items.map((item) => item.error).filter((error) => error !== undefined) as string[]
}
@modelAction
addItem(item: Item): void {
this.items.push(item)
}
canAddItem(item: Item): boolean {
return !!sandboxCtx.get(this)?.withSandbox([this], (node) => {
node.addItem(item)
return { commit: false, return: node.errors.length === 0 }
})
}
}
canAddItem
requires access to the sandbox manager which is provided using a context.
const store = new ItemStore({})
const storeSandbox = sandbox(store)
sandboxCtx.setComputed(store, () => storeSandbox)
Now, consider the following item model which, in this example, may only exist once per item store:
@model("MyApp/ItemA")
class ItemA extends Model({}) implements Item {
@computed
get error(): string | undefined {
return getParent<Item[]>(this)?.some((item) => item !== this && item instanceof ItemA)
? "only 1 instance of ItemA allowed"
: undefined
}
}
When the store does not yet contain an item of type ItemA
, canAddItem
returns true
when called with an instance of ItemA
and, thus, this item can be added to the item store without error:
const item1 = new ItemA({})
console.log(store.canAddItem(item1)) // => `true`
store.addItem(item1)
console.log(store.errors) // => `[]`
However, adding a second item of type ItemA
would incur an error:
const item2 = new ItemA({})
console.log(store.canAddItem(item2)) // => `["only 1 instance of ItemA allowed"]`