diff --git a/__tests__/__snapshots__/manual.js.snap b/__tests__/__snapshots__/manual.js.snap new file mode 100644 index 00000000..7146da2c --- /dev/null +++ b/__tests__/__snapshots__/manual.js.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`manual - es5 cannot modify after finish 1`] = `"Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? {\\"a\\":2}"`; + +exports[`manual - es5 should check arguments 1`] = `"First argument to createDraft should be a plain object, an array, or an immerable object."`; + +exports[`manual - es5 should check arguments 2`] = `"First argument to createDraft should be a plain object, an array, or an immerable object."`; + +exports[`manual - es5 should check arguments 3`] = `"First argument to finishDraft should be an object from createDraft."`; + +exports[`manual - es5 should not finish drafts from produce 1`] = `"The draft provided was not created using \`createDraft\`"`; + +exports[`manual - es5 should not finish twice 1`] = `"The draft provided was has already been finished"`; + +exports[`manual - es5 should support patches drafts 1`] = ` +Array [ + Array [ + Array [ + Object { + "op": "replace", + "path": Array [ + "a", + ], + "value": 2, + }, + Object { + "op": "add", + "path": Array [ + "b", + ], + "value": 3, + }, + ], + Array [ + Object { + "op": "replace", + "path": Array [ + "a", + ], + "value": 1, + }, + Object { + "op": "remove", + "path": Array [ + "b", + ], + }, + ], + ], +] +`; + +exports[`manual - proxy cannot modify after finish 1`] = `"Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? {\\"a\\":2}"`; + +exports[`manual - proxy should check arguments 1`] = `"First argument to createDraft should be a plain object, an array, or an immerable object."`; + +exports[`manual - proxy should check arguments 2`] = `"First argument to createDraft should be a plain object, an array, or an immerable object."`; + +exports[`manual - proxy should check arguments 3`] = `"First argument to finishDraft should be an object from createDraft."`; + +exports[`manual - proxy should not finish drafts from produce 1`] = `"The draft provided was not created using \`createDraft\`"`; + +exports[`manual - proxy should not finish twice 1`] = `"The draft provided was has already been finished"`; + +exports[`manual - proxy should support patches drafts 1`] = ` +Array [ + Array [ + Array [ + Object { + "op": "replace", + "path": Array [ + "a", + ], + "value": 2, + }, + Object { + "op": "add", + "path": Array [ + "b", + ], + "value": 3, + }, + ], + Array [ + Object { + "op": "replace", + "path": Array [ + "a", + ], + "value": 1, + }, + Object { + "op": "remove", + "path": Array [ + "b", + ], + }, + ], + ], +] +`; diff --git a/__tests__/base.js b/__tests__/base.js index c494f334..0a8fc964 100644 --- a/__tests__/base.js +++ b/__tests__/base.js @@ -815,7 +815,7 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { expect(next.obj).toBe(next.arr[0]) }) - it("can return an object with two references to any pristine draft", () => { + it("can return an object with two references to an unmodified draft", () => { const base = {a: {}} const next = produce(base, d => { return [d.a, d.a] @@ -833,6 +833,65 @@ function runBaseTest(name, useProxies, autoFreeze, useListener) { }) }) + // TODO: rewrite tests with async/await once node 6 support is dropped + describe("async recipe function", () => { + it("can modify the draft", () => { + const base = {a: 0, b: 0} + return produce(base, d => { + d.a = 1 + return Promise.resolve().then(() => { + d.b = 1 + }) + }).then(res => { + expect(res).not.toBe(base) + expect(res).toEqual({a: 1, b: 1}) + }) + }) + + it("works with rejected promises", () => { + let draft + const base = {a: 0, b: 0} + const err = new Error("passed") + return produce(base, d => { + draft = d + draft.b = 1 + return Promise.reject(err) + }).then( + () => { + throw "failed" + }, + e => { + expect(e).toBe(err) + expect(() => draft.a).toThrowError(/revoked/) + } + ) + }) + + it("supports recursive produce calls after await", () => { + const base = {obj: {k: 1}} + return produce(base, d => { + const obj = d.obj + delete d.obj + return Promise.resolve().then(() => { + d.a = produce({}, d => { + d.b = obj // Assign a draft owned by the parent scope. + }) + + // Auto-freezing is prevented when an unowned draft exists. + expect(Object.isFrozen(d.a)).toBeFalsy() + + // Ensure `obj` is not revoked. + obj.c = 1 + }) + }).then(res => { + expect(res).not.toBe(base) + expect(res).toEqual({ + a: {b: {k: 1, c: 1}} + }) + }) + }) + }) + it("throws when the draft is modified and another object is returned", () => { const base = {x: 3} expect(() => { diff --git a/__tests__/manual.js b/__tests__/manual.js new file mode 100644 index 00000000..ae27917b --- /dev/null +++ b/__tests__/manual.js @@ -0,0 +1,136 @@ +"use strict" +import { + setUseProxies, + createDraft, + finishDraft, + produce, + isDraft +} from "../src/index" + +runTests("proxy", true) +runTests("es5", false) + +function runTests(name, useProxies) { + describe("manual - " + name, () => { + setUseProxies(useProxies) + + it("should check arguments", () => { + expect(() => createDraft(3)).toThrowErrorMatchingSnapshot() + const buf = new Buffer([]) + expect(() => createDraft(buf)).toThrowErrorMatchingSnapshot() + expect(() => finishDraft({})).toThrowErrorMatchingSnapshot() + }) + + it("should support manual drafts", () => { + const state = [{}, {}, {}] + + const draft = createDraft(state) + draft.forEach((item, index) => { + item.index = index + }) + + const result = finishDraft(draft) + + expect(result).not.toBe(state) + expect(result).toEqual([{index: 0}, {index: 1}, {index: 2}]) + expect(state).toEqual([{}, {}, {}]) + }) + + it("cannot modify after finish", () => { + const state = {a: 1} + + const draft = createDraft(state) + draft.a = 2 + expect(finishDraft(draft)).toEqual({a: 2}) + expect(() => { + draft.a = 3 + }).toThrowErrorMatchingSnapshot() + }) + + it("should support patches drafts", () => { + const state = {a: 1} + + const draft = createDraft(state) + draft.a = 2 + draft.b = 3 + + const listener = jest.fn() + const result = finishDraft(draft, listener) + + expect(result).not.toBe(state) + expect(result).toEqual({a: 2, b: 3}) + expect(listener.mock.calls).toMatchSnapshot() + }) + + it("should handle multiple create draft calls", () => { + const state = {a: 1} + + const draft = createDraft(state) + draft.a = 2 + + const draft2 = createDraft(state) + draft2.b = 3 + + const result = finishDraft(draft) + + expect(result).not.toBe(state) + expect(result).toEqual({a: 2}) + + draft2.a = 4 + const result2 = finishDraft(draft2) + expect(result2).not.toBe(result) + expect(result2).toEqual({a: 4, b: 3}) + }) + + it("combines with produce - 1", () => { + const state = {a: 1} + + const draft = createDraft(state) + draft.a = 2 + const res1 = produce(draft, d => { + d.b = 3 + }) + draft.b = 4 + const res2 = finishDraft(draft) + expect(res1).toEqual({a: 2, b: 3}) + expect(res2).toEqual({a: 2, b: 4}) + }) + + it("combines with produce - 2", () => { + const state = {a: 1} + + const res1 = produce(state, draft => { + draft.b = 3 + const draft2 = createDraft(draft) + draft.c = 4 + draft2.d = 5 + const res2 = finishDraft(draft2) + expect(res2).toEqual({ + a: 1, + b: 3, + d: 5 + }) + draft.d = 2 + }) + expect(res1).toEqual({ + a: 1, + b: 3, + c: 4, + d: 2 + }) + }) + + it("should not finish drafts from produce", () => { + produce({x: 1}, draft => { + expect(() => finishDraft(draft)).toThrowErrorMatchingSnapshot() + }) + }) + + it("should not finish twice", () => { + const draft = createDraft({a: 1}) + draft.a++ + finishDraft(draft) + expect(() => finishDraft(draft)).toThrowErrorMatchingSnapshot() + }) + }) +} diff --git a/__tests__/produce.ts b/__tests__/produce.ts index 41cdfcd3..4b31cdb3 100644 --- a/__tests__/produce.ts +++ b/__tests__/produce.ts @@ -196,15 +196,52 @@ it("does not enforce immutability at the type level", () => { exactType(result, {} as any[]) }) -it("can produce nothing", () => { - let result = produce({}, _ => nothing) +it("can produce an undefined value", () => { + let base = {} as {readonly a: number} + + // Return only nothing. + let result = produce(base, _ => nothing) exactType(result, undefined) + + // Return maybe nothing. + let result2 = produce(base, draft => { + if (draft.a > 0) return nothing + }) + exactType(result2, {} as typeof base | undefined) +}) + +it("can return the draft itself", () => { + let base = {} as {readonly a: number} + let result = produce(base, draft => draft) + + // Currently, the `readonly` modifier is lost. + exactType(result, {} as {a: number} | undefined) +}) + +it("can return a promise", () => { + let base = {} as {readonly a: number} + + // Return a promise only. + exactType( + produce(base, draft => { + return Promise.resolve(draft.a > 0 ? null : undefined) + }), + {} as Promise<{readonly a: number} | null> + ) + + // Return a promise or undefined. + exactType( + produce(base, draft => { + if (draft.a > 0) return Promise.resolve() + }), + {} as (Promise<{readonly a: number}> | {readonly a: number}) + ) }) it("works with `void` hack", () => { - let obj = {} as {readonly a: number} - let res = produce(obj, s => void s.a++) - exactType(res, obj) + let base = {} as {readonly a: number} + let copy = produce(base, s => void s.a++) + exactType(copy, base) }) it("works with generic parameters", () => { diff --git a/readme.md b/readme.md index 6c8a3c9c..1cb0dcfc 100644 --- a/readme.md +++ b/readme.md @@ -335,9 +335,45 @@ For a more in-depth study, see [Distributing patches and rebasing actions using Tip: Check this trick to [compress patches](https://medium.com/@david.b.edelstein/using-immer-to-compress-immer-patches-f382835b6c69) produced over time. -## Auto freezing +## Async producers -Immer automatically freezes any state trees that are modified using `produce`. This protects against accidental modifications of the state tree outside of a producer. This comes with a performance impact, so it is recommended to disable this option in production. It is by default enabled. By default, it is turned on during local development and turned off in production. Use `setAutoFreeze(true / false)` to explicitly turn this feature on or off. +It is allowed to return Promise objects from recipes. Or, in other words, to use `async / await`. This can be pretty useful for long running processes, that only produce the new object once the promise chain resolves. Note that `produce` itself (even in the curried form) will return a promise if the producer is async. Example: + +```javascript +import produce from "immer" + +const user = { + name: "michel", + todos: [] +} + +const loadedUser = await produce(user, async function(draft) { + user.todos = await (await window.fetch("http://host/" + draft.name)).json() +}) +``` + +_Warning: please note that the draft shouldn't be 'leaked' from the async process and stored else where. The draft will still be revoked as soon as the async process completes._ + +## `createDraft` and `finishDraft` + +`createDraft` and `finishDraft` are two low-level functions that are mostly useful for libraries that build abstractions on top of immer. It avoids the need to always create a function in order to work with drafts. Instead, one can create a draft, modify it, and at some time in the future finish the draft, in which case the next immutable state will be produced. We could for example rewrite our above example as: + +```javascript +import {createDraft, finishDraft} from "immer" + +const user = { + name: "michel", + todos: [] +} + +const draft = createDraft(user) +draft.todos = await (await window.fetch("http://host/" + draft.name)).json() +const loadedUser = finishDraft(draft) +``` + +Note: `finishDraft` takes a `patchListener` as second argument, which can be used to record the patches, similarly to `produce`. + +_Warning: in general, we recommend to use `produce` instead of the `createDraft` / `finishDraft` combo, `produce` is less error prone in usage, and more clearly separates the concepts of mutability and immutability in your code base._ ## Returning data from producers @@ -422,6 +458,22 @@ produce(state, draft => nothing) N.B. Note that this problem is specific for the `undefined` value, any other value, including `null`, doesn't suffer from this issue. +## Inline shortcuts using `void` + +Draft mutations in Immer usually warrant a code block, since a return denotes an overwrite. Sometimes that can stretch code a little more than you might be comfortable with. + +In such cases, you can use javascripts [`void`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/void) operator, which evaluates expressions and returns `undefined`. + +```javascript +// Single mutation +produce(draft => void (draft.user.age += 1)) + +// Multiple mutations +produce(draft => void ((draft.user.age += 1), (draft.user.height = 186))) +``` + +Code style is highly personal, but for code bases that are to be understood by many, we recommend to stick to the classic `draft => { draft.user.age += 1}` to avoid cognitive overhead. + ## Extracting the original object from a proxied instance Immer exposes a named export `original` that will get the original object from the proxied instance inside `produce` (or return `undefined` for unproxied values). A good example of when this can be useful is when searching for nodes in a tree-like state using strict equality. @@ -447,72 +499,9 @@ const nextState = produce(baseState, draft => { isDraft(nextState) // => false ``` -## Using `this` - -The recipe will be always invoked with the `draft` as `this` context. - -This means that the following constructions are also valid: - -```javascript -const base = {counter: 0} - -const next = produce(base, function() { - this.counter++ -}) -console.log(next.counter) // 1 - -// OR -const increment = produce(function() { - this.counter++ -}) -console.log(increment(base).counter) // 1 -``` - -## Inline shortcuts using `void` - -Draft mutations in Immer usually warrant a code block, since a return denotes an overwrite. Sometimes that can stretch code a little more than you might be comfortable with. - -In such cases, you can use javascripts [`void`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/void) operator, which evaluates expressions and returns `undefined`. - -```javascript -// Single mutation -produce(draft => void (draft.user.age += 1)) - -// Multiple mutations -produce(draft => void ((draft.user.age += 1), (draft.user.height = 186))) -``` - -Code style is highly personal, but for code bases that are to be understood by many, we recommend to stick to the classic `draft => { draft.user.age += 1}` to avoid cognitive overhead. - -## TypeScript or Flow - -The Immer package ships with type definitions inside the package, which should be picked up by TypeScript and Flow out of the box and without further configuration. - -The TypeScript typings automatically remove `readonly` modifiers from your draft types and return a value that matches your original type. See this practical example: - -```ts -import produce from "immer" - -interface State { - readonly x: number -} - -// `x` cannot be modified here -const state: State = { - x: 0 -} - -const newState = produce(state, draft => { - // `x` can be modified here - draft.x++ -}) - -// `newState.x` cannot be modified here -``` - -This ensures that the only place you can modify your state is in your produce callbacks. It even works recursively and with `ReadonlyArray`s! +## Auto freezing -**Note:** Immer v1.9+ supports Typescript v3.1+ only. +Immer automatically freezes any state trees that are modified using `produce`. This protects against accidental modifications of the state tree outside of a producer. This comes with a performance impact, so it is recommended to disable this option in production. It is by default enabled. By default, it is turned on during local development and turned off in production. Use `setAutoFreeze(true / false)` to explicitly turn this feature on or off. ## Immer on older JavaScript environments? @@ -576,6 +565,59 @@ const nextState = produce(state, draft => { }) ``` +## TypeScript or Flow + +The Immer package ships with type definitions inside the package, which should be picked up by TypeScript and Flow out of the box and without further configuration. + +The TypeScript typings automatically remove `readonly` modifiers from your draft types and return a value that matches your original type. See this practical example: + +```ts +import produce from "immer" + +interface State { + readonly x: number +} + +// `x` cannot be modified here +const state: State = { + x: 0 +} + +const newState = produce(state, draft => { + // `x` can be modified here + draft.x++ +}) + +// `newState.x` cannot be modified here +``` + +This ensures that the only place you can modify your state is in your produce callbacks. It even works recursively and with `ReadonlyArray`s! + +**Note:** Immer v1.9+ supports Typescript v3.1+ only. + +## Using `this` + +_Deprecated, this will probably be removed in a next major version, see [#308](https://github.com/mweststrate/immer/issues/308)_ + +The recipe will be always invoked with the `draft` as `this` context. + +This means that the following constructions are also valid: + +```javascript +const base = {counter: 0} + +const next = produce(base, function() { + this.counter++ +}) +console.log(next.counter) // 1 + +// OR +const increment = produce(function() { + this.counter++ +}) +console.log(increment(base).counter) // 1 +``` + # Pitfalls 1. Don't redefine draft like, `draft = myCoolNewState`. Instead, either modify the `draft` or return a new state. See [Returning data from producers](#returning-data-from-producers). @@ -669,6 +711,12 @@ Most important observation: - Generating patches doesn't significantly slow immer down - The ES5 fallback implementation is roughly twice as slow as the proxy implementation, in some cases worse. +## Migration + +**Immer 1.\* -> 2.0** + +Make sure you don't return any promises as state, because `produce` will actually invoke the promise and wait until it settles. + ## FAQ _(for those who skimmed the above instead of actually reading)_ diff --git a/src/es5.js b/src/es5.js index 05414f4b..053a8370 100644 --- a/src/es5.js +++ b/src/es5.js @@ -1,6 +1,4 @@ "use strict" -// @ts-check - import { each, has, @@ -11,24 +9,26 @@ import { shallowCopy, DRAFT_STATE } from "./common" +import {ImmerScope} from "./scope" +// property descriptors are recycled to make sure we don't create a get and set closure per property, +// but share them all instead const descriptors = {} -// 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) +export function willFinalize(scope, result, isReplaced) { + scope.drafts.forEach(draft => { + draft[DRAFT_STATE].finalizing = true + }) + if (!isReplaced) { + if (scope.patches) { + markChangesRecursively(scope.drafts[0]) + } // This is faster when we don't care about which attributes changed. - markChangesSweep(scope) + markChangesSweep(scope.drafts) } } -export function createDraft(base, parent) { +export function createProxy(base, parent) { const isArray = Array.isArray(base) const draft = clonePotentialDraft(base) each(draft, prop => { @@ -36,8 +36,9 @@ export function createDraft(base, parent) { }) // See "proxy.js" for property documentation. + const scope = parent ? parent.scope : ImmerScope.current const state = { - scope: parent ? parent.scope : currentScope(), + scope, modified: false, finalizing: false, // es5 only finalized: false, @@ -51,7 +52,7 @@ export function createDraft(base, parent) { } createHiddenProperty(draft, DRAFT_STATE, state) - state.scope.push(state) + scope.drafts.push(draft) return draft } @@ -69,7 +70,7 @@ function get(state, prop) { // 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] = createDraft(value, state)) + return (state.copy[prop] = createProxy(value, state)) } return value } @@ -135,14 +136,14 @@ function assertUnrevoked(state) { } // This looks expensive, but only proxies are visited, and only objects without known changes are scanned. -function markChangesSweep(scope) { +function markChangesSweep(drafts) { // 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) { + for (let i = drafts.length - 1; i >= 0; i--) { + const state = drafts[i][DRAFT_STATE] + if (!state.modified) { if (Array.isArray(state.base)) { if (hasArrayChanges(state)) markChanged(state) } else if (hasObjectChanges(state)) markChanged(state) diff --git a/src/immer.d.ts b/src/immer.d.ts index 216a7259..c5bee92d 100644 --- a/src/immer.d.ts +++ b/src/immer.d.ts @@ -43,17 +43,15 @@ export interface Patch { export type PatchListener = (patches: Patch[], inversePatches: Patch[]) => void -type IsVoidLike = T extends void | undefined ? 1 : 0 - /** Converts `nothing` into `undefined` */ -type FromNothing = Nothing extends T ? Exclude | undefined : T +type FromNothing = T extends Nothing ? undefined : T /** The inferred return type of `produce` */ -export type Produced = IsVoidLike extends 0 - ? FromNothing - : IsVoidLike extends 1 - ? T - : T | FromNothing> +export type Produced = Return extends void + ? Base + : Return extends Promise + ? Promise> + : FromNothing type ImmutableTuple> = { readonly [P in keyof T]: Immutable @@ -154,6 +152,22 @@ export function setUseProxies(useProxies: boolean): void */ export function applyPatches(base: S, patches: Patch[]): S +/** + * Create an Immer draft from the given base state, which may be a draft itself. + * The draft can be modified until you finalize it with the `finishDraft` function. + */ +export function createDraft(base: T): Draft + +/** + * Finalize an Immer draft from a `createDraft` call, returning the base state + * (if no changes were made) or a modified copy. The draft must *not* be + * mutated afterwards. + * + * Pass a function as the 2nd argument to generate Immer patches based on the + * changes that were made. + */ +export function finishDraft(draft: T, listener?: PatchListener): Immutable + /** Get the underlying object that is represented by the given draft */ export function original(value: T): T | void diff --git a/src/immer.js b/src/immer.js index 7bea00ca..d24d1804 100644 --- a/src/immer.js +++ b/src/immer.js @@ -13,6 +13,7 @@ import { DRAFT_STATE, NOTHING } from "./common" +import {ImmerScope} from "./scope" function verifyMinified() {} @@ -51,62 +52,58 @@ export class Immer { } let result - // Only create proxies for plain objects/arrays. - if (!isDraftable(base)) { - result = recipe(base) - if (result === undefined) return base - } - // The given value must be proxied. - else { - this.scopes.push([]) - const baseDraft = this.createDraft(base) + + // Only plain objects, arrays, and "immerable classes" are drafted. + if (isDraftable(base)) { + const scope = ImmerScope.enter() + const proxy = this.createProxy(base) + let hasError = true try { - result = recipe.call(baseDraft, baseDraft) - this.willFinalize(result, baseDraft, !!patchListener) - - // Never generate patches when no listener exists. - var patches = patchListener && [], - inversePatches = patchListener && [] - - // 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 - - // Finalize the replacement in case it contains (or is) a subset of the draft. - if (isDraftable(result)) result = this.finalize(result) - - if (patchListener) { - patches.push({ - op: "replace", - path: [], - value: result - }) - inversePatches.push({ - op: "replace", - path: [], - value: base - }) - } - } + result = recipe.call(proxy, proxy) + hasError = false } finally { - this.currentScope().forEach(state => state.revoke()) - this.scopes.pop() + // finally instead of catch + rethrow better preserves original stack + if (hasError) scope.revoke() + else scope.leave() } - patchListener && patchListener(patches, inversePatches) + if (result instanceof Promise) { + return result.then( + result => { + scope.usePatches(patchListener) + return this.processResult(result, scope) + }, + error => { + scope.revoke() + throw error + } + ) + } + scope.usePatches(patchListener) + return this.processResult(result, scope) + } else { + result = recipe(base) + if (result === undefined) return base + return result !== NOTHING ? result : undefined } - // Normalize the result. - return result === NOTHING ? undefined : result + } + createDraft(base) { + if (!isDraftable(base)) throw new Error("First argument to createDraft should be a plain object, an array, or an immerable object.") // prettier-ignore + const scope = ImmerScope.enter() + const proxy = this.createProxy(base) + scope.leave() + proxy[DRAFT_STATE].customDraft = true + return proxy + } + finishDraft(draft, patchListener) { + if (!isDraft(draft)) throw new Error("First argument to finishDraft should be an object from createDraft.") // prettier-ignore + const state = draft[DRAFT_STATE] + if (!state.customDraft) throw new Error("The draft provided was not created using `createDraft`") // prettier-ignore + if (state.finalized) throw new Error("The draft provided was has already been finished") // prettier-ignore + // TODO: check if created with createDraft + // TODO: check if not finsihed twice + const {scope} = state + scope.usePatches(patchListener) + return this.processResult(undefined, scope) } setAutoFreeze(value) { this.autoFreeze = value @@ -123,25 +120,64 @@ export class Immer { // Otherwise, produce a copy of the base state. return this.produce(base, draft => applyPatches(draft, patches)) } + /** @internal */ + processResult(result, scope) { + const baseDraft = scope.drafts[0] + const isReplaced = result !== undefined && result !== baseDraft + this.willFinalize(scope, result, isReplaced) + if (isReplaced) { + if (baseDraft[DRAFT_STATE].modified) { + scope.revoke() + 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 + } + if (isDraftable(result)) { + // Finalize the result in case it contains (or is) a subset of the draft. + result = this.finalize(result, null, scope) + } + if (scope.patches) { + scope.patches.push({ + op: "replace", + path: [], + value: result + }) + scope.inversePatches.push({ + op: "replace", + path: [], + value: baseDraft[DRAFT_STATE].base + }) + } + } else { + // Finalize the base draft. + result = this.finalize(baseDraft, [], scope) + } + scope.revoke() + if (scope.patches) { + scope.patchListener(scope.patches, scope.inversePatches) + } + return result !== NOTHING ? result : undefined + } /** * @internal * Finalize a draft, returning either the unmodified base state or a modified * copy of the base state. */ - finalize(draft, path, patches, inversePatches) { + finalize(draft, path, scope) { const state = draft[DRAFT_STATE] if (!state) { if (Object.isFrozen(draft)) return draft - return this.finalizeTree(draft) + return this.finalizeTree(draft, null, scope) } - // Never finalize drafts owned by an outer scope. - if (state.scope !== this.currentScope()) { + // Never finalize drafts owned by another scope. + if (state.scope !== scope) { return draft } - if (!state.modified) return state.base + if (!state.modified) { + return state.base + } if (!state.finalized) { state.finalized = true - this.finalizeTree(state.draft, path, patches, inversePatches) + this.finalizeTree(state.draft, path, scope) + if (this.onDelete) { // The `assigned` object is unreliable with ES5 drafts. if (this.useProxies) { @@ -156,15 +192,24 @@ export class Immer { }) } } - if (this.onCopy) this.onCopy(state) + if (this.onCopy) { + this.onCopy(state) + } - // Nested producers must never auto-freeze their result, - // because it may contain drafts from parent producers. - if (this.autoFreeze && this.scopes.length === 1) { + // At this point, all descendants of `state.copy` have been finalized, + // so we can be sure that `scope.canAutoFreeze` is accurate. + if (this.autoFreeze && scope.canAutoFreeze) { Object.freeze(state.copy) } - if (patches) generatePatches(state, path, patches, inversePatches) + if (path && scope.patches) { + generatePatches( + state, + path, + scope.patches, + scope.inversePatches + ) + } } return state.copy } @@ -172,7 +217,7 @@ export class Immer { * @internal * Finalize all drafts in the given state tree. */ - finalizeTree(root, path, patches, inversePatches) { + finalizeTree(root, rootPath, scope) { const state = root[DRAFT_STATE] if (state) { if (!this.useProxies) { @@ -183,21 +228,28 @@ export class Immer { root = state.copy } - const {onAssign} = this + const needPatches = !!rootPath && !!scope.patches const finalizeProperty = (prop, value, parent) => { if (value === parent) { throw Error("Immer forbids circular references") } - // The only possible draft (in the scope of a `finalizeTree` call) is the `root` object. - const inDraft = !!state && parent === root + // In the `finalizeTree` method, only the `root` object may be a draft. + const isDraftProp = !!state && parent === root if (isDraft(value)) { - value = - // Patches are never generated for assigned properties. - patches && inDraft && !state.assigned[prop] - ? this.finalize(value, path.concat(prop), patches, inversePatches) // prettier-ignore - : this.finalize(value) + const path = + isDraftProp && needPatches && !state.assigned[prop] + ? rootPath.concat(prop) + : null + + // Drafts owned by `scope` are finalized here. + value = this.finalize(value, path, scope) + + // Drafts from another scope must prevent auto-freezing. + if (isDraft(value)) { + scope.canAutoFreeze = false + } // Preserve non-enumerable properties. if (Array.isArray(parent) || isEnumerable(parent, prop)) { @@ -207,10 +259,10 @@ export class Immer { } // Unchanged drafts are never passed to the `onAssign` hook. - if (inDraft && value === state.base[prop]) return + if (isDraftProp && value === state.base[prop]) return } // Unchanged draft properties are ignored. - else if (inDraft && is(value, state.base[prop])) { + else if (isDraftProp && is(value, state.base[prop])) { return } // Search new objects for unfinalized drafts. Frozen objects should never contain drafts. @@ -218,8 +270,8 @@ export class Immer { each(value, finalizeProperty) } - if (inDraft && onAssign) { - onAssign(state, prop, value) + if (isDraftProp && this.onAssign) { + this.onAssign(state, prop, value) } } diff --git a/src/immer.js.flow b/src/immer.js.flow index 73018431..221aecde 100644 --- a/src/immer.js.flow +++ b/src/immer.js.flow @@ -90,3 +90,16 @@ declare export function applyPatches(state: S, patches: Patch[]): S declare export function original(value: S): ?S declare export function isDraft(value: any): boolean + +/** + * Creates a mutable draft from an (immutable) object / array. + * The draft can be modified until `finishDraft` is called + */ +declare export function createDraft(base: T): T + +/** + * Given a draft that was created using `createDraft`, + * finalizes the draft into a new immutable object. + * Optionally a patch-listener can be provided to gather the patches that are needed to construct the object. + */ +declare export function finishDraft(base: T, listener?: PatchListener): T diff --git a/src/index.js b/src/index.js index 99dfc0c1..3d354b59 100644 --- a/src/index.js +++ b/src/index.js @@ -46,6 +46,22 @@ export const setUseProxies = immer.setUseProxies.bind(immer) */ export const applyPatches = immer.applyPatches.bind(immer) +/** + * Create an Immer draft from the given base state, which may be a draft itself. + * The draft can be modified until you finalize it with the `finishDraft` function. + */ +export const createDraft = immer.createDraft.bind(immer) + +/** + * Finalize an Immer draft from a `createDraft` call, returning the base state + * (if no changes were made) or a modified copy. The draft must *not* be + * mutated afterwards. + * + * Pass a function as the 2nd argument to generate Immer patches based on the + * changes that were made. + */ +export const finishDraft = immer.finishDraft.bind(immer) + export { original, isDraft, diff --git a/src/proxy.js b/src/proxy.js index 6aee2a64..c0da9ca9 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -1,6 +1,4 @@ "use strict" -// @ts-check - import { assign, each, @@ -11,18 +9,16 @@ import { shallowCopy, DRAFT_STATE } from "./common" - -// For nested produce calls: -export const scopes = [] -export const currentScope = () => scopes[scopes.length - 1] +import {ImmerScope} from "./scope" // Do nothing before being finalized. export function willFinalize() {} -export function createDraft(base, parent) { +export function createProxy(base, parent) { + const scope = parent ? parent.scope : ImmerScope.current const state = { // Track which produce call this is associated with. - scope: parent ? parent.scope : currentScope(), + scope, // True for both shallow and deep changes. modified: false, // Used during finalization. @@ -44,13 +40,15 @@ export function createDraft(base, parent) { } const {revoke, proxy} = Array.isArray(base) - ? Proxy.revocable([state], arrayTraps) + ? // [state] is used for arrays, to make sure the proxy is array-ish and not violate invariants, + // although state itself is an object + Proxy.revocable([state], arrayTraps) : Proxy.revocable(state, objectTraps) state.draft = proxy state.revoke = revoke - state.scope.push(state) + scope.drafts.push(proxy) return proxy } @@ -96,6 +94,7 @@ arrayTraps.set = function(state, prop, value) { return objectTraps.set.call(this, state[0], prop, value) } +// returns the object we should be reading the current value from, which is base, until some change has been made function source(state) { return state.copy || state.base } @@ -120,7 +119,7 @@ function get(state, prop) { drafts = state.copy } - return (drafts[prop] = createDraft(value, state)) + return (drafts[prop] = createProxy(value, state)) } function set(state, prop, value) { diff --git a/src/scope.js b/src/scope.js new file mode 100644 index 00000000..cf9b1c95 --- /dev/null +++ b/src/scope.js @@ -0,0 +1,42 @@ +import {DRAFT_STATE} from "./common" + +/** Each scope represents a `produce` call. */ +export class ImmerScope { + constructor(parent) { + this.drafts = [] + this.parent = parent + + // Whenever the modified draft contains a draft from another scope, we + // need to prevent auto-freezing so the unowned draft can be finalized. + this.canAutoFreeze = true + + // To avoid prototype lookups: + this.patches = null + } + usePatches(patchListener) { + if (patchListener) { + this.patches = [] + this.inversePatches = [] + this.patchListener = patchListener + } + } + revoke() { + this.leave() + this.drafts.forEach(revoke) + this.drafts = null // Make draft-related methods throw. + } + leave() { + if (this === ImmerScope.current) { + ImmerScope.current = this.parent + } + } +} + +ImmerScope.current = null +ImmerScope.enter = function() { + return (this.current = new ImmerScope(this.current)) +} + +function revoke(draft) { + draft[DRAFT_STATE].revoke() +}