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 #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. The key has expired.
1 parent d157d7a commit 0728d61
Showing 11 changed files with 365 additions and 420 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 __tests__/patch.js
Original file line number Diff line number Diff line change
@@ -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/)
})
})

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,
110 changes: 3 additions & 107 deletions src/common.js
Original file line number Diff line number Diff line change
@@ -1,46 +1,10 @@
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
}
typeof Symbol !== "undefined" ? Symbol("immer-state") : "__$immer_state"

export function isProxy(value) {
return !!value && !!value[PROXY_STATE]
@@ -54,13 +18,6 @@ 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
@@ -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) {
144 changes: 57 additions & 87 deletions src/es5.js
Original file line number Diff line number Diff line change
@@ -2,52 +2,78 @@
// @ts-check

import {
each,
has,
is,
isProxy,
isProxyable,
PROXY_STATE,
shallowCopy,
RETURNED_AND_MODIFIED_ERROR,
has,
each,
finalize
PROXY_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 createProxy(base, parent) {
if (isProxy(base)) throw new Error("This should never happen. Please report: https://github.com/mweststrate/immer/issues/new") // prettier-ignore

const proxy = shallowCopy(base)
each(base, prop => {
Object.defineProperty(proxy, "" + 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
copy: null,
revoke,
revoked: false
}

createHiddenProperty(proxy, PROXY_STATE, state)
currentScope().push(state)
return proxy
}

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]
// Drafts are only created for proxyable values that exist in the base state.
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
prepareCopy(state)
return (state.copy[prop] = createProxy(state, value))
return (state.copy[prop] = createProxy(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) {
@@ -98,23 +110,22 @@ function createPropertyProxy(prop) {
)
}

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 proxies in the `scope` array is based on when they
// were accessed. By processing proxies 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)
@@ -127,7 +138,7 @@ function markChangesRecursively(object) {
if (!object || typeof object !== "object") return
const state = object[PROXY_STATE]
if (!state) return
const {proxy, base, assigned} = state
const {base, proxy, assigned} = state
if (!Array.isArray(object)) {
// Look for added keys.
Object.keys(proxy).forEach(key => {
@@ -198,47 +209,6 @@ function hasArrayChanges(state) {
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,
Loading

0 comments on commit 0728d61

Please sign in to comment.