Skip to main content

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 - When true any changes made to the sandbox copy are applied to the original subtree. When false any changes made to the copy are rejected, i.e. rolled back.
  • { commit: boolean; return: R } - The commit property is equivalent to the boolean return value described above. The value of the return property is also returned by withSandbox.

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 - Executes fn with sandbox copies of the elements of nodes. Any changes made to the sandbox are applied to the original subtree when fn returns true or { commit: true, ... }. When fn returns false or { commit: false, ... } the changes made to the sandbox are rejected. When fn returns an object of type { commit: boolean; return: R } then withSandbox returns a value of type R.
  • 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, or undefined 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"]`