diff --git a/__tests__/base.js b/__tests__/base.js index e1d756d2..ebe76cdb 100644 --- a/__tests__/base.js +++ b/__tests__/base.js @@ -1,245 +1,256 @@ "use strict" -import immer from ".." +import immerProxy from ".." +import immerEs5 from "../es5" -describe("base", () => { - let baseState - let origBaseState +runBaseTest("proxy", immerProxy) +runBaseTest("es5", immerEs5) - beforeEach(() => { - origBaseState = baseState = createBaseState() - }) +function runBaseTest(name, immer) { + describe(`base functionality - ${name}`, () => { + let baseState + let origBaseState - it("should return the original without modifications", () => { - const nextState = immer(baseState, () => {}) - expect(nextState).toBe(baseState) - }) + beforeEach(() => { + origBaseState = baseState = createBaseState() + }) - it("should return the original without modifications when reading stuff", () => { - const nextState = immer(baseState, s => { - expect(s.aProp).toBe("hi") - expect(s.anObject.nested).toEqual({yummie: true}) + it("should return the original without modifications", () => { + const nextState = immer(baseState, () => {}) + expect(nextState).toBe(baseState) }) - expect(nextState).toBe(baseState) - }) - it("should not return any value: thunk", () => { - const warning = jest.spyOn(console, "warn") - immer(baseState, () => ({bad: "don't do this"})) - immer(baseState, () => [1, 2, 3]) - immer(baseState, () => false) - immer(baseState, () => "") + it("should return the original without modifications when reading stuff", () => { + const nextState = immer(baseState, s => { + expect(s.aProp).toBe("hi") + expect(s.anObject.nested).toMatchObject({yummie: true}) + }) + expect(nextState).toBe(baseState) + }) - expect(warning).toHaveBeenCalledTimes(4) - }) + it("should not return any value: thunk", () => { + const warning = jest.spyOn(console, "warn") + immer(baseState, () => ({bad: "don't do this"})) + immer(baseState, () => [1, 2, 3]) + immer(baseState, () => false) + immer(baseState, () => "") - it("should return a copy when modifying stuff", () => { - const nextState = immer(baseState, s => { - s.aProp = "hello world" + expect(warning).toHaveBeenCalledTimes(4) + warning.mockClear() }) - expect(nextState).not.toBe(baseState) - expect(baseState.aProp).toBe("hi") - expect(nextState.aProp).toBe("hello world") - // structural sharing? - expect(nextState.nested).toBe(baseState.nested) - }) - it("deep change bubbles up", () => { - const nextState = immer(baseState, s => { - s.anObject.nested.yummie = false + it("should return a copy when modifying stuff", () => { + const nextState = immer(baseState, s => { + s.aProp = "hello world" + }) + expect(nextState).not.toBe(baseState) + expect(baseState.aProp).toBe("hi") + expect(nextState.aProp).toBe("hello world") + // structural sharing? + expect(nextState.nested).toBe(baseState.nested) }) - 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 => { - s.anObject.cookie = {tasty: true} + 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(nextState.anObject.nested).toBe(baseState.anObject.nested) - expect(nextState.anObject.cookie).toEqual({tasty: true}) - }) - it("can delete props", () => { - const nextState = immer(baseState, s => { - delete s.anObject.nested + it("can add props", () => { + const nextState = immer(baseState, s => { + s.anObject.cookie = {tasty: true} + }) + expect(nextState).not.toBe(baseState) + expect(nextState.anObject).not.toBe(baseState.anObject) + expect(nextState.anObject.nested).toBe(baseState.anObject.nested) + expect(nextState.anObject.cookie).toEqual({tasty: true}) }) - expect(nextState).not.toBe(baseState) - expect(nextState.anObject).not.toBe(baseState.anObject) - expect(nextState.anObject.nested).toBe(undefined) - }) - it("ignores single non-modification", () => { - const nextState = immer(baseState, s => { - s.aProp = "hi" + it("can delete props", () => { + const nextState = immer(baseState, s => { + delete s.anObject.nested + }) + expect(nextState).not.toBe(baseState) + expect(nextState.anObject).not.toBe(baseState.anObject) + expect(nextState.anObject.nested).toBe(undefined) }) - expect(nextState).toBe(baseState) - }) - it("processes single modification", () => { - const nextState = immer(baseState, s => { - s.aProp = "hello" - s.aProp = "hi" + it("ignores single non-modification", () => { + const nextState = immer(baseState, s => { + s.aProp = "hi" + }) + expect(nextState).toBe(baseState) }) - expect(nextState).not.toBe(baseState) - expect(nextState).toEqual(baseState) - }) - it("should support reading arrays", () => { - const nextState = immer(baseState, s => { - s.anArray.slice() + it("processes single modification", () => { + const nextState = immer(baseState, s => { + s.aProp = "hello" + s.aProp = "hi" + }) + expect(nextState).not.toBe(baseState) + expect(nextState).toEqual(baseState) }) - expect(nextState.anArray).toBe(baseState.anArray) - expect(nextState).toBe(baseState) - }) - it("should support changing arrays", () => { - const nextState = immer(baseState, s => { - s.anArray[3] = true + it("should support reading arrays", () => { + const nextState = immer(baseState, s => { + s.anArray.slice() + }) + expect(nextState.anArray).toBe(baseState.anArray) + expect(nextState).toBe(baseState) }) - expect(nextState).not.toBe(baseState) - expect(nextState.anArray).not.toBe(baseState.anArray) - expect(nextState.anArray[3]).toEqual(true) - }) - it("should support changing arrays - 2", () => { - const nextState = immer(baseState, s => { - s.anArray.splice(1, 1, "a", "b") + it("should support changing arrays", () => { + const nextState = immer(baseState, s => { + s.anArray[3] = true + }) + expect(nextState).not.toBe(baseState) + expect(nextState.anArray).not.toBe(baseState.anArray) + expect(nextState.anArray[3]).toEqual(true) }) - expect(nextState).not.toBe(baseState) - expect(nextState.anArray).not.toBe(baseState.anArray) - expect(nextState.anArray).toEqual([3, "a", "b", {c: 3}, 1]) - }) + it("should support changing arrays - 2", () => { + const nextState = immer(baseState, s => { + s.anArray.splice(1, 1, "a", "b") + }) + expect(nextState).not.toBe(baseState) + expect(nextState.anArray).not.toBe(baseState.anArray) - it("should support sorting arrays", () => { - const nextState = immer(baseState, s => { - s.anArray[2].c = 4 - s.anArray.sort() - s.anArray[3].c = 5 + expect(nextState.anArray).toEqual([3, "a", "b", {c: 3}, 1]) }) - expect(nextState).not.toBe(baseState) - expect(nextState.anArray).not.toBe(baseState.anArray) - expect(nextState.anArray).toEqual([1, 2, 3, {c: 5}]) - }) - it("should updating inside arrays", () => { - const nextState = immer(baseState, s => { - s.anArray[2].test = true + it("should support sorting arrays", () => { + const nextState = immer(baseState, s => { + s.anArray[2].c = 4 + s.anArray.sort() + s.anArray[3].c = 5 + }) + expect(nextState).not.toBe(baseState) + expect(nextState.anArray).not.toBe(baseState.anArray) + expect(nextState.anArray).toEqual([1, 2, 3, {c: 5}]) }) - expect(nextState).not.toBe(baseState) - expect(nextState.anArray).not.toBe(baseState.anArray) - expect(nextState.anArray).toEqual([3, 2, {c: 3, test: true}, 1]) - }) - it("reusing object should work", () => { - const nextState = immer(baseState, s => { - const obj = s.anObject - delete s.anObject - s.messy = obj - }) - expect(nextState).not.toBe(baseState) - expect(nextState.anArray).toBe(baseState.anArray) - expect(nextState).toEqual({ - anArray: [3, 2, {c: 3}, 1], - aProp: "hi", - messy: { - nested: { - yummie: true - }, - coffee: false - } + it("should updating inside arrays", () => { + const nextState = immer(baseState, s => { + s.anArray[2].test = true + }) + expect(nextState).not.toBe(baseState) + expect(nextState.anArray).not.toBe(baseState.anArray) + expect(nextState.anArray).toEqual([3, 2, {c: 3, test: true}, 1]) }) - expect(nextState.messy.nested).toBe(baseState.anObject.nested) - }) - it("refs should be transparent", () => { - const nextState = immer(baseState, s => { - const obj = s.anObject - s.aProp = "hello" - delete s.anObject - obj.coffee = true - s.messy = obj - }) - expect(nextState).not.toBe(baseState) - expect(nextState.anArray).toBe(baseState.anArray) - expect(nextState).toEqual({ - anArray: [3, 2, {c: 3}, 1], - aProp: "hello", - messy: { - nested: { - yummie: true - }, - coffee: true - } + it("reusing object should work", () => { + const nextState = immer(baseState, s => { + const obj = s.anObject + delete s.anObject + s.messy = obj + }) + expect(nextState).not.toBe(baseState) + expect(nextState.anArray).toBe(baseState.anArray) + expect(nextState).toEqual({ + anArray: [3, 2, {c: 3}, 1], + aProp: "hi", + messy: { + nested: { + yummie: true + }, + coffee: false + } + }) + expect(nextState.messy.nested).toBe(baseState.anObject.nested) }) - expect(nextState.messy.nested).toBe(baseState.anObject.nested) - }) - it("should allow setting to undefined a defined draft property", () => { - const nextState = immer(baseState, s => { - s.aProp = undefined + it("refs should be transparent", () => { + const nextState = immer(baseState, s => { + const obj = s.anObject + s.aProp = "hello" + delete s.anObject + obj.coffee = true + s.messy = obj + }) + expect(nextState).not.toBe(baseState) + expect(nextState.anArray).toBe(baseState.anArray) + expect(nextState).toEqual({ + anArray: [3, 2, {c: 3}, 1], + aProp: "hello", + messy: { + nested: { + yummie: true + }, + coffee: true + } + }) + expect(nextState.messy.nested).toBe(baseState.anObject.nested) }) - expect(nextState).not.toBe(baseState) - expect(baseState.aProp).toBe("hi") - expect(nextState.aProp).toBe(undefined) - }) - it("should revoke the proxy of the baseState after immer function is executed", () => { - let proxy - const nextState = immer(baseState, s => { - proxy = s - s.aProp = "hello" - }) - expect(nextState).not.toBe(baseState) - expect(baseState.aProp).toBe("hi") - expect(nextState.aProp).toBe("hello") - - expect(() => { - proxy.aProp = "Hallo" - }).toThrowError(/^Cannot perform.*on a proxy that has been revoked/) - expect(() => { - const aProp = proxy.aProp - }).toThrowError(/^Cannot perform.*on a proxy that has been revoked/) - - expect(nextState).not.toBe(baseState) - expect(baseState.aProp).toBe("hi") - expect(nextState.aProp).toBe("hello") - }) + it("should allow setting to undefined a defined draft property", () => { + const nextState = immer(baseState, s => { + s.aProp = undefined + }) + expect(nextState).not.toBe(baseState) + expect(baseState.aProp).toBe("hi") + expect(nextState.aProp).toBe(undefined) + }) - it("should revoke the proxy of the baseState after immer function is executed - 2", () => { - let proxy - const nextState = immer(baseState, s => { - proxy = s.anObject - }) - expect(nextState).toBe(baseState) - expect(() => { - proxy.test = "Hallo" - }).toThrowError(/^Cannot perform.*on a proxy that has been revoked/) - expect(() => { - const test = proxy.test - }).toThrowError(/^Cannot perform.*on a proxy that has been revoked/) - }) + // ES implementation does't protect against all outside modifications, just some.. + if (name === "proxy") { + it("should revoke the proxy of the baseState after immer function is executed", () => { + let proxy + const nextState = immer(baseState, s => { + proxy = s + s.aProp = "hello" + }) + expect(nextState).not.toBe(baseState) + expect(baseState.aProp).toBe("hi") + expect(nextState.aProp).toBe("hello") - afterEach(() => { - expect(baseState).toBe(origBaseState) - expect(baseState).toEqual(createBaseState()) - }) + expect(() => { + proxy.aProp = "Hallo" + }).toThrowError(/revoked/) + expect(() => { + const aProp = proxy.aProp + }).toThrowError(/revoked/) + + expect(nextState).not.toBe(baseState) + expect(baseState.aProp).toBe("hi") + expect(nextState.aProp).toBe("hello") + }) + } + + it("should revoke the proxy of the baseState after immer function is executed - 2", () => { + let proxy + const nextState = immer(baseState, s => { + proxy = s.anObject + }) + expect(nextState).toBe(baseState) + expect(() => { + // In ES5 implemenation only protects existing props, but alas.. + proxy.coffee = "Hallo" + }).toThrowError(/revoked/) + expect(() => { + const test = proxy.coffee + }).toThrowError(/revoked/) + }) + + afterEach(() => { + expect(baseState).toBe(origBaseState) + expect(baseState).toEqual(createBaseState()) + }) - function createBaseState() { - return { - anArray: [3, 2, {c: 3}, 1], - aProp: "hi", - anObject: { - nested: { - yummie: true - }, - coffee: false + function createBaseState() { + return { + anArray: [3, 2, {c: 3}, 1], + aProp: "hi", + anObject: { + nested: { + yummie: true + }, + coffee: false + } } } - } -}) + }) +} diff --git a/__tests__/performance.js b/__tests__/performance.js index 1efd2292..87268a5b 100644 --- a/__tests__/performance.js +++ b/__tests__/performance.js @@ -1,5 +1,6 @@ "use strict" -import immer, {setAutoFreeze} from ".." +import immerProxy, {setAutoFreeze as setAutoFreezeProxy} from ".." +import immerEs5, {setAutoFreeze as setAutoFreezeEs5} from "../es5" import cloneDeep from "lodash.clonedeep" import {List, Record} from "immutable" @@ -71,22 +72,41 @@ describe("performance", () => { }) }) - test("immer - with autofreeze", () => { - setAutoFreeze(true) - immer(frozenBazeState, draft => { + test("immer (proxy) - with autofreeze", () => { + setAutoFreezeProxy(true) + immerProxy(frozenBazeState, draft => { for (let i = 0; i < MAX * MODIFY_FACTOR; i++) { draft[i].done = true } }) }) - test("immer - without autofreeze", () => { - setAutoFreeze(false) - immer(baseState, draft => { + test("immer (proxy) - without autofreeze", () => { + setAutoFreezeProxy(false) + immerProxy(baseState, draft => { for (let i = 0; i < MAX * MODIFY_FACTOR; i++) { draft[i].done = true } }) - setAutoFreeze(true) + setAutoFreezeProxy(true) + }) + + test("immer (es5) - with autofreeze", () => { + setAutoFreezeEs5(true) + immerEs5(frozenBazeState, draft => { + for (let i = 0; i < MAX * MODIFY_FACTOR; i++) { + draft[i].done = true + } + }) + }) + + test("immer (es5) - without autofreeze", () => { + setAutoFreezeEs5(false) + immerEs5(baseState, draft => { + for (let i = 0; i < MAX * MODIFY_FACTOR; i++) { + draft[i].done = true + } + }) + setAutoFreezeEs5(true) }) }) diff --git a/es5.js b/es5.js new file mode 100644 index 00000000..7c2b6d47 --- /dev/null +++ b/es5.js @@ -0,0 +1,259 @@ +"use strict" +// @ts-check + +const PROXY_TARGET = Symbol("immer-proxy") +const CHANGED_STATE = Symbol("immer-changed-state") +const PARENT = Symbol("immer-parent") + +let autoFreeze = true + +/** + * Immer takes a state, and runs a function against it. + * That function can freely mutate the state, as it will create copies-on-write. + * This means that the original state will stay unchanged, and once the function finishes, the modified state is returned + * + * @export + * @param {any} baseState - the state to start with + * @param {Function} thunk - function that receives a proxy of the base state as first argument and which can be freely modified + * @returns {any} a new state, or the base state if nothing was modified + */ +function immer(baseState, thunk) { + let finalizing = false + let finished = false + const descriptors = {} + const proxies = [] + + // 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 + } + + 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?" + ) + } + + 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 + }) + } + + function createPropertyProxy(prop) { + return ( + descriptors[prop] || + (descriptors[prop] = { + 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 + }, + set(value) { + proxySet(this, prop, value) + } + }) + ) + } + + function createObjectProxy(base) { + const proxy = {} + Object.keys(base).forEach(prop => + Object.defineProperty(proxy, prop, createPropertyProxy(prop)) + ) + return proxy + } + + function createArrayProxy(base) { + const proxy = [] + for (let i = 0; i < base.length; i++) + Object.defineProperty(proxy, "" + i, createPropertyProxy("" + i)) + return proxy + } + + // 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) + function markChanges() { + // 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) + } + } + } + + function hasObjectChanges(proxy) { + const baseKeys = Object.keys(proxy[PROXY_TARGET]) + const keys = Object.keys(proxy) + return !shallowEqual(baseKeys, keys) + } + + function hasArrayChanges(proxy) { + return proxy[PROXY_TARGET].length !== proxy.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) + if (Array.isArray(proxy)) return finalizeArray(proxy) + throw new Error("Illegal state") + } + + function finalizeObject(proxy) { + const res = {} + Object.keys(proxy).forEach(prop => { + res[prop] = finalize(proxy[prop]) + }) + return freeze(res) + } + + function finalizeArray(proxy) { + return freeze(proxy.map(finalize)) + } + + // create proxy for root + const rootClone = createProxy(baseState, undefined) + // execute the thunk + const maybeVoidReturn = thunk(rootClone) + //values either than undefined will trigger warning; + !Object.is(maybeVoidReturn, undefined) && + console.warn( + `Immer callback expects no return value. However ${typeof maybeVoidReturn} was returned` + ) + // and finalize the modified proxy + finalizing = true + // find and mark all changes (for parts not done yet) + markChanges() + const res = finalize(rootClone) + // make sure all proxies become unusable + finished = true + 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 isPlainObject(value) { + if (value === null || typeof value !== "object") return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +function isProxy(value) { + return !!(value && value[PROXY_TARGET]) +} + +function freeze(value) { + if (autoFreeze) { + Object.freeze(value) + } + return value +} + +function createHiddenProperty(target, prop, value) { + Object.defineProperty(target, prop, { + value: value, + enumerable: false, + writable: true + }) +} + +function shallowEqual(objA, objB) { + //From: https://github.com/facebook/fbjs/blob/c69904a511b900266935168223063dd8772dfc40/packages/fbjs/src/core/shallowEqual.js + if (Object.is(objA, objB)) return true + if ( + typeof objA !== "object" || + objA === null || + typeof objB !== "object" || + objB === null + ) { + return false + } + const keysA = Object.keys(objA) + const keysB = Object.keys(objB) + if (keysA.length !== keysB.length) return false + for (let i = 0; i < keysA.length; i++) { + if ( + !hasOwnProperty.call(objB, keysA[i]) || + !Object.is(objA[keysA[i]], objB[keysA[i]]) + ) { + return false + } + } + return true +} + +/** + * Automatically freezes any state trees generated by immer. + * This protects against accidental modifications of the state tree outside of an immer function. + * This comes with a performance impact, so it is recommended to disable this option in production. + * It is by default enabled. + * + * @returns {void} + */ +function setAutoFreeze(enableAutoFreeze) { + autoFreeze = enableAutoFreeze +} + +createHiddenProperty(exports, "__esModule", true) +module.exports.default = immer +module.exports.setAutoFreeze = setAutoFreeze diff --git a/immer.js b/immer.js index 3eeb8e81..9085ff34 100644 --- a/immer.js +++ b/immer.js @@ -1,6 +1,11 @@ "use strict" // @ts-check +if (typeof Proxy === "undefined") + throw new Error( + "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 diff --git a/package.json b/package.json index d8b834f1..35d2c91a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "types": "./index.d.ts", "scripts": { "test": "jest", + "test:perf": "jest --verbose __tests__/performance.js", "prettier": "prettier \"*/**/*.js\" --ignore-path ./.prettierignore --write && git add . && git status" }, "pre-commit": [ diff --git a/readme.md b/readme.md index a4ab64b7..b6336480 100644 --- a/readme.md +++ b/readme.md @@ -17,6 +17,10 @@ Using immer is like having a personal assistant; he takes a letter (the current A mindful reader might notice that this is quite similar to `withMutations` of ImmutableJS. It is indeed, but generalized and applicable to plain, native JavaScript data structures (arrays and objects) without further needing any library. +## Installation + +`npm install immer` + ## API The immer package exposes a single function: @@ -26,6 +30,8 @@ The immer package exposes a single function: ## Example ```javascript +import immer from "immer" + const baseState = [ { todo: "Learn typescript", @@ -61,9 +67,22 @@ expect(nextState[0]).toBe(baseState[0]) expect(nextState[1]).not.toBe(baseState[1]) ``` +## Using immer on older JavaScript environments + +By default `immer` tries to use proxies for optimal performance. +However, on older JavaScript engines `Proxy` is not available. +For example, Microsoft Internet Explorer or React Native on Android. +In these cases, import the ES5 compatibile implementation first, which is a bit slower (see below) but semantically equivalent: + +```javascript +import immer from "immer/es5" +``` + ## Benefits * Use the language© to construct create your next state +* Use JavaScript native arrays and object +* Automatic immutability; any state tree produced by `immer` will by defualt be deeply frozen * Strongly typed, no string based paths etc * Deep updates are trivial * Small, dependency free library with minimal api surface @@ -80,7 +99,7 @@ expect(nextState[1]).not.toBe(baseState[1]) ## Reducer Example -A lot of words; here is a simple example of the difference that this approach could make in practice. +Here is a simple example of the difference that this approach could make in practice. The todo reducers from the official Redux [todos-with-undo example](https://codesandbox.io/s/github/reactjs/redux/tree/master/examples/todos-with-undo) _Note, this is just a sample application of the `immer` package. Immer is not just designed to simplify Redux reducers. It can be used in any context where you have an immutable data tree that you want to clone and modify (with structural sharing)_ @@ -158,18 +177,26 @@ This test takes 100.000 todo items, and updates 10.000 of them. These tests were executed on Node 8.4.0 ``` - performance - ✓ just mutate (1ms) // No immutability at all - ✓ deepclone, then mutate (647ms) // Clone entire tree, then mutate (no structural sharing!) - ✓ handcrafted reducer (17ms) // Implement it as typical Redux reducer, with slices and spread operator - ✓ immutableJS (81ms) // Use immutableJS and leverage `withMutations` for best performance - ✓ immer - with autofreeze (309ms) // Immer, with auto freeze enabled - ✓ immer - without autofreeze (148ms) // Immer, but without auto freeze enabled + ✓ just mutate (2ms) + (No immutability at all) + ✓ deepclone, then mutate (390ms) + (Clone entire tree, then mutate (no structural sharing!)) + ✓ handcrafted reducer (27ms) + (Implement it as typical Redux reducer, with slices and spread operator) + ✓ immutableJS (68ms) + (Use immutableJS and leverage `withMutations` for best performance) + ✓ immer (proxy) - with autofreeze (303ms) + (Immer, with auto freeze enabled, default implementation) + ✓ immer (proxy) - without autofreeze (142ms) + (Immer, with auto freeze disabled, default implementation) + ✓ immer (es5) - with autofreeze (414ms) + (Immer, with auto freeze enabled, compatibility implementation) + ✓ immer (es5) - without autofreeze (341ms) + (Immer, with auto freeze disabled, default implementation) ``` ## Limitations -* This package requires Proxies, so Safari > 9, no Internet Explorer, no React Native on Android. This can potentially done, so feel free to upvote on [#8](https://github.com/mweststrate/immer/issues/8) if you need this :) * Currently, only tree shaped states are supported. Cycles could potentially be supported as well (PR's welcome) * Currently, only supports plain objects and arrays. Non-plain data structures (like `Map`, `Set`) not (yet). (PR's welcome)