diff --git a/src/core/immerClass.ts b/src/core/immerClass.ts index f827361c..6e7c0f94 100644 --- a/src/core/immerClass.ts +++ b/src/core/immerClass.ts @@ -3,27 +3,25 @@ import { IProduce, ImmerState, Drafted, - isDraftable, - processResult, Patch, Objectish, - DRAFT_STATE, Draft, PatchListener, - isDraft, isMap, isSet, createProxyProxy, getPlugin, - die, - enterScope, - revokeScope, - leaveScope, - usePatchesInScope, getCurrentScope, - NOTHING, - freeze, - current + DEFAULT_AUTOFREEZE, + DEFAULT_USE_STRICT_SHALLOW_COPY, + ImmerContext, + applyPatchesImpl, + createDraftImpl, + finishDraftImpl, + produceImpl, + produceWithPatchesImpl, + setAutoFreezeImpl, + setUseStrictShallowCopyImpl } from "../internal" interface ProducersFns { @@ -33,9 +31,9 @@ interface ProducersFns { export type StrictMode = boolean | "class_only"; -export class Immer implements ProducersFns { - autoFreeze_: boolean = true - useStrictShallowCopy_: StrictMode = false +export class Immer implements ProducersFns, ImmerContext { + autoFreeze_: boolean = DEFAULT_AUTOFREEZE + useStrictShallowCopy_: StrictMode = DEFAULT_USE_STRICT_SHALLOW_COPY constructor(config?: { autoFreeze?: boolean @@ -66,139 +64,37 @@ export class Immer implements ProducersFns { * @param {Function} patchListener - optional function that will be called with all the patches produced here * @returns {any} a new state, or the initial state if nothing was modified */ - produce: IProduce = (base: any, recipe?: any, patchListener?: any) => { - // curried invocation - if (typeof base === "function" && typeof recipe !== "function") { - const defaultBase = recipe - recipe = base + produce: IProduce = produceImpl.bind(this) - const self = this - return function curriedProduce( - this: any, - base = defaultBase, - ...args: any[] - ) { - return self.produce(base, (draft: Drafted) => recipe.call(this, draft, ...args)) // prettier-ignore - } - } + produceWithPatches: IProduceWithPatches = produceWithPatchesImpl.bind(this) - if (typeof recipe !== "function") die(6) - if (patchListener !== undefined && typeof patchListener !== "function") - die(7) + createDraft = createDraftImpl.bind(this) as ( + base: T + ) => Draft - let result - - // Only plain objects, arrays, and "immerable classes" are drafted. - if (isDraftable(base)) { - const scope = enterScope(this) - const proxy = createProxy(base, undefined) - let hasError = true - try { - result = recipe(proxy) - hasError = false - } finally { - // finally instead of catch + rethrow better preserves original stack - if (hasError) revokeScope(scope) - else leaveScope(scope) - } - usePatchesInScope(scope, patchListener) - return processResult(result, scope) - } else if (!base || typeof base !== "object") { - result = recipe(base) - if (result === undefined) result = base - if (result === NOTHING) result = undefined - if (this.autoFreeze_) freeze(result, true) - if (patchListener) { - const p: Patch[] = [] - const ip: Patch[] = [] - getPlugin("Patches").generateReplacementPatches_(base, result, p, ip) - patchListener(p, ip) - } - return result - } else die(1, base) - } - - produceWithPatches: IProduceWithPatches = (base: any, recipe?: any): any => { - // curried invocation - if (typeof base === "function") { - return (state: any, ...args: any[]) => - this.produceWithPatches(state, (draft: any) => base(draft, ...args)) - } - - let patches: Patch[], inversePatches: Patch[] - const result = this.produce(base, recipe, (p: Patch[], ip: Patch[]) => { - patches = p - inversePatches = ip - }) - return [result, patches!, inversePatches!] - } - - createDraft(base: T): Draft { - if (!isDraftable(base)) die(8) - if (isDraft(base)) base = current(base) - const scope = enterScope(this) - const proxy = createProxy(base, undefined) - proxy[DRAFT_STATE].isManual_ = true - leaveScope(scope) - return proxy as any - } - - finishDraft>( + finishDraft = finishDraftImpl.bind(this) as >( draft: D, patchListener?: PatchListener - ): D extends Draft ? T : never { - const state: ImmerState = draft && (draft as any)[DRAFT_STATE] - if (!state || !state.isManual_) die(9) - const {scope_: scope} = state - usePatchesInScope(scope, patchListener) - return processResult(undefined, scope) - } + ) => D extends Draft ? T : never /** * Pass true to automatically freeze all copies created by Immer. * * By default, auto-freezing is enabled. */ - setAutoFreeze(value: boolean) { - this.autoFreeze_ = value - } + setAutoFreeze = setAutoFreezeImpl.bind(this) /** * Pass true to enable strict shallow copy. * * By default, immer does not copy the object descriptors such as getter, setter and non-enumrable properties. */ - setUseStrictShallowCopy(value: StrictMode) { - this.useStrictShallowCopy_ = value - } - - applyPatches(base: T, patches: readonly Patch[]): T { - // If a patch replaces the entire state, take that replacement as base - // before applying patches - let i: number - for (i = patches.length - 1; i >= 0; i--) { - const patch = patches[i] - if (patch.path.length === 0 && patch.op === "replace") { - base = patch.value - break - } - } - // If there was a patch that replaced the entire state, start from the - // patch after that. - if (i > -1) { - patches = patches.slice(i + 1) - } + setUseStrictShallowCopy = setUseStrictShallowCopyImpl.bind(this) - const applyPatchesImpl = getPlugin("Patches").applyPatches_ - if (isDraft(base)) { - // N.B: never hits if some patch a replacement, patches are never drafts - return applyPatchesImpl(base, patches) - } - // Otherwise, produce a copy of the base state. - return this.produce(base, (draft: Drafted) => - applyPatchesImpl(draft, patches) - ) - } + applyPatches = applyPatchesImpl.bind(this) as ( + base: T, + patches: readonly Patch[] + ) => T } export function createProxy( diff --git a/src/core/immerContext.ts b/src/core/immerContext.ts new file mode 100644 index 00000000..64ad3f09 --- /dev/null +++ b/src/core/immerContext.ts @@ -0,0 +1,16 @@ +import {StrictMode} from "../internal" + +export const DEFAULT_AUTOFREEZE = true +export const DEFAULT_USE_STRICT_SHALLOW_COPY = false + +export interface ImmerContext { + autoFreeze_: boolean + useStrictShallowCopy_: StrictMode +} + +export function createImmerContext(): ImmerContext { + return { + autoFreeze_: DEFAULT_AUTOFREEZE, + useStrictShallowCopy_: DEFAULT_USE_STRICT_SHALLOW_COPY + } +} diff --git a/src/core/implementation.ts b/src/core/implementation.ts new file mode 100644 index 00000000..226d1cda --- /dev/null +++ b/src/core/implementation.ts @@ -0,0 +1,224 @@ +import { + DRAFT_STATE, + Draft, + Drafted, + ImmerContext, + ImmerState, + NOTHING, + Objectish, + Patch, + PatchListener, + StrictMode, + createProxy, + current, + die, + enterScope, + freeze, + getPlugin, + isDraft, + isDraftable, + leaveScope, + processResult, + revokeScope, + usePatchesInScope +} from "../internal" + +/** + * The `produce` function takes a value and a "recipe function" (whose + * return value often depends on the base state). The recipe function is + * free to mutate its first argument however it wants. All mutations are + * only ever applied to a __copy__ of the base state. + * + * Pass only a function to create a "curried producer" which relieves you + * from passing the recipe function every time. + * + * Only plain objects and arrays are made mutable. All other objects are + * considered uncopyable. + * + * Note: This function is __bound__ to its `Immer` instance. + * + * @param {any} base - the initial state + * @param {Function} recipe - function that receives a proxy of the base state as first argument and which can be freely modified + * @param {Function} patchListener - optional function that will be called with all the patches produced here + * @returns {any} a new state, or the initial state if nothing was modified + */ +export function produceImpl( + this: ImmerContext, + base: any, + recipe?: any, + patchListener?: any +) { + // curried invocation + if (typeof base === "function" && typeof recipe !== "function") { + const defaultBase = recipe + recipe = base + + const self = this + return function curriedProduce( + this: any, + base = defaultBase, + ...args: any[] + ) { + return produceImpl.call(self, base, (draft: Drafted) => recipe.call(this, draft, ...args)) // prettier-ignore + } + } + + if (typeof recipe !== "function") die(6) + if (patchListener !== undefined && typeof patchListener !== "function") die(7) + + let result + + // Only plain objects, arrays, and "immerable classes" are drafted. + if (isDraftable(base)) { + const scope = enterScope(this) + const proxy = createProxy(base, undefined) + let hasError = true + try { + result = recipe(proxy) + hasError = false + } finally { + // finally instead of catch + rethrow better preserves original stack + if (hasError) revokeScope(scope) + else leaveScope(scope) + } + usePatchesInScope(scope, patchListener) + return processResult(result, scope) + } else if (!base || typeof base !== "object") { + result = recipe(base) + if (result === undefined) result = base + if (result === NOTHING) result = undefined + if (this.autoFreeze_) freeze(result, true) + if (patchListener) { + const p: Patch[] = [] + const ip: Patch[] = [] + getPlugin("Patches").generateReplacementPatches_(base, result, p, ip) + patchListener(p, ip) + } + return result + } else die(1, base) +} + +/** + * Like `produce`, but `produceWithPatches` always returns a tuple + * [nextState, patches, inversePatches] (instead of just the next state) + */ +export function produceWithPatchesImpl( + this: ImmerContext, + base: any, + recipe?: any +): any { + // curried invocation + if (typeof base === "function") { + return (state: any, ...args: any[]) => + produceWithPatchesImpl.call(this, state, (draft: any) => + base(draft, ...args) + ) + } + + let patches: Patch[], inversePatches: Patch[] + const result = produceImpl.call( + this, + base, + recipe, + (p: Patch[], ip: Patch[]) => { + patches = p + inversePatches = ip + } + ) + return [result, patches!, inversePatches!] +} + +/** + * Create an Immer draft from the given base state, which may be a draft itself. + * The draft can be modified until you finalize it with the `finishDraft` function. + */ +export function createDraftImpl( + this: ImmerContext, + base: T +): Draft { + if (!isDraftable(base)) die(8) + if (isDraft(base)) base = current(base) + const scope = enterScope(this) + const proxy = createProxy(base, undefined) + proxy[DRAFT_STATE].isManual_ = true + leaveScope(scope) + return proxy as any +} + +/** + * Finalize an Immer draft from a `createDraft` call, returning the base state + * (if no changes were made) or a modified copy. The draft must *not* be + * mutated afterwards. + * + * Pass a function as the 2nd argument to generate Immer patches based on the + * changes that were made. + */ +export function finishDraftImpl>( + this: ImmerContext, + draft: D, + patchListener?: PatchListener +): D extends Draft ? T : never { + const state: ImmerState = draft && (draft as any)[DRAFT_STATE] + if (!state || !state.isManual_) die(9) + const {scope_: scope} = state + usePatchesInScope(scope, patchListener) + return processResult(undefined, scope) +} + +/** + * Pass true to automatically freeze all copies created by Immer. + * + * By default, auto-freezing is enabled. + */ +export function setAutoFreezeImpl(this: ImmerContext, value: boolean) { + this.autoFreeze_ = value +} + +/** + * Pass true to enable strict shallow copy. + * + * By default, immer does not copy the object descriptors such as getter, setter and non-enumrable properties. + */ +export function setUseStrictShallowCopyImpl( + this: ImmerContext, + value: StrictMode +) { + this.useStrictShallowCopy_ = value +} + +/** + * Apply an array of Immer patches to the first argument. + * + * This function is a producer, which means copy-on-write is in effect. + */ +export function applyPatchesImpl( + this: ImmerContext, + base: T, + patches: readonly Patch[] +): T { + // If a patch replaces the entire state, take that replacement as base + // before applying patches + let i: number + for (i = patches.length - 1; i >= 0; i--) { + const patch = patches[i] + if (patch.path.length === 0 && patch.op === "replace") { + base = patch.value + break + } + } + // If there was a patch that replaced the entire state, start from the + // patch after that. + if (i > -1) { + patches = patches.slice(i + 1) + } + + const applyPatchesPluginImpl = getPlugin("Patches").applyPatches_ + if (isDraft(base)) { + // N.B: never hits if some patch a replacement, patches are never drafts + return applyPatchesPluginImpl(base, patches) + } + // Otherwise, produce a copy of the base state. + return produceImpl.call(this, base, (draft: Drafted) => + applyPatchesPluginImpl(draft, patches) + ) +} diff --git a/src/core/scope.ts b/src/core/scope.ts index 65eb5eed..5b1becda 100644 --- a/src/core/scope.ts +++ b/src/core/scope.ts @@ -2,7 +2,7 @@ import { Patch, PatchListener, Drafted, - Immer, + ImmerContext, DRAFT_STATE, ImmerState, ArchType, @@ -18,7 +18,7 @@ export interface ImmerScope { drafts_: any[] parent_?: ImmerScope patchListener_?: PatchListener - immer_: Immer + immer_: ImmerContext unfinalizedDrafts_: number } @@ -30,7 +30,7 @@ export function getCurrentScope() { function createScope( parent_: ImmerScope | undefined, - immer_: Immer + immer_: ImmerContext ): ImmerScope { return { drafts_: [], @@ -68,8 +68,8 @@ export function leaveScope(scope: ImmerScope) { } } -export function enterScope(immer: Immer) { - return (currentScope = createScope(currentScope, immer)) +export function enterScope(immerContext: ImmerContext) { + return (currentScope = createScope(currentScope, immerContext)) } function revokeDraft(draft: Drafted) { diff --git a/src/immer.ts b/src/immer.ts index 01998487..8b045d46 100644 --- a/src/immer.ts +++ b/src/immer.ts @@ -1,9 +1,20 @@ import { + applyPatchesImpl, + createDraftImpl, + createImmerContext, + Draft, + finishDraftImpl, + Immer, + Immutable, IProduce, IProduceWithPatches, - Immer, - Draft, - Immutable + Objectish, + Patch, + PatchListener, + produceImpl, + produceWithPatchesImpl, + setAutoFreezeImpl, + setUseStrictShallowCopyImpl } from "./internal" export { @@ -24,7 +35,7 @@ export { StrictMode } from "./internal" -const immer = new Immer() +const globalImmerContext = createImmerContext() /** * The `produce` function takes a value and a "recipe function" (whose @@ -45,14 +56,14 @@ const immer = new Immer() * @param {Function} patchListener - optional function that will be called with all the patches produced here * @returns {any} a new state, or the initial state if nothing was modified */ -export const produce: IProduce = immer.produce +export const produce: IProduce = produceImpl.bind(globalImmerContext) /** * Like `produce`, but `produceWithPatches` always returns a tuple * [nextState, patches, inversePatches] (instead of just the next state) */ -export const produceWithPatches: IProduceWithPatches = immer.produceWithPatches.bind( - immer +export const produceWithPatches: IProduceWithPatches = produceWithPatchesImpl.bind( + globalImmerContext ) /** @@ -60,27 +71,38 @@ export const produceWithPatches: IProduceWithPatches = immer.produceWithPatches. * * Always freeze by default, even in production mode */ -export const setAutoFreeze = immer.setAutoFreeze.bind(immer) +export const setAutoFreeze = setAutoFreezeImpl.bind(globalImmerContext) /** * Pass true to enable strict shallow copy. * * By default, immer does not copy the object descriptors such as getter, setter and non-enumrable properties. */ -export const setUseStrictShallowCopy = immer.setUseStrictShallowCopy.bind(immer) +export const setUseStrictShallowCopy = setUseStrictShallowCopyImpl.bind( + globalImmerContext +) /** * Apply an array of Immer patches to the first argument. * * This function is a producer, which means copy-on-write is in effect. */ -export const applyPatches = immer.applyPatches.bind(immer) +export const applyPatches = applyPatchesImpl.bind(globalImmerContext) as < + T extends Objectish +>( + base: T, + patches: readonly Patch[] +) => T /** * Create an Immer draft from the given base state, which may be a draft itself. * The draft can be modified until you finalize it with the `finishDraft` function. */ -export const createDraft = immer.createDraft.bind(immer) +export const createDraft = createDraftImpl.bind(globalImmerContext) as < + T extends Objectish +>( + base: T +) => Draft /** * Finalize an Immer draft from a `createDraft` call, returning the base state @@ -90,7 +112,12 @@ export const createDraft = immer.createDraft.bind(immer) * Pass a function as the 2nd argument to generate Immer patches based on the * changes that were made. */ -export const finishDraft = immer.finishDraft.bind(immer) +export const finishDraft = finishDraftImpl.bind(globalImmerContext) as < + D extends Draft +>( + draft: D, + patchListener?: PatchListener +) => D extends Draft ? T : never /** * This function is actually a no-op, but can be used to cast an immutable type diff --git a/src/internal.ts b/src/internal.ts index de7236a6..3b9a6e82 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -8,4 +8,6 @@ export * from "./core/scope" export * from "./core/finalize" export * from "./core/proxy" export * from "./core/immerClass" +export * from "./core/immerContext" +export * from "./core/implementation" export * from "./core/current"