From da8634f515d45fc4d60dd2ec9125d7e82dd8aff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Scott=20C=C3=B4t=C3=A9?= Date: Tue, 18 Jun 2024 18:03:26 -0400 Subject: [PATCH 1/3] Add snapshottedViews --- README.md | 105 ++++++++- ...class-model-snapshotted-views.spec.ts.snap | 51 +++++ spec/class-model-mixins.spec.ts | 67 ++++-- spec/class-model-snapshotted-views.spec.ts | 211 ++++++++++++++++++ spec/class-model.spec.ts | 14 +- spec/getSnapshot.spec.ts | 4 +- spec/helpers.ts | 2 +- src/api.ts | 3 +- src/class-model.ts | 105 ++++++++- src/fast-getter.ts | 10 +- src/fast-instantiator.ts | 64 ++++-- src/types.ts | 5 +- 12 files changed, 587 insertions(+), 54 deletions(-) create mode 100644 spec/__snapshots__/class-model-snapshotted-views.spec.ts.snap create mode 100644 spec/class-model-snapshotted-views.spec.ts diff --git a/README.md b/README.md index df4f3fd..7d6304c 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,11 @@ ## Why? -[`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. +[`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. `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. -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. +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. ### Two APIs @@ -164,7 +164,7 @@ class Car extends ClassModel({ } ``` -Each Class Model **must** be registered with the system using the `@register` decorator in order to be instantiated. +Each Class Model **must** be registered with the system using the `@register` decorator to be instantiated. `@register` is necessary for setting up the internal state of the class and generating the observable MST type. 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({ } ``` -Explicit decoration of views is exactly equivalent to implicit declaration of views without a decorator. +Explicit decoration of views is exactly equivalent to an implicit declaration of views without a decorator. #### Defining actions with `@action` -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. +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. To define an action on a Class Model, define a function within a Class Model body, and register it as an action with `@action`. @@ -433,6 +433,95 @@ watch.stop(); **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. +#### Caching view values in snapshots with `snapshottedView` + +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. + +To hydrate a view's value from a snapshot, define a view with the `@snapshottedView` decorator. + +```typescript +import { ClassModel, register, view, snapshottedView } from "@gadgetinc/mobx-quick-tree"; + +@register +class Car extends ClassModel({ + make: types.string, + model: types.string, + year: types.number, +}) { + @snapshottedView() + get name() { + console.log("computing name"); // pretend this is expensive + return `${this.year} ${this.model} ${this.make}`; + } +} + +// create an observable instance +const car = Car.create({ make: "Toyota", model: "Prius", year: 2008 }); +car.name; // => "2008 Toyota Prius" (logs "computing name") + +// create a snapshot of the observable instance +const snapshot = { + ...getSnapshot(car), + name: car.name, // NOTE: you must add the snapshotted view's value to the snapshot manually +}; + +const readOnlyCar = Car.createReadOnly(snapshot); +readOnlyCar.name; // => "2008 Toyota Prius" (does not log "computing name") +``` + +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. + +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: + +```typescript +import { ClassModel, register, view, snapshottedView } from "@gadgetinc/mobx-quick-tree"; + +@register +class TransformExample extends ClassModel({ url: types.string }) { + @snapshottedView({ + createReadOnly(value, snapshot, node) { + return value ? new URL(value) : undefined; + }, + }) + get withoutParams() { + const url = new URL(this.url); + for (const [key] of url.searchParams.entries()) { + url.searchParams.delete(key); + } + return url; + } + + @action + setURL(url: string) { + this.url = url; + } +} + +const example = TransformExample.create({ url: "https://example.com?foo=bar" }); + +const snapshot = { + ...getSnapshot(example), + withoutParams: example.withoutParams.toString(), +}; + +snapshot.withoutParams; // => "https://example.com" + +const readOnlyExample = TransformExample.createReadOnly(snapshot); +readOnlyExample.withoutParams; // => URL { href: "https://example.com" } +``` + +##### Snapshotted view semantics + +Snapshotted views are a complicated beast, and are best avoided until your performance demands less computation on readonly instances. + +On observable instances, snapshotted views act like normal views and **are not** populated from the snapshot. + +On readonly instances, snapshotted views go through the following lifecycle: + +- when a readonly instance is created, any snapshotted view values in the snapshot are memoized and stored in the readonly instance +- snapshotted views are never re-computed on readonly instances, and their value is always returned from the snapshot if present +- 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 + #### References to and from class models 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 = () => { someView: view, someAction: action, }, - "Example" + "Example", ); }; @@ -728,7 +817,7 @@ class Student extends addName( firstName: types.string, lastName: types.string, homeroom: types.string, - }) + }), ) {} @register @@ -737,7 +826,7 @@ class Teacher extends addName( firstName: types.string, lastName: types.string, email: types.string, - }) + }), ) {} ``` diff --git a/spec/__snapshots__/class-model-snapshotted-views.spec.ts.snap b/spec/__snapshots__/class-model-snapshotted-views.spec.ts.snap new file mode 100644 index 0000000..948a91e --- /dev/null +++ b/spec/__snapshots__/class-model-snapshotted-views.spec.ts.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`class model snapshotted views an observable instance emits a patch when the view value changes 1`] = ` +[MockFunction] { + "calls": [ + [ + { + "op": "replace", + "path": "/__snapshottedViewsEpoch", + "value": 1, + }, + { + "op": "replace", + "path": "/__snapshottedViewsEpoch", + "value": 0, + }, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + ], +} +`; + +exports[`class model snapshotted views references references to models with snapshotted views can be instantiated 1`] = ` +{ + "examples": { + "1": { + "key": "1", + "name": "Alice", + }, + "2": { + "key": "2", + "name": "Bob", + }, + }, + "referrers": { + "a": { + "example": "1", + "id": "a", + }, + "b": { + "example": "2", + "id": "b", + }, + }, +} +`; diff --git a/spec/class-model-mixins.spec.ts b/spec/class-model-mixins.spec.ts index 18c69f2..96cac6d 100644 --- a/spec/class-model-mixins.spec.ts +++ b/spec/class-model-mixins.spec.ts @@ -1,8 +1,8 @@ import type { IsExact } from "conditional-type-checks"; import { assert } from "conditional-type-checks"; import type { Constructor } from "../src"; -import { isType } from "../src"; -import { IAnyClassModelType, IAnyStateTreeNode, extend } from "../src"; +import { isType, snapshottedView } from "../src"; +import { extend } from "../src"; import { getSnapshot } from "../src"; import { ClassModel, action, register, types } from "../src"; import { volatile } from "../src/class-model"; @@ -69,6 +69,17 @@ const AddViewMixin = >(Klass: T) => { return MixedIn; }; +const AddSnapshottedViewMixin = >(Klass: T) => { + class MixedIn extends Klass { + @snapshottedView() + get snapshottedMixinGetter() { + return this.name.toUpperCase(); + } + } + + return MixedIn; +}; + const AddActionMixin = >(Klass: T) => { class MixedIn extends Klass { @action @@ -97,21 +108,25 @@ const AddVolatileMixin = >(Klass: T) => @register class ChainedA extends AddVolatileMixin( AddViewMixin( - AddActionMixin( - ClassModel({ - name: types.string, - }), + AddSnapshottedViewMixin( + AddActionMixin( + ClassModel({ + name: types.string, + }), + ), ), ), ) {} @register class ChainedB extends AddActionMixin( - AddViewMixin( - AddVolatileMixin( - ClassModel({ - name: types.string, - }), + AddSnapshottedViewMixin( + AddViewMixin( + AddVolatileMixin( + ClassModel({ + name: types.string, + }), + ), ), ), ) {} @@ -120,9 +135,24 @@ class ChainedB extends AddActionMixin( class ChainedC extends AddActionMixin( AddVolatileMixin( AddViewMixin( - ClassModel({ - name: types.string, - }), + AddSnapshottedViewMixin( + ClassModel({ + name: types.string, + }), + ), + ), + ), +) {} + +@register +class ChainedD extends AddSnapshottedViewMixin( + AddActionMixin( + AddVolatileMixin( + AddViewMixin( + ClassModel({ + name: types.string, + }), + ), ), ), ) {} @@ -132,6 +162,7 @@ describe("class model mixins", () => { ["Chain A", ChainedA], ["Chain B", ChainedB], ["Chain C", ChainedC], + ["Chain D", ChainedD], ])("%s", (_name, Klass) => { test("function views can be added to classes by mixins", () => { let instance = Klass.createReadOnly({ name: "Test" }); @@ -149,6 +180,14 @@ describe("class model mixins", () => { expect(instance.mixinGetter).toBe("TEST"); }); + test("snapshotted views can be added to classes by mixins", () => { + let instance = Klass.createReadOnly({ name: "Test" }); + expect(instance.snapshottedMixinGetter).toBe("TEST"); + + instance = Klass.createReadOnly({ name: "Test", snapshottedMixinGetter: "foobar" } as any); + expect(instance.snapshottedMixinGetter).toBe("foobar"); + }); + test("actions can be added to classes by mixins", () => { const instance = Klass.create({ name: "Test" }); instance.mixinSetName("another test"); diff --git a/spec/class-model-snapshotted-views.spec.ts b/spec/class-model-snapshotted-views.spec.ts new file mode 100644 index 0000000..1935dd7 --- /dev/null +++ b/spec/class-model-snapshotted-views.spec.ts @@ -0,0 +1,211 @@ +import { observable } from "mobx"; +import { ClassModel, action, snapshottedView, getSnapshot, register, types, onPatch } from "../src"; +import { Apple } from "./fixtures/FruitAisle"; +import { create } from "./helpers"; + +@register +class ViewExample extends ClassModel({ key: types.identifier, name: types.string }) { + @snapshottedView() + get slug() { + return this.name.toLowerCase().replace(/ /g, "-"); + } + + @action + setName(name: string) { + this.name = name; + } +} + +@register +class Outer extends ClassModel({ name: types.string, examples: types.array(ViewExample) }) { + @snapshottedView() + get upperName() { + return this.name.toUpperCase(); + } +} + +describe("class model snapshotted views", () => { + describe.each([ + ["read-only", true], + ["observable", false], + ])("%s", (_name, readOnly) => { + test("instances don't require the snapshot to include the cache", () => { + const instance = create(ViewExample, { key: "1", name: "Test" }, readOnly); + expect(instance.slug).toEqual("test"); + }); + + test("models with cached views still correctly report .is on totally different models", () => { + const instance = create(ViewExample, { key: "1", name: "Test" }, readOnly); + expect(ViewExample.is(instance)).toBe(true); + expect(Apple.is(instance)).toBe(false); + + const other = create(Apple, { type: "Apple", ripeness: 1 }, readOnly); + expect(ViewExample.is(other)).toBe(false); + expect(Apple.is(other)).toBe(true); + }); + + test("instances of models with all optional properties arent .is of other models with all optional properties", () => { + @register + class AllOptionalA extends ClassModel({ name: types.optional(types.string, "Jim") }) {} + + @register + class AllOptionalB extends ClassModel({ title: types.optional(types.string, "Jest") }) {} + + // the empty snapshot matches both types + expect(AllOptionalA.is({})).toBe(true); + expect(AllOptionalB.is({})).toBe(true); + + const instanceA = create(AllOptionalA, {}, readOnly); + expect(AllOptionalA.is(instanceA)).toBe(true); + expect(AllOptionalB.is(instanceA)).toBe(false); + + const instanceB = create(AllOptionalA, {}, readOnly); + expect(AllOptionalA.is(instanceB)).toBe(true); + expect(AllOptionalB.is(instanceB)).toBe(false); + }); + }); + + 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 observable instance emits a patch when the view value changes", () => { + const observableArray = observable.array([]); + + @register + class MyViewExample extends ClassModel({ key: types.identifier, name: types.string }) { + @snapshottedView() + get arrayLength() { + return observableArray.length; + } + } + + const fn = jest.fn(); + const instance = MyViewExample.create({ key: "1", name: "Test" }); + onPatch(instance, fn); + + observableArray.push("a"); + expect(fn).toMatchSnapshot(); + }); + + test("an observable instance's snapshot includes the snapshotted views epoch", () => { + const instance = ViewExample.create({ key: "1", name: "Test" }); + expect(getSnapshot(instance)).toEqual({ __snapshottedViewsEpoch: 0, key: "1", name: "Test" }); + }); + + test("a readonly instance's snapshot doesn't include the snapshotted views epoch", () => { + const instance = ViewExample.createReadOnly({ key: "1", name: "Test" }); + expect(getSnapshot(instance)).toEqual({ key: "1", name: "Test" }); + }); + + test("a 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("a 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("a 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 }) { + @snapshottedView() + 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("snapshotted views can be passed nested within snapshots", () => { + const instance = Outer.createReadOnly({ + name: "foo", + upperName: "SNAPSHOT", + examples: [{ key: "1", name: "Test", slug: "test-foobar" } as any, { key: "2", name: "Test 2", slug: "test-qux" } as any], + } as any); + + expect(instance.upperName).toEqual("SNAPSHOT"); + 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({ url: types.string }) { + @snapshottedView({ + createReadOnly(value, snapshot, node) { + expect(snapshot).toBeDefined(); + expect(node).toBeDefined(); + return value ? new URL(value) : undefined; + }, + }) + get withoutParams() { + const url = new URL(this.url); + for (const [key] of url.searchParams.entries()) { + url.searchParams.delete(key); + } + return url; + } + + @action + setURL(url: string) { + this.url = url; + } + } + + test("snapshotted views with processors can be accessed on observable instances", () => { + const instance = HydrateExample.create({ url: "https://gadget.dev/blog/feature?utm=whatever" }); + expect(instance.withoutParams).toEqual(new URL("https://gadget.dev/blog/feature")); + }); + + test("snapshotted views with processors can be accessed on readonly instances when there's no input data", () => { + const instance = HydrateExample.create({ url: "https://gadget.dev/blog/feature?utm=whatever" }); + expect(instance.withoutParams).toEqual(new URL("https://gadget.dev/blog/feature")); + }); + + test("snapshotted views with processors can be accessed on readonly instances when there is input data", () => { + const instance = HydrateExample.createReadOnly({ + url: "https://gadget.dev/blog/feature?utm=whatever", + withoutParams: "https://gadget.dev/blog/feature/extra", // pass a different value so we can be sure it is what is being used + } as any); + expect(instance.withoutParams).toEqual(new URL("https://gadget.dev/blog/feature/extra")); + }); + }); + + describe("references", () => { + @register + class Referencer extends ClassModel({ + id: types.identifier, + example: types.reference(ViewExample), + }) {} + + @register + class Root extends ClassModel({ + referrers: types.map(Referencer), + examples: types.map(ViewExample), + }) {} + + test("references to models with snapshotted views can be instantiated", () => { + const root = Root.createReadOnly({ + referrers: { + a: { id: "a", example: "1" }, + b: { id: "b", example: "2" }, + }, + examples: { + "1": { key: "1", name: "Alice" }, + "2": { key: "2", name: "Bob" }, + }, + }); + + expect(getSnapshot(root)).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/class-model.spec.ts b/spec/class-model.spec.ts index 1771908..803e76e 100644 --- a/spec/class-model.spec.ts +++ b/spec/class-model.spec.ts @@ -12,7 +12,7 @@ import type { ModelPropertiesDeclaration, SnapshotIn, } from "../src"; -import { flow, getSnapshot, getType, isReadOnlyNode, isStateTreeNode, isType, types } from "../src"; +import { flow, getSnapshot, getType, isReadOnlyNode, isStateTreeNode, isType, snapshottedView, types } from "../src"; import { ClassModel, action, extend, register, view, volatile, volatileAction } from "../src/class-model"; import { $identifier } from "../src/symbols"; import { NameExample } from "./fixtures/NameExample"; @@ -135,6 +135,17 @@ class ExtendedMixedInNameExample extends extendingClassModelMixin(NameExample) { } } +/** + * Example class wth a snapshotted view + */ +@register +class NameExampleWithSnapshottedView extends NameExample { + @snapshottedView() + get extendedNameLength() { + return this.name.length; + } +} + @register class AutoIdentified extends ClassModel({ key: types.optional(types.identifier, () => "test") }) { testKeyIsAlwaysSet() { @@ -180,6 +191,7 @@ describe("class models", () => { ["mixin'd class model", MixedInNameExample], ["extended props class model", ExtendedNameExample], ["extended mixin'd props class model", ExtendedMixedInNameExample], + ["class model with snapshotted views", NameExampleWithSnapshottedView], ])("%s", (_name, NameExample) => { test("types are identified as quick types", () => { expect(isType(NameExample)).toBe(true); diff --git a/spec/getSnapshot.spec.ts b/spec/getSnapshot.spec.ts index ea1e353..97e1051 100644 --- a/spec/getSnapshot.spec.ts +++ b/spec/getSnapshot.spec.ts @@ -79,7 +79,7 @@ describe("getSnapshot", () => { assert>>(true); }); - test("snapshots an readonly node model instance", () => { + test("snapshots a readonly node model instance", () => { const instance = TestModel.createReadOnly(TestModelSnapshot); const snapshot = getSnapshot(instance); verifySnapshot(snapshot); @@ -93,7 +93,7 @@ describe("getSnapshot", () => { assert>>(true); }); - test("snapshots an readonly class model instance", () => { + test("snapshots a readonly class model instance", () => { const instance = TestClassModel.createReadOnly(TestModelSnapshot); const snapshot = getSnapshot(instance); verifySnapshot(snapshot); diff --git a/spec/helpers.ts b/spec/helpers.ts index fac5b59..a089e0d 100644 --- a/spec/helpers.ts +++ b/spec/helpers.ts @@ -1,6 +1,6 @@ import type { IAnyType, Instance, SnapshotIn } from "../src"; -/** Easily reate an observable or readonly instance of a type */ +/** Easily create an observable or readonly instance of a type */ export const create = (type: T, snapshot?: SnapshotIn, readOnly = false): Instance => { if (readOnly) { return type.createReadOnly(snapshot); diff --git a/src/api.ts b/src/api.ts index 95fbf02..471e4cb 100644 --- a/src/api.ts +++ b/src/api.ts @@ -74,8 +74,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, snapshottedView, 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 177c7a5..8be1db9 100644 --- a/src/class-model.ts +++ b/src/class-model.ts @@ -1,10 +1,10 @@ import "reflect-metadata"; import memoize from "lodash.memoize"; -import type { IModelType as MSTIModelType, ModelActions } from "mobx-state-tree"; -import { types as mstTypes } from "mobx-state-tree"; +import type { Instance, IModelType as MSTIModelType, ModelActions } from "mobx-state-tree"; +import { isRoot, types as mstTypes } from "mobx-state-tree"; import { RegistrationError } from "./errors"; -import { buildFastInstantiator } from "./fast-instantiator"; +import { InstantiatorBuilder } from "./fast-instantiator"; import { FastGetBuilder } from "./fast-getter"; import { defaultThrowAction, mstPropsFromQuickProps, propsFromModelPropsDeclaration } from "./model"; import { @@ -22,6 +22,7 @@ import { import type { Constructor, ExtendedClassModel, + IAnyClassModelType, IAnyType, IClassModelType, IStateTreeNode, @@ -30,6 +31,8 @@ import type { TypesForModelPropsDeclaration, } from "./types"; import { cyrb53 } from "./utils"; +import { comparer, reaction } from "mobx"; +import { getSnapshot } from "./snapshot"; /** @internal */ type ActionMetadata = { @@ -38,12 +41,28 @@ type ActionMetadata = { volatile: boolean; }; +/** Options that configure a snapshotted view */ +export interface SnapshottedViewOptions { + /** A function for converting a stored value in the snapshot back to the rich type for the view to return */ + createReadOnly?: (value: V | undefined, snapshot: T["InputType"], node: Instance) => V | undefined; + + /** A function for converting the view value to a snapshot value */ + createSnapshot?: (value: V) => any; +} + /** @internal */ export type ViewMetadata = { type: "view"; property: string; }; +/** @internal */ +export type SnapshottedViewMetadata = { + type: "snapshotted-view"; + property: string; + options: SnapshottedViewOptions; +}; + /** @internal */ export type VolatileMetadata = { type: "volatile"; @@ -53,7 +72,7 @@ export type VolatileMetadata = { type VolatileInitializer = (instance: T) => Record; /** @internal */ -export type PropertyMetadata = ActionMetadata | ViewMetadata | VolatileMetadata; +export type PropertyMetadata = ActionMetadata | ViewMetadata | SnapshottedViewMetadata | VolatileMetadata; const metadataPrefix = "mqt:properties"; const viewKeyPrefix = `${metadataPrefix}:view`; @@ -158,6 +177,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)); + } + + klass.snapshottedViews = metadatas.filter((metadata) => metadata.type == "snapshotted-view") as SnapshottedViewMetadata[]; + if (klass.snapshottedViews.length > 0) { + // add a property to the MST type to track changes to a @snapshottedView when none of its model's properties changed + mstType = mstType + .props({ __snapshottedViewsEpoch: mstTypes.optional(mstTypes.number, 0) }) + .actions((self) => ({ __incrementSnapshottedViewsEpoch: () => self.__snapshottedViewsEpoch++ })) + .actions((self) => { + const hook = isRoot(self) ? "afterCreate" : "afterAttach"; + return { + [hook]() { + reaction( + () => { + return klass.snapshottedViews.map((sv) => { + const value = self[sv.property]; + if (sv.options.createSnapshot) { + return sv.options.createSnapshot(value); + } + if (Array.isArray(value)) { + return value.map(getSnapshot); + } + return getSnapshot(value); + }); + }, + () => { + self.__incrementSnapshottedViewsEpoch(); + }, + { equals: comparer.structural }, + ); + }, + }; + }); } + klass.mstType = mstType; (klass as any)[$registered] = true; // define the class constructor and the following hot path functions dynamically // - .createReadOnly // - .is // - .instantiate - return buildFastInstantiator(klass, fastGetters) as any; + return new InstantiatorBuilder(klass, fastGetters).build() as any; } /** @@ -294,6 +355,36 @@ export const view = (target: any, property: string, _descriptor: PropertyDescrip Reflect.defineMetadata(`${viewKeyPrefix}:${property}`, metadata, target); }; +/** + * Function decorator for registering MQT snapshotted views within MQT class models. + * + * Can be passed an `options` object with a `createReadOnly` function for transforming the cached value stored in the snapshot from the snapshot state. + * + * @example + * class Example extends ClassModel({ name: types.string }) { + * @snapshottedView() + * get slug() { + * return this.name.toLowerCase().replace(/ /g, "-"); + * } + * } + * + * @example + * class Example extends ClassModel({ timestamp: types.string }) { + * @snapshottedView({ createReadOnly: (value) => new Date(value) }) + * get date() { + * return new Date(timestamp).setTime(0); + * } + * } + */ +export function snapshottedView( + options: SnapshottedViewOptions = {}, +): (target: any, property: string, _descriptor: PropertyDescriptor) => void { + return (target: any, property: string, _descriptor: PropertyDescriptor) => { + const metadata: SnapshottedViewMetadata = { type: "snapshotted-view", property, options }; + Reflect.defineMetadata(`${viewKeyPrefix}:${property}`, metadata, target); + }; +} + /** * A function for defining a volatile **/ diff --git a/src/fast-getter.ts b/src/fast-getter.ts index d72d55b..7bdfa15 100644 --- a/src/fast-getter.ts +++ b/src/fast-getter.ts @@ -13,7 +13,7 @@ export class FastGetBuilder { ) { this.memoizableProperties = metadatas .filter((metadata): metadata is ViewMetadata => { - if (metadata.type !== "view") return false; + if (metadata.type !== "view" && metadata.type !== "snapshotted-view") return false; const property = metadata.property; const descriptor = getPropertyDescriptor(klass.prototype, property); if (!descriptor) { @@ -24,11 +24,15 @@ export class FastGetBuilder { .map((metadata) => metadata.property); } + memoSymbolName(property: string) { + return `mqt/${property}-memo`; + } + outerClosureStatements(className: string) { return this.memoizableProperties .map( (property) => ` - const ${property}Memo = Symbol.for("mqt/${property}-memo"); + const ${property}Memo = Symbol.for("${this.memoSymbolName(property)}"); ${className}.prototype[${property}Memo] = $notYetMemoized; `, ) @@ -36,7 +40,7 @@ export class FastGetBuilder { } buildGetter(property: string, descriptor: PropertyDescriptor) { - const $memo = Symbol.for(`mqt/${property}-memo`); + const $memo = Symbol.for(this.memoSymbolName(property)); const source = ` ( function build({ $readOnly, $memo, $notYetMemoized, getValue }) { diff --git a/src/fast-instantiator.ts b/src/fast-instantiator.ts index b470752..c1574a7 100644 --- a/src/fast-instantiator.ts +++ b/src/fast-instantiator.ts @@ -1,24 +1,16 @@ -import { isReferenceType, isUnionType } from "mobx-state-tree"; +import { isReferenceType } from "mobx-state-tree"; +import type { IAnyModelType as MSTIAnyModelType, IAnyType as MSTIAnyType } from "mobx-state-tree"; import { ArrayType, QuickArray } from "./array"; +import type { SnapshottedViewMetadata } from "./class-model"; import type { FastGetBuilder } from "./fast-getter"; import { FrozenType } from "./frozen"; import { MapType, QuickMap } from "./map"; +import { MaybeNullType, MaybeType } from "./maybe"; import { OptionalType } from "./optional"; import { ReferenceType, SafeReferenceType } from "./reference"; import { DateType, IntegerType, LiteralType, SimpleType } from "./simple"; import { $context, $identifier, $notYetMemoized, $parent, $readOnly, $type } from "./symbols"; import type { IAnyType, IClassModelType, ValidOptionalValue } from "./types"; -import { MaybeNullType, MaybeType } from "./maybe"; - -/** - * Compiles a fast function for taking snapshots and turning them into instances of a class model. - **/ -export const buildFastInstantiator = , any, any>>( - model: T, - fastGetters: FastGetBuilder, -): T => { - return new InstantiatorBuilder(model, fastGetters).build(); -}; type DirectlyAssignableType = SimpleType | IntegerType | LiteralType | DateType; const isDirectlyAssignableType = (type: IAnyType): type is DirectlyAssignableType => { @@ -31,7 +23,10 @@ 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( @@ -74,7 +69,8 @@ class InstantiatorBuilder, an `); } - const identifierProp = this.model.mstType.identifierAttribute; + const modelType = innerModelType(this.model.mstType); + const identifierProp = modelType?.identifierAttribute; if (identifierProp) { segments.push(` const id = this["${identifierProp}"]; @@ -83,6 +79,10 @@ class InstantiatorBuilder, an `); } + for (const [index, snapshottedView] of this.model.snapshottedViews.entries()) { + segments.push(this.assignSnapshottedViewExpression(snapshottedView, index)); + } + let className = this.model.name; if (!className || className.trim().length == 0) { className = "AnonymousModel"; @@ -144,7 +144,7 @@ class InstantiatorBuilder, an `; const aliasFuncBody = ` - const { QuickMap, QuickArray, $identifier, $context, $parent, $notYetMemoized, $readOnly, $type } = imports; + const { QuickMap, QuickArray, $identifier, $context, $parent, $notYetMemoized, $readOnly, $type, snapshottedViews } = imports; ${Array.from(this.aliases.entries()) .map(([expression, alias]) => `const ${alias} = ${expression};`) @@ -182,6 +182,7 @@ class InstantiatorBuilder, an $notYetMemoized, QuickMap, QuickArray, + snapshottedViews: this.model.snapshottedViews, }) as T; } catch (e) { console.warn("failed to build fast instantiator for", this.model.name); @@ -331,6 +332,25 @@ class InstantiatorBuilder, an }`; } + private assignSnapshottedViewExpression(snapshottedView: SnapshottedViewMetadata, index: number) { + const varName = `view${snapshottedView.property}`; + + let valueExpression = `snapshot?.["${snapshottedView.property}"]`; + if (snapshottedView.options.createReadOnly) { + const alias = this.alias(`snapshottedViews[${index}].options.createReadOnly`); + valueExpression = `${alias}(${valueExpression}, snapshot, this)`; + } + const memoSymbolAlias = this.alias(`Symbol.for("${this.getters.memoSymbolName(snapshottedView.property)}")`); + + return ` + // setup snapshotted view for ${snapshottedView.property} + const ${varName} = ${valueExpression}; + if (typeof ${varName} != "undefined") { + this[${memoSymbolAlias}] = ${varName}; + } + `; + } + alias(expression: string): string { const existing = this.aliases.get(expression); if (existing) { @@ -344,3 +364,17 @@ class InstantiatorBuilder, an } const safeTypeName = (type: IAnyType) => type.name.replace(/\n/g, ""); + +const innerModelType = (type: MSTIAnyType): MSTIAnyModelType | undefined => { + if ("identifierAttribute" in type) return type as MSTIAnyModelType; + if ("getSubTypes" in type) { + const subTypes = (type as { getSubTypes(): MSTIAnyType[] | MSTIAnyType | null }).getSubTypes(); + if (subTypes) { + if (Array.isArray(subTypes)) { + return innerModelType(subTypes[0]); + } else if (subTypes && typeof subTypes == "object") { + return innerModelType(subTypes); + } + } + } +}; diff --git a/src/types.ts b/src/types.ts index cba009c..e50f3c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,6 @@ import type { IInterceptor, IMapDidChange, IMapWillChange, Lambda } from "mobx"; import type { IAnyType as MSTAnyType } from "mobx-state-tree"; -import type { VolatileMetadata } from "./class-model"; +import type { SnapshottedViewMetadata, VolatileMetadata } from "./class-model"; import type { $quickType, $registered, $type } from "./symbols"; export type { $quickType, $registered, $type } from "./symbols"; @@ -147,6 +147,9 @@ export interface IClassModelType< /** @hidden */ volatiles: Record; + /** @hidden */ + snapshottedViews: SnapshottedViewMetadata[]; + /** @hidden */ instantiate(snapshot: this["InputType"] | undefined, context: TreeContext, parent: IStateTreeNode | null): InstanceType; From 48c554202dba815712eea71c474983fc007d98dd Mon Sep 17 00:00:00 2001 From: Harry Brundage Date: Thu, 20 Jun 2024 18:27:53 -0400 Subject: [PATCH 2/3] Hydrate snapshotted views lazily to avoid excessive root boot costs [no-changelog-required] --- spec/class-model-snapshotted-views.spec.ts | 28 ++++++-- src/class-model.ts | 4 +- src/fast-getter.ts | 76 +++++++++++++++++----- src/fast-instantiator.ts | 19 +++--- 4 files changed, 96 insertions(+), 31 deletions(-) diff --git a/spec/class-model-snapshotted-views.spec.ts b/spec/class-model-snapshotted-views.spec.ts index 1935dd7..9361601 100644 --- a/spec/class-model-snapshotted-views.spec.ts +++ b/spec/class-model-snapshotted-views.spec.ts @@ -1,4 +1,4 @@ -import { observable } from "mobx"; +import { observable, runInAction } from "mobx"; import { ClassModel, action, snapshottedView, getSnapshot, register, types, onPatch } from "../src"; import { Apple } from "./fixtures/FruitAisle"; import { create } from "./helpers"; @@ -85,7 +85,9 @@ describe("class model snapshotted views", () => { const instance = MyViewExample.create({ key: "1", name: "Test" }); onPatch(instance, fn); - observableArray.push("a"); + runInAction(() => { + observableArray.push("a"); + }); expect(fn).toMatchSnapshot(); }); @@ -141,8 +143,7 @@ describe("class model snapshotted views", () => { @register class HydrateExample extends ClassModel({ url: types.string }) { @snapshottedView({ - createReadOnly(value, snapshot, node) { - expect(snapshot).toBeDefined(); + createReadOnly(value, node) { expect(node).toBeDefined(); return value ? new URL(value) : undefined; }, @@ -178,6 +179,25 @@ describe("class model snapshotted views", () => { } as any); expect(instance.withoutParams).toEqual(new URL("https://gadget.dev/blog/feature/extra")); }); + + test("hydrators aren't called eagerly on readonly instances in case they are expensive", () => { + const fn = jest.fn().mockReturnValue("whatever"); + @register + class HydrateExampleSpy extends ClassModel({}) { + @snapshottedView({ + createReadOnly: fn, + }) + get someView() { + return "view value"; + } + } + + const instance = HydrateExampleSpy.createReadOnly({ someView: "snapshot value" }); + expect(fn).not.toHaveBeenCalled(); + + expect(instance.someView).toEqual("whatever"); + expect(fn).toHaveBeenCalledTimes(1); + }); }); describe("references", () => { diff --git a/src/class-model.ts b/src/class-model.ts index 8be1db9..37a16f1 100644 --- a/src/class-model.ts +++ b/src/class-model.ts @@ -44,7 +44,7 @@ type ActionMetadata = { /** Options that configure a snapshotted view */ export interface SnapshottedViewOptions { /** A function for converting a stored value in the snapshot back to the rich type for the view to return */ - createReadOnly?: (value: V | undefined, snapshot: T["InputType"], node: Instance) => V | undefined; + createReadOnly?: (value: V | undefined, node: Instance) => V | undefined; /** A function for converting the view value to a snapshot value */ createSnapshot?: (value: V) => any; @@ -195,7 +195,7 @@ export function register Date: Tue, 25 Jun 2024 11:05:36 -0400 Subject: [PATCH 3/3] Track each snapshottedView individually --- src/class-model.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/class-model.ts b/src/class-model.ts index 37a16f1..336380d 100644 --- a/src/class-model.ts +++ b/src/class-model.ts @@ -298,24 +298,24 @@ export function register { - return klass.snapshottedViews.map((sv) => { - const value = self[sv.property]; - if (sv.options.createSnapshot) { - return sv.options.createSnapshot(value); + for (const view of klass.snapshottedViews) { + reaction( + () => { + const value = self[view.property]; + if (view.options.createSnapshot) { + return view.options.createSnapshot(value); } if (Array.isArray(value)) { return value.map(getSnapshot); } return getSnapshot(value); - }); - }, - () => { - self.__incrementSnapshottedViewsEpoch(); - }, - { equals: comparer.structural }, - ); + }, + () => { + self.__incrementSnapshottedViewsEpoch(); + }, + { equals: comparer.structural }, + ); + } }, }; });