Skip to content

Commit 79be376

Browse files
authored
Merge pull request #103 from gadget-inc/sc/snapshotted-views
2 parents 603c08b + 3702fcd commit 79be376

12 files changed

+670
-72
lines changed

README.md

Lines changed: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@
1313

1414
## Why?
1515

16-
[`mobx-state-tree`](https://mobx-state-tree.js.org/) is great for modeling data and observing changes to it, but it adds a lot of runtime overhead! Raw `mobx` itself adds substantial overhead over plain old JS objects or ES6 classes, and `mobx-state-tree` adds more on top of that. If you want to use your MST data models in a non-reactive or non-observing context, all that runtime overhead for observability is just wasted, as nothing is ever changing.
16+
[`mobx-state-tree`](https://mobx-state-tree.js.org/) is great for modeling data and observing changes to it, but it adds a lot of runtime overhead! Raw `mobx` itself adds substantial overhead over plain old JS objects or ES6 classes, and `mobx-state-tree` adds more on top of that. If you want to use your MST data models in a non-reactive or non-observing context, all that runtime overhead for observability is just wasted, as nothing is ever-changing.
1717

1818
`mobx-quick-tree` implements the same API as MST and exposes the same useful observable instances for use in observable contexts, but adds a second option for instantiating a read-only instance that is 100x faster.
1919

20-
If `mobx-state-tree` instances are great for modeling within an "editor" part of an app where nodes and properties are changed all over the place, the performant, read-only instances constructed by `mobx-quick-tree` are great for using within a "read" part of an app that displays data in the tree without ever changing it. For a website builder for example, you might use MST in the page builder area where someone arranges components within a page, and then use MQT in the part of the app that needs to render those webpages frequently.
20+
If `mobx-state-tree` instances are great for modeling within an "editor" part of an app where nodes and properties are changed all over the place, the performant, read-only instances constructed by `mobx-quick-tree` are great for use within a "read" part of an app that displays data in the tree without ever changing it. For a website builder for example, you might use MST in the page builder area where someone arranges components within a page, and then use MQT in the part of the app that needs to render those web pages frequently.
2121

2222
### Two APIs
2323

@@ -164,7 +164,7 @@ class Car extends ClassModel({
164164
}
165165
```
166166

167-
Each Class Model **must** be registered with the system using the `@register` decorator in order to be instantiated.
167+
Each Class Model **must** be registered with the system using the `@register` decorator to be instantiated.
168168
`@register` is necessary for setting up the internal state of the class and generating the observable MST type.
169169

170170
Within Class Model class bodies, refer to the current instance using the standard ES6/JS `this` keyword. `mobx-state-tree` users tend to use `self` within view or action blocks, but Class Models return to using standard JS `this` for performance.
@@ -284,11 +284,11 @@ class Car extends ClassModel({
284284
}
285285
```
286286

287-
Explicit decoration of views is exactly equivalent to implicit declaration of views without a decorator.
287+
Explicit decoration of views is exactly equivalent to an implicit declaration of views without a decorator.
288288

289289
#### Defining actions with `@action`
290290

291-
Class Models support actions on instances, which are functions that change state on the instance or it's children. Class Model actions are exactly the same as `mobx-state-tree` actions defined using the `.actions()` API on a `types.model`. See the [`mobx-state-tree` actions docs](https://mobx-state-tree.js.org/concepts/actions) for more information.
291+
Class Models support actions on instances, which are functions that change the state of the instance or its children. Class Model actions are exactly the same as `mobx-state-tree` actions defined using the `.actions()` API on a `types.model`. See the [`mobx-state-tree` actions docs](https://mobx-state-tree.js.org/concepts/actions) for more information.
292292

293293
To define an action on a Class Model, define a function within a Class Model body, and register it as an action with `@action`.
294294

@@ -433,6 +433,95 @@ watch.stop();
433433

434434
**Note**: Volatile actions will _not_ trigger observers on readonly instances. Readonly instances are not observable because they are readonly (and for performance), and so volatiles aren't observable, and so volatile actions that change them won't fire observers. This makes volatile actions appropriate for reference tracking and implementation that syncs with external systems, but not for general state management. If you need to be able to observe state, use an observable instance.
435435

436+
#### Caching view values in snapshots with `snapshottedView`
437+
438+
For expensive views, `mobx-quick-tree` supports hydrating computed views from a snapshot. This allows read-only instances to skip re-computing the expensive view, and instead return a cached value from the snapshot quickly.
439+
440+
To hydrate a view's value from a snapshot, define a view with the `@snapshottedView` decorator.
441+
442+
```typescript
443+
import { ClassModel, register, view, snapshottedView } from "@gadgetinc/mobx-quick-tree";
444+
445+
@register
446+
class Car extends ClassModel({
447+
make: types.string,
448+
model: types.string,
449+
year: types.number,
450+
}) {
451+
@snapshottedView()
452+
get name() {
453+
console.log("computing name"); // pretend this is expensive
454+
return `${this.year} ${this.model} ${this.make}`;
455+
}
456+
}
457+
458+
// create an observable instance
459+
const car = Car.create({ make: "Toyota", model: "Prius", year: 2008 });
460+
car.name; // => "2008 Toyota Prius" (logs "computing name")
461+
462+
// create a snapshot of the observable instance
463+
const snapshot = {
464+
...getSnapshot(car),
465+
name: car.name, // NOTE: you must add the snapshotted view's value to the snapshot manually
466+
};
467+
468+
const readOnlyCar = Car.createReadOnly(snapshot);
469+
readOnlyCar.name; // => "2008 Toyota Prius" (does not log "computing name")
470+
```
471+
472+
Snapshotted views can transform the value from the snapshot before it is stored on the read-only instance. To transform the value of a snapshotted view, pass a `createReadOnly` function to the `@snapshottedView` decorator.
473+
474+
For example, for a view that returns a rich type like a `URL`, we can store the view's value as a string in the snapshot, and re-create the rich type when a read-only instance is created:
475+
476+
```typescript
477+
import { ClassModel, register, view, snapshottedView } from "@gadgetinc/mobx-quick-tree";
478+
479+
@register
480+
class TransformExample extends ClassModel({ url: types.string }) {
481+
@snapshottedView<string>({
482+
createReadOnly(value, snapshot, node) {
483+
return value ? new URL(value) : undefined;
484+
},
485+
})
486+
get withoutParams() {
487+
const url = new URL(this.url);
488+
for (const [key] of url.searchParams.entries()) {
489+
url.searchParams.delete(key);
490+
}
491+
return url;
492+
}
493+
494+
@action
495+
setURL(url: string) {
496+
this.url = url;
497+
}
498+
}
499+
500+
const example = TransformExample.create({ url: "https://example.com?foo=bar" });
501+
502+
const snapshot = {
503+
...getSnapshot(example),
504+
withoutParams: example.withoutParams.toString(),
505+
};
506+
507+
snapshot.withoutParams; // => "https://example.com"
508+
509+
const readOnlyExample = TransformExample.createReadOnly(snapshot);
510+
readOnlyExample.withoutParams; // => URL { href: "https://example.com" }
511+
```
512+
513+
##### Snapshotted view semantics
514+
515+
Snapshotted views are a complicated beast, and are best avoided until your performance demands less computation on readonly instances.
516+
517+
On observable instances, snapshotted views act like normal views and **are not** populated from the snapshot.
518+
519+
On readonly instances, snapshotted views go through the following lifecycle:
520+
521+
- when a readonly instance is created, any snapshotted view values in the snapshot are memoized and stored in the readonly instance
522+
- snapshotted views are never re-computed on readonly instances, and their value is always returned from the snapshot if present
523+
- if the incoming snapshot does not have a value for the view, then the view is lazily computed on first access like a normal `@view`, and memoized forever after that
524+
436525
#### References to and from class models
437526

438527
Class Models support `types.references` within their properties as well as being the target of `types.reference`s on other models or class models.
@@ -588,7 +677,7 @@ const buildClass = () => {
588677
someView: view,
589678
someAction: action,
590679
},
591-
"Example"
680+
"Example",
592681
);
593682
};
594683

@@ -728,7 +817,7 @@ class Student extends addName(
728817
firstName: types.string,
729818
lastName: types.string,
730819
homeroom: types.string,
731-
})
820+
}),
732821
) {}
733822

734823
@register
@@ -737,7 +826,7 @@ class Teacher extends addName(
737826
firstName: types.string,
738827
lastName: types.string,
739828
email: types.string,
740-
})
829+
}),
741830
) {}
742831
```
743832

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`class model snapshotted views an observable instance emits a patch when the view value changes 1`] = `
4+
[MockFunction] {
5+
"calls": [
6+
[
7+
{
8+
"op": "replace",
9+
"path": "/__snapshottedViewsEpoch",
10+
"value": 1,
11+
},
12+
{
13+
"op": "replace",
14+
"path": "/__snapshottedViewsEpoch",
15+
"value": 0,
16+
},
17+
],
18+
],
19+
"results": [
20+
{
21+
"type": "return",
22+
"value": undefined,
23+
},
24+
],
25+
}
26+
`;
27+
28+
exports[`class model snapshotted views references references to models with snapshotted views can be instantiated 1`] = `
29+
{
30+
"examples": {
31+
"1": {
32+
"key": "1",
33+
"name": "Alice",
34+
},
35+
"2": {
36+
"key": "2",
37+
"name": "Bob",
38+
},
39+
},
40+
"referrers": {
41+
"a": {
42+
"example": "1",
43+
"id": "a",
44+
},
45+
"b": {
46+
"example": "2",
47+
"id": "b",
48+
},
49+
},
50+
}
51+
`;

spec/class-model-mixins.spec.ts

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { IsExact } from "conditional-type-checks";
22
import { assert } from "conditional-type-checks";
33
import type { Constructor } from "../src";
4-
import { isType } from "../src";
5-
import { IAnyClassModelType, IAnyStateTreeNode, extend } from "../src";
4+
import { isType, snapshottedView } from "../src";
5+
import { extend } from "../src";
66
import { getSnapshot } from "../src";
77
import { ClassModel, action, register, types } from "../src";
88
import { volatile } from "../src/class-model";
@@ -69,6 +69,17 @@ const AddViewMixin = <T extends Constructor<{ name: string }>>(Klass: T) => {
6969
return MixedIn;
7070
};
7171

72+
const AddSnapshottedViewMixin = <T extends Constructor<{ name: string }>>(Klass: T) => {
73+
class MixedIn extends Klass {
74+
@snapshottedView()
75+
get snapshottedMixinGetter() {
76+
return this.name.toUpperCase();
77+
}
78+
}
79+
80+
return MixedIn;
81+
};
82+
7283
const AddActionMixin = <T extends Constructor<{ name: string }>>(Klass: T) => {
7384
class MixedIn extends Klass {
7485
@action
@@ -97,21 +108,25 @@ const AddVolatileMixin = <T extends Constructor<{ name: string }>>(Klass: T) =>
97108
@register
98109
class ChainedA extends AddVolatileMixin(
99110
AddViewMixin(
100-
AddActionMixin(
101-
ClassModel({
102-
name: types.string,
103-
}),
111+
AddSnapshottedViewMixin(
112+
AddActionMixin(
113+
ClassModel({
114+
name: types.string,
115+
}),
116+
),
104117
),
105118
),
106119
) {}
107120

108121
@register
109122
class ChainedB extends AddActionMixin(
110-
AddViewMixin(
111-
AddVolatileMixin(
112-
ClassModel({
113-
name: types.string,
114-
}),
123+
AddSnapshottedViewMixin(
124+
AddViewMixin(
125+
AddVolatileMixin(
126+
ClassModel({
127+
name: types.string,
128+
}),
129+
),
115130
),
116131
),
117132
) {}
@@ -120,9 +135,24 @@ class ChainedB extends AddActionMixin(
120135
class ChainedC extends AddActionMixin(
121136
AddVolatileMixin(
122137
AddViewMixin(
123-
ClassModel({
124-
name: types.string,
125-
}),
138+
AddSnapshottedViewMixin(
139+
ClassModel({
140+
name: types.string,
141+
}),
142+
),
143+
),
144+
),
145+
) {}
146+
147+
@register
148+
class ChainedD extends AddSnapshottedViewMixin(
149+
AddActionMixin(
150+
AddVolatileMixin(
151+
AddViewMixin(
152+
ClassModel({
153+
name: types.string,
154+
}),
155+
),
126156
),
127157
),
128158
) {}
@@ -132,6 +162,7 @@ describe("class model mixins", () => {
132162
["Chain A", ChainedA],
133163
["Chain B", ChainedB],
134164
["Chain C", ChainedC],
165+
["Chain D", ChainedD],
135166
])("%s", (_name, Klass) => {
136167
test("function views can be added to classes by mixins", () => {
137168
let instance = Klass.createReadOnly({ name: "Test" });
@@ -149,6 +180,14 @@ describe("class model mixins", () => {
149180
expect(instance.mixinGetter).toBe("TEST");
150181
});
151182

183+
test("snapshotted views can be added to classes by mixins", () => {
184+
let instance = Klass.createReadOnly({ name: "Test" });
185+
expect(instance.snapshottedMixinGetter).toBe("TEST");
186+
187+
instance = Klass.createReadOnly({ name: "Test", snapshottedMixinGetter: "foobar" } as any);
188+
expect(instance.snapshottedMixinGetter).toBe("foobar");
189+
});
190+
152191
test("actions can be added to classes by mixins", () => {
153192
const instance = Klass.create({ name: "Test" });
154193
instance.mixinSetName("another test");

0 commit comments

Comments
 (0)