diff --git a/src/api/autorun.ts b/src/api/autorun.ts index 4ca96344a..f7b5d0815 100644 --- a/src/api/autorun.ts +++ b/src/api/autorun.ts @@ -1,8 +1,9 @@ -import {Lambda, getNextId, invariant, valueDidChange, fail} from "../utils/utils"; +import {Lambda, getNextId, invariant, fail} from "../utils/utils"; import {isModifierDescriptor} from "../types/modifiers"; import {Reaction, IReactionPublic, IReactionDisposer} from "../core/reaction"; import {untrackedStart, untrackedEnd} from "../core/derivation"; import {action, isAction} from "./action"; +import {IEqualsComparer, comparer} from "../types/comparer"; import {getMessage} from "../utils/messages"; /** @@ -153,9 +154,10 @@ export interface IReactionOptions { context?: any; fireImmediately?: boolean; delay?: number; - compareStructural?: boolean; + compareStructural?: boolean; // TODO: remove in 4.0 in favor of equals /** alias for compareStructural */ - struct?: boolean; + struct?: boolean; // TODO: remove in 4.0 in favor of equals + equals?: IEqualsComparer; name?: string; } @@ -194,7 +196,13 @@ export function reaction(expression: (r: IReactionPublic) => T, effect: (arg: let firstTime = true; let isScheduled = false; - let nextValue: T; + let value: T; + + const equals = opts.equals + ? opts.equals + : (opts.compareStructural || opts.struct) + ? comparer.structural + : comparer.default; const r = new Reaction(opts.name, () => { if (firstTime || (opts.delay as any) < 1) { @@ -213,14 +221,14 @@ export function reaction(expression: (r: IReactionPublic) => T, effect: (arg: return; let changed = false; r.track(() => { - const v = expression(r); - changed = valueDidChange(opts.compareStructural!, nextValue, v); - nextValue = v; + const nextValue = expression(r); + changed = firstTime || !equals(value, nextValue); + value = nextValue; }); if (firstTime && opts.fireImmediately!) - effect(nextValue, r); + effect(value, r); if (!firstTime && (changed as boolean) === true) - effect(nextValue, r); + effect(value, r); if (firstTime) firstTime = false; } diff --git a/src/api/computed.ts b/src/api/computed.ts index 0386af753..cf0396c08 100644 --- a/src/api/computed.ts +++ b/src/api/computed.ts @@ -1,3 +1,4 @@ +import {IEqualsComparer, comparer} from "../types/comparer"; import {asObservableObject, defineComputedProperty} from "../types/observableobject"; import {invariant} from "../utils/utils"; import {createClassPropertyDecorator} from "../utils/decorators"; @@ -5,8 +6,9 @@ import {ComputedValue, IComputedValue} from "../core/computedvalue"; import {getMessage} from "../utils/messages"; export interface IComputedValueOptions { - compareStructural?: boolean; - struct?: boolean; + compareStructural?: boolean; // TODO: remove in 4.0 in favor of equals + struct?: boolean; // TODO: remove in 4.0 in favor of equals + equals?: IEqualsComparer; name?: string; setter?: (value: T) => void; context?: any; @@ -17,17 +19,17 @@ export interface IComputed { (func: () => T, options: IComputedValueOptions): IComputedValue; (target: Object, key: string | symbol, baseDescriptor?: PropertyDescriptor): void; struct(target: Object, key: string | symbol, baseDescriptor?: PropertyDescriptor): void; + equals(equals: IEqualsComparer): PropertyDecorator; } - -function createComputedDecorator(compareStructural) { +function createComputedDecorator(equals: IEqualsComparer) { return createClassPropertyDecorator( (target, name, _, __, originalDescriptor) => { invariant(typeof originalDescriptor !== "undefined", getMessage("m009")); invariant(typeof originalDescriptor.get === "function", getMessage("m010")); const adm = asObservableObject(target, ""); - defineComputedProperty(adm, name, originalDescriptor.get, originalDescriptor.set, compareStructural, false); + defineComputedProperty(adm, name, originalDescriptor.get, originalDescriptor.set, equals, false); }, function (name) { const observable = this.$mobx.values[name]; @@ -43,8 +45,8 @@ function createComputedDecorator(compareStructural) { ); } -const computedDecorator = createComputedDecorator(false); -const computedStructDecorator = createComputedDecorator(true); +const computedDecorator = createComputedDecorator(comparer.default); +const computedStructDecorator = createComputedDecorator(comparer.structural); /** * Decorator for class properties: @computed get value() { return expr; }. @@ -59,8 +61,16 @@ export var computed: IComputed = ( invariant(arguments.length < 3, getMessage("m012")); const opts: IComputedValueOptions = typeof arg2 === "object" ? arg2 : {}; opts.setter = typeof arg2 === "function" ? arg2 : opts.setter; - return new ComputedValue(arg1, opts.context, opts.compareStructural || opts.struct || false, opts.name || arg1.name || "", opts.setter); + + const equals = opts.equals + ? opts.equals + : (opts.compareStructural || opts.struct) + ? comparer.structural + : comparer.default; + + return new ComputedValue(arg1, opts.context, equals, opts.name || arg1.name || "", opts.setter); } ) as any; computed.struct = computedStructDecorator; +computed.equals = createComputedDecorator; \ No newline at end of file diff --git a/src/api/createtransformer.ts b/src/api/createtransformer.ts index c3485ed81..4ee34fd0d 100644 --- a/src/api/createtransformer.ts +++ b/src/api/createtransformer.ts @@ -1,6 +1,7 @@ import {ComputedValue} from "../core/computedvalue"; -import {invariant, getNextId, addHiddenProp} from "../utils/utils"; import {globalState} from "../core/globalstate"; +import {comparer} from "../types/comparer"; +import {invariant, getNextId, addHiddenProp} from "../utils/utils"; export type ITransformer = (object: A) => B; @@ -17,7 +18,7 @@ export function createTransformer(transformer: ITransformer, onClean // Local transformer class specifically for this transformer class Transformer extends ComputedValue { constructor(private sourceIdentifier: string, private sourceObject: A) { - super(() => transformer(sourceObject), undefined, false, `Transformer-${(transformer).name}-${sourceIdentifier}`, undefined); + super(() => transformer(sourceObject), undefined, comparer.default, `Transformer-${(transformer).name}-${sourceIdentifier}`, undefined); } onBecomeUnobserved() { const lastValue = this.value; diff --git a/src/core/computedvalue.ts b/src/core/computedvalue.ts index 4cef54a60..32bee5bd9 100644 --- a/src/core/computedvalue.ts +++ b/src/core/computedvalue.ts @@ -2,9 +2,10 @@ import {IObservable, reportObserved, propagateMaybeChanged, propagateChangeConfi import {IDerivation, IDerivationState, trackDerivedFunction, clearObserving, untrackedStart, untrackedEnd, shouldCompute, CaughtException, isCaughtException} from "./derivation"; import {globalState} from "./globalstate"; import {createAction} from "./action"; -import {createInstanceofPredicate, getNextId, valueDidChange, invariant, Lambda, unique, joinStrings, primitiveSymbol, toPrimitive} from "../utils/utils"; +import {createInstanceofPredicate, getNextId, invariant, Lambda, unique, joinStrings, primitiveSymbol, toPrimitive} from "../utils/utils"; import {isSpyEnabled, spyReport} from "./spy"; import {autorun} from "../api/autorun"; +import {IEqualsComparer} from "../types/comparer"; import {IValueDidChange} from "../types/observablevalue"; import {getMessage} from "../utils/messages"; @@ -46,7 +47,7 @@ export class ComputedValue implements IObservable, IComputedValue, IDeriva lowestObserverState = IDerivationState.UP_TO_DATE; unboundDepsCount = 0; __mapid = "#" + getNextId(); - protected value: T | undefined | CaughtException = undefined; + protected value: T | undefined | CaughtException = new CaughtException(null); name: string; isComputing: boolean = false; // to check for cycles isRunningSetter: boolean = false; @@ -57,12 +58,14 @@ export class ComputedValue implements IObservable, IComputedValue, IDeriva * * The `name` property is for debug purposes only. * - * The `compareStructural` property indicates whether the return values should be compared structurally. - * Normally, a computed value will not notify an upstream observer if a newly produced value is strictly equal to the previously produced value. - * However, enabling compareStructural can be convenient if you always produce an new aggregated object and don't want to notify observers if it is structurally the same. + * The `equals` property specifies the comparer function to use to determine if a newly produced + * value differs from the previous value. Two comparers are provided in the library; `defaultComparer` + * compares based on identity comparison (===), and `structualComparer` deeply compares the structure. + * Structural comparison can be convenient if you always produce an new aggregated object and + * don't want to notify observers if it is structurally the same. * This is useful for working with vectors, mouse coordinates etc. */ - constructor(public derivation: () => T, public scope: Object | undefined, private compareStructural: boolean, name: string, setter?: (v: T) => void) { + constructor(public derivation: () => T, public scope: Object | undefined, private equals: IEqualsComparer, name: string, setter?: (v: T) => void) { this.name = name || "ComputedValue@" + getNextId(); if (setter) this.setter = createAction(name + "-setter", setter) as any; @@ -135,7 +138,11 @@ export class ComputedValue implements IObservable, IComputedValue, IDeriva } const oldValue = this.value; const newValue = this.value = this.computeValue(true); - return isCaughtException(newValue) || valueDidChange(this.compareStructural, newValue, oldValue); + return ( + isCaughtException(oldValue) || + isCaughtException(newValue) || + !this.equals(oldValue, newValue) + ); } computeValue(track: boolean) { diff --git a/src/mobx.ts b/src/mobx.ts index 49865f3a8..15bcbb977 100644 --- a/src/mobx.ts +++ b/src/mobx.ts @@ -32,6 +32,7 @@ export { useStrict, isStrictModeEnabled, IAction } from "./core/act export { spy } from "./core/spy"; export { IComputedValue } from "./core/computedvalue"; +export { IEqualsComparer, comparer } from "./types/comparer"; export { asReference, asFlat, asStructure, asMap } from "./types/modifiers-old"; export { IModifierDescriptor, IEnhancer, isModifierDescriptor } from "./types/modifiers"; export { IInterceptable, IInterceptor } from "./types/intercept-utils"; @@ -74,8 +75,8 @@ import { isComputingDerivation } from "./core/derivation"; import { setReactionScheduler, onReactionError } from "./core/reaction"; import { reserveArrayBuffer, IObservableArray } from "./types/observablearray"; import { interceptReads } from "./api/intercept-read"; -import { ObservableMap } from './types/observablemap'; -import { IObservableValue } from './types/observablevalue'; +import { ObservableMap } from "./types/observablemap"; +import { IObservableValue } from "./types/observablevalue"; import {registerGlobals} from "./core/globalstate"; // This line should come after all the imports as well, for the same reason @@ -115,6 +116,7 @@ import { IAtom, Atom, BaseAtom } from "./core/ato import { useStrict, isStrictModeEnabled, IAction } from "./core/action"; import { spy } from "./core/spy"; import { IComputedValue } from "./core/computedvalue"; +import { IEqualsComparer, comparer } from "./types/comparer"; import { asReference, asFlat, asStructure, asMap } from "./types/modifiers-old"; import { IModifierDescriptor, IEnhancer, isModifierDescriptor } from "./types/modifiers"; import { IInterceptable, IInterceptor } from "./types/intercept-utils"; @@ -146,6 +148,7 @@ const everything = { Atom, BaseAtom, useStrict, isStrictModeEnabled, spy, + comparer, asReference, asFlat, asStructure, asMap, isModifierDescriptor, isObservableObject, diff --git a/src/types/comparer.ts b/src/types/comparer.ts new file mode 100644 index 000000000..9445fefc3 --- /dev/null +++ b/src/types/comparer.ts @@ -0,0 +1,29 @@ +import { deepEqual } from '../utils/utils'; + +export interface IEqualsComparer { + (a: T, b: T): boolean; +} + +function identityComparer(a: any, b: any): boolean { + return a === b; +} + +function structuralComparer(a: any, b: any): boolean { + if (typeof a === 'number' && typeof b === 'number' && isNaN(a) && isNaN(b)) { + return true; + } + return deepEqual(a, b); +} + +function defaultComparer(a: any, b: any): boolean { + if (typeof a === 'number' && typeof b === 'number' && isNaN(a) && isNaN(b)) { + return true; + } + return identityComparer(a, b); +} + +export const comparer = { + identity: identityComparer, + structural: structuralComparer, + default: defaultComparer +}; diff --git a/src/types/observableobject.ts b/src/types/observableobject.ts index 5aab209e8..6ec022b27 100644 --- a/src/types/observableobject.ts +++ b/src/types/observableobject.ts @@ -5,12 +5,12 @@ import {runLazyInitializers} from "../utils/decorators"; import {hasInterceptors, IInterceptable, registerInterceptor, interceptChange} from "./intercept-utils"; import {IListenable, registerListener, hasListeners, notifyListeners} from "./listen-utils"; import {isSpyEnabled, spyReportStart, spyReportEnd} from "../core/spy"; +import {IEqualsComparer, comparer} from "./comparer"; import {IEnhancer, isModifierDescriptor, IModifierDescriptor} from "./modifiers"; import {isAction, defineBoundAction} from "../api/action"; import {getMessage} from "../utils/messages"; - export interface IObservableObject { "observable-object": IObservableObject; } @@ -100,7 +100,7 @@ export function defineObservablePropertyFromDescriptor(adm: ObservableObjectAdmi } } else { // get x() { return 3 } set x(v) { } - defineComputedProperty(adm, propName, descriptor.get, descriptor.set, false, true); + defineComputedProperty(adm, propName, descriptor.get, descriptor.set, comparer.default, true); } } @@ -135,13 +135,13 @@ export function defineComputedProperty( propName: string, getter, setter, - compareStructural: boolean, + equals: IEqualsComparer, asInstanceProperty: boolean ) { if (asInstanceProperty) assertPropertyConfigurable(adm.target, propName); - adm.values[propName] = new ComputedValue(getter, adm.target, compareStructural, `${adm.name}.${propName}`, setter); + adm.values[propName] = new ComputedValue(getter, adm.target, equals, `${adm.name}.${propName}`, setter); if (asInstanceProperty) { Object.defineProperty(adm.target, propName, generateComputedPropConfig(propName)); } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 28d02d26b..cfba77887 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -94,15 +94,6 @@ export function objectAssign() { return res; } -export function valueDidChange(compareStructural: boolean, oldValue, newValue): boolean { - if (typeof oldValue === 'number' && isNaN(oldValue)) { - return typeof newValue !== 'number' || !isNaN(newValue); - } - return compareStructural - ? !deepEqual(oldValue, newValue) - : oldValue !== newValue; -} - const prototypeHasOwnProperty = Object.prototype.hasOwnProperty; export function hasOwnProperty(object: Object, propName: string) { return prototypeHasOwnProperty.call(object, propName); diff --git a/test/api.js b/test/api.js index 2f44117b2..177c4742a 100644 --- a/test/api.js +++ b/test/api.js @@ -17,6 +17,7 @@ test('correct api should be exposed', function(t) { 'autorun', 'autorunAsync', 'computed', + 'comparer', 'createTransformer', 'default', 'expr', diff --git a/test/babel/babel-tests.js b/test/babel/babel-tests.js index 45a3629a2..2cc2bd13e 100644 --- a/test/babel/babel-tests.js +++ b/test/babel/babel-tests.js @@ -873,3 +873,38 @@ test("action.bound binds (Babel)", t=> { t.end(); }) + +test("@computed.equals (Babel)", t => { + const sameTime = (from, to) => from.hour === to.hour && from.minute === to.minute; + class Time { + constructor(hour, minute) { + this.hour = hour; + this.minute = minute; + } + + @observable hour: number; + @observable minute: number; + + @computed.equals(sameTime) get time() { + return { hour: this.hour, minute: this.minute }; + } + } + const time = new Time(9, 0); + + const changes = []; + const disposeAutorun = autorun(() => changes.push(time.time)); + + t.deepEqual(changes, [ { hour: 9, minute: 0 }]); + time.hour = 9; + t.deepEqual(changes, [ { hour: 9, minute: 0 }]); + time.minute = 0; + t.deepEqual(changes, [ { hour: 9, minute: 0 }]); + time.hour = 10; + t.deepEqual(changes, [ { hour: 9, minute: 0 }, { hour: 10, minute: 0 }]); + time.minute = 30; + t.deepEqual(changes, [ { hour: 9, minute: 0 }, { hour: 10, minute: 0 }, { hour: 10, minute: 30 }]); + + disposeAutorun(); + + t.end(); +}); \ No newline at end of file diff --git a/test/observables.js b/test/observables.js index fbe36c7a7..03bdc6055 100644 --- a/test/observables.js +++ b/test/observables.js @@ -1730,3 +1730,51 @@ test('observables should not fail when ES6 Map is missing', t => { global.Map = globalMapFunction; t.end(); }) + +test("computed equals function only invoked when necessary", t => { + const comparisons = []; + const loggingComparer = (from, to) => { + comparisons.push({ from, to }); + return from === to; + }; + + const left = mobx.observable("A"); + const right = mobx.observable("B"); + const combinedToLowerCase = mobx.computed( + () => left.get().toLowerCase() + right.get().toLowerCase(), + { equals: loggingComparer } + ); + + const values = []; + const disposeAutorun = mobx.autorun(() => values.push(combinedToLowerCase.get())); + + // No comparison should be made on the first value + t.deepEqual(comparisons, []); + + // First change will cause a comparison + left.set("C"); + t.deepEqual(comparisons, [{ from: "ab", to: "cb" }]); + + // Transition *to* CaughtException in the computed won't cause a comparison + left.set(null); + t.deepEqual(comparisons, [{ from: "ab", to: "cb" }]); + + // Transition *between* CaughtException-s in the computed won't cause a comparison + right.set(null); + t.deepEqual(comparisons, [{ from: "ab", to: "cb" }]); + + // Transition *from* CaughtException in the computed won't cause a comparison + left.set("D"); + right.set("E"); + t.deepEqual(comparisons, [{ from: "ab", to: "cb" }]); + + // Another value change will cause a comparison + right.set("F"); + t.deepEqual(comparisons, [{ from: "ab", to: "cb" }, { from: "de", to: "df" }]); + + t.deepEqual(values, ["ab", "cb", "de", "df"]); + + disposeAutorun(); + + t.end(); +}); diff --git a/test/reaction.js b/test/reaction.js index d3f663a41..37c893fc8 100644 --- a/test/reaction.js +++ b/test/reaction.js @@ -284,3 +284,73 @@ test("do not rerun if prev & next expr output is NaN", t => { t.deepEqual(valuesS, [ 'a', 'NaN', 'b']); t.end(); }) + +test("reaction uses equals", t => { + const o = mobx.observable("a"); + const values = []; + const disposeReaction = mobx.reaction( + () => o.get(), + (value) => values.push(value.toLowerCase()), + { equals: (from, to) => from.toUpperCase() === to.toUpperCase(), fireImmediately: true } + ); + t.deepEqual(values, ["a"]); + o.set("A"); + t.deepEqual(values, ["a"]); + o.set("B"); + t.deepEqual(values, ["a", "b"]); + o.set("A"); + t.deepEqual(values, ["a", "b", "a"]); + + disposeReaction(); + + t.end(); +}); + + +test("reaction equals function only invoked when necessary", t => { + const comparisons = []; + const loggingComparer = (from, to) => { + comparisons.push({ from, to }); + return from === to; + }; + + const left = mobx.observable("A"); + const right = mobx.observable("B"); + + const values = []; + const disposeReaction = mobx.reaction( + () => left.get().toLowerCase() + right.get().toLowerCase(), + (value) => values.push(value), + { equals: loggingComparer, fireImmediately: true } + ); + + // No comparison should be made on the first value + t.deepEqual(comparisons, []); + + // First change will cause a comparison + left.set("C"); + t.deepEqual(comparisons, [{ from: "ab", to: "cb" }]); + + // Exception in the reaction expression won't cause a comparison + left.set(null); + t.deepEqual(comparisons, [{ from: "ab", to: "cb" }]); + + // Another exception in the reaction expression won't cause a comparison + right.set(null); + t.deepEqual(comparisons, [{ from: "ab", to: "cb" }]); + + // Transition from exception in the expression will cause a comparison with the last valid value + left.set("D"); + right.set("E"); + t.deepEqual(comparisons, [{ from: "ab", to: "cb" }, { from: 'cb', to: 'de' }]); + + // Another value change will cause a comparison + right.set("F"); + t.deepEqual(comparisons, [{ from: "ab", to: "cb" }, { from: 'cb', to: 'de' }, { from: "de", to: "df" }]); + + t.deepEqual(values, ["ab", "cb", "de", "df"]); + + disposeReaction(); + + t.end(); +}); diff --git a/test/typescript/typescript-tests.ts b/test/typescript/typescript-tests.ts index eb1064b47..961a10f70 100644 --- a/test/typescript/typescript-tests.ts +++ b/test/typescript/typescript-tests.ts @@ -1134,6 +1134,80 @@ test("803 - action.bound and action preserve type info", t => { t.end() }) +test("@computed.equals (TS)", t => { + const sameTime = (from: Time, to: Time) => from.hour === to.hour && from.minute === to.minute; + class Time { + constructor(hour: number, minute: number) { + this.hour = hour; + this.minute = minute; + } + + @observable public hour: number; + @observable public minute: number; + + @computed.equals(sameTime) public get time() { + return { hour: this.hour, minute: this.minute }; + } + } + const time = new Time(9, 0); + + const changes: Array<{ hour: number, minute: number }> = []; + const disposeAutorun = autorun(() => changes.push(time.time)); + + t.deepEqual(changes, [ { hour: 9, minute: 0 }]); + time.hour = 9; + t.deepEqual(changes, [ { hour: 9, minute: 0 }]); + time.minute = 0; + t.deepEqual(changes, [ { hour: 9, minute: 0 }]); + time.hour = 10; + t.deepEqual(changes, [ { hour: 9, minute: 0 }, { hour: 10, minute: 0 }]); + time.minute = 30; + t.deepEqual(changes, [ { hour: 9, minute: 0 }, { hour: 10, minute: 0 }, { hour: 10, minute: 30 }]); + + disposeAutorun(); + + t.end(); +}); + +test("computed comparer works with extendObservable (TS)", t => { + const sameTime = (from: Time, to: Time) => from.hour === to.hour && from.minute === to.minute; + class Time { + constructor(hour: number, minute: number) { + this.hour = hour; + this.minute = minute; + extendObservable(this, { + hour, + minute, + time: computed(() => { + return { hour: this.hour, minute: this.minute }; + }, { equals: sameTime }) + }) + } + + public hour: number; + public minute: number; + public time: { hour: number, minute: number }; + } + const time = new Time(9, 0); + + const changes: Array<{ hour: number, minute: number }> = []; + const disposeAutorun = autorun(() => changes.push(time.time)); + + t.deepEqual(changes, [ { hour: 9, minute: 0 }]); + time.hour = 9; + t.deepEqual(changes, [ { hour: 9, minute: 0 }]); + time.minute = 0; + t.deepEqual(changes, [ { hour: 9, minute: 0 }]); + time.hour = 10; + t.deepEqual(changes, [ { hour: 9, minute: 0 }, { hour: 10, minute: 0 }]); + time.minute = 30; + t.deepEqual(changes, [ { hour: 9, minute: 0 }, { hour: 10, minute: 0 }, { hour: 10, minute: 30 }]); + + disposeAutorun(); + + t.end(); +}); + test("1072 - @observable without initial value and observe before first access", t => { class User { @observable loginCount: number; @@ -1142,4 +1216,4 @@ test("1072 - @observable without initial value and observe before first access", const user = new User(); observe(user, 'loginCount', () => {}); t.end() -}) \ No newline at end of file +})