immer
Version:
Create your next immutable state by mutating the current one
304 lines (279 loc) • 8.74 kB
text/typescript
import {
each,
has,
is,
isDraft,
isDraftable,
isEnumerable,
shallowCopy,
latest,
createHiddenProperty,
ImmerScope,
ImmerState,
Drafted,
AnyObject,
Objectish,
ImmerBaseState,
AnyArray,
ProxyType,
MapState,
SetState,
DRAFT_STATE
} from "./internal"
interface ES5BaseState extends ImmerBaseState {
finalizing: boolean
assigned: {[key: string]: any}
parent?: ImmerState
revoked: boolean
}
export interface ES5ObjectState extends ES5BaseState {
type: ProxyType.ES5Object
draft: Drafted<AnyObject, ES5ObjectState>
base: AnyObject
copy: AnyObject | null
}
export interface ES5ArrayState extends ES5BaseState {
type: ProxyType.ES5Array
draft: Drafted<AnyObject, ES5ArrayState>
base: AnyArray
copy: AnyArray | null
}
type ES5State = ES5ArrayState | ES5ObjectState
export function willFinalizeES5(
scope: ImmerScope,
result: any,
isReplaced: boolean
) {
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.drafts)
}
// When a child draft is returned, look for changes.
else if (isDraft(result) && result[DRAFT_STATE].scope === scope) {
markChangesSweep(scope.drafts)
}
}
export function createES5Proxy<T>(
base: T,
parent?: ImmerState
): Drafted<T, ES5ObjectState | ES5ArrayState> {
const isArray = Array.isArray(base)
const draft = clonePotentialDraft(base)
each(draft, prop => {
proxyProperty(draft, prop, isArray || isEnumerable(base, prop))
})
const state: ES5ObjectState | ES5ArrayState = {
type: isArray ? ProxyType.ES5Array : (ProxyType.ES5Object as any),
scope: parent ? parent.scope : ImmerScope.current!,
modified: false,
finalizing: false,
finalized: false,
assigned: {},
parent,
base,
draft,
copy: null,
revoked: false,
isManual: false
}
createHiddenProperty(draft, DRAFT_STATE, state)
return draft
}
// Access a property without creating an Immer draft.
function peek(draft: Drafted, prop: PropertyKey) {
const state = draft[DRAFT_STATE]
if (state && !state.finalizing) {
state.finalizing = true
const value = draft[prop]
state.finalizing = false
return value
}
return draft[prop]
}
function get(state: ES5State, prop: string | number) {
assertUnrevoked(state)
const value = peek(latest(state), prop)
if (state.finalizing) return value
// Create a draft if the value is unmodified.
if (value === peek(state.base, prop) && isDraftable(value)) {
prepareCopy(state)
// @ts-ignore
return (state.copy![prop] = state.scope.immer.createProxy(value, state))
}
return value
}
function set(state: ES5State, prop: string | number, value: any) {
assertUnrevoked(state)
state.assigned[prop] = true
if (!state.modified) {
if (is(value, peek(latest(state), prop))) return
markChangedES5(state)
prepareCopy(state)
}
// @ts-ignore
state.copy![prop] = value
}
export function markChangedES5(state: ImmerState) {
if (!state.modified) {
state.modified = true
if (state.parent) markChangedES5(state.parent)
}
}
function prepareCopy(state: ES5State) {
if (!state.copy) state.copy = clonePotentialDraft(state.base)
}
function clonePotentialDraft(base: Objectish) {
const state = base && (base as any)[DRAFT_STATE]
if (state) {
state.finalizing = true
const draft = shallowCopy(state.draft, true)
state.finalizing = false
return draft
}
return shallowCopy(base)
}
// 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: {[prop: string]: PropertyDescriptor} = {}
function proxyProperty(
draft: Drafted<any, ES5State>,
prop: string | number,
enumerable: boolean
) {
let desc = descriptors[prop]
if (desc) {
desc.enumerable = enumerable
} else {
descriptors[prop] = desc = {
configurable: true,
enumerable,
get(this: any) {
return get(this[DRAFT_STATE], prop)
},
set(this: any, value) {
set(this[DRAFT_STATE], prop, value)
}
}
}
Object.defineProperty(draft, prop, desc)
}
export function assertUnrevoked(state: ES5State | MapState | SetState) {
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(latest(state))
)
}
// This looks expensive, but only proxies are visited, and only objects without known changes are scanned.
function markChangesSweep(drafts: Drafted<any, ImmerState>[]) {
// 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 = drafts.length - 1; i >= 0; i--) {
const state = drafts[i][DRAFT_STATE]
if (!state.modified) {
switch (state.type) {
case ProxyType.ES5Array:
if (hasArrayChanges(state)) markChangedES5(state)
break
case ProxyType.ES5Object:
if (hasObjectChanges(state)) markChangedES5(state)
break
}
}
}
}
function markChangesRecursively(object: any) {
if (!object || typeof object !== "object") return
const state = object[DRAFT_STATE]
if (!state) return
const {base, draft, assigned, type} = state
if (type === ProxyType.ES5Object) {
// Look for added keys.
// TODO: looks quite duplicate to hasObjectChanges,
// probably there is a faster way to detect changes, as sweep + recurse seems to do some
// unnecessary work.
// also: probably we can store the information we detect here, to speed up tree finalization!
each(draft, key => {
if ((key as any) === DRAFT_STATE) return
// The `undefined` check is a fast path for pre-existing keys.
if (base[key] === undefined && !has(base, key)) {
assigned[key] = true
markChangedES5(state)
} else if (!assigned[key]) {
// Only untouched properties trigger recursion.
markChangesRecursively(draft[key])
}
})
// Look for removed keys.
each(base, key => {
// The `undefined` check is a fast path for pre-existing keys.
if (draft[key] === undefined && !has(draft, key)) {
assigned[key] = false
markChangedES5(state)
}
})
} else if (type === ProxyType.ES5Array && hasArrayChanges(state)) {
markChangedES5(state)
assigned.length = true
if (draft.length < base.length) {
for (let i = draft.length; i < base.length; i++) assigned[i] = false
} else {
for (let i = base.length; i < draft.length; i++) assigned[i] = true
}
for (let i = 0; i < draft.length; i++) {
// Only untouched indices trigger recursion.
if (assigned[i] === undefined) markChangesRecursively(draft[i])
}
}
}
function hasObjectChanges(state: ES5ObjectState) {
const {base, draft} = state
// Search for added keys and changed keys. Start at the back, because
// non-numeric keys are ordered by time of definition on the object.
const keys = Object.keys(draft)
for (let i = keys.length - 1; i >= 0; i--) {
const key = keys[i]
const baseValue = base[key]
// The `undefined` check is a fast path for pre-existing keys.
if (baseValue === undefined && !has(base, key)) {
return true
}
// Once a base key is deleted, future changes go undetected, because its
// descriptor is erased. This branch detects any missed changes.
else {
const value = draft[key]
const state = value && value[DRAFT_STATE]
if (state ? state.base !== baseValue : !is(value, baseValue)) {
return true
}
}
}
// At this point, no keys were added or changed.
// Compare key count to determine if keys were deleted.
return keys.length !== Object.keys(base).length
}
function hasArrayChanges(state: ES5ArrayState) {
const {draft} = state
if (draft.length !== state.base.length) return true
// See #116
// If we first shorten the length, our array interceptors will be removed.
// If after that new items are added, result in the same original length,
// those last items will have no intercepting property.
// So if there is no own descriptor on the last position, we know that items were removed and added
// N.B.: splice, unshift, etc only shift values around, but not prop descriptors, so we only have to check
// the last one
const descriptor = Object.getOwnPropertyDescriptor(draft, draft.length - 1)
// descriptor can be null, but only for newly created sparse arrays, eg. new Array(10)
if (descriptor && !descriptor.get) return true
// For all other cases, we don't have to compare, as they would have been picked up by the index setters
return false
}