diff --git a/package.json b/package.json index 2e2e646..0869c20 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dependencies": { "lodash.memoize": "^4.1.2", "mobx": "^6.5.0", - "mobx-state-tree": "^5.3.0", + "mobx-state-tree": "https://codeload.github.com/airhorns/mobx-state-tree/tar.gz/mobx-state-tree-v5.3.1-gitpkg-4b79173e", "reflect-metadata": "^0.1.13" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e533b4e..876329d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ dependencies: specifier: ^6.5.0 version: 6.8.0 mobx-state-tree: - specifier: ^5.3.0 - version: 5.3.0(mobx@6.8.0) + specifier: https://codeload.github.com/airhorns/mobx-state-tree/tar.gz/mobx-state-tree-v5.3.1-gitpkg-4b79173e + version: '@codeload.github.com/airhorns/mobx-state-tree/tar.gz/mobx-state-tree-v5.3.1-gitpkg-4b79173e(mobx@6.8.0)' reflect-metadata: specifier: ^0.1.13 version: 0.1.13 @@ -3724,14 +3724,6 @@ packages: minimist: 1.2.8 dev: true - /mobx-state-tree@5.3.0(mobx@6.8.0): - resolution: {integrity: sha512-2XInCjIxGQx/UmTbpAreWKcHswmuOKOV23HJmy1x4gqyDqCcOHJcaClpWnD2/qQlGncg8gAUfP/mm+cLpTrtlQ==} - peerDependencies: - mobx: ^6.3.0 - dependencies: - mobx: 6.8.0 - dev: false - /mobx@6.8.0: resolution: {integrity: sha512-+o/DrHa4zykFMSKfS8Z+CPSEg5LW9tSNGTuN8o6MF1GKxlfkSHSeJn5UtgxvPkGgaouplnrLXCF+duAsmm6FHQ==} dev: false @@ -4712,3 +4704,14 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} dev: true + + '@codeload.github.com/airhorns/mobx-state-tree/tar.gz/mobx-state-tree-v5.3.1-gitpkg-4b79173e(mobx@6.8.0)': + resolution: {tarball: https://codeload.github.com/airhorns/mobx-state-tree/tar.gz/mobx-state-tree-v5.3.1-gitpkg-4b79173e} + id: '@codeload.github.com/airhorns/mobx-state-tree/tar.gz/mobx-state-tree-v5.3.1-gitpkg-4b79173e' + name: mobx-state-tree + version: 5.3.1 + peerDependencies: + mobx: ^6.3.0 + dependencies: + mobx: 6.8.0 + dev: false diff --git a/spec/class-model-cached-views.spec.ts b/spec/class-model-cached-views.spec.ts new file mode 100644 index 0000000..f79462b --- /dev/null +++ b/spec/class-model-cached-views.spec.ts @@ -0,0 +1,125 @@ +import { ClassModel, action, cachedView, getSnapshot, register, types } from "../src"; + +@register +class ViewExample extends ClassModel({ key: types.identifier, name: types.string }) { + @cachedView() + get slug() { + return this.name.toLowerCase().replace(/ /g, "-"); + } + + @action + setName(name: string) { + this.name = name; + } +} + +describe("class model cached views", () => { + test("an observable instance saves the view value in a snapshot", () => { + const instance = ViewExample.create({ key: "1", name: "Test" }); + expect(instance.slug).toEqual("test"); + const snapshot = getSnapshot(instance); + expect(snapshot).toEqual({ key: "1", name: "Test", slug: "test" }); + }); + + test("an observable instance updates the saved view when the observed view value changes", () => { + const instance = ViewExample.create({ key: "1", name: "Test" }); + instance.setName("New Name"); + expect(instance.slug).toEqual("new-name"); + const snapshot = getSnapshot(instance); + expect(snapshot).toEqual({ key: "1", name: "New Name", slug: "new-name" }); + }); + + test("an observable instance ignores the input snapshot value as the logic may have changed", () => { + const instance = ViewExample.create({ key: "1", name: "Test", slug: "outdated-cache" } as any); + expect(instance.slug).toEqual("test"); + }); + + test("an readonly instance returns the view value from the snapshot if present", () => { + const instance = ViewExample.createReadOnly({ key: "1", name: "Test", slug: "test" } as any); + expect(instance.slug).toEqual("test"); + }); + + test("an readonly instance doesn't recompute the view value from the snapshot", () => { + const instance = ViewExample.createReadOnly({ key: "1", name: "Test", slug: "whatever" } as any); + expect(instance.slug).toEqual("whatever"); + }); + + test("an readonly instance doesn't call the computed function if given a snapshot value", () => { + const fn = jest.fn(); + @register + class Spy extends ClassModel({ name: types.string }) { + @cachedView() + get slug() { + fn(); + return this.name.toLowerCase().replace(/ /g, "-"); + } + } + + const instance = Spy.createReadOnly({ name: "Test", slug: "whatever" } as any); + expect(instance.slug).toEqual("whatever"); + expect(fn).not.toHaveBeenCalled(); + }); + + test("an readonly instance doesn't require the snapshot to include the cache", () => { + const instance = ViewExample.createReadOnly({ key: "1", name: "Test" }); + expect(instance.slug).toEqual("test"); + }); + + test("cached views can be passed nested within snapshots", () => { + @register + class Outer extends ClassModel({ examples: types.array(ViewExample) }) {} + + const instance = Outer.createReadOnly({ + examples: [{ key: "1", name: "Test", slug: "test-foobar" } as any, { key: "2", name: "Test 2", slug: "test-qux" } as any], + }); + + expect(instance.examples[0].slug).toEqual("test-foobar"); + expect(instance.examples[1].slug).toEqual("test-qux"); + }); + + describe("with a hydrator", () => { + @register + class HydrateExample extends ClassModel({ timestamp: types.string }) { + @cachedView({ + getSnapshot(value, snapshot, node) { + expect(snapshot).toBeDefined(); + expect(node).toBeDefined(); + return value.toISOString(); + }, + createReadOnly(value, snapshot, node) { + expect(snapshot).toBeDefined(); + expect(node).toBeDefined(); + return value ? new Date(value) : undefined; + }, + }) + get startOfMonth() { + const date = new Date(this.timestamp); + date.setDate(0); + return date; + } + + @action + setTimestamp(timestamp: string) { + this.timestamp = timestamp; + } + } + + test("cached views with processors can be accessed on observable instances", () => { + const instance = HydrateExample.create({ timestamp: "2021-01-02T00:00:00.000Z" }); + expect(instance.startOfMonth).toEqual(new Date("2021-01-01T00:00:00.000Z")); + }); + + test("cached views with processors can be accessed on readonly instances when there's no input data", () => { + const instance = HydrateExample.createReadOnly({ timestamp: "2021-01-02T00:00:00.000Z" }); + expect(instance.startOfMonth).toEqual(new Date("2021-01-01T00:00:00.000Z")); + }); + + test("cached views with processors can be accessed on readonly instances when there is input data", () => { + const instance = HydrateExample.createReadOnly({ + timestamp: "2021-01-02T00:00:00.000Z", + startOfMonth: "2021-02-01T00:00:00.000Z", + } as any); + expect(instance.startOfMonth).toEqual(new Date("2021-02-01T00:00:00.000Z")); + }); + }); +}); diff --git a/src/api.ts b/src/api.ts index 3f7e28d..dd24f76 100644 --- a/src/api.ts +++ b/src/api.ts @@ -71,7 +71,7 @@ export { unescapeJsonPath, walk, } from "mobx-state-tree"; -export { ClassModel, action, extend, register, view, volatile, volatileAction } from "./class-model"; +export { ClassModel, action, extend, register, view, cachedView, volatile, volatileAction } from "./class-model"; export { getSnapshot } from "./snapshot"; export const isType = (value: any): value is IAnyType => { diff --git a/src/class-model.ts b/src/class-model.ts index b04504f..14ac495 100644 --- a/src/class-model.ts +++ b/src/class-model.ts @@ -1,9 +1,9 @@ import memoize from "lodash.memoize"; -import type { IModelType as MSTIModelType, ModelActions } from "mobx-state-tree"; +import type { Instance, IModelType as MSTIModelType, ModelActions } from "mobx-state-tree"; import { types as mstTypes } from "mobx-state-tree"; import "reflect-metadata"; import { RegistrationError } from "./errors"; -import { buildFastInstantiator } from "./fast-instantiator"; +import { InstantiatorBuilder } from "./fast-instantiator"; import { defaultThrowAction, mstPropsFromQuickProps, propsFromModelPropsDeclaration } from "./model"; import { $env, @@ -22,6 +22,7 @@ import { import type { Constructor, ExtendedClassModel, + IAnyClassModelType, IAnyType, IClassModelType, IStateTreeNode, @@ -39,12 +40,24 @@ type ActionMetadata = { volatile: boolean; }; +export interface CachedViewOptions { + createReadOnly?: (value: V | undefined, snapshot: T["InputType"], node: Instance) => V | undefined; + getSnapshot?: (value: V, snapshot: T["InputType"], node: Instance) => any; +} + /** @internal */ -type ViewMetadata = { +export type ViewMetadata = { type: "view"; property: string; }; +/** @internal */ +export type CachedViewMetadata = { + type: "cached-view"; + property: string; + cache: CachedViewOptions; +}; + /** @internal */ export type VolatileMetadata = { type: "volatile"; @@ -53,7 +66,7 @@ export type VolatileMetadata = { }; type VolatileInitializer = (instance: T) => Record; -type PropertyMetadata = ActionMetadata | ViewMetadata | VolatileMetadata; +type PropertyMetadata = ActionMetadata | ViewMetadata | CachedViewMetadata | VolatileMetadata; const metadataPrefix = "mqt:properties"; const viewKeyPrefix = `${metadataPrefix}:view`; @@ -158,6 +171,7 @@ export function register bindToSelf(self, mstViews)) .actions((self) => bindToSelf(self, mstActions)); if (Object.keys(mstVolatiles).length > 0) { // define the volatile properties in one shot by running any passed initializers - (klass as any).mstType = (klass as any).mstType.volatile((self: any) => initializeVolatiles({}, self, mstVolatiles)); + mstType = mstType.volatile((self: any) => initializeVolatiles({}, self, mstVolatiles)); + } + + const cachedViews = metadatas.filter((metadata) => metadata.type == "cached-view") as CachedViewMetadata[]; + if (cachedViews.length > 0) { + mstType = mstTypes.snapshotProcessor(mstType, { + postProcessor(snapshot, node) { + for (const cachedView of cachedViews) { + let value = node[cachedView.property]; + if (cachedView.cache.getSnapshot) { + value = cachedView.cache.getSnapshot(value, snapshot, node); + } + snapshot[cachedView.property] = value; + } + return snapshot; + }, + }) as any; } - klass = buildFastInstantiator(klass); + klass.mstType = mstType; + klass = new InstantiatorBuilder(klass, cachedViews).build(); (klass as any)[$registered] = true; return klass as any; @@ -318,6 +356,36 @@ export const view = (target: any, property: string, _descriptor: PropertyDescrip Reflect.defineMetadata(`${viewKeyPrefix}:${property}`, metadata, target); }; +/** + * 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. + * + * 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. + * + * @example + * class Example extends ClassModel({ name: types.string }) { + * @cachedView + * get slug() { + * return this.name.toLowerCase().replace(/ /g, "-"); + * } + * } + * + * @example + * class Example extends ClassModel({ timestamp: types.string }) { + * @cachedView({ preProcess: (value) => new Date(value), postProcess: (value) => value.toISOString() }) + * get date() { + * return new Date(timestamp).setTime(0); + * } + * } + */ +export function cachedView( + options: CachedViewOptions = {} +): (target: any, property: string, _descriptor: PropertyDescriptor) => void { + return (target: any, property: string, _descriptor: PropertyDescriptor) => { + const metadata: CachedViewMetadata = { type: "cached-view", property, cache: options }; + Reflect.defineMetadata(`${viewKeyPrefix}:${property}`, metadata, target); + }; +} + /** * A function for defining a volatile **/ diff --git a/src/fast-instantiator.ts b/src/fast-instantiator.ts index e2e487f..167db1e 100644 --- a/src/fast-instantiator.ts +++ b/src/fast-instantiator.ts @@ -1,4 +1,5 @@ import { ArrayType, QuickArray } from "./array"; +import type { CachedViewMetadata } from "./class-model"; import { FrozenType } from "./frozen"; import { MapType, QuickMap } from "./map"; import { OptionalType } from "./optional"; @@ -7,13 +8,6 @@ import { DateType, IntegerType, LiteralType, SimpleType } from "./simple"; import { $env, $identifier, $memoizedKeys, $memos, $parent, $readOnly, $type } from "./symbols"; import type { IAnyType, IClassModelType, ValidOptionalValue } from "./types"; -/** - * Compiles a fast function for taking snapshots and turning them into instances of a class model. - **/ -export const buildFastInstantiator = , any, any>>(model: T): T => { - return new InstantiatorBuilder(model).build(); -}; - type DirectlyAssignableType = SimpleType | IntegerType | LiteralType | DateType; const isDirectlyAssignableType = (type: IAnyType): type is DirectlyAssignableType => { return ( @@ -25,10 +19,13 @@ const isDirectlyAssignableType = (type: IAnyType): type is DirectlyAssignableTyp ); }; -class InstantiatorBuilder, any, any>> { +/** + * Compiles a fast class constructor that takes snapshots and turns them into instances of a class model. + **/ +export class InstantiatorBuilder, any, any>> { aliases = new Map(); - constructor(readonly model: T) {} + constructor(readonly model: T, readonly cachedViews: CachedViewMetadata[]) {} build(): T { const segments: string[] = []; @@ -74,6 +71,10 @@ class InstantiatorBuilder, an `); } + for (const [index, cachedView] of this.cachedViews.entries()) { + segments.push(this.assignCachedViewExpression(cachedView, index)); + } + const defineClassStatement = ` return class ${this.model.name} extends model { [$memos] = null; @@ -108,7 +109,7 @@ class InstantiatorBuilder, an `; const aliasFuncBody = ` - const { QuickMap, QuickArray, $identifier, $env, $parent, $memos, $memoizedKeys, $readOnly, $type } = imports; + const { QuickMap, QuickArray, $identifier, $env, $parent, $memos, $memoizedKeys, $readOnly, $type, cachedViews } = imports; ${Array.from(this.aliases.entries()) .map(([expression, alias]) => `const ${alias} = ${expression};`) @@ -125,7 +126,18 @@ class InstantiatorBuilder, an const aliasFunc = new Function("model", "imports", aliasFuncBody); // evaluate aliases and get created inner function - return aliasFunc(this.model, { $identifier, $env, $parent, $memos, $memoizedKeys, $readOnly, $type, QuickMap, QuickArray }) as T; + return aliasFunc(this.model, { + $identifier, + $env, + $parent, + $memos, + $memoizedKeys, + $readOnly, + $type, + QuickMap, + QuickArray, + cachedViews: this.cachedViews, + }) as T; } private expressionForDirectlyAssignableType(key: string, type: DirectlyAssignableType) { @@ -249,6 +261,27 @@ class InstantiatorBuilder, an }`; } + private assignCachedViewExpression(cachedView: CachedViewMetadata, index: number) { + const varName = `view${cachedView.property}`; + + let valueExpression = `snapshot?.["${cachedView.property}"]`; + if (cachedView.cache.createReadOnly) { + const alias = this.alias(`cachedViews[${index}].cache.createReadOnly`); + valueExpression = `${alias}(${valueExpression}, snapshot, this)`; + } + + return ` + // setup cached view for ${cachedView.property} + const ${varName} = ${valueExpression}; + if (typeof ${varName} != "undefined") { + this[$memoizedKeys] ??= {}; + this[$memos] ??= {}; + this[$memoizedKeys]["${cachedView.property}"] = true; + this[$memos]["${cachedView.property}"] = ${varName}; + } + `; + } + alias(expression: string): string { const existing = this.aliases.get(expression); if (existing) {