Skip to content

Commit cce22c1

Browse files
committed
WIP view cacher
[no-changelog-required]
1 parent 9d0c1d2 commit cce22c1

6 files changed

+292
-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: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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({ url: types.string }) {
110+
@cachedView<URL>({
111+
getSnapshot(value, snapshot, node) {
112+
expect(snapshot).toBeDefined();
113+
expect(node).toBeDefined();
114+
return value.toString();
115+
},
116+
createReadOnly(value, snapshot, node) {
117+
expect(snapshot).toBeDefined();
118+
expect(node).toBeDefined();
119+
return value ? new URL(value) : undefined;
120+
},
121+
})
122+
get withoutParams() {
123+
const url = new URL(this.url);
124+
for (const [key] of url.searchParams.entries()) {
125+
url.searchParams.delete(key);
126+
}
127+
return url;
128+
}
129+
130+
@action
131+
setURL(url: string) {
132+
this.url = url;
133+
}
134+
}
135+
136+
test("cached views with processors can be accessed on observable instances", () => {
137+
const instance = HydrateExample.create({ url: "https://gadget.dev/blog/feature?utm=whatever" });
138+
expect(instance.withoutParams).toEqual(new URL("https://gadget.dev/blog/feature"));
139+
});
140+
141+
test("cached views with processors can be accessed on readonly instances when there's no input data", () => {
142+
const instance = HydrateExample.create({ url: "https://gadget.dev/blog/feature?utm=whatever" });
143+
expect(instance.withoutParams).toEqual(new URL("https://gadget.dev/blog/feature"));
144+
});
145+
146+
test("cached views with processors can be accessed on readonly instances when there is input data", () => {
147+
const instance = HydrateExample.createReadOnly({
148+
url: "https://gadget.dev/blog/feature?utm=whatever",
149+
withoutParams: "https://gadget.dev/blog/feature/extra", // pass a different value so we can be sure it is what is being used
150+
} as any);
151+
expect(instance.withoutParams).toEqual(new URL("https://gadget.dev/blog/feature/extra"));
152+
});
153+
});
154+
});

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: 79 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

@@ -260,21 +281,42 @@ export function register<Instance, Klass extends { new (...args: any[]): Instanc
260281
});
261282

262283
// create the MST type for not-readonly versions of this using the views and actions extracted from the class
263-
klass.mstType = mstTypes
284+
let mstType = mstTypes
264285
.model(klass.name, mstPropsFromQuickProps(klass.properties))
265286
.views((self) => bindToSelf(self, mstViews))
266287
.actions((self) => bindToSelf(self, mstActions));
267288

268289
if (Object.keys(mstVolatiles).length > 0) {
269290
// define the volatile properties in one shot by running any passed initializers
270-
(klass as any).mstType = (klass as any).mstType.volatile((self: any) => initializeVolatiles({}, self, mstVolatiles));
291+
mstType = mstType.volatile((self: any) => initializeVolatiles({}, self, mstVolatiles));
271292
}
272293

294+
const cachedViews = metadatas.filter((metadata) => metadata.type == "cached-view") as CachedViewMetadata[];
295+
if (cachedViews.length > 0) {
296+
mstType = mstTypes.snapshotProcessor(mstType, {
297+
postProcessor(snapshot, node) {
298+
const stn = node.$treenode!;
299+
if (stn.state == 2 /** NodeLifeCycle.FINALIZED */) {
300+
for (const cachedView of cachedViews) {
301+
let value = node[cachedView.property];
302+
if (cachedView.cache.getSnapshot) {
303+
value = cachedView.cache.getSnapshot(value, snapshot, node);
304+
}
305+
snapshot[cachedView.property] = value;
306+
}
307+
}
308+
return snapshot;
309+
},
310+
}) as any;
311+
}
312+
313+
klass.mstType = mstType;
314+
273315
// define the class constructor and the following hot path functions dynamically
274316
// .createReadOnly
275317
// .is
276318
// .instantiate
277-
klass = buildFastInstantiator(klass);
319+
klass = new InstantiatorBuilder(klass, cachedViews).build();
278320

279321
(klass as any)[$registered] = true;
280322

@@ -305,6 +347,36 @@ export const view = (target: any, property: string, _descriptor: PropertyDescrip
305347
Reflect.defineMetadata(`${viewKeyPrefix}:${property}`, metadata, target);
306348
};
307349

350+
/**
351+
* 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.
352+
*
353+
* 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.
354+
*
355+
* @example
356+
* class Example extends ClassModel({ name: types.string }) {
357+
* @cachedView
358+
* get slug() {
359+
* return this.name.toLowerCase().replace(/ /g, "-");
360+
* }
361+
* }
362+
*
363+
* @example
364+
* class Example extends ClassModel({ timestamp: types.string }) {
365+
* @cachedView({ preProcess: (value) => new Date(value), postProcess: (value) => value.toISOString() })
366+
* get date() {
367+
* return new Date(timestamp).setTime(0);
368+
* }
369+
* }
370+
*/
371+
export function cachedView<V, T extends IAnyClassModelType = IAnyClassModelType>(
372+
options: CachedViewOptions<V, T> = {}
373+
): (target: any, property: string, _descriptor: PropertyDescriptor) => void {
374+
return (target: any, property: string, _descriptor: PropertyDescriptor) => {
375+
const metadata: CachedViewMetadata = { type: "cached-view", property, cache: options };
376+
Reflect.defineMetadata(`${viewKeyPrefix}:${property}`, metadata, target);
377+
};
378+
}
379+
308380
/**
309381
* A function for defining a volatile
310382
**/

0 commit comments

Comments
 (0)