Skip to content

Commit 2aee2bf

Browse files
committed
Add functionality for caching view values in the snapshot for fast
readonly access [no-changelog-required]
1 parent 0ebab7f commit 2aee2bf

8 files changed

+391
-34
lines changed

README.md

Lines changed: 87 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,85 @@ 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 caching the computed value of the view from an observable instance in the snapshot. This allows the read-only instance to skip re-computing the cached value, and instead return a cached value from the snapshot quickly.
439+
440+
To cache a view's value in the snapshot, define a view with the `@snapshottedView` decorator. Each `getSnapshot` call of your observable instance will include the view's result in the snapshot under the view's key.
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+
return `${this.year} ${this.model} ${this.make}`;
454+
}
455+
}
456+
457+
// create an observable instance
458+
const car = Car.create({ make: "Toyota", model: "Prius", year: 2008 });
459+
460+
// get a snapshot of the observable instance
461+
const snapshot = getSnapshot(car);
462+
463+
// the snapshot will include the cached view value
464+
snapshot.name; // => "2008 Toyota Prius"
465+
```
466+
467+
Snapshotted views can also transform the value of the view before it is cached in the snapshot. To transform the value of a snapshotted view, pass a function to the `@snapshottedView` decorator that takes the view's value and returns the transformed value.
468+
469+
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::
470+
471+
```typescript
472+
@register
473+
class TransformExample extends ClassModel({ url: types.string }) {
474+
@snapshottedView<URL>({
475+
getSnapshot(value, snapshot, node) {
476+
return value.toString();
477+
},
478+
createReadOnly(value, snapshot, node) {
479+
return value ? new URL(value) : undefined;
480+
},
481+
})
482+
get withoutParams() {
483+
const url = new URL(this.url);
484+
for (const [key] of url.searchParams.entries()) {
485+
url.searchParams.delete(key);
486+
}
487+
return url;
488+
}
489+
490+
@action
491+
setURL(url: string) {
492+
this.url = url;
493+
}
494+
}
495+
```
496+
497+
##### Snapshotted view semantics
498+
499+
Snapshotted views are a complicated beast, and are best avoided until your performance demands less computation on readonly instances.
500+
501+
On observable instances, snapshotted views go through the following lifecycle:
502+
503+
- when an observable instance is created, any view values in the snapshot are _ignored_
504+
- like mobx-state-tree, view values aren't computed until the first time they are accessed. On observable instances, snapshotted views will _always_ be recomputed when accessed the first time
505+
- once a snapshotted view is computed, it will follow the standard mobx-state-tree rules for recomputation, recomputing when any of its dependencies change, and only observing dependencies if it has one or more observers of it's own
506+
- when the observable instance is snapshotted via `getSnapshot` or an observer of the snapshot, the view will always be computed, and the view's value will be JSON serialized into the snapshot
507+
508+
On readonly instances, snapshotted views go through the following lifecycle:
509+
510+
- when a readonly instance is created, any snapshotted view values in the snapshot are memoized and stored in the readonly instance
511+
- snapshotted views are never re-computed on readonly instances, and their value is always returned from the snapshot if present
512+
- 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
513+
- when a readonly instance is snapshotted via `getSnapshot` or an observer of the snapshot, the view will be computed if it hasn't been already, and the view's value will be JSON serialized into the snapshot
514+
436515
#### References to and from class models
437516

438517
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 +667,7 @@ const buildClass = () => {
588667
someView: view,
589668
someAction: action,
590669
},
591-
"Example"
670+
"Example",
592671
);
593672
};
594673

@@ -728,7 +807,7 @@ class Student extends addName(
728807
firstName: types.string,
729808
lastName: types.string,
730809
homeroom: types.string,
731-
})
810+
}),
732811
) {}
733812

734813
@register
@@ -737,7 +816,7 @@ class Teacher extends addName(
737816
firstName: types.string,
738817
lastName: types.string,
739818
email: types.string,
740-
})
819+
}),
741820
) {}
742821
```
743822

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { ClassModel, action, snapshottedView, getSnapshot, register, types } from "../src";
2+
3+
@register
4+
class ViewExample extends ClassModel({ key: types.identifier, name: types.string }) {
5+
@snapshottedView()
6+
get slug() {
7+
return this.name.toLowerCase().replace(/ /g, "-");
8+
}
9+
10+
@action
11+
setName(name: string) {
12+
this.name = name;
13+
}
14+
}
15+
16+
describe("class model snapshotted views", () => {
17+
test("an observable instance saves the view value in a snapshot when changed", () => {
18+
const instance = ViewExample.create({ key: "1", name: "Test" });
19+
expect(instance.slug).toEqual("test");
20+
let snapshot = getSnapshot(instance);
21+
expect(snapshot).toEqual({ key: "1", name: "Test" }); // no snapshot output as the object hasn't changed yet
22+
instance.setName("New Name");
23+
snapshot = getSnapshot(instance);
24+
expect(snapshot).toEqual({ key: "1", name: "New Name", slug: "new-name" });
25+
});
26+
27+
test("an observable instance updates the saved view when the observed view value changes", () => {
28+
const instance = ViewExample.create({ key: "1", name: "Test" });
29+
instance.setName("New Name");
30+
expect(instance.slug).toEqual("new-name");
31+
const snapshot = getSnapshot(instance);
32+
expect(snapshot).toEqual({ key: "1", name: "New Name", slug: "new-name" });
33+
});
34+
35+
test("an observable instance ignores the input snapshot value as the logic may have changed", () => {
36+
const instance = ViewExample.create({ key: "1", name: "Test", slug: "outdated-cache" } as any);
37+
expect(instance.slug).toEqual("test");
38+
});
39+
40+
test("an readonly instance returns the view value from the snapshot if present", () => {
41+
const instance = ViewExample.createReadOnly({ key: "1", name: "Test", slug: "test" } as any);
42+
expect(instance.slug).toEqual("test");
43+
});
44+
45+
test("an readonly instance doesn't recompute the view value from the snapshot", () => {
46+
const instance = ViewExample.createReadOnly({ key: "1", name: "Test", slug: "whatever" } as any);
47+
expect(instance.slug).toEqual("whatever");
48+
});
49+
50+
test("an readonly instance doesn't call the computed function if given a snapshot value", () => {
51+
const fn = jest.fn();
52+
@register
53+
class Spy extends ClassModel({ name: types.string }) {
54+
@snapshottedView()
55+
get slug() {
56+
fn();
57+
return this.name.toLowerCase().replace(/ /g, "-");
58+
}
59+
}
60+
61+
const instance = Spy.createReadOnly({ name: "Test", slug: "whatever" } as any);
62+
expect(instance.slug).toEqual("whatever");
63+
expect(fn).not.toHaveBeenCalled();
64+
});
65+
66+
test("an observable instance doesn't call the computed function until snapshotted", () => {
67+
const fn = jest.fn();
68+
@register
69+
class Spy extends ClassModel({ name: types.string }) {
70+
@snapshottedView()
71+
get slug() {
72+
fn();
73+
return this.name.toLowerCase().replace(/ /g, "-");
74+
}
75+
@action
76+
setName(name: string) {
77+
this.name = name;
78+
}
79+
}
80+
81+
const instance = Spy.create({ name: "Test", slug: "whatever" } as any);
82+
expect(fn).not.toHaveBeenCalled();
83+
getSnapshot(instance);
84+
expect(fn).not.toHaveBeenCalled();
85+
86+
instance.setName("New Name");
87+
expect(fn).toHaveBeenCalled();
88+
});
89+
90+
test("an readonly instance doesn't require the snapshot to include the cache", () => {
91+
const instance = ViewExample.createReadOnly({ key: "1", name: "Test" });
92+
expect(instance.slug).toEqual("test");
93+
});
94+
95+
test("snapshotted views can be passed nested within snapshots", () => {
96+
@register
97+
class Outer extends ClassModel({ examples: types.array(ViewExample) }) {}
98+
99+
const instance = Outer.createReadOnly({
100+
examples: [{ key: "1", name: "Test", slug: "test-foobar" } as any, { key: "2", name: "Test 2", slug: "test-qux" } as any],
101+
});
102+
103+
expect(instance.examples[0].slug).toEqual("test-foobar");
104+
expect(instance.examples[1].slug).toEqual("test-qux");
105+
});
106+
107+
test("an readonly instance returns the view value in a snapshot of itself when the view is given in the input snapshot", () => {
108+
const instance = ViewExample.createReadOnly({ key: "1", name: "Test", slug: "foobar" } as any);
109+
const snapshot = getSnapshot(instance);
110+
expect((snapshot as any).slug).toEqual("foobar");
111+
});
112+
113+
test("an readonly instance returns the view value in a snapshot of itself when the view is not given in the input snapshot", () => {
114+
const instance = ViewExample.createReadOnly({ key: "1", name: "Test" } as any);
115+
const snapshot = getSnapshot(instance);
116+
expect((snapshot as any).slug).toEqual("test");
117+
});
118+
119+
describe("with a hydrator", () => {
120+
@register
121+
class HydrateExample extends ClassModel({ url: types.string }) {
122+
@snapshottedView<URL>({
123+
getSnapshot(value, snapshot, node) {
124+
expect(snapshot).toBeDefined();
125+
expect(node).toBeDefined();
126+
return value.toString();
127+
},
128+
createReadOnly(value, snapshot, node) {
129+
expect(snapshot).toBeDefined();
130+
expect(node).toBeDefined();
131+
return value ? new URL(value) : undefined;
132+
},
133+
})
134+
get withoutParams() {
135+
const url = new URL(this.url);
136+
for (const [key] of url.searchParams.entries()) {
137+
url.searchParams.delete(key);
138+
}
139+
return url;
140+
}
141+
142+
@action
143+
setURL(url: string) {
144+
this.url = url;
145+
}
146+
}
147+
148+
test("snapshotted views with processors can be accessed on observable instances", () => {
149+
const instance = HydrateExample.create({ url: "https://gadget.dev/blog/feature?utm=whatever" });
150+
expect(instance.withoutParams).toEqual(new URL("https://gadget.dev/blog/feature"));
151+
});
152+
153+
test("snapshotted views with processors can be accessed on readonly instances when there's no input data", () => {
154+
const instance = HydrateExample.create({ url: "https://gadget.dev/blog/feature?utm=whatever" });
155+
expect(instance.withoutParams).toEqual(new URL("https://gadget.dev/blog/feature"));
156+
});
157+
158+
test("snapshotted views with processors can be accessed on readonly instances when there is input data", () => {
159+
const instance = HydrateExample.createReadOnly({
160+
url: "https://gadget.dev/blog/feature?utm=whatever",
161+
withoutParams: "https://gadget.dev/blog/feature/extra", // pass a different value so we can be sure it is what is being used
162+
} as any);
163+
expect(instance.withoutParams).toEqual(new URL("https://gadget.dev/blog/feature/extra"));
164+
});
165+
});
166+
});

src/api.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,7 @@ export {
7474
unescapeJsonPath,
7575
walk,
7676
} from "mobx-state-tree";
77-
78-
export { ClassModel, action, extend, register, view, volatile, volatileAction } from "./class-model";
77+
export { ClassModel, action, extend, register, view, snapshottedView, volatile, volatileAction } from "./class-model";
7978
export { getSnapshot } from "./snapshot";
8079

8180
export const isType = (value: any): value is IAnyType => {

0 commit comments

Comments
 (0)