diff --git a/__tests__/curry.js b/__tests__/curry.js index 951c2dcd..704615c1 100644 --- a/__tests__/curry.js +++ b/__tests__/curry.js @@ -9,14 +9,14 @@ function runTests(name, useProxies) { setUseProxies(useProxies) it("should check arguments", () => { - expect(() => produce()).toThrow(/produce expects 1 to 3 arguments/) + let error = /if first argument is not a function, the second argument to produce should be a function/ + expect(() => produce()).toThrow(error) + expect(() => produce({})).toThrow(error) + + expect(() => produce({}, {})).toThrow(/should be a function/) expect(() => produce({}, () => {}, [])).toThrow( /third argument of a producer/ ) - expect(() => produce({}, {})).toThrow(/should be a function/) - expect(() => produce({})).toThrow( - /if first argument is not a function, the second argument to produce should be a function/ - ) }) it("should support currying", () => { diff --git a/__tests__/patch.js b/__tests__/patch.js index 439e2acf..41afce2b 100644 --- a/__tests__/patch.js +++ b/__tests__/patch.js @@ -69,7 +69,7 @@ describe("applyPatches", () => { expect(() => { const patch = {op: "remove", path: [0]} applyPatches([1, 2], [patch]) - }).toThrowError(/^Remove can only remove the last key of an array/) + }).toThrowError(/^Only the last index of an array can be removed/) }) }) diff --git a/rollup.config.js b/rollup.config.js index b704234d..023fa068 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -7,7 +7,7 @@ import babel from "rollup-plugin-babel" function getConfig(dest, format, ugly) { const conf = { - input: "src/immer.js", + input: "src/index.js", output: { exports: "named", file: dest, diff --git a/src/common.js b/src/common.js index 1aadd821..a78e5b0a 100644 --- a/src/common.js +++ b/src/common.js @@ -1,52 +1,16 @@ -import {generatePatches} from "./patches" - export const NOTHING = typeof Symbol !== "undefined" ? Symbol("immer-nothing") : {["immer-nothing"]: true} -export const PROXY_STATE = - typeof Symbol !== "undefined" - ? Symbol("immer-proxy-state") - : "__$immer_state" - -export const RETURNED_AND_MODIFIED_ERROR = - "An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft." - -function verifyMinified() {} - -const inProduction = - (typeof process !== "undefined" && process.env.NODE_ENV === "production") || - verifyMinified.name !== "verifyMinified" - -let autoFreeze = !inProduction -let useProxies = typeof Proxy !== "undefined" && typeof Reflect !== "undefined" - -/** - * 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} - */ -export function setAutoFreeze(enableAutoFreeze) { - autoFreeze = enableAutoFreeze -} - -export function setUseProxies(value) { - useProxies = value -} - -export function getUseProxies() { - return useProxies -} +export const DRAFT_STATE = + typeof Symbol !== "undefined" ? Symbol("immer-state") : "__$immer_state" -export function isProxy(value) { - return !!value && !!value[PROXY_STATE] +export function isDraft(value) { + return !!value && !!value[DRAFT_STATE] } -export function isProxyable(value) { +export function isDraftable(value) { if (!value) return false if (typeof value !== "object") return false if (Array.isArray(value)) return true @@ -54,16 +18,9 @@ export function isProxyable(value) { return proto === null || proto === Object.prototype } -export function freeze(value) { - if (autoFreeze) { - Object.freeze(value) - } - return value -} - export function original(value) { - if (value && value[PROXY_STATE]) { - return value[PROXY_STATE].base + if (value && value[DRAFT_STATE]) { + return value[DRAFT_STATE].base } // otherwise return undefined } @@ -87,9 +44,9 @@ export function shallowCopy(value) { export function each(value, cb) { if (Array.isArray(value)) { - for (let i = 0; i < value.length; i++) cb(i, value[i]) + for (let i = 0; i < value.length; i++) cb(i, value[i], value) } else { - for (let key in value) cb(key, value[key]) + for (let key in value) cb(key, value[key], value) } } @@ -97,67 +54,6 @@ export function has(thing, prop) { return Object.prototype.hasOwnProperty.call(thing, prop) } -// given a base object, returns it if unmodified, or return the changed cloned if modified -export function finalize(base, path, patches, inversePatches) { - if (isProxy(base)) { - const state = base[PROXY_STATE] - if (state.modified === true) { - if (state.finalized === true) return state.copy - state.finalized = true - const result = finalizeObject( - useProxies ? state.copy : (state.copy = shallowCopy(base)), - state, - path, - patches, - inversePatches - ) - generatePatches( - state, - path, - patches, - inversePatches, - state.base, - result - ) - return result - } else { - return state.base - } - } - finalizeNonProxiedObject(base) - return base -} - -function finalizeObject(copy, state, path, patches, inversePatches) { - const base = state.base - each(copy, (prop, value) => { - if (value !== base[prop]) { - // if there was an assignment on this property, we don't need to generate - // patches for the subtree - const generatePatches = patches && !has(state.assigned, prop) - copy[prop] = finalize( - value, - generatePatches && path.concat(prop), - generatePatches && patches, - inversePatches - ) - } - }) - return freeze(copy) -} - -function finalizeNonProxiedObject(parent) { - // If finalize is called on an object that was not a proxy, it means that it is an object that was not there in the original - // tree and it could contain proxies at arbitrarily places. Let's find and finalize them as well - if (!isProxyable(parent)) return - if (Object.isFrozen(parent)) return - each(parent, (i, child) => { - if (isProxy(child)) { - parent[i] = finalize(child) - } else finalizeNonProxiedObject(child) - }) -} - export function is(x, y) { // From: https://github.com/facebook/fbjs/blob/c69904a511b900266935168223063dd8772dfc40/packages/fbjs/src/core/shallowEqual.js if (x === y) { diff --git a/src/es5.js b/src/es5.js index a9e21a52..4d5413cf 100644 --- a/src/es5.js +++ b/src/es5.js @@ -2,52 +2,78 @@ // @ts-check import { + each, + has, is, - isProxyable, - PROXY_STATE, + isDraft, + isDraftable, shallowCopy, - RETURNED_AND_MODIFIED_ERROR, - has, - each, - finalize + DRAFT_STATE } from "./common" const descriptors = {} -let states = null -function createState(parent, proxy, base) { - return { +// For nested produce calls: +export const scopes = [] +export const currentScope = () => scopes[scopes.length - 1] + +export function willFinalize(result, baseDraft, needPatches) { + const scope = currentScope() + scope.forEach(state => (state.finalizing = true)) + if (result === undefined || result === baseDraft) { + if (needPatches) markChangesRecursively(baseDraft) + // This is faster when we don't care about which attributes changed. + markChangesSweep(scope) + } +} + +export function createDraft(base, parent) { + if (isDraft(base)) throw new Error("This should never happen. Please report: https://github.com/mweststrate/immer/issues/new") // prettier-ignore + + const draft = shallowCopy(base) + each(base, prop => { + Object.defineProperty(draft, "" + prop, createPropertyProxy("" + prop)) + }) + + const state = { modified: false, + finalizing: false, + finalized: false, assigned: {}, // true: value was assigned to these props, false: was removed - hasCopy: false, parent, base, - proxy, - copy: undefined, - finished: false, - finalizing: false, - finalized: false + draft, + copy: null, + revoke, + revoked: false } + + createHiddenProperty(draft, DRAFT_STATE, state) + currentScope().push(state) + return draft +} + +function revoke() { + this.revoked = true } function source(state) { - return state.hasCopy ? state.copy : state.base + return state.copy || state.base } function get(state, prop) { - assertUnfinished(state) + assertUnrevoked(state) const value = source(state)[prop] - if (!state.finalizing && value === state.base[prop] && isProxyable(value)) { - // only create a proxy if the value is proxyable, and the value was in the base state - // if it wasn't in the base state, the object is already modified and we will process it in finalize + // Drafts are only created for proxyable values that exist in the base state. + if (!state.finalizing && value === state.base[prop] && isDraftable(value)) { prepareCopy(state) - return (state.copy[prop] = createProxy(state, value)) + return (state.copy[prop] = createDraft(value, state)) } return value } function set(state, prop, value) { - assertUnfinished(state) + assertUnrevoked(state) state.assigned[prop] = true // optimization; skip this if there is no listener if (!state.modified) { if (is(source(state)[prop], value)) return @@ -65,21 +91,7 @@ function markChanged(state) { } function prepareCopy(state) { - if (state.hasCopy) return - state.hasCopy = true - state.copy = shallowCopy(state.base) -} - -// creates a proxy for plain objects / arrays -function createProxy(parent, base) { - const proxy = shallowCopy(base) - each(base, i => { - Object.defineProperty(proxy, "" + i, createPropertyProxy("" + i)) - }) - const state = createState(parent, proxy, base) - createHiddenProperty(proxy, PROXY_STATE, state) - states.push(state) - return proxy + if (!state.copy) state.copy = shallowCopy(state.base) } function createPropertyProxy(prop) { @@ -89,32 +101,31 @@ function createPropertyProxy(prop) { configurable: true, enumerable: true, get() { - return get(this[PROXY_STATE], prop) + return get(this[DRAFT_STATE], prop) }, set(value) { - set(this[PROXY_STATE], prop, value) + set(this[DRAFT_STATE], prop, value) } }) ) } -function assertUnfinished(state) { - if (state.finished === true) +function assertUnrevoked(state) { + if (state.revoked === true) 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? " + JSON.stringify(state.copy || state.base) ) } -// this sounds very expensive, but actually it is not that expensive 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 markChangesSweep() { - // 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 = states.length - 1; i >= 0; i--) { - const state = states[i] +// This looks expensive, but only proxies are visited, and only objects without known changes are scanned. +function markChangesSweep(scope) { + // The natural order of drafts in the `scope` array is based on when they + // were accessed. By processing drafts in reverse natural order, we have a + // better chance of processing leaf nodes first. When a leaf node is known to + // have changed, we can avoid any traversal of its ancestor nodes. + for (let i = scope.length - 1; i >= 0; i--) { + const state = scope[i] if (state.modified === false) { if (Array.isArray(state.base)) { if (hasArrayChanges(state)) markChanged(state) @@ -125,25 +136,25 @@ function markChangesSweep() { function markChangesRecursively(object) { if (!object || typeof object !== "object") return - const state = object[PROXY_STATE] + const state = object[DRAFT_STATE] if (!state) return - const {proxy, base, assigned} = state + const {base, draft, assigned} = state if (!Array.isArray(object)) { // Look for added keys. - Object.keys(proxy).forEach(key => { + Object.keys(draft).forEach(key => { // The `undefined` check is a fast path for pre-existing keys. if (base[key] === undefined && !has(base, key)) { assigned[key] = true markChanged(state) } else if (!assigned[key]) { // Only untouched properties trigger recursion. - markChangesRecursively(proxy[key]) + markChangesRecursively(draft[key]) } }) // Look for removed keys. Object.keys(base).forEach(key => { // The `undefined` check is a fast path for pre-existing keys. - if (proxy[key] === undefined && !has(proxy, key)) { + if (draft[key] === undefined && !has(draft, key)) { assigned[key] = false markChanged(state) } @@ -151,24 +162,24 @@ function markChangesRecursively(object) { } else if (hasArrayChanges(state)) { markChanged(state) assigned.length = true - if (proxy.length < base.length) { - for (let i = proxy.length; i < base.length; i++) assigned[i] = false + if (draft.length < base.length) { + for (let i = draft.length; i < base.length; i++) assigned[i] = false } else { - for (let i = base.length; i < proxy.length; i++) assigned[i] = true + for (let i = base.length; i < draft.length; i++) assigned[i] = true } - for (let i = 0; i < proxy.length; i++) { + for (let i = 0; i < draft.length; i++) { // Only untouched indices trigger recursion. - if (assigned[i] === undefined) markChangesRecursively(proxy[i]) + if (assigned[i] === undefined) markChangesRecursively(draft[i]) } } } function hasObjectChanges(state) { - const {base, proxy} = state + const {base, draft} = state // Search for added keys. Start at the back, because non-numeric keys // are ordered by time of definition on the object. - const keys = Object.keys(proxy) + const keys = Object.keys(draft) for (let i = keys.length - 1; i >= 0; i--) { // The `undefined` check is a fast path for pre-existing keys. if (base[keys[i]] === undefined && !has(base, keys[i])) { @@ -182,8 +193,8 @@ function hasObjectChanges(state) { } function hasArrayChanges(state) { - const {proxy} = state - if (proxy.length !== state.base.length) return true + const {draft} = state + if (draft.length !== state.base.length) return true // See #116 // If we first shorten the length, our array interceptors will be removed. // If after that new items are added, result in the same original length, @@ -191,54 +202,13 @@ function hasArrayChanges(state) { // So if there is no own descriptor on the last position, we know that items were removed and added // N.B.: splice, unshift, etc only shift values around, but not prop descriptors, so we only have to check // the last one - const descriptor = Object.getOwnPropertyDescriptor(proxy, proxy.length - 1) + const descriptor = Object.getOwnPropertyDescriptor(draft, draft.length - 1) // descriptor can be null, but only for newly created sparse arrays, eg. new Array(10) if (descriptor && !descriptor.get) return true // For all other cases, we don't have to compare, as they would have been picked up by the index setters return false } -export function produceEs5(baseState, producer, patchListener) { - const prevStates = states - states = [] - const patches = patchListener && [] - const inversePatches = patchListener && [] - try { - // create proxy for root - const rootProxy = createProxy(undefined, baseState) - // execute the thunk - const returnValue = producer.call(rootProxy, rootProxy) - // and finalize the modified proxy - each(states, (_, state) => { - state.finalizing = true - }) - let result - // check whether the draft was modified and/or a value was returned - if (returnValue !== undefined && returnValue !== rootProxy) { - // something was returned, and it wasn't the proxy itself - if (rootProxy[PROXY_STATE].modified) - throw new Error(RETURNED_AND_MODIFIED_ERROR) - result = finalize(returnValue) - if (patches) { - patches.push({op: "replace", path: [], value: result}) - inversePatches.push({op: "replace", path: [], value: baseState}) - } - } else { - if (patchListener) markChangesRecursively(rootProxy) - markChangesSweep() // this one is more efficient if we don't need to know which attributes have changed - result = finalize(rootProxy, [], patches, inversePatches) - } - // make sure all proxies become unusable - each(states, (_, state) => { - state.finished = true - }) - patchListener && patchListener(patches, inversePatches) - return result - } finally { - states = prevStates - } -} - function createHiddenProperty(target, prop, value) { Object.defineProperty(target, prop, { value: value, diff --git a/src/immer.js b/src/immer.js index 93cea8b0..c0a8290b 100644 --- a/src/immer.js +++ b/src/immer.js @@ -1,77 +1,168 @@ -export { - setAutoFreeze, - setUseProxies, - original, - isProxy as isDraft +import * as legacyProxy from "./es5" +import * as modernProxy from "./proxy" +import {generatePatches} from "./patches" +import { + assign, + each, + has, + is, + isDraft, + isDraftable, + shallowCopy, + DRAFT_STATE, + NOTHING } from "./common" -import {applyPatches as applyPatchesImpl} from "./patches" -import {isProxy, isProxyable, getUseProxies, NOTHING} from "./common" -import {produceProxy} from "./proxy" -import {produceEs5} from "./es5" +function verifyMinified() {} -/** - * produce 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} producer - function that receives a proxy of the base state as first argument and which can be freely modified - * @param {Function} patchListener - optional function that will be called with all the patches produced here - * @returns {any} a new state, or the base state if nothing was modified - */ -export function produce(baseState, producer, patchListener) { - // prettier-ignore - if (arguments.length < 1 || arguments.length > 3) throw new Error("produce expects 1 to 3 arguments, got " + arguments.length) - - // curried invocation - if (typeof baseState === "function" && typeof producer !== "function") { - const initialState = producer - const recipe = baseState - - return function(currentState = initialState, ...args) { - return produce(currentState, draft => - recipe.call(draft, draft, ...args) - ) - } - } +const configDefaults = { + useProxies: typeof Proxy !== "undefined" && typeof Reflect !== "undefined", + autoFreeze: + typeof process !== "undefined" + ? process.env.NODE_ENV !== "production" + : verifyMinified.name === "verifyMinified" +} - // prettier-ignore - { - if (typeof producer !== "function") throw new Error("if first argument is not a function, the second argument to produce should be a function") - if (patchListener !== undefined && typeof patchListener !== "function") throw new Error("the third argument of a producer should not be set or a function") +export class Immer { + constructor(config) { + assign(this, configDefaults, config) + this.setUseProxies(this.useProxies) + this.produce = this.produce.bind(this) } + produce(base, recipe, patchListener) { + // curried invocation + if (typeof base === "function" && typeof recipe !== "function") { + const defaultBase = recipe + recipe = base - // avoid proxying anything except plain objects and arrays - if (!isProxyable(baseState)) { - const returnValue = producer(baseState) - return returnValue === undefined - ? baseState - : normalizeResult(returnValue) - } + // prettier-ignore + return (base = defaultBase, ...args) => + this.produce(base, draft => recipe.call(draft, draft, ...args)) + } - // See #100, don't nest producers - if (isProxy(baseState)) { - const returnValue = producer.call(baseState, baseState) - return returnValue === undefined - ? baseState - : normalizeResult(returnValue) - } + // prettier-ignore + { + if (typeof recipe !== "function") throw new Error("if first argument is not a function, the second argument to produce should be a function") + if (patchListener !== undefined && typeof patchListener !== "function") throw new Error("the third argument of a producer should not be set or a function") + } - return normalizeResult( - getUseProxies() - ? produceProxy(baseState, producer, patchListener) - : produceEs5(baseState, producer, patchListener) - ) -} + let result + // Only create proxies for plain objects/arrays. + if (!isDraftable(base)) { + result = recipe(base) + if (result === undefined) return base + } + // See #100, don't nest producers + else if (isDraft(base)) { + result = recipe.call(base, base) + if (result === undefined) return base + } + // The given value must be proxied. + else { + this.scopes.push([]) + const baseDraft = this.createDraft(base) + try { + result = recipe.call(baseDraft, baseDraft) + this.willFinalize(result, baseDraft, !!patchListener) -function normalizeResult(result) { - return result === NOTHING ? undefined : result -} + // Never generate patches when no listener exists. + var patches = patchListener && [], + inversePatches = patchListener && [] -export default produce + // Finalize the modified draft... + if (result === undefined || result === baseDraft) { + result = this.finalize( + baseDraft, + [], + patches, + inversePatches + ) + } + // ...or use a replacement value. + else { + // Users must never modify the draft _and_ return something else. + if (baseDraft[DRAFT_STATE].modified) + throw new Error("An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft.") // prettier-ignore -export const applyPatches = produce(applyPatchesImpl) + // Finalize the replacement in case it contains (or is) a subset of the draft. + if (isDraftable(result)) result = this.finalize(result) -export const nothing = NOTHING + if (patchListener) { + patches.push({ + op: "replace", + path: [], + value: result + }) + inversePatches.push({ + op: "replace", + path: [], + value: base + }) + } + } + } finally { + this.currentScope().forEach(state => state.revoke()) + this.scopes.pop() + } + patchListener && patchListener(patches, inversePatches) + } + // Normalize the result. + return result === NOTHING ? undefined : result + } + setAutoFreeze(value) { + this.autoFreeze = value + } + setUseProxies(value) { + this.useProxies = value + assign(this, value ? modernProxy : legacyProxy) + } + /** + * @internal + * Finalize a draft, returning either the unmodified base state or a modified + * copy of the base state. + */ + finalize(draft, path, patches, inversePatches) { + const state = draft[DRAFT_STATE] + if (!state) { + if (Object.isFrozen(draft)) return draft + return this.finalizeTree(draft) + } + if (!state.modified) return state.base + if (!state.finalized) { + state.finalized = true + this.finalizeTree(state.draft, path, patches, inversePatches) + if (this.autoFreeze) Object.freeze(state.copy) + if (patches) generatePatches(state, path, patches, inversePatches) + } + return state.copy + } + /** + * @internal + * Finalize all drafts in the given state tree. + */ + finalizeTree(root, path, patches, inversePatches) { + const state = root[DRAFT_STATE] + if (state) { + root = this.useProxies + ? state.copy + : (state.copy = shallowCopy(state.draft)) + } + const finalizeProperty = (prop, value, parent) => { + // Skip unchanged properties in draft objects. + if (state && parent === root && is(value, state.base[prop])) return + if (!isDraftable(value)) return + if (!isDraft(value)) { + // Frozen values are already finalized. + return Object.isFrozen(value) || each(value, finalizeProperty) + } + // prettier-ignore + parent[prop] = + // Patches are never generated for assigned properties. + patches && parent === root && !(state && has(state.assigned, prop)) + ? this.finalize(value, path.concat(prop), patches, inversePatches) + : this.finalize(value) + } + each(root, finalizeProperty) + return root + } +} diff --git a/src/immer.d.ts b/src/index.d.ts similarity index 100% rename from src/immer.d.ts rename to src/index.d.ts diff --git a/src/index.js b/src/index.js new file mode 100644 index 00000000..d7cf54c7 --- /dev/null +++ b/src/index.js @@ -0,0 +1,52 @@ +import {applyPatches as applyPatchesImpl} from "./patches" +import {Immer} from "./immer" + +const immer = new Immer() + +/** + * The `produce` function takes a value and a "recipe function" (whose + * return value often depends on the base state). The recipe function is + * free to mutate its first argument however it wants. All mutations are + * only ever applied to a __copy__ of the base state. + * + * Pass only a function to create a "curried producer" which relieves you + * from passing the recipe function every time. + * + * Only plain objects and arrays are made mutable. All other objects are + * considered uncopyable. + * + * Note: This function is __bound__ to its `Immer` instance. + * + * @param {any} base - the initial state + * @param {Function} producer - function that receives a proxy of the base state as first argument and which can be freely modified + * @param {Function} patchListener - optional function that will be called with all the patches produced here + * @returns {any} a new state, or the initial state if nothing was modified + */ +export const produce = immer.produce +export default produce + +/** + * Pass true to automatically freeze all copies created by Immer. + * + * By default, auto-freezing is disabled in production. + */ +export const setAutoFreeze = value => immer.setAutoFreeze(value) + +/** + * Pass true to use the ES2015 `Proxy` class when creating drafts, which is + * always faster than using ES5 proxies. + * + * By default, feature detection is used, so calling this is rarely necessary. + */ +export const setUseProxies = value => immer.setUseProxies(value) + +/** + * Apply an array of Immer patches to the first argument. + * + * This function is a producer, which means copy-on-write is in effect. + */ +export const applyPatches = produce(applyPatchesImpl) + +export {original, isDraft, NOTHING as nothing} from "./common" + +export {Immer} diff --git a/src/immer.js.flow b/src/index.js.flow similarity index 100% rename from src/immer.js.flow rename to src/index.js.flow diff --git a/src/patches.js b/src/patches.js index 16ef1677..6833fd9d 100644 --- a/src/patches.js +++ b/src/patches.js @@ -1,100 +1,72 @@ import {each} from "./common" -export function generatePatches( - state, - basepath, - patches, - inversePatches, - baseValue, - resultValue -) { - if (patches) - if (Array.isArray(baseValue)) - generateArrayPatches( - state, - basepath, - patches, - inversePatches, - baseValue, - resultValue - ) - else - generateObjectPatches( - state, - basepath, - patches, - inversePatches, - baseValue, - resultValue - ) +export function generatePatches(state, basePath, patches, inversePatches) { + Array.isArray(state.base) + ? generateArrayPatches(state, basePath, patches, inversePatches) + : generateObjectPatches(state, basePath, patches, inversePatches) } -export function generateArrayPatches( - state, - basepath, - patches, - inversePatches, - baseValue, - resultValue -) { - const shared = Math.min(baseValue.length, resultValue.length) - for (let i = 0; i < shared; i++) { - if (state.assigned[i] && baseValue[i] !== resultValue[i]) { - const path = basepath.concat(i) - patches.push({op: "replace", path, value: resultValue[i]}) - inversePatches.push({op: "replace", path, value: baseValue[i]}) +export function generateArrayPatches(state, basePath, patches, inversePatches) { + const {base, copy, assigned} = state + const minLength = Math.min(base.length, copy.length) + + // Look for replaced indices. + for (let i = 0; i < minLength; i++) { + if (assigned[i] && base[i] !== copy[i]) { + const path = basePath.concat(i) + patches.push({op: "replace", path, value: copy[i]}) + inversePatches.push({op: "replace", path, value: base[i]}) } } - if (shared < resultValue.length) { - // stuff was added - for (let i = shared; i < resultValue.length; i++) { - const path = basepath.concat(i) - patches.push({op: "add", path, value: resultValue[i]}) + + // Did the array expand? + if (minLength < copy.length) { + for (let i = minLength; i < copy.length; i++) { + patches.push({ + op: "add", + path: basePath.concat(i), + value: copy[i] + }) } inversePatches.push({ op: "replace", - path: basepath.concat("length"), - value: baseValue.length + path: basePath.concat("length"), + value: base.length }) - } else if (shared < baseValue.length) { - // stuff was removed + } + + // ...or did it shrink? + else if (minLength < base.length) { patches.push({ op: "replace", - path: basepath.concat("length"), - value: resultValue.length + path: basePath.concat("length"), + value: copy.length }) - for (let i = shared; i < baseValue.length; i++) { - const path = basepath.concat(i) - inversePatches.push({op: "add", path, value: baseValue[i]}) + for (let i = minLength; i < base.length; i++) { + inversePatches.push({ + op: "add", + path: basePath.concat(i), + value: base[i] + }) } } } -function generateObjectPatches( - state, - basepath, - patches, - inversePatches, - baseValue, - resultValue -) { +function generateObjectPatches(state, basePath, patches, inversePatches) { + const {base, copy} = state each(state.assigned, (key, assignedValue) => { - const origValue = baseValue[key] - const value = resultValue[key] - const op = !assignedValue - ? "remove" - : key in baseValue - ? "replace" - : "add" - if (origValue === baseValue && op === "replace") return - const path = basepath.concat(key) + const origValue = base[key] + const value = copy[key] + const op = !assignedValue ? "remove" : key in base ? "replace" : "add" + if (origValue === base && op === "replace") return + const path = basePath.concat(key) patches.push(op === "remove" ? {op, path} : {op, path, value}) inversePatches.push( op === "add" ? {op: "remove", path} : op === "remove" - ? {op: "add", path, value: origValue} - : {op: "replace", path, value: origValue} + ? {op: "add", path, value: origValue} + : {op: "replace", path, value: origValue} ) }) } @@ -110,10 +82,7 @@ export function applyPatches(draft, patches) { for (let i = 0; i < path.length - 1; i++) { base = base[path[i]] if (!base || typeof base !== "object") - throw new Error( - "Cannot apply patch, path doesn't resolve: " + - path.join("/") - ) + throw new Error("Cannot apply patch, path doesn't resolve: " + path.join("/")) // prettier-ignore } const key = path[path.length - 1] switch (patch.op) { @@ -124,14 +93,12 @@ export function applyPatches(draft, patches) { break case "remove": if (Array.isArray(base)) { - if (key === base.length - 1) base.length -= 1 - else - throw new Error( - `Remove can only remove the last key of an array, index: ${key}, length: ${ - base.length - }` - ) - } else delete base[key] + if (key !== base.length - 1) + throw new Error(`Only the last index of an array can be removed, index: ${key}, length: ${base.length}`) // prettier-ignore + base.length -= 1 + } else { + delete base[key] + } break default: throw new Error("Unsupported patch operation: " + patch.op) diff --git a/src/proxy.js b/src/proxy.js index 24129376..ceadb0bb 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -6,15 +6,44 @@ import { each, has, is, - isProxyable, - isProxy, - finalize, + isDraftable, + isDraft, shallowCopy, - PROXY_STATE, - RETURNED_AND_MODIFIED_ERROR + DRAFT_STATE } from "./common" -let proxies = null +// For nested produce calls: +export const scopes = [] +export const currentScope = () => scopes[scopes.length - 1] + +// Do nothing before being finalized. +export function willFinalize() {} + +export function createDraft(base, parent) { + if (isDraft(base)) throw new Error("This should never happen. Please report: https://github.com/mweststrate/immer/issues/new") // prettier-ignore + + const state = { + modified: false, // this tree is modified (either this object or one of it's children) + assigned: {}, // true: value was assigned to these props, false: was removed + parent, + base, + draft: null, // the root proxy + drafts: {}, // proxied properties + copy: null, + revoke: null, + finalized: false + } + + const {revoke, proxy} = Array.isArray(base) + ? Proxy.revocable([state], arrayTraps) + : Proxy.revocable(state, objectTraps) + + state.draft = proxy + state.revoke = revoke + + currentScope().push(state) + return proxy +} const objectTraps = { get, @@ -56,36 +85,24 @@ arrayTraps.set = function(state, prop, value) { return objectTraps.set.call(this, state[0], prop, value) } -function createState(parent, base) { - return { - modified: false, // this tree is modified (either this object or one of it's children) - assigned: {}, // true: value was assigned to these props, false: was removed - finalized: false, - parent, - base, - copy: undefined, - proxies: {} - } -} - function source(state) { return state.modified === true ? state.copy : state.base } function get(state, prop) { - if (prop === PROXY_STATE) return state + if (prop === DRAFT_STATE) return state if (state.modified) { const value = state.copy[prop] - if (value === state.base[prop] && isProxyable(value)) + if (value === state.base[prop] && isDraftable(value)) // only create proxy if it is not yet a proxy, and not a new object // (new objects don't need proxying, they will be processed in finalize anyway) - return (state.copy[prop] = createProxy(state, value)) + return (state.copy[prop] = createDraft(value, state)) return value } else { - if (has(state.proxies, prop)) return state.proxies[prop] + if (has(state.drafts, prop)) return state.drafts[prop] const value = state.base[prop] - if (!isProxy(value) && isProxyable(value)) - return (state.proxies[prop] = createProxy(state, value)) + if (!isDraft(value) && isDraftable(value)) + return (state.drafts[prop] = createDraft(value, state)) return value } } @@ -94,9 +111,9 @@ function set(state, prop, value) { if (!state.modified) { // Optimize based on value's truthiness. Truthy values are guaranteed to // never be undefined, so we can avoid the `in` operator. Lastly, truthy - // values may be proxies, but falsy values are never proxies. + // values may be drafts, but falsy values are never drafts. const isUnchanged = value - ? is(state.base[prop], value) || value === state.proxies[prop] + ? is(state.base[prop], value) || value === state.drafts[prop] : is(state.base[prop], value) && prop in state.base if (isUnchanged) return true markChanged(state) @@ -119,8 +136,8 @@ function deleteProperty(state, prop) { function getOwnPropertyDescriptor(state, prop) { const owner = state.modified ? state.copy - : has(state.proxies, prop) - ? state.proxies + : has(state.drafts, prop) + ? state.drafts : state.base const descriptor = Reflect.getOwnPropertyDescriptor(owner, prop) if (descriptor && !(Array.isArray(owner) && prop === "length")) @@ -138,55 +155,8 @@ function markChanged(state) { if (!state.modified) { state.modified = true state.copy = shallowCopy(state.base) - // copy the proxies over the base-copy - assign(state.copy, state.proxies) // yup that works for arrays as well + // copy the drafts over the base-copy + assign(state.copy, state.drafts) // yup that works for arrays as well if (state.parent) markChanged(state.parent) } } - -// creates a proxy for plain objects / arrays -function createProxy(parentState, base) { - if (isProxy(base)) throw new Error("Immer bug. Plz report.") - const state = createState(parentState, base) - const proxy = Array.isArray(base) - ? Proxy.revocable([state], arrayTraps) - : Proxy.revocable(state, objectTraps) - proxies.push(proxy) - return proxy.proxy -} - -export function produceProxy(baseState, producer, patchListener) { - const previousProxies = proxies - proxies = [] - const patches = patchListener && [] - const inversePatches = patchListener && [] - try { - // create proxy for root - const rootProxy = createProxy(undefined, baseState) - // execute the producer function - const returnValue = producer.call(rootProxy, rootProxy) - // and finalize the modified proxy - let result - // check whether the draft was modified and/or a value was returned - if (returnValue !== undefined && returnValue !== rootProxy) { - // something was returned, and it wasn't the proxy itself - if (rootProxy[PROXY_STATE].modified) - throw new Error(RETURNED_AND_MODIFIED_ERROR) - - // we need to finalize the return value in case it's a subset of the draft - result = finalize(returnValue) - if (patches) { - patches.push({op: "replace", path: [], value: result}) - inversePatches.push({op: "replace", path: [], value: baseState}) - } - } else { - result = finalize(rootProxy, [], patches, inversePatches) - } - // revoke all proxies - each(proxies, (_, p) => p.revoke()) - patchListener && patchListener(patches, inversePatches) - return result - } finally { - proxies = previousProxies - } -}