immer
Version:
Create your next immutable state by mutating the current one
258 lines (238 loc) • 7.01 kB
text/typescript
import {
each,
has,
is,
isDraftable,
shallowCopy,
latest,
ImmerBaseState,
ImmerState,
Drafted,
ProxyType,
AnyObject,
AnyArray,
Objectish,
ImmerScope,
DRAFT_STATE
} from "./internal"
interface ProxyBaseState extends ImmerBaseState {
assigned: {
[property: string]: boolean
}
parent?: ImmerState
drafts?: {
[property: string]: Drafted<any, any>
}
revoke(): void
}
export interface ProxyObjectState extends ProxyBaseState {
type: ProxyType.ProxyObject
base: AnyObject
copy: AnyObject | null
draft: Drafted<AnyObject, ProxyObjectState>
}
export interface ProxyArrayState extends ProxyBaseState {
type: ProxyType.ProxyArray
base: AnyArray
copy: AnyArray | null
draft: Drafted<AnyArray, ProxyArrayState>
}
type ProxyState = ProxyObjectState | ProxyArrayState
/**
* Returns a new draft of the `base` object.
*
* The second argument is the parent draft-state (used internally).
*/
export function createProxy<T extends Objectish>(
base: T,
parent?: ImmerState
): Drafted<T, ProxyState> {
const isArray = Array.isArray(base)
const state: ProxyState = {
type: isArray ? ProxyType.ProxyArray : (ProxyType.ProxyObject as any),
// Track which produce call this is associated with.
scope: parent ? parent.scope : ImmerScope.current!,
// True for both shallow and deep changes.
modified: false,
// Used during finalization.
finalized: false,
// Track which properties have been assigned (true) or deleted (false).
assigned: {},
// The parent draft state.
parent,
// The base state.
base,
// The base proxy.
draft: null as any, // set below
// Any property proxies.
drafts: {},
// The base copy with any updated values.
copy: null,
// Called by the `produce` function.
revoke: null as any,
isManual: false
}
// the traps must target something, a bit like the 'real' base.
// but also, we need to be able to determine from the target what the relevant state is
// (to avoid creating traps per instance to capture the state in closure,
// and to avoid creating weird hidden properties as well)
// So the trick is to use 'state' as the actual 'target'! (and make sure we intercept everything)
// Note that in the case of an array, we put the state in an array to have better Reflect defaults ootb
let target: T = state as any
let traps: ProxyHandler<object | Array<any>> = objectTraps
if (isArray) {
target = [state] as any
traps = arrayTraps
}
// TODO: optimization: might be faster, cheaper if we created a non-revocable proxy
// and administrate revoking ourselves
const {revoke, proxy} = Proxy.revocable(target, traps)
state.draft = proxy as any
state.revoke = revoke
return proxy as any
}
/**
* Object drafts
*/
const objectTraps: ProxyHandler<ProxyState> = {
get(state, prop) {
if (prop === DRAFT_STATE) return state
let {drafts} = state
// Check for existing draft in unmodified state.
if (!state.modified && has(drafts, prop)) {
return drafts![prop as any]
}
const value = latest(state)[prop]
if (state.finalized || !isDraftable(value)) {
return value
}
// Check for existing draft in modified state.
if (state.modified) {
// Assigned values are never drafted. This catches any drafts we created, too.
if (value !== peek(state.base, prop)) return value
// Store drafts on the copy (when one exists).
// @ts-ignore
drafts = state.copy
}
return (drafts![prop as any] = state.scope.immer.createProxy(value, state))
},
has(state, prop) {
return prop in latest(state)
},
ownKeys(state) {
return Reflect.ownKeys(latest(state))
},
set(state, prop: string /* strictly not, but helps TS */, value) {
if (!state.modified) {
const baseValue = peek(state.base, prop)
// Optimize based on value's truthiness. Truthy values are guaranteed to
// never be undefined, so we can avoid the `in` operator. Lastly, truthy
// values may be drafts, but falsy values are never drafts.
const isUnchanged = value
? is(baseValue, value) || value === state.drafts![prop]
: is(baseValue, value) && prop in state.base
if (isUnchanged) return true
prepareCopy(state)
markChanged(state)
}
state.assigned[prop] = true
// @ts-ignore
state.copy![prop] = value
return true
},
deleteProperty(state, prop: string) {
// The `undefined` check is a fast path for pre-existing keys.
if (peek(state.base, prop) !== undefined || prop in state.base) {
state.assigned[prop] = false
prepareCopy(state)
markChanged(state)
} else if (state.assigned[prop]) {
// if an originally not assigned property was deleted
delete state.assigned[prop]
}
// @ts-ignore
if (state.copy) delete state.copy[prop]
return true
},
// Note: We never coerce `desc.value` into an Immer draft, because we can't make
// the same guarantee in ES5 mode.
getOwnPropertyDescriptor(state, prop) {
const owner = latest(state)
const desc = Reflect.getOwnPropertyDescriptor(owner, prop)
if (desc) {
desc.writable = true
desc.configurable =
state.type !== ProxyType.ProxyArray || prop !== "length"
}
return desc
},
defineProperty() {
throw new Error("Object.defineProperty() cannot be used on an Immer draft") // prettier-ignore
},
getPrototypeOf(state) {
return Object.getPrototypeOf(state.base)
},
setPrototypeOf() {
throw new Error("Object.setPrototypeOf() cannot be used on an Immer draft") // prettier-ignore
}
}
/**
* Array drafts
*/
const arrayTraps: ProxyHandler<[ProxyArrayState]> = {}
each(objectTraps, (key, fn) => {
// @ts-ignore
arrayTraps[key] = function() {
arguments[0] = arguments[0][0]
return fn.apply(this, arguments)
}
})
arrayTraps.deleteProperty = function(state, prop) {
if (isNaN(parseInt(prop as any))) {
throw new Error("Immer only supports deleting array indices") // prettier-ignore
}
return objectTraps.deleteProperty!.call(this, state[0], prop)
}
arrayTraps.set = function(state, prop, value) {
if (prop !== "length" && isNaN(parseInt(prop as any))) {
throw new Error("Immer only supports setting array indices and the 'length' property") // prettier-ignore
}
return objectTraps.set!.call(this, state[0], prop, value, state[0])
}
/**
* Map drafts
*/
// Access a property without creating an Immer draft.
function peek(draft: Drafted, prop: PropertyKey): any {
const state = draft[DRAFT_STATE]
const desc = Reflect.getOwnPropertyDescriptor(
state ? latest(state) : draft,
prop
)
return desc && desc.value
}
export function markChanged(state: ImmerState) {
if (!state.modified) {
state.modified = true
if (
state.type === ProxyType.ProxyObject ||
state.type === ProxyType.ProxyArray
) {
const copy = (state.copy = shallowCopy(state.base))
each(state.drafts!, (key, value) => {
// @ts-ignore
copy[key] = value
})
state.drafts = undefined
}
if (state.parent) {
markChanged(state.parent)
}
}
}
function prepareCopy(state: ProxyState) {
if (!state.copy) {
state.copy = shallowCopy(state.base)
}
}