Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add shouldTrackChanges option to @snapshottedView #107

Merged
merged 1 commit into from
Jul 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
Loading