Skip to content

Commit 1766986

Browse files
committed
WIP view cacher
[no-changelog-required]
1 parent d9932f3 commit 1766986

6 files changed

+289
-30
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"dependencies": {
2929
"lodash.memoize": "^4.1.2",
3030
"mobx": "^6.5.0",
31-
"mobx-state-tree": "^5.3.0",
31+
"mobx-state-tree": "https://codeload.github.com/airhorns/mobx-state-tree/tar.gz/mobx-state-tree-v5.3.1-gitpkg-4b79173e",
3232
"reflect-metadata": "^0.1.13"
3333
},
3434
"devDependencies": {

pnpm-lock.yaml

Lines changed: 13 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

spec/class-model-cached-views.spec.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { ClassModel, action, cachedView, getSnapshot, register, types } from "../src";
2+
3+
@register
4+
class ViewExample extends ClassModel({ key: types.identifier, name: types.string }) {
5+
@cachedView()
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 cached 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+
@cachedView()
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+
@cachedView()
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("cached 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+
describe("with a hydrator", () => {
108+
@register
109+
class HydrateExample extends ClassModel({ timestamp: types.string }) {
110+
@cachedView<Date>({
111+
getSnapshot(value, snapshot, node) {
112+
expect(snapshot).toBeDefined();
113+
expect(node).toBeDefined();
114+
return value.toISOString();
115+
},
116+
createReadOnly(value, snapshot, node) {
117+
expect(snapshot).toBeDefined();
118+
expect(node).toBeDefined();
119+
return value ? new Date(value) : undefined;
120+
},
121+
})
122+
get startOfMonth() {
123+
const date = new Date(this.timestamp);
124+
date.setDate(0);
125+
return date;
126+
}
127+
128+
@action
129+
setTimestamp(timestamp: string) {
130+
this.timestamp = timestamp;
131+
}
132+
}
133+
134+
test("cached views with processors can be accessed on observable instances", () => {
135+
const instance = HydrateExample.create({ timestamp: "2021-01-02T00:00:00.000Z" });
136+
expect(instance.startOfMonth).toEqual(new Date("2021-01-01T00:00:00.000Z"));
137+
});
138+
139+
test("cached views with processors can be accessed on readonly instances when there's no input data", () => {
140+
const instance = HydrateExample.createReadOnly({ timestamp: "2021-01-02T00:00:00.000Z" });
141+
expect(instance.startOfMonth).toEqual(new Date("2021-01-01T00:00:00.000Z"));
142+
});
143+
144+
test("cached views with processors can be accessed on readonly instances when there is input data", () => {
145+
const instance = HydrateExample.createReadOnly({
146+
timestamp: "2021-01-02T00:00:00.000Z",
147+
startOfMonth: "2021-02-01T00:00:00.000Z",
148+
} as any);
149+
expect(instance.startOfMonth).toEqual(new Date("2021-02-01T00:00:00.000Z"));
150+
});
151+
});
152+
});

src/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export {
7171
unescapeJsonPath,
7272
walk,
7373
} from "mobx-state-tree";
74-
export { ClassModel, action, extend, register, view, volatile, volatileAction } from "./class-model";
74+
export { ClassModel, action, extend, register, view, cachedView, volatile, volatileAction } from "./class-model";
7575
export { getSnapshot } from "./snapshot";
7676

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

src/class-model.ts

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import memoize from "lodash.memoize";
2-
import type { IModelType as MSTIModelType, ModelActions } from "mobx-state-tree";
2+
import type { Instance, IModelType as MSTIModelType, ModelActions } from "mobx-state-tree";
33
import { types as mstTypes } from "mobx-state-tree";
44
import "reflect-metadata";
55
import { RegistrationError } from "./errors";
6-
import { buildFastInstantiator } from "./fast-instantiator";
6+
import { InstantiatorBuilder } from "./fast-instantiator";
77
import { defaultThrowAction, mstPropsFromQuickProps, propsFromModelPropsDeclaration } from "./model";
88
import {
99
$env,
@@ -22,6 +22,7 @@ import {
2222
import type {
2323
Constructor,
2424
ExtendedClassModel,
25+
IAnyClassModelType,
2526
IAnyType,
2627
IClassModelType,
2728
IStateTreeNode,
@@ -39,12 +40,24 @@ type ActionMetadata = {
3940
volatile: boolean;
4041
};
4142

43+
export interface CachedViewOptions<V, T extends IAnyClassModelType> {
44+
createReadOnly?: (value: V | undefined, snapshot: T["InputType"], node: Instance<T>) => V | undefined;
45+
getSnapshot?: (value: V, snapshot: T["InputType"], node: Instance<T>) => any;
46+
}
47+
4248
/** @internal */
43-
type ViewMetadata = {
49+
export type ViewMetadata = {
4450
type: "view";
4551
property: string;
4652
};
4753

54+
/** @internal */
55+
export type CachedViewMetadata = {
56+
type: "cached-view";
57+
property: string;
58+
cache: CachedViewOptions<any, any>;
59+
};
60+
4861
/** @internal */
4962
export type VolatileMetadata = {
5063
type: "volatile";
@@ -53,7 +66,7 @@ export type VolatileMetadata = {
5366
};
5467

5568
type VolatileInitializer<T> = (instance: T) => Record<string, any>;
56-
type PropertyMetadata = ActionMetadata | ViewMetadata | VolatileMetadata;
69+
type PropertyMetadata = ActionMetadata | ViewMetadata | CachedViewMetadata | VolatileMetadata;
5770

5871
const metadataPrefix = "mqt:properties";
5972
const viewKeyPrefix = `${metadataPrefix}:view`;
@@ -158,13 +171,20 @@ export function register<Instance, Klass extends { new (...args: any[]): Instanc
158171

159172
for (const metadata of metadatas) {
160173
switch (metadata.type) {
174+
case "cached-view":
161175
case "view": {
162176
const property = metadata.property;
163177
const descriptor = getPropertyDescriptor(klass.prototype, property);
164178
if (!descriptor) {
165179
throw new RegistrationError(`Property ${property} not found on ${klass} prototype, can't register view for class model`);
166180
}
167181

182+
if ("cache" in metadata && !descriptor.get) {
183+
throw new RegistrationError(
184+
`Cached view property ${property} on ${klass} must be a getter -- can't use cached views with views that are functions or take arguments`
185+
);
186+
}
187+
168188
// memoize getters on readonly instances
169189
if (descriptor.get) {
170190
Object.defineProperty(klass.prototype, property, {
@@ -186,6 +206,7 @@ export function register<Instance, Klass extends { new (...args: any[]): Instanc
186206
...descriptor,
187207
enumerable: true,
188208
});
209+
189210
break;
190211
}
191212

@@ -278,17 +299,37 @@ export function register<Instance, Klass extends { new (...args: any[]): Instanc
278299
});
279300

280301
// create the MST type for not-readonly versions of this using the views and actions extracted from the class
281-
klass.mstType = mstTypes
302+
let mstType = mstTypes
282303
.model(klass.name, mstPropsFromQuickProps(klass.properties))
283304
.views((self) => bindToSelf(self, mstViews))
284305
.actions((self) => bindToSelf(self, mstActions));
285306

286307
if (Object.keys(mstVolatiles).length > 0) {
287308
// define the volatile properties in one shot by running any passed initializers
288-
(klass as any).mstType = (klass as any).mstType.volatile((self: any) => initializeVolatiles({}, self, mstVolatiles));
309+
mstType = mstType.volatile((self: any) => initializeVolatiles({}, self, mstVolatiles));
310+
}
311+
312+
const cachedViews = metadatas.filter((metadata) => metadata.type == "cached-view") as CachedViewMetadata[];
313+
if (cachedViews.length > 0) {
314+
mstType = mstTypes.snapshotProcessor(mstType, {
315+
postProcessor(snapshot, node) {
316+
const stn = node.$treenode!;
317+
if (stn.state == 2 /** NodeLifeCycle.FINALIZED */) {
318+
for (const cachedView of cachedViews) {
319+
let value = node[cachedView.property];
320+
if (cachedView.cache.getSnapshot) {
321+
value = cachedView.cache.getSnapshot(value, snapshot, node);
322+
}
323+
snapshot[cachedView.property] = value;
324+
}
325+
}
326+
return snapshot;
327+
},
328+
}) as any;
289329
}
290330

291-
klass = buildFastInstantiator(klass);
331+
klass.mstType = mstType;
332+
klass = new InstantiatorBuilder(klass, cachedViews).build();
292333
(klass as any)[$registered] = true;
293334

294335
return klass as any;
@@ -318,6 +359,36 @@ export const view = (target: any, property: string, _descriptor: PropertyDescrip
318359
Reflect.defineMetadata(`${viewKeyPrefix}:${property}`, metadata, target);
319360
};
320361

362+
/**
363+
* Function decorator for registering MQT cached views within MQT class models. Stores the view's value into the snapshot when an instance is snapshotted, and uses that stored value for readonly instances created from snapshots.
364+
*
365+
* Can be passed an `options` object with a `preProcess` and/or `postProcess` function for transforming the cached value stored in the snapshot to and from the snapshot state.
366+
*
367+
* @example
368+
* class Example extends ClassModel({ name: types.string }) {
369+
* @cachedView
370+
* get slug() {
371+
* return this.name.toLowerCase().replace(/ /g, "-");
372+
* }
373+
* }
374+
*
375+
* @example
376+
* class Example extends ClassModel({ timestamp: types.string }) {
377+
* @cachedView({ preProcess: (value) => new Date(value), postProcess: (value) => value.toISOString() })
378+
* get date() {
379+
* return new Date(timestamp).setTime(0);
380+
* }
381+
* }
382+
*/
383+
export function cachedView<V, T extends IAnyClassModelType = IAnyClassModelType>(
384+
options: CachedViewOptions<V, T> = {}
385+
): (target: any, property: string, _descriptor: PropertyDescriptor) => void {
386+
return (target: any, property: string, _descriptor: PropertyDescriptor) => {
387+
const metadata: CachedViewMetadata = { type: "cached-view", property, cache: options };
388+
Reflect.defineMetadata(`${viewKeyPrefix}:${property}`, metadata, target);
389+
};
390+
}
391+
321392
/**
322393
* A function for defining a volatile
323394
**/

0 commit comments

Comments
 (0)