diff --git a/__performance_tests__/todo.js b/__performance_tests__/todo.js index b4fef0a1..9dc71866 100644 --- a/__performance_tests__/todo.js +++ b/__performance_tests__/todo.js @@ -3,6 +3,12 @@ import immerProxy, {setAutoFreeze as setAutoFreezeProxy} from ".." import immerEs5, {setAutoFreeze as setAutoFreezeEs5} from "../es5" import cloneDeep from "lodash.clonedeep" import {List, Record} from "immutable" +import deepFreeze from "deep-freeze" + +function freeze(x) { + Object.freeze(x) + return x +} describe("performance", () => { const MAX = 100000 @@ -21,14 +27,7 @@ describe("performance", () => { } // Produce the frozen bazeState - frozenBazeState = baseState.map(todo => { - const newTodo = {...todo} - newTodo.someThingCompletelyIrrelevant = todo.someThingCompletelyIrrelevant.slice() - Object.freeze(newTodo.someThingCompletelyIrrelevant) - Object.freeze(newTodo) - return newTodo - }) - Object.freeze(frozenBazeState) + frozenBazeState = deepFreeze(cloneDeep(baseState)) // generate immutalbeJS base state const todoRecord = Record({ @@ -43,20 +42,41 @@ describe("performance", () => { test(name, fn) } - measure("just mutate", () => { + { + const draft = cloneDeep(baseState) + measure("just mutate", () => { + for (let i = 0; i < MAX * MODIFY_FACTOR; i++) { + draft[i].done = true + } + }) + } + + { + const draft = cloneDeep(baseState) + measure("just mutate, freeze", () => { + for (let i = 0; i < MAX * MODIFY_FACTOR; i++) { + draft[i].done = true + } + deepFreeze(draft) + }) + } + + measure("deepclone, then mutate", () => { + const draft = cloneDeep(baseState) for (let i = 0; i < MAX * MODIFY_FACTOR; i++) { - baseState[i].done = true + draft[i].done = true } }) - measure("deepclone, then mutate", () => { + measure("deepclone, then mutate, then freeze", () => { const draft = cloneDeep(baseState) for (let i = 0; i < MAX * MODIFY_FACTOR; i++) { draft[i].done = true } + deepFreeze(draft) }) - measure("handcrafted reducer", () => { + measure("handcrafted reducer (no freeze)", () => { const nextState = [ ...baseState.slice(0, MAX * MODIFY_FACTOR).map(todo => ({ ...todo, @@ -66,6 +86,42 @@ describe("performance", () => { ] }) + measure("handcrafted reducer (with freeze)", () => { + const nextState = freeze([ + ...baseState.slice(0, MAX * MODIFY_FACTOR).map(todo => + freeze({ + ...todo, + done: true + }) + ), + ...baseState.slice(MAX * MODIFY_FACTOR) + ]) + }) + + measure("naive handcrafted reducer (without freeze)", () => { + const nextState = baseState.map((todo, index) => { + if (index < MAX * MODIFY_FACTOR) + return { + ...todo, + done: true + } + else return todo + }) + }) + + measure("naive handcrafted reducer (with freeze)", () => { + const nextState = deepFreeze( + baseState.map((todo, index) => { + if (index < MAX * MODIFY_FACTOR) + return { + ...todo, + done: true + } + else return todo + }) + ) + }) + measure("immutableJS", () => { let state = immutableJsBaseState state.withMutations(state => { @@ -75,13 +131,14 @@ describe("performance", () => { }) }) - measure("immer (proxy) - with autofreeze", () => { - setAutoFreezeProxy(true) - immerProxy(frozenBazeState, draft => { - for (let i = 0; i < MAX * MODIFY_FACTOR; i++) { - draft[i].done = true - } - }) + measure("immutableJS + toJS", () => { + let state = immutableJsBaseState + .withMutations(state => { + for (let i = 0; i < MAX * MODIFY_FACTOR; i++) { + state.setIn([i, "done"], true) + } + }) + .toJS() }) measure("immer (proxy) - without autofreeze", () => { @@ -94,9 +151,9 @@ describe("performance", () => { setAutoFreezeProxy(true) }) - measure("immer (es5) - with autofreeze", () => { - setAutoFreezeEs5(true) - immerEs5(frozenBazeState, draft => { + measure("immer (proxy) - with autofreeze", () => { + setAutoFreezeProxy(true) + immerProxy(frozenBazeState, draft => { for (let i = 0; i < MAX * MODIFY_FACTOR; i++) { draft[i].done = true } @@ -112,4 +169,13 @@ describe("performance", () => { }) setAutoFreezeEs5(true) }) + + measure("immer (es5) - with autofreeze", () => { + setAutoFreezeEs5(true) + immerEs5(frozenBazeState, draft => { + for (let i = 0; i < MAX * MODIFY_FACTOR; i++) { + draft[i].done = true + } + }) + }) }) diff --git a/__tests__/base.js b/__tests__/base.js index 3909d743..73f509e2 100644 --- a/__tests__/base.js +++ b/__tests__/base.js @@ -54,16 +54,31 @@ function runBaseTest(name, lib, freeze) { expect(nextState.nested).toBe(baseState.nested) }) - it("deep change bubbles up", () => { - const nextState = immer(baseState, s => { - s.anObject.nested.yummie = false + if ( + ("should preserve type", + () => { + const nextState = immer(baseState, s => { + expect(Array.isArray(s)).toBe(true) + expect(s.protoType).toBe(Object) + s.anArray.push(3) + s.aProp = "hello world" + expect(Array.isArray(s)).toBe(true) + expect(s.protoType).toBe(Object) + }) + expect(Array.isArray(nextState)).toBe(true) + expect(nextState.protoType).toBe(Object) + }) + ) + it("deep change bubbles up", () => { + const nextState = immer(baseState, s => { + s.anObject.nested.yummie = false + }) + expect(nextState).not.toBe(baseState) + expect(nextState.anObject).not.toBe(baseState.anObject) + expect(baseState.anObject.nested.yummie).toBe(true) + expect(nextState.anObject.nested.yummie).toBe(false) + expect(nextState.anArray).toBe(baseState.anArray) }) - expect(nextState).not.toBe(baseState) - expect(nextState.anObject).not.toBe(baseState.anObject) - expect(baseState.anObject.nested.yummie).toBe(true) - expect(nextState.anObject.nested.yummie).toBe(false) - expect(nextState.anArray).toBe(baseState.anArray) - }) it("can add props", () => { const nextState = immer(baseState, s => { @@ -133,6 +148,16 @@ function runBaseTest(name, lib, freeze) { ]) }) + it("can delete array items", () => { + const nextState = immer(baseState, s => { + s.anArray.length = 3 + }) + expect(nextState).not.toBe(baseState) + expect(nextState.anObject).toBe(baseState.anObject) + expect(nextState.anArray).not.toBe(baseState.anArray) + expect(nextState.anArray).toEqual([3, 2, {c: 3}]) + }) + it("should support sorting arrays", () => { const nextState = immer(baseState, s => { s.anArray[2].c = 4 @@ -223,6 +248,7 @@ function runBaseTest(name, lib, freeze) { delete s.anObject obj.coffee = true s.messy = obj + debugger }) expect(nextState).not.toBe(baseState) expect(nextState.anArray).toBe(baseState.anArray) @@ -330,3 +356,8 @@ function enumerableOnly(x) { // this can be done better... return JSON.parse(JSON.stringify(x)) } + +// TODO: test problem scenarios +// nesting immmer +// non-trees +// complex objects / functions diff --git a/es5.js b/es5.js index 7c2b6d47..0bddb711 100644 --- a/es5.js +++ b/es5.js @@ -1,9 +1,7 @@ "use strict" // @ts-check -const PROXY_TARGET = Symbol("immer-proxy") -const CHANGED_STATE = Symbol("immer-changed-state") -const PARENT = Symbol("immer-parent") +const PROXY_STATE = Symbol("immer-proxy-state") // TODO: create per closure, to avoid sharing proxies between multiple immer version let autoFreeze = true @@ -21,42 +19,67 @@ function immer(baseState, thunk) { let finalizing = false let finished = false const descriptors = {} - const proxies = [] + const states = [] - // creates a proxy for plain objects / arrays - function createProxy(base, parent) { - let proxy - if (isPlainObject(base)) proxy = createObjectProxy(base) - else if (Array.isArray(base)) proxy = createArrayProxy(base) - else throw new Error("Expected a plain object or array") - createHiddenProperty(proxy, PROXY_TARGET, base) - createHiddenProperty(proxy, CHANGED_STATE, false) - createHiddenProperty(proxy, PARENT, parent) - proxies.push(proxy) - return proxy - } + class State { + constructor(parent, proxy, base) { + this.modified = false + this.hasCopy = false + this.parent = parent + this.base = base + this.proxy = proxy + this.copy = undefined + } - function assertUnfinished() { - if (finished) - throw new Error( - "Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process?" - ) + get source() { + return this.hasCopy ? this.copy : this.base + } + + get(prop) { + assertUnfinished() + const value = this.source[prop] + if (!finalizing && !isProxy(value) && isProxyable(value)) { + this.prepareCopy() + return (this.copy[prop] = createProxy(this, value)) + } + return value + } + + set(prop, value) { + assertUnfinished() + if (!this.modified) { + if (this.source[prop] === value) return + this.markChanged() + } + this.prepareCopy() + this.copy[prop] = value + } + + markChanged() { + if (!this.modified) { + this.modified = true + if (this.parent) this.parent.markChanged() + } + } + + prepareCopy() { + if (this.hasCopy) return + this.hasCopy = true + this.copy = Array.isArray(this.base) + ? this.base.slice() + : Object.assign({}, this.base) + } } - function proxySet(proxy, prop, value) { - // immer func not ended? - assertUnfinished() - // actually a change? - if (Object.is(proxy[prop], value)) return - // mark changed - markDirty(proxy) - // and stop proxying, we know this object has changed - Object.defineProperty(proxy, prop, { - enumerable: true, - writable: true, - configurable: true, - value: value - }) + // creates a proxy for plain objects / arrays + function createProxy(parent, base) { + let proxy + if (Array.isArray(base)) proxy = createArrayProxy(base) + else proxy = createObjectProxy(base) + const state = new State(parent, proxy, base) + createHiddenProperty(proxy, PROXY_STATE, state) + states.push(state) + return proxy } function createPropertyProxy(prop) { @@ -66,41 +89,17 @@ function immer(baseState, thunk) { configurable: true, enumerable: true, get() { - assertUnfinished() - // find the target object - const target = this[PROXY_TARGET] - // find the original value - const value = target[prop] - // if we are finalizing, don't bother creating proxies, just return base value - if (finalizing) return value - // if not proxy-able, return value - if (!isPlainObject(value) && !Array.isArray(value)) - return value - // otherwise, create proxy - const proxy = createProxy(value, this) - // and make sure this proxy is returned from this prop in the future if read - // (write behavior as is) - Object.defineProperty(this, prop, { - configurable: true, - enumerable: true, - get() { - return proxy - }, - set(value) { - proxySet(this, prop, value) - } - }) - return proxy + return this[PROXY_STATE].get(prop) }, set(value) { - proxySet(this, prop, value) + this[PROXY_STATE].set(prop, value) } }) ) } function createObjectProxy(base) { - const proxy = {} + const proxy = Object.assign({}, base) Object.keys(base).forEach(prop => Object.defineProperty(proxy, prop, createPropertyProxy(prop)) ) @@ -108,12 +107,19 @@ function immer(baseState, thunk) { } function createArrayProxy(base) { - const proxy = [] + const proxy = new Array(base.length) for (let i = 0; i < base.length; i++) Object.defineProperty(proxy, "" + i, createPropertyProxy("" + i)) return proxy } + function assertUnfinished() { + if (finished) + throw new Error( + "Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process?" + ) + } + // this sounds very expensive, but actually it is not that extensive in practice // as it will only visit proxies, and only do key-based change detection for objects for // which it is not already know that they are changed (that is, only object for which no known key was changed) @@ -121,41 +127,39 @@ function immer(baseState, thunk) { // intentionally we process the proxies in reverse order; // ideally we start by processing leafs in the tree, because if a child has changed, we don't have to check the parent anymore // reverse order of proxy creation approximates this - for (let i = proxies.length - 1; i >= 0; i--) { - const proxy = proxies[i] - if ( - proxy[CHANGED_STATE] === false && - ((isPlainObject(proxy) && hasObjectChanges(proxy)) || - (Array.isArray(proxy) && hasArrayChanges(proxy))) - ) { - markDirty(proxy) + for (let i = states.length - 1; i >= 0; i--) { + const state = states[i] + if (state.modified === false) { + if (Array.isArray(state.base)) { + if (hasArrayChanges(state)) state.markChanged() + } else if (hasObjectChanges(state)) state.markChanged() } } } - function hasObjectChanges(proxy) { - const baseKeys = Object.keys(proxy[PROXY_TARGET]) - const keys = Object.keys(proxy) + function hasObjectChanges(state) { + const baseKeys = Object.keys(state.base) + const keys = Object.keys(state.proxy) return !shallowEqual(baseKeys, keys) } - function hasArrayChanges(proxy) { - return proxy[PROXY_TARGET].length !== proxy.length + function hasArrayChanges(state) { + return state.proxy.length !== state.base.length } function finalize(proxy) { // given a base object, returns it if unmodified, or return the changed cloned if modified if (!isProxy(proxy)) return proxy - if (!proxy[CHANGED_STATE]) return proxy[PROXY_TARGET] // return the original target - if (isPlainObject(proxy)) return finalizeObject(proxy) + const state = proxy[PROXY_STATE] + if (state.modified === false) return state.base // return the original target if (Array.isArray(proxy)) return finalizeArray(proxy) - throw new Error("Illegal state") + return finalizeObject(proxy) } function finalizeObject(proxy) { - const res = {} - Object.keys(proxy).forEach(prop => { - res[prop] = finalize(proxy[prop]) + const res = Object.assign({}, proxy) + Object.keys(res).forEach(prop => { + res[prop] = finalize(res[prop]) }) return freeze(res) } @@ -165,7 +169,7 @@ function immer(baseState, thunk) { } // create proxy for root - const rootClone = createProxy(baseState, undefined) + const rootClone = createProxy(undefined, baseState) // execute the thunk const maybeVoidReturn = thunk(rootClone) //values either than undefined will trigger warning; @@ -183,23 +187,16 @@ function immer(baseState, thunk) { return res } -function markDirty(proxy) { - proxy[CHANGED_STATE] = true - let parent = proxy - while ((parent = parent[PARENT])) { - if (parent[CHANGED_STATE] === true) return - parent[CHANGED_STATE] = true - } +function isProxy(value) { + return !!value && !!value[PROXY_STATE] } -function isPlainObject(value) { - if (value === null || typeof value !== "object") return false +function isProxyable(value) { + if (!value) return false + if (typeof value !== "object") return false + if (Array.isArray(value)) return true const proto = Object.getPrototypeOf(value) - return proto === Object.prototype || proto === null -} - -function isProxy(value) { - return !!(value && value[PROXY_TARGET]) + return (proto === proto) === null || Object.prototype } function freeze(value) { diff --git a/immer.png b/images/immer.png similarity index 100% rename from immer.png rename to images/immer.png diff --git a/images/performance.png b/images/performance.png new file mode 100644 index 00000000..3c59bd1e Binary files /dev/null and b/images/performance.png differ diff --git a/immer.js b/immer.js index b2bb4ba1..a7880309 100644 --- a/immer.js +++ b/immer.js @@ -6,19 +6,70 @@ if (typeof Proxy === "undefined") "Immer requires `Proxy` to be available, but it seems to be not available on your platform. Consider requiring immer '\"immer/es5\"' instead." ) -/** - * @typedef {Object} RevocableProxy - * @property {any} proxy - * @property {Function} revoke - */ - -const IMMER_PROXY = Symbol("immer-proxy") // TODO: create per closure, to avoid sharing proxies between multiple immer version +const PROXY_STATE = Symbol("immer-proxy-state") // TODO: create per closure, to avoid sharing proxies between multiple immer version +let autoFreeze = true -// This property indicates that the current object is cloned for another object, -// to make sure the proxy of a frozen object is writeable -const CLONE_TARGET = Symbol("immer-clone-target") +const objectTraps = { + get(target, prop) { + if (prop === PROXY_STATE) return target + return target.get(prop) + }, + has(target, prop) { + return prop in target.source + }, + ownKeys(target) { + return Reflect.ownKeys(target.source) + }, + set(target, prop, value) { + target.set(prop, value) + return true + }, + deleteProperty(target, prop) { + target.deleteProp(prop) + return true + }, + getOwnPropertyDescriptor(target, prop) { + return target.getOwnPropertyDescriptor(prop) + }, + defineProperty(target, property, descriptor) { + target.defineProperty(property, descriptor) + return true + }, + setPrototypeOf() { + throw new Error("Don't even try this...") + } +} -let autoFreeze = true +const arrayTraps = { + get(target, prop) { + if (prop === PROXY_STATE) return target[0] + return target[0].get(prop) + }, + has(target, prop) { + return prop in target[0].source + }, + ownKeys(target) { + return Reflect.ownKeys(target[0].source) + }, + set(target, prop, value) { + target[0].set(prop, value) + return true + }, + deleteProperty(target, prop) { + target[0].deleteProp(prop) + return true + }, + getOwnPropertyDescriptor(target, prop) { + return target[0].getOwnPropertyDescriptor(prop) + }, + defineProperty(target, property, descriptor) { + target[0].defineProperty(property, descriptor) + return true + }, + setPrototypeOf() { + throw new Error("Don't even try this...") + } +} /** * Immer takes a state, and runs a function against it. @@ -31,155 +82,124 @@ let autoFreeze = true * @returns {any} a new state, or the base state if nothing was modified */ function immer(baseState, thunk) { - /** - * Maps baseState objects to revocable proxies - * @type {Map} - */ - const revocableProxies = new Map() - // Maps baseState objects to their copies - - const copies = new Map() - - const objectTraps = { - get(target, prop) { - if (prop === IMMER_PROXY) return target - return createProxy(getCurrentSource(target)[prop]) - }, - has(target, prop) { - return prop in getCurrentSource(target) - }, - ownKeys(target) { - return Reflect.ownKeys(getCurrentSource(target)) - }, - set(target, prop, value) { - const current = createProxy(getCurrentSource(target)[prop]) - const newValue = createProxy(value) - if (current !== newValue) { - const copy = getOrCreateCopy(target) - copy[prop] = isProxy(newValue) - ? newValue[IMMER_PROXY] - : newValue + const revocableProxies = [] + + class State { + constructor(parent, base) { + this.modified = false + this.parent = parent + this.base = base + this.copy = undefined + this.proxies = {} + } + + get source() { + return this.modified === true ? this.copy : this.base + } + + get(prop) { + if (this.modified) { + const value = this.copy[prop] + if (!isProxy(value) && isProxyable(value)) + return (this.copy[prop] = createProxy(this, value)) + return value + } else { + if (prop in this.proxies) return this.proxies[prop] + const value = this.base[prop] + if (!isProxy(value) && isProxyable(value)) + return (this.proxies[prop] = createProxy(this, value)) + return value } - return true - }, - deleteProperty(target, property) { - const copy = getOrCreateCopy(target) - delete copy[property] - return true - }, - getOwnPropertyDescriptor(target, prop) { - return Reflect.getOwnPropertyDescriptor( - getCurrentSource(target), - prop - ) - }, - defineProperty(target, property, descriptor) { - Object.defineProperty(getOrCreateCopy(target), property, descriptor) - return true - }, - setPrototypeOf() { - throw new Error("Don't even try this...") } - } - // creates a copy for a base object if there ain't one - function getOrCreateCopy(base) { - let copy = copies.get(base) - if (copy) return copy - const cloneTarget = base[CLONE_TARGET] - if (cloneTarget) { - // base is a clone already (source was frozen), no need to create addtional copy - copies.set(cloneTarget, base) - return base + set(prop, value) { + if (!this.modified) { + if ( + (prop in this.base && this.base[prop] === value) || + (prop in this.proxies && this.proxies[prop] === value) + ) + return + this.markChanged() + } + this.copy[prop] = value } - // create a fresh copy - copy = Array.isArray(base) ? base.slice() : Object.assign({}, base) - copies.set(base, copy) - return copy - } - // returns the current source of truth for a base object - function getCurrentSource(base) { - const copy = copies.get(base) - return copy || base - } + deleteProp(prop) { + this.markChanged() + delete this.copy[prop] + } - // creates a proxy for plain objects / arrays - function createProxy(base) { - if (isPlainObject(base) || Array.isArray(base)) { - if (isProxy(base)) return base // avoid double wrapping - if (revocableProxies.has(base)) - return revocableProxies.get(base).proxy - let proxyTarget - // special case, if the base tree is frozen, we cannot modify it's proxy it in strict mode, clone first. - if (Object.isFrozen(base)) { - proxyTarget = Array.isArray(base) - ? base.slice() - : Object.assign({}, base) - Object.defineProperty(proxyTarget, CLONE_TARGET, { - enumerable: false, - value: base, - configurable: true - }) - } else { - proxyTarget = base + getOwnPropertyDescriptor(prop) { + const owner = this.modified + ? this.copy + : prop in this.proxies ? this.proxies : this.base + const descriptor = Reflect.getOwnPropertyDescriptor(owner, prop) + if (descriptor) descriptor.configurable = true // XXX: is this really needed? + return descriptor + } + + defineProperty(property, descriptor) { + this.markChanged() + Object.defineProperty(this.copy, property, descriptor) + } + + markChanged() { + if (!this.modified) { + this.modified = true + this.copy = Array.isArray(this.base) // TODO: eliminate those isArray checks? + ? this.base.slice() + : Object.assign({}, this.base) + Object.assign(this.copy, this.proxies) // yup that works for arrays as well + if (this.parent) this.parent.markChanged() } - // create the proxy - const revocableProxy = Proxy.revocable(proxyTarget, objectTraps) - revocableProxies.set(base, revocableProxy) - return revocableProxy.proxy } - return base } - // checks if the given base object has modifications, either because it is modified, or - // because one of it's children is - function hasChanges(base) { - const proxy = revocableProxies.get(base) - if (!proxy) return false // nobody did read this object - if (copies.has(base)) return true // a copy was created, so there are changes - // look deeper - const keys = Object.keys(base) - for (let i = 0; i < keys.length; i++) { - const value = base[keys[i]] - if ( - (Array.isArray(value) || isPlainObject(value)) && - hasChanges(value) - ) - return true + // creates a proxy for plain objects / arrays + function createProxy(parentState, base) { + const state = new State(parentState, base) + let proxy + if (Array.isArray(base)) { + // Proxy should be created with an array to make it an array for JS + // so... here you have it! + proxy = Proxy.revocable([state], arrayTraps) + } else { + proxy = Proxy.revocable(state, objectTraps) } - return false + revocableProxies.push(proxy) + return proxy.proxy } // given a base object, returns it if unmodified, or return the changed cloned if modified function finalize(base) { - if (isPlainObject(base)) return finalizeObject(base) - if (Array.isArray(base)) return finalizeArray(base) + if (isProxy(base)) { + const state = base[PROXY_STATE] + if (state.modified === true) { + if (Array.isArray(state.base)) return finalizeArray(state) + return finalizeObject(state) + } else return state.base + } return base } - function finalizeObject(thing) { - if (!hasChanges(thing)) return thing - const copy = getOrCreateCopy(thing) // TODO: getOrCreate is weird here.. + function finalizeObject(state) { + const copy = state.copy Object.keys(copy).forEach(prop => { copy[prop] = finalize(copy[prop]) }) - delete copy[CLONE_TARGET] return freeze(copy) } - function finalizeArray(thing) { - if (!hasChanges(thing)) return thing - const copy = getOrCreateCopy(thing) // TODO: getOrCreate is weird here.. + function finalizeArray(state) { + const copy = state.copy copy.forEach((value, index) => { copy[index] = finalize(copy[index]) }) - delete copy[CLONE_TARGET] return freeze(copy) } // create proxy for root - const rootClone = createProxy(baseState) + const rootClone = createProxy(undefined, baseState) // execute the thunk const maybeVoidReturn = thunk(rootClone) //values either than undefined will trigger warning; @@ -187,32 +207,23 @@ function immer(baseState, thunk) { console.warn( `Immer callback expects no return value. However ${typeof maybeVoidReturn} was returned` ) - // console.log(`proxies: ${revocableProxies.size}, copies: ${copies.size}`) - // revoke all proxies - revoke(revocableProxies) // and finalize the modified proxy - return finalize(baseState) + const res = finalize(rootClone) + // revoke all proxies + revocableProxies.forEach(p => p.revoke()) + return res } -/** - * Revoke all the proxies stored in the revocableProxies map - * - * @param {Map} revocableProxies - */ -function revoke(revocableProxies) { - for (var revocableProxy of revocableProxies.values()) { - revocableProxy.revoke() - } +function isProxy(value) { + return !!value && !!value[PROXY_STATE] } -function isPlainObject(value) { - if (value === null || typeof value !== "object") return false +function isProxyable(value) { + if (!value) return false + if (typeof value !== "object") return false + if (Array.isArray(value)) return true const proto = Object.getPrototypeOf(value) - return proto === Object.prototype || proto === null -} - -function isProxy(value) { - return !!value && !!value[IMMER_PROXY] + return (proto === proto) === null || Object.prototype } function freeze(value) { diff --git a/package.json b/package.json index 73713190..4598b9cd 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "test": "jest", "test:perf": "node --expose-gc node_modules/jest-cli/bin/jest.js --verbose --testRegex '__performance_tests__/.*'", - "prettier": "prettier \"*/**/*.js\" --ignore-path ./.prettierignore --write && git add . && git status" + "prettier": "prettier \"*/**/*.js\" --ignore-path ./.prettierignore --write" }, "pre-commit": [ "prettier" diff --git a/readme.md b/readme.md index 44e9ddc6..c24ec8f2 100644 --- a/readme.md +++ b/readme.md @@ -11,7 +11,7 @@ The basic idea is that you will apply all your changes to a temporarily _draftSt Once all your mutations are completed, immer will produce the _nextState_ based on the mutations to the draft state. This means that you can interact with your data by simply modifying it, while keeping all the benefits of immutable data. -![immer.png](immer.png) +![immer.png](images/immer.png) Using immer is like having a personal assistant; he takes a letter (the current state), and gives you a copy (draft) to jot changes onto. Once you are done, the assistant will take your draft and produce the real immutable, final letter for you (the next state). @@ -233,33 +233,6 @@ function updateObjectInArray(array, action) { } ``` -## Performance - -Here is a [simple benchmark](__tests__/performance.js) on the performance of `immer`. -This test takes 100.000 todo items, and updates 10.000 of them. -These tests were executed on Node 8.4.0. - -Use `yarn test:perf` to reproduce them locally - -``` - ✓ just mutate (3ms) - (No immutability at all) - ✓ deepclone, then mutate (409ms) - (Clone entire tree, then mutate (no structural sharing!)) - ✓ handcrafted reducer (17ms) - (Implement it as typical Redux reducer, with slices and spread operator) - ✓ immutableJS (60ms) - (Use immutableJS and leverage `withMutations` for best performance) - ✓ immer (proxy) - with autofreeze (305ms) - (Immer, with auto freeze enabled, default implementation) - ✓ immer (proxy) - without autofreeze (149ms) - (Immer, with auto freeze disabled, default implementation) - ✓ immer (es5) - with autofreeze (436ms) - (Immer, with auto freeze enabled, compatibility implementation) - ✓ immer (es5) - without autofreeze (336ms) - (Immer, with auto freeze disabled, default implementation) -``` - ## Limitations * Currently, only tree shaped states are supported. Cycles could potentially be supported as well (PR's welcome) @@ -270,6 +243,25 @@ Use `yarn test:perf` to reproduce them locally * Make sure to modify the draft state you get passed in in the callback function, not the original current state that was passed as the first argument to `immer`! * Since immer uses proxies, reading huge amounts of data from state comes with an overhead. If this ever becomes an issue (measure before you optimize!), do the current state analysis before entering the `immer` block or read from the `currentState` rather than the `draftState` +## Performance + +Here is a [simple benchmark](__tests__/performance.js) on the performance of `immer`. +This test takes 100.000 todo items, and updates 10.000 of them. +_Freeze_ indicates that the state tree has been frozen after producing it. This is a _development_ best practice, as it prevents developers from accidentally modifying the state tree. + +These tests were executed on Node 8.4.0. +Use `yarn test:perf` to reproduce them locally. + +![immer.png](images/immer.png) + +Some observations: +* The _mutate_, and _deepclone, mutate_ benchmarks establish a baseline on how expensive changing the data is, without immutability (or structural sharing in the deep clone case). +* The _reducure_ and _naive reducer_ are implemented in typical Redux style reducers. The "smart" implementation slices the collection first, and then maps and freezes only the relevant todos. The "naive" implementation just maps over and processes the entire collection. +* Immer with proxies is roughly speaking twice as slow as a hand written reducer. This is in practice negclectable. +* Immer is roughly as fast as ImmutableJS. However, the _immutableJS + toJS_ makes clear the cost that often needs to be paid later; converting the immutableJS objects back to plain objects, to be able to pass them to components, over the network etc... (And there is also the upfront cost of converting data received from e.g. the server to immutable JS) +* The ES5 implentation of immer is significantly slower. For most reducers this won't matter, but reducers that process large amounts of data might benefit from not (or only partially) using an immer producer. Luckily, immer is fully opt-in. +* The peeks in the _frozen_ versions of _just mutate_, _deepclone_ and _naive reducer_ come from the fact that they recursively freeze the full state tree, while the other test cases only freeze the modified parts of the tree. + ## Credits Special thanks goes to @Mendix, which supports it's employees to experiment completely freely two full days a month, which formed the kick-start for this project. \ No newline at end of file