diff --git a/__tests__/isModified.js b/__tests__/isModified.js new file mode 100644 index 000000000..d47962833 --- /dev/null +++ b/__tests__/isModified.js @@ -0,0 +1,128 @@ +import { + produce, + createDraft, + finishDraft, + isModified, + enableMapSet +} from "../src/immer" + +enableMapSet() + +describe("isModified", () => { + it("returns false for values that are not drafts", () => { + expect(isModified({})).toBe(false) + expect(isModified([])).toBe(false) + expect(isModified(null)).toBe(false) + expect(isModified(undefined)).toBe(false) + expect(isModified(42)).toBe(false) + expect(isModified("draft")).toBe(false) + const next = produce({x: 1}, draft => { + draft.x = 2 + }) + expect(isModified(next)).toBe(false) + }) + + it("returns false for an unmodified draft", () => { + produce({item: {count: 1}}, draft => { + expect(isModified(draft)).toBe(false) + expect(isModified(draft.item)).toBe(false) + }) + }) + + it("returns true after a direct modification", () => { + produce({x: 1}, draft => { + draft.x = 2 + expect(isModified(draft)).toBe(true) + }) + }) + + it("returns true after adding or deleting a property", () => { + produce({x: 1}, draft => { + draft.y = 2 + expect(isModified(draft)).toBe(true) + }) + produce({x: 1}, draft => { + delete draft.x + expect(isModified(draft)).toBe(true) + }) + }) + + it("returns false when the same value is assigned back", () => { + produce({x: 1}, draft => { + draft.x = 1 + expect(isModified(draft)).toBe(false) + }) + }) + + it("tracks nested drafts and their parents, but not untouched siblings", () => { + const base = { + item: {count: 1}, + other: {count: 10} + } + produce(base, draft => { + draft.item.count = 2 + expect(isModified(draft.item)).toBe(true) + // modifications propagate to parent drafts + expect(isModified(draft)).toBe(true) + expect(isModified(draft.other)).toBe(false) + }) + }) + + it("tracks array modifications", () => { + produce([1, 2, 3], draft => { + expect(isModified(draft)).toBe(false) + draft.push(4) + expect(isModified(draft)).toBe(true) + }) + produce({list: [1, 2, 3]}, draft => { + draft.list[0] = 99 + expect(isModified(draft.list)).toBe(true) + expect(isModified(draft)).toBe(true) + }) + }) + + it("works with manually created drafts", () => { + const draft = createDraft({item: {count: 1}}) + expect(isModified(draft)).toBe(false) + expect(isModified(draft.item)).toBe(false) + draft.item.count = 2 + expect(isModified(draft.item)).toBe(true) + expect(isModified(draft)).toBe(true) + const next = finishDraft(draft) + expect(next.item.count).toBe(2) + }) + + it("tracks Map and Set drafts", () => { + const base = { + map: new Map([["a", 1]]), + set: new Set([1]) + } + produce(base, draft => { + expect(isModified(draft.map)).toBe(false) + draft.map.set("b", 2) + expect(isModified(draft.map)).toBe(true) + expect(isModified(draft)).toBe(true) + expect(isModified(draft.set)).toBe(false) + draft.set.add(2) + expect(isModified(draft.set)).toBe(true) + }) + }) + + it("supports the derived-data use case from the proposal", () => { + const base = {item: {count: 1, modifiedOn: null}} + const next = produce(base, draft => { + draft.item.count++ + if (isModified(draft.item)) { + draft.item.modifiedOn = "now" + } + }) + expect(next.item.modifiedOn).toBe("now") + + const untouched = produce(base, draft => { + if (isModified(draft.item)) { + draft.item.modifiedOn = "now" + } + }) + expect(untouched).toBe(base) + }) +}) diff --git a/src/immer.ts b/src/immer.ts index 0256afa57..9da4b088c 100644 --- a/src/immer.ts +++ b/src/immer.ts @@ -17,6 +17,7 @@ export { current, isDraft, isDraftable, + isModified, NOTHING as nothing, DRAFTABLE as immerable, freeze, diff --git a/src/types/index.js.flow b/src/types/index.js.flow index 14f61fd2f..d70fa62ac 100644 --- a/src/types/index.js.flow +++ b/src/types/index.js.flow @@ -106,6 +106,8 @@ declare export function current(value: S): S declare export function isDraft(value: any): boolean +declare export function isModified(value: any): boolean + /** * Creates a mutable draft from an (immutable) object / array. * The draft can be modified until `finishDraft` is called diff --git a/src/utils/common.ts b/src/utils/common.ts index 513c153e8..67cfd54bd 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -28,6 +28,19 @@ export const VALUE = "value" /*#__PURE__*/ export let isDraft = (value: any): boolean => !!value && !!value[DRAFT_STATE] +/** + * Returns true if the given value is an Immer draft that has been modified, + * either directly or through one of its nested drafts. Modifications made to + * a nested draft mark all of its parent drafts as modified as well, so this + * also reports modifications that happened anywhere inside the given draft. + * Returns false for values that are not drafts. + */ +/*#__PURE__*/ +export function isModified(value: any): boolean { + if (!isDraft(value)) return false + return value[DRAFT_STATE].modified_ +} + /** Returns true if the given value can be drafted by Immer */ /*#__PURE__*/ export function isDraftable(value: any): boolean { diff --git a/website/docs/api.md b/website/docs/api.md index 53e911c95..9899756aa 100644 --- a/website/docs/api.md +++ b/website/docs/api.md @@ -25,6 +25,7 @@ title: API overview | `immerable` | Symbol that can be added to a constructor or prototype, to indicate that Immer should treat the class as something that can be safely drafted | [Classes](./complex-objects.md) | | `Immutable` | Exposed TypeScript type to convert mutable types to immutable types | | | `isDraft` | Returns true if the given object is a draft object | | +| `isModified` | Returns true if the given object is a draft object that has been modified, either directly or through one of its nested drafts. Returns false for values that are not drafts | | | `isDraftable` | Returns true if Immer is capable of turning this object into a draft. Which is true for: arrays, objects without prototype, objects with `Object` as their prototype, objects that have the `immerable` symbol on their constructor or prototype | | | `nothing` | Value that can be returned from a recipe, to indicate that the value `undefined` should be produced | [Return](./return.mdx) | | `original` | Given a draft object (doesn't have to be a tree root), returns the original object at the same path in the original state tree, if present | [Original](./original.md) |