Skip to content

Commit

Permalink
BREAKING CHANGE: In development mode, any new value stored in a tree …
Browse files Browse the repository at this point in the history
…will be deeply frozen. Fixes #412 #363 #260 #230 #313 #229

Fixes #412 #363 #260 #230 #313 #229
  • Loading branch information
mweststrate authored Sep 11, 2019
2 parents ac61b8d + 9064d26 commit 9fa2581
Show file tree
Hide file tree
Showing 4 changed files with 68 additions and 44 deletions.
85 changes: 44 additions & 41 deletions __tests__/frozen.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,72 +50,75 @@ function runTests(name, useProxies) {
expect(isFrozen(next)).toBeTruthy()
expect(isFrozen(next.a)).toBeTruthy()
})
})

describe("the result is never auto-frozen when", () => {
it("the producer is a no-op", () => {
const base = {}
const next = produce(base, () => {})
expect(next).toBe(base)
expect(isFrozen(next)).toBeFalsy()
it("a new object replaces the entire draft", () => {
const obj = {a: {b: {}}}
const next = produce({}, () => obj)
expect(next).toBe(obj)
expect(isFrozen(next)).toBeTruthy()
expect(isFrozen(next.a)).toBeTruthy()
expect(isFrozen(next.a.b)).toBeTruthy()
})

it("the root draft is returned", () => {
it("a new object is added to the root draft", () => {
const base = {}
const next = produce(base, draft => draft)
expect(next).toBe(base)
expect(isFrozen(next)).toBeFalsy()
const next = produce(base, draft => {
draft.a = {b: []}
})
expect(next).not.toBe(base)
expect(isFrozen(next)).toBeTruthy()
expect(isFrozen(next.a)).toBeTruthy()
expect(isFrozen(next.b)).toBeTruthy()
})

it("a new object is added to a nested draft", () => {
const base = {a: {}}
const next = produce(base, draft => {
draft.a.b = {c: {}}
})
expect(next).not.toBe(base)
expect(isFrozen(next)).toBeTruthy()
expect(isFrozen(next.a)).toBeTruthy()
expect(isFrozen(next.a.b)).toBeTruthy()
expect(isFrozen(next.a.b.c)).toBeTruthy()
})

it("a nested draft is returned", () => {
const base = {a: {}}
const next = produce(base, draft => draft.a)
expect(next).toBe(base.a)
expect(isFrozen(next)).toBeFalsy()
expect(isFrozen(next)).toBeTruthy()
})

it("the base state is returned", () => {
const base = {}
const next = produce(base, () => base)
expect(next).toBe(base)
expect(isFrozen(next)).toBeFalsy()
})

it("a new object replaces a primitive base", () => {
const obj = {}
const next = produce(null, () => obj)
expect(next).toBe(obj)
expect(isFrozen(next)).toBeFalsy()
expect(isFrozen(next)).toBeTruthy()
})

it("a new object replaces the entire draft", () => {
const obj = {a: {b: {}}}
const next = produce({}, () => obj)
expect(next).toBe(obj)
expect(isFrozen(next)).toBeFalsy()
expect(isFrozen(next.a)).toBeFalsy()
expect(isFrozen(next.a.b)).toBeFalsy()
it("the producer is a no-op", () => {
const base = {a: {}}
const next = produce(base, () => {})
expect(next).toBe(base)
expect(isFrozen(next)).toBeTruthy()
expect(isFrozen(next.a)).toBeTruthy()
})

it("a new object is added to the root draft", () => {
const base = {}
const next = produce(base, draft => {
draft.a = {}
})
expect(next).not.toBe(base)
it("the root draft is returned", () => {
const base = {a: {}}
const next = produce(base, draft => draft)
expect(next).toBe(base)
expect(isFrozen(next)).toBeTruthy()
expect(isFrozen(next.a)).toBeFalsy()
expect(isFrozen(next.a)).toBeTruthy()
})

it("a new object is added to a nested draft", () => {
const base = {a: {}}
const next = produce(base, draft => {
draft.a.b = {}
})
expect(next).not.toBe(base)
it("a new object replaces a primitive base", () => {
const obj = {a: {}}
const next = produce(null, () => obj)
expect(next).toBe(obj)
expect(isFrozen(next)).toBeTruthy()
expect(isFrozen(next.a)).toBeTruthy()
expect(isFrozen(next.a.b)).toBeFalsy()
})
})

Expand Down
2 changes: 2 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,8 @@ isDraft(nextState) // => false

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.

_⚠️ If auto freezing is enabled, recipes are not entirely side-effect free: Any plain object or array that ends up in the produced result, will be frozen, even when these objects were not frozen before the start of the producer! ⚠️_

## Immer on older JavaScript environments?

By default `produce` tries to use proxies for optimal performance. However, on older JavaScript engines `Proxy` is not available. For example, when running Microsoft Internet Explorer or React Native (< v0.59) on Android. In such cases, Immer will fallback to an ES5 compatible implementation which works identical, but is a bit slower.
Expand Down
7 changes: 7 additions & 0 deletions src/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,10 @@ export function clone(obj) {
for (const key in obj) cloned[key] = clone(obj[key])
return cloned
}

export function deepFreeze(obj) {
if (!isDraftable(obj) || isDraft(obj) || Object.isFrozen(obj)) return
Object.freeze(obj)
if (Array.isArray(obj)) obj.forEach(deepFreeze)
else for (const key in obj) deepFreeze(obj[key])
}
18 changes: 15 additions & 3 deletions src/immer.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
isEnumerable,
shallowCopy,
DRAFT_STATE,
NOTHING
NOTHING,
deepFreeze
} from "./common"
import {ImmerScope} from "./scope"

Expand Down Expand Up @@ -87,8 +88,10 @@ export class Immer {
return this.processResult(result, scope)
} else {
result = recipe(base)
if (result === undefined) return base
return result !== NOTHING ? result : undefined
if (result === NOTHING) return undefined
if (result === undefined) result = base
this.maybeFreeze(result, true)
return result
}
}
produceWithPatches(arg1, arg2, arg3) {
Expand Down Expand Up @@ -170,6 +173,7 @@ export class Immer {
if (isDraftable(result)) {
// Finalize the result in case it contains (or is) a subset of the draft.
result = this.finalize(result, null, scope)
this.maybeFreeze(result)
}
if (scope.patches) {
scope.patches.push({
Expand Down Expand Up @@ -209,6 +213,7 @@ export class Immer {
return draft
}
if (!state.modified) {
this.maybeFreeze(state.base, true)
return state.base
}
if (!state.finalized) {
Expand Down Expand Up @@ -299,6 +304,7 @@ export class Immer {
// Search new objects for unfinalized drafts. Frozen objects should never contain drafts.
else if (isDraftable(value) && !Object.isFrozen(value)) {
each(value, finalizeProperty)
this.maybeFreeze(value)
}

if (isDraftProp && this.onAssign) {
Expand All @@ -309,4 +315,10 @@ export class Immer {
each(root, finalizeProperty)
return root
}
maybeFreeze(value, deep = false) {
if (this.autoFreeze && !isDraft(value)) {
if (deep) deepFreeze(value)
else Object.freeze(value)
}
}
}

0 comments on commit 9fa2581

Please sign in to comment.