Skip to content

Commit

Permalink
Async recipes (#307)
Browse files Browse the repository at this point in the history
* test: recursive produce call in async recipe

* fix: recursive produce call in async recipe

* feat: support async recipes

Drafts won't be revoked until the returned promise is fulfilled or rejected.

* test: avoid async/await for node 6

* Some additional comments, improvement in error handling

* feat: createDraft/finishDraft functions

Closes #302

* types: createDraft/finishDraft functions

* refactor: rename createPublicDraft/finishPublicDraft for consistency

* test: use snapshots in createDraft tests

* types: async produce

* chores: added additional invariants

* docs: reordered readme a bit to a more logical order

* docs: async and createDraft / finishDraft

* docs: fixes

* BREAKING CHANGE: added migration guide: Promises returned from producer will be evaluated
  • Loading branch information
mweststrate committed Feb 2, 2019
1 parent eabe9db commit 0f76f98
Show file tree
Hide file tree
Showing 12 changed files with 706 additions and 188 deletions.
101 changes: 101 additions & 0 deletions __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",
],
},
],
],
]
`;
61 changes: 60 additions & 1 deletion __tests__/base.js
Expand Up @@ -826,7 +826,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]
Expand All @@ -844,6 +844,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(() => {
Expand Down
136 changes: 136 additions & 0 deletions __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()
})
})
}
47 changes: 42 additions & 5 deletions __tests__/produce.ts
Expand Up @@ -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", () => {
Expand Down

0 comments on commit 0f76f98

Please sign in to comment.