Skip to content

Commit

Permalink
Add shouldTrackChanges option to @snapshottedView
Browse files Browse the repository at this point in the history
  • Loading branch information
scott-rc committed Jul 10, 2024
1 parent 3dea56c commit e96e7a5
Show file tree
Hide file tree
Showing 3 changed files with 186 additions and 18 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,8 @@ const readOnlyExample = TransformExample.createReadOnly(snapshot);
readOnlyExample.withoutParams; // => URL { href: "https://example.com" }
```

Snapshotted views emit patches when their values change. If you don't want snapshotted views to emit a patch when they change, you can pass a `shouldEmitPatchOnChange` function that returns `false` to the `@snapshottedView`, or you can pass `false` to `setDefaultShouldEmitPatchOnChange` to disable patch emission for all snapshotted views.

##### Snapshotted view semantics

Snapshotted views are a complicated beast, and are best avoided until your performance demands less computation on readonly instances.
Expand Down
146 changes: 146 additions & 0 deletions spec/class-model-snapshotted-views.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ClassModel, action, snapshottedView, getSnapshot, register, types, onPa
import { Apple } from "./fixtures/FruitAisle";
import { create } from "./helpers";
import { getParent } from "mobx-state-tree";
import { setDefaultShouldEmitPatchOnChange } from "../src/class-model";

@register
class ViewExample extends ClassModel({ key: types.identifier, name: types.string }) {
Expand Down Expand Up @@ -285,4 +286,149 @@ describe("class model snapshotted views", () => {
expect(onError).not.toHaveBeenCalled();
});
});

describe("shouldEmitPatchOnChange", () => {
afterEach(() => {
// reset the default value
setDefaultShouldEmitPatchOnChange(true);
});

test("readonly instances don't use the shouldEmitPatchOnChange option", () => {
const fn = jest.fn();
@register
class MyViewExample extends ClassModel({ key: types.identifier, name: types.string }) {
@snapshottedView({ shouldEmitPatchOnChange: fn })
get slug() {
return this.name.toLowerCase().replace(/ /g, "-");
}
}

const instance = MyViewExample.createReadOnly({ key: "1", name: "Test" });
expect(instance.slug).toEqual("test");
expect(fn).not.toHaveBeenCalled();
});

test("observable instances don't emit a patch when shouldEmitPatchOnChange returns false", () => {
const shouldEmitPatchOnChangeFn = jest.fn(() => false);
const observableArray = observable.array<string>([]);

@register
class MyViewExample extends ClassModel({ key: types.identifier, name: types.string }) {
@snapshottedView({ shouldEmitPatchOnChange: shouldEmitPatchOnChangeFn })
get arrayLength() {
return observableArray.length;
}
}

const instance = MyViewExample.create({ key: "1", name: "Test" });
expect(shouldEmitPatchOnChangeFn).toHaveBeenCalled();

const onPatchFn = jest.fn();
onPatch(instance, onPatchFn);

runInAction(() => {
observableArray.push("a");
});

expect(onPatchFn).not.toHaveBeenCalled();
});

test("observable instances do emit a patch when shouldEmitPatchOnChange returns true", () => {
const shouldEmitPatchOnChangeFn = jest.fn(() => true);
const observableArray = observable.array<string>([]);

@register
class MyViewExample extends ClassModel({ key: types.identifier, name: types.string }) {
@snapshottedView({ shouldEmitPatchOnChange: shouldEmitPatchOnChangeFn })
get arrayLength() {
return observableArray.length;
}
}

const instance = MyViewExample.create({ key: "1", name: "Test" });
expect(shouldEmitPatchOnChangeFn).toHaveBeenCalled();

const onPatchFn = jest.fn();
onPatch(instance, onPatchFn);

runInAction(() => {
observableArray.push("a");
});

expect(onPatchFn).toHaveBeenCalled();
});

test("observable instances do emit a patch when shouldEmitPatchOnChange is undefined and setDefaultShouldEmitPatchOnChange hasn't been called", () => {
const observableArray = observable.array<string>([]);

@register
class MyViewExample extends ClassModel({ key: types.identifier, name: types.string }) {
@snapshottedView()
get arrayLength() {
return observableArray.length;
}
}

const instance = MyViewExample.create({ key: "1", name: "Test" });

const onPatchFn = jest.fn();
onPatch(instance, onPatchFn);

runInAction(() => {
observableArray.push("a");
});

expect(onPatchFn).toHaveBeenCalled();
});

test("observable instances do emit a patch when shouldEmitPatchOnChange is undefined and setDefaultShouldEmitPatchOnChange was passed true", () => {
setDefaultShouldEmitPatchOnChange(true);

const observableArray = observable.array<string>([]);

@register
class MyViewExample extends ClassModel({ key: types.identifier, name: types.string }) {
@snapshottedView()
get arrayLength() {
return observableArray.length;
}
}

const instance = MyViewExample.create({ key: "1", name: "Test" });

const onPatchFn = jest.fn();
onPatch(instance, onPatchFn);

runInAction(() => {
observableArray.push("a");
});

expect(onPatchFn).toHaveBeenCalled();
});

test("observable instances don't emit a patch when shouldEmitPatchOnChange is undefined and setDefaultShouldEmitPatchOnChange was passed false", () => {
setDefaultShouldEmitPatchOnChange(false);

const observableArray = observable.array<string>([]);

@register
class MyViewExample extends ClassModel({ key: types.identifier, name: types.string }) {
@snapshottedView()
get arrayLength() {
return observableArray.length;
}
}

const instance = MyViewExample.create({ key: "1", name: "Test" });

const onPatchFn = jest.fn();
onPatch(instance, onPatchFn);

runInAction(() => {
observableArray.push("a");
});

expect(onPatchFn).not.toHaveBeenCalled();
});
});
});
56 changes: 38 additions & 18 deletions src/class-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export interface SnapshottedViewOptions<V, T extends IAnyClassModelType> {
/** A function for converting the view value to a snapshot value */
createSnapshot?: (value: V) => any;

/** A function that determines whether the view should emit a patch when it changes. */
shouldEmitPatchOnChange?: (node: Instance<T>) => boolean;

/** A function that will be called when the view's reaction throws an error. */
onError?: (error: any) => void;
}
Expand Down Expand Up @@ -303,24 +306,26 @@ export function register<Instance, Klass extends { new (...args: any[]): Instanc
return {
afterCreate() {
for (const view of klass.snapshottedViews) {
reactions.push(
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, onError: view.options.onError },
),
);
if (view.options.shouldEmitPatchOnChange?.(self) ?? defaultShouldEmitPatchOnChange) {
reactions.push(
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, onError: view.options.onError },
),
);
}
}
},
beforeDestroy() {
Expand Down Expand Up @@ -554,3 +559,18 @@ export function getPropertyDescriptor(obj: any, property: string) {
export const isClassModel = (type: IAnyType): type is IClassModelType<any, any, any> => {
return (type as any).isMQTClassModel;
};

let defaultShouldEmitPatchOnChange = true;

/**
* Sets the default value for the `shouldEmitPatchOnChange` option for
* snapshotted views.
*
* If a snapshotted view does not have a `shouldEmitPatchOnChange`
* function defined, this value will be used instead.
*
* @param value - The new default value for the `shouldEmitPatchOnChange` option.
*/
export function setDefaultShouldEmitPatchOnChange(value: boolean) {
defaultShouldEmitPatchOnChange = value;
}

0 comments on commit e96e7a5

Please sign in to comment.