@uniflowed/immer/draft
type
DraftKind
export type DraftKind = "object" | "array" | "map" | "set";
Which of the four kinds of value a draft stands in for.
type
Draft
export type Draft<T> = T extends $ReadOnlyArray<infer Item>
? Array<Draft<Item>>
: T extends $ReadOnlyMap<infer K, infer V>
? Map<K, Draft<V>>
: T extends $ReadOnlySet<infer V>
? Set<Draft<V>>
: T extends { ... }
? { ...{ [K in keyof T]: Draft<T[K]> } }
: T;
A writable view of T, for the recipe's parameter.
The object arm is {...{[K in keyof T]: ...}} rather than the mapped type alone because a bare mapped type keeps the source property's variance, so Draft<{readonly a: number}> would still reject draft.a = 1 — which is the entire point of a draft. Spreading the mapped result drops the variance and leaves the recursion intact.
type
Scope
export type Scope = {
/** Every draft made in this scope, in creation order, for revocation. */
drafts: Array<DraftState>,
/** The scope this one interrupted, or `null` at the top. */
suspended: null | Scope,
/** Whether writes must record which keys they touched, for patches. */
records: boolean,
/** Whether the produced value may be frozen; see `finalizeProperty`. */
freezable: boolean,
/** Drafts made and not yet resolved by the finalize walk. */
pending: number,
};
One produce call's drafts.
Scopes nest because a recipe may call produce again, and the inner call must not freeze or revoke anything the outer one still owns — hence suspended, which is restored when the inner call leaves.
type
DraftState
export type DraftState = {
kind: DraftKind,
scope: Scope,
/** The draft this one hangs off, or `null` for the root. */
parent: null | DraftState,
/** Where this draft sits in `parent`. Meaningless when `parent` is null. */
key: mixed,
base: mixed,
copy: mixed,
/** The proxy handed to the recipe. */
draft: mixed,
modified: boolean,
finalized: boolean,
/** Drafted children, keyed the way the parent keys them. */
children: null | Map<mixed, DraftState>,
/** key to `true` (written) or `false` (removed), while recording patches. */
assigned: null | Map<mixed, boolean>,
revoke: () => void,
};
What a draft is, underneath.
copy is null until the first write, and that is the whole copy-on-write story: a draft with no copy contributes its base to the result by reference, which is why an untouched subtree comes out of produce as the same object that went in.
function
stateOf
export function stateOf(value: mixed): null | DraftState { ... }
The state behind value, or null when value is not a draft.
function
isDraft
export function isDraft(value: mixed): boolean { ... }
Whether value is a draft.
Throws on a revoked draft rather than answering false, because a revoked draft is not "not a draft" — it is a draft someone kept, and reporting that as a plain value is how a stale proxy ends up published as state.
function
isDraftable
export function isDraftable(value: mixed): boolean { ... }
Whether produce can draft value, which is also what freeze recurses into and what applyPatches may walk through.
Class instances are deliberately not draftable. Copying one means copying whatever invariants its constructor established, and this library has no way to know them; treating it as an opaque leaf is the only honest answer.
function
shallowCopy
export function shallowCopy(value: mixed): mixed { ... }
A copy of value one level deep, which the draft then writes into.
Plain objects take the spread, which is the fast path a JIT recognises. Anything else goes through descriptors so a null prototype survives; losing it would silently give the copy an Object.prototype, and code that chose Object.create(null) chose it to avoid exactly that.
function
eachEntry
export function eachEntry(target: mixed, visit: (key: mixed, value: mixed) => void): void { ... }
Every entry of a value this package owns, as the pair its kind is keyed by.
function
setEntry
export function setEntry(target: mixed, key: mixed, value: mixed): void { ... }
Write value at key, in whichever way this kind of container is written.
function
getEntry
export function getEntry(target: mixed, key: mixed): mixed { ... }
Read the value at key, in whichever way this kind of container is read.
function
deleteEntry
export function deleteEntry(target: mixed, key: mixed): void { ... }
Remove key, in whichever way this kind of container is emptied.
function
hasEntry
export function hasEntry(target: mixed, key: mixed): boolean { ... }
Whether key is present, in whichever way this kind of container answers.
function
hasAssigned
export function hasAssigned(state: DraftState, key: mixed): boolean { ... }
Whether the recipe assigned or removed key on this draft.
function
enterScope
export function enterScope(records: boolean): Scope { ... }
Open a scope for one produce call, suspending whatever was open.
function
leaveScope
export function leaveScope(scope: Scope): void { ... }
Restore the scope enterScope suspended.
function
revokeScope
export function revokeScope(scope: Scope): void { ... }
Revoke every draft the scope made.
Reverse order so a child is dead before its parent, which is the order the finalize walk released them in and the order a debugger reads best.
function
createDraft
export function createDraft(
scope: Scope,
base: mixed,
parent: null | DraftState,
key: mixed,
): DraftState { ... }
Build the draft standing in for base, and register it with its scope.
function
original
export function original<T>(draft: T): T { ... }
The value draft was made from.
The base, not a copy: it is the caller's own object, and handing it back is the point — original(draft) === base is how a recipe compares what it has to what it started with without paying for a snapshot.
function
current
export function current<T>(draft: T): T { ... }
A plain snapshot of what draft holds right now.
Deliberately a copy, and deliberately not frozen. current exists to be logged, compared and kept while the recipe carries on writing, so it must not share the mutable copy the draft is still writing into. An *unmodified* draft is the exception and returns its base directly: nothing is going to change it, and copying it would throw away the structural sharing that makes this library worth using.
That copy is the cost. current is for inspection; the cheap snapshot is the value produce returns.
@uniflowed/immer/patches
type
PatchOp
export type PatchOp = "add" | "remove" | "replace";
What a patch does at its path.
type
Patch
export type Patch = {|
readonly op: PatchOp,
readonly path: $ReadOnlyArray<mixed>,
readonly value?: mixed,
|};
One change, addressed by the path from the root of the state.
The path is mixed rather than Array<string | number>, which is what most implementations of this shape declare, because a Map key is any value at all: the moment state contains a Map keyed by an object, a narrower type is a lie the checker would help enforce. Code that only ever patches objects and arrays can narrow each step where it reads it.
A path of length zero addresses the whole state, and only replace uses it: it is what produceWithPatches records when a recipe returns a new value instead of writing to the draft.
function
recordPatches
export function recordPatches(
state: DraftState,
path: $ReadOnlyArray<mixed>,
patches: Array<Patch>,
inverse: Array<Patch>,
): void { ... }
Append the patches for one changed draft, and their inverses.
Called from the finalize walk, after the draft's children have been finalized, so every value read here is a published value and never a live draft. Doing it earlier would put proxies into the patch stream, and a patch holding a revoked proxy is worse than no patch at all.
function
recordReplacement
export function recordReplacement(
base: mixed,
replacement: mixed,
patches: Array<Patch>,
inverse: Array<Patch>,
): void { ... }
The patches for a recipe that returned a value instead of writing to one.
function
applyPatches
export function applyPatches<T>(base: T, patches: $ReadOnlyArray<Patch>): T { ... }
base with patches applied, sharing everything they did not touch.
A draft is written to in place and returned, which is what makes produce(base, (draft) => applyPatches(draft, patches)) work and is how a caller replays a patch stream alongside its own edits. Anything else is treated as published state and is never written to: the result is a new value, frozen when auto-freezing is on.
Patch values are used by reference rather than deep-copied. Freezing the result makes the sharing safe, and copying every incoming value would double the cost of applying a stream of patches for a guarantee the freeze already gives. With auto-freezing off, a caller that keeps mutating a value it put in a patch will see the applied state change with it.