Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add Immer class
Browse files Browse the repository at this point in the history
Every instance of the `Immer` class provides a bound `produce` function. As explained in immerjs#254, this class makes room for advanced configuration (if we need it) and allows for interop between Immer-using libraries that have different needs.

I took the time to refactor while I was implementing this, so the diff may look
more overwhelming than it is. :)

Summary:

1. Renamed `immer.js` to `index.js` so the `Immer` class can have `immer.js`

2. Use the word "draft" in place of "proxy" in most places, because "draft" is
   a higher level of abstraction. It also makes sense to use the same semantics
   both internally and externally.

3. Moved `finalize` logic to the `Immer` class

4. Inlined the `freeze` function as only one callsite existed

5. Inlined the `createState` functions in both `proxy.js` and `es5.js`

6. Extract repeated code from `produceEs5` and `produceProxy` (which have since
   been removed) into the `Immer` class

7. The `es5.js` and `proxy.js` modules now have different exports:
    - `scopes`: an array of nested `produce` calls, where each value is an array
      of unrevoked proxies
    - `currentScope()`: shortcut for getting the last value of the `scopes` array
    - `createDraft()`: a renamed `createProxy` with the arguments swapped
    - `willFinalize()`: called right after the recipe function returns (only
      used by ES5 proxies)

8. Changed some "draft state" properties:
    - removed `hasCopy` in ES5 state (checking for truthiness of `copy` does the job)
    - renamed `proxy` to `draft` in ES5 state
    - renamed `finished` to `revoked` in ES5 state
    - renamed `proxies` to `drafts` in Proxy state
    - added `revoke` method (called by the `Immer` class)
    - added `draft` property to Proxy state
    - use null literals instead of undefined

9. Delay creation of `patches` and `inversePatches` arrays until the recipe
   function returns. This avoids array allocations when a rollback is performed
   by throwing.

10. Simplified `generatePatches` by removing the last two arguments, since they
    can be obtained from the `state` argument.
aleclarson committed Nov 30, 2018

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent d157d7a commit 622438a
Showing 8 changed files with 396 additions and 454 deletions.
10 changes: 5 additions & 5 deletions __tests__/curry.js
Original file line number Diff line number Diff line change
@@ -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", () => {
2 changes: 1 addition & 1 deletion rollup.config.js
Original file line number Diff line number Diff line change
@@ -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,
122 changes: 9 additions & 113 deletions src/common.js
Original file line number Diff line number Diff line change
@@ -1,69 +1,26 @@
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
const proto = Object.getPrototypeOf(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,77 +44,16 @@ 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)
}
}

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) {
182 changes: 76 additions & 106 deletions src/es5.js
Original file line number Diff line number Diff line change
@@ -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,50 +136,50 @@ 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)
}
})
} 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,63 +193,22 @@ 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,
// those last items will have no intercepting property.
// 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,
224 changes: 159 additions & 65 deletions src/immer.js
Original file line number Diff line number Diff line change
@@ -1,77 +1,171 @@
export {
setAutoFreeze,
setUseProxies,
original,
isProxy as isDraft
import * as legacyImpl from "./es5"
import * as modernImpl from "./proxy"
import {generatePatches} from "./patches"
import {
assign,
each,
has,
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)
const configDefaults = {
useProxies: typeof Proxy !== "undefined" && typeof Reflect !== "undefined",
autoFreeze:
typeof process !== "undefined"
? process.env.NODE_ENV !== "production"
: verifyMinified.name === "verifyMinified"
}

// curried invocation
if (typeof baseState === "function" && typeof producer !== "function") {
const initialState = producer
const recipe = baseState
export class Immer {
constructor(config) {
assign(this, configDefaults, config)
this.setUseProxies(this.useProxies)

return function(currentState = initialState, ...args) {
return produce(currentState, draft =>
recipe.call(draft, draft, ...args)
)
}
}

// 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")
}
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.
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 ? modernImpl : legacyImpl)
}
/**
* @internal
* Finalize a draft, returning either the unmodified base state or a modified
* copy of the base state.
*
* For undrafted objects, only the first argument is required. Otherwise,
* the first two arguments are required.
*/
finalize(value, path, patches, inversePatches) {
if (!isDraftable(value)) return value
const state = value[DRAFT_STATE]
if (!state) {
if (Object.isFrozen(value)) return value
return this.finalizeTree(value)
}
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 && 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
}
}
45 changes: 45 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {applyPatches as applyPatchesImpl} from "./patches"
import {Immer} from "./immer"

const immer = new Immer()

/**
* The `produce` function takes a value and a producer function whose return
* value typically depends on the input value. When the given value is
* proxyable, the producer function can perform mutations, even when the given
* value is immutable. When mutations are made, a fresh copy is created and returned.
* The input value is never _actually_ mutated.
*
* @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 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}
139 changes: 53 additions & 86 deletions src/patches.js
Original file line number Diff line number Diff line change
@@ -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(...args) {
Array.isArray(args[0].base)
? generateArrayPatches(...args)
: generateObjectPatches(...args)
}

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)
126 changes: 48 additions & 78 deletions src/proxy.js
Original file line number Diff line number Diff line change
@@ -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)
@@ -116,9 +133,9 @@ function deleteProperty(state, prop) {
function getOwnPropertyDescriptor(state, prop) {
const owner = state.modified
? state.copy
: has(state.proxies, prop)
? state.proxies
: state.base
: has(state.drafts, prop)
? state.drafts
: state.base
const descriptor = Reflect.getOwnPropertyDescriptor(owner, prop)
if (descriptor && !(Array.isArray(owner) && prop === "length"))
descriptor.configurable = true
@@ -135,55 +152,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
}
}

0 comments on commit 622438a

Please sign in to comment.