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

Shallow comparer #2151

Merged
merged 2 commits into from
Oct 12, 2019
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 CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
* Added `comparer.shallow` for shallow object/array comparisons [#1561](https://github.com/mobxjs/mobx/issues/1561).

# 5.14.0 / 4.14.0

* Added experimental `reactionRequiresObservable` & `observableRequiresReaction` config [#2079](https://github.com/mobxjs/mobx/pull/2079), [Docs](https://github.com/mobxjs/mobx/pull/2082)
Expand Down
2 changes: 1 addition & 1 deletion docs/refguide/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ The expression will automatically be re-evaluated if any observables it uses cha

There are various `options` that can be used to control the behavior of `computed`. These include:

- **`equals: (value, value) => boolean`** Comparison method can be used to override the default detection on when something is changed. Built-in comparers are: `comparer.identity`, `comparer.default`, `comparer.structural`.
- **`equals: (value, value) => boolean`** Comparison method can be used to override the default detection on when something is changed. Built-in comparers are: `comparer.identity`, `comparer.default`, `comparer.structural`, `comparer.shallow`.
- **`name: string`** Provide a debug name to this computed property
- **`requiresReaction: boolean`** Wait for a change in value of the tracked observables, before recomputing the derived property
- **`get: () => value)`** Override the getter for the computed property.
Expand Down
3 changes: 2 additions & 1 deletion docs/refguide/computed-decorator.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ When using `computed` as modifier or as box, it accepts a second options argumen
- `name`: String, the debug name used in spy and the MobX devtools
- `context`: The `this` that should be used in the provided expression
- `set`: The setter function to be used. Without setter it is not possible to assign new values to a computed value. If the second argument passed to `computed` is a function, this is assumed to be a setter.
- `equals`: By default `comparer.default`. This acts as a comparison function for comparing the previous value with the next value. If this function considers the previous and next values to be equal, then observers will not be re-evaluated. This is useful when working with structural data, and types from other libraries. For example, a computed [moment](https://momentjs.com/) instance could use `(a, b) => a.isSame(b)`. `comparer.structural` comes in handy if you want to use structural comparison to determine whether the new value is different from the previous value (and as a result notify observers).
- `equals`: By default `comparer.default`. This acts as a comparison function for comparing the previous value with the next value. If this function considers the previous and next values to be equal, then observers will not be re-evaluated. This is useful when working with structural data, and types from other libraries. For example, a computed [moment](https://momentjs.com/) instance could use `(a, b) => a.isSame(b)`. `comparer.structural` and `comparer.shallow` come in handy if you want to use structural/shallow comparison to determine whether the new value is different from the previous value (and as a result notify observers).
- `requiresReaction`: It is recommended to set this one to `true` on very expensive computed values. If you try to read it's value, but the value is not being tracked by some observer (in which case MobX won't cache the value), it will cause the computed to throw, instead of doing an expensive re-evalution.
- `keepAlive`: don't suspend this computed value if it is not observed by anybody. _Be aware, this can easily lead to memory leaks as it will result in every observable used by this computed value, keeping the computed value in memory!_

Expand All @@ -174,6 +174,7 @@ MobX provides three built-in `comparer`s which should cover most needs:
- `comparer.identity`: Uses the identity (`===`) operator to determine if two values are the same.
- `comparer.default`: The same as `comparer.identity`, but also considers `NaN` to be equal to `NaN`.
- `comparer.structural`: Performs deep structural comparison to determine if two values are the same.
- `comparer.shallow`: Performs shallow structural comparison to determine if two values are the same.

## Computed values run more often than expected

Expand Down
7 changes: 6 additions & 1 deletion src/utils/comparer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@ function structuralComparer(a: any, b: any): boolean {
return deepEqual(a, b)
}

function shallowComparer(a: any, b: any): boolean {
return deepEqual(a, b, 1)
}

function defaultComparer(a: any, b: any): boolean {
return Object.is(a, b)
}

export const comparer = {
identity: identityComparer,
structural: structuralComparer,
default: defaultComparer
default: defaultComparer,
shallow: shallowComparer
}
21 changes: 12 additions & 9 deletions src/utils/eq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ import {
declare const Symbol
const toString = Object.prototype.toString

export function deepEqual(a: any, b: any): boolean {
return eq(a, b)
export function deepEqual(a: any, b: any, depth: number = -1): boolean {
return eq(a, b, depth)
}

// Copied from https://github.com/jashkenas/underscore/blob/5c237a7c682fb68fd5378203f0bf22dce1624854/underscore.js#L1186-L1289
// Internal recursive comparison function for `isEqual`.
function eq(a: any, b: any, aStack?: any[], bStack?: any[]) {
function eq(a: any, b: any, depth: number, aStack?: any[], bStack?: any[]) {
// Identical objects are equal. `0 === -0`, but they aren't identical.
// See the [Harmony `egal` proposal](http://wiki.ecmascript.org/doku.php?id=harmony:egal).
if (a === b) return a !== 0 || 1 / a === 1 / b
Expand All @@ -26,11 +26,7 @@ function eq(a: any, b: any, aStack?: any[], bStack?: any[]) {
// Exhaust primitive checks
const type = typeof a
if (type !== "function" && type !== "object" && typeof b != "object") return false
return deepEq(a, b, aStack, bStack)
}

// Internal recursive comparison function for `isEqual`.
function deepEq(a: any, b: any, aStack?: any[], bStack?: any[]) {
// Unwrap any wrapped objects.
a = unwrap(a)
b = unwrap(b)
Expand Down Expand Up @@ -84,6 +80,13 @@ function deepEq(a: any, b: any, aStack?: any[], bStack?: any[]) {
return false
}
}

if (depth === 0) {
return false
} else if (depth < 0) {
depth = -1
}

// Assume equality for cyclic structures. The algorithm for detecting cyclic
// structures is adapted from ES 5.1 section 15.12.3, abstract operation `JO`.

Expand All @@ -109,7 +112,7 @@ function deepEq(a: any, b: any, aStack?: any[], bStack?: any[]) {
if (length !== b.length) return false
// Deep compare the contents, ignoring non-numeric properties.
while (length--) {
if (!eq(a[length], b[length], aStack, bStack)) return false
if (!eq(a[length], b[length], depth - 1, aStack, bStack)) return false
}
} else {
// Deep compare objects.
Expand All @@ -121,7 +124,7 @@ function deepEq(a: any, b: any, aStack?: any[], bStack?: any[]) {
while (length--) {
// Deep compare each member
key = keys[length]
if (!(has(b, key) && eq(a[key], b[key], aStack, bStack))) return false
if (!(has(b, key) && eq(a[key], b[key], depth - 1, aStack, bStack))) return false
}
}
// Remove the first object from the stack of traversed objects.
Expand Down
26 changes: 26 additions & 0 deletions test/base/extras.js
Original file line number Diff line number Diff line change
Expand Up @@ -487,3 +487,29 @@ test("deepEquals should yield correct results for complex objects #1118 - 2", ()
expect(mobx.comparer.structural(a1, a2)).toBe(true)
expect(mobx.comparer.structural(a1, a4)).toBe(false)
})

test("comparer.shallow should work", () => {
const sh = mobx.comparer.shallow

expect(sh(1, 1)).toBe(true)

expect(sh(1, 2)).toBe(false)

expect(sh({}, {})).toBe(true)
expect(sh([], [])).toBe(true)

expect(sh({}, [])).toBe(false)
expect(sh([], {})).toBe(false)

expect(sh({ a: 1, b: 2, c: 3 }, { a: 1, b: 2, c: 3 })).toBe(true)

expect(sh({ a: 1, b: 2, c: 3, d: 4 }, { a: 1, b: 2, c: 3 })).toBe(false)
expect(sh({ a: 1, b: 2, c: 3 }, { a: 1, b: 2, c: 3, d: 4 })).toBe(false)
expect(sh({ a: {}, b: 2, c: 3 }, { a: {}, b: 2, c: 3 })).toBe(false)

expect(sh([1, 2, 3], [1, 2, 3])).toBe(true)

expect(sh([1, 2, 3, 4], [1, 2, 3])).toBe(false)
expect(sh([1, 2, 3], [1, 2, 3, 4])).toBe(false)
expect(sh([{}, 2, 3], [{}, 2, 3])).toBe(false)
})