From 7d86e1722d864e410eaa102e1c4c5bede97ca787 Mon Sep 17 00:00:00 2001 From: unadlib Date: Mon, 23 Jan 2023 14:54:03 +0800 Subject: [PATCH 01/11] feat(return-value): support return values in the draft function --- src/create.ts | 76 +++++++++++++++++++++++++++++++++---------- src/current.ts | 22 +++++++++++++ src/draft.ts | 53 +++++++++++++++++++----------- src/draftify.ts | 26 +++------------ src/index.ts | 1 + src/interface.ts | 12 +++---- src/map.ts | 6 ++-- src/patch.ts | 11 ++++--- src/safeReturn.ts | 17 ++++++++++ src/set.ts | 9 +++-- src/utils/draft.ts | 5 +-- src/utils/finalize.ts | 4 +-- src/utils/mark.ts | 1 + test/index.test.ts | 8 +++-- 14 files changed, 170 insertions(+), 81 deletions(-) create mode 100644 src/safeReturn.ts diff --git a/src/create.ts b/src/create.ts index 0bfca7ce..5817a48d 100644 --- a/src/create.ts +++ b/src/create.ts @@ -1,8 +1,15 @@ import { CreateResult, Draft, Options, Result } from './interface'; import { draftify } from './draftify'; import { dataTypes } from './constant'; -import { getProxyDraft, isDraft, revokeProxy } from './utils'; -import { current } from './current'; +import { + getProxyDraft, + isDraft, + isDraftable, + isEqual, + revokeProxy, +} from './utils'; +import { current, handleReturnValue } from './current'; +import { safeReturnValue } from './safeReturn'; /** * `create(baseState, callback, options)` to create the next state @@ -27,17 +34,17 @@ import { current } from './current'; * ``` */ function create< - T extends object, + T extends any, F extends boolean = false, O extends boolean = false, - R extends void | Promise = void + R extends void | Promise | T | Promise = void >( base: T, mutate: (draft: Draft) => R, options?: Options ): CreateResult; function create< - T extends object, + T extends any, F extends boolean = false, O extends boolean = false, R extends void | Promise = void @@ -47,7 +54,7 @@ function create< options?: Options ): CreateResult; function create< - T extends object, + T extends any, P extends any[] = [], F extends boolean = false, O extends boolean = false, @@ -57,7 +64,7 @@ function create< options?: Options ): (base: T, ...args: P) => CreateResult; function create< - T extends object, + T extends any, O extends boolean = false, F extends boolean = false >(base: T, options?: Options): [T, () => Result]; @@ -86,8 +93,21 @@ function create(arg0: any, arg1: any, arg2?: any): any { ); } const state = isDraft(base) ? current(base) : base; - if (options?.mark?.(state, dataTypes) === dataTypes.mutable) { - const finalization = options.enablePatches ? [state, [], []] : state; + const mark = options?.mark; + const enablePatches = options?.enablePatches ?? false; + const strict = options?.strict ?? false; + const enableAutoFreeze = options?.enableAutoFreeze ?? false; + const _options = { + enableAutoFreeze, + mark, + strict, + enablePatches, + }; + if ( + _options.mark?.(state, dataTypes) === dataTypes.mutable || + !isDraftable(state, _options) + ) { + const finalization = _options.enablePatches ? [state, [], []] : state; if (typeof arg1 !== 'function') { return [state, () => finalization]; } @@ -97,7 +117,7 @@ function create(arg0: any, arg1: any, arg2?: any): any { } return finalization; } - const [draft, finalize] = draftify(state, options); + const [draft, finalize] = draftify(state, _options); if (typeof arg1 !== 'function') { return [draft, finalize]; } @@ -108,18 +128,40 @@ function create(arg0: any, arg1: any, arg2?: any): any { revokeProxy(getProxyDraft(draft)!); throw error; } + const returnValue = (value: any) => { + if (!isDraft(value)) { + const proxyDraft = getProxyDraft(draft); + if (!isEqual(value, draft) && proxyDraft?.operated) { + throw new Error( + `Either the value is returned as a new non-draft value, or only the draft is modified without returning any value.` + ); + } + if (safeReturnValue.length) { + const _value = safeReturnValue.pop(); + if (typeof _value !== 'undefined') { + handleReturnValue(value); + } + return finalize(_value); + } + if (typeof value !== 'undefined') { + if (_options.strict) { + handleReturnValue(value, true); + } + return finalize(value); + } + } + if (typeof value !== 'undefined' && value !== draft) { + throw new Error(`The return draft should be the current root draft.`); + } + return finalize(); + }; if (result instanceof Promise) { - return result.then(finalize, (error) => { + return result.then(returnValue, (error) => { revokeProxy(getProxyDraft(draft)!); throw error; }); } - if (result !== undefined) { - throw new Error( - `The create() callback must return 'void' or 'Promise'.` - ); - } - return finalize(); + return returnValue(result); } export { create }; diff --git a/src/current.ts b/src/current.ts index 5fd7cdcd..95b04b5b 100644 --- a/src/current.ts +++ b/src/current.ts @@ -11,6 +11,28 @@ import { shallowCopy, } from './utils'; +export function handleReturnValue(value: T, warning = false) { + forEach(value, (key, item, source) => { + if (isDraft(item)) { + if (warning) { + console.warn( + `The return value contains drafts, please use safeReturn() to wrap the return value.` + ); + } + const currentValue = current(item); + if (source instanceof Set) { + const arr = Array.from(source); + source.clear(); + arr.forEach((item) => source.add(key === item ? currentValue : item)); + } else { + set(source, key, currentValue); + } + } else if (typeof item === 'object') { + handleReturnValue(item, warning); + } + }); +} + function getCurrent(target: any) { const proxyDraft = getProxyDraft(target); if (!isDraftable(target, proxyDraft?.options)) return target; diff --git a/src/draft.ts b/src/draft.ts index f8162f2c..97a67657 100644 --- a/src/draft.ts +++ b/src/draft.ts @@ -4,6 +4,7 @@ import { Patches, ProxyDraft, Options, + Operation, } from './interface'; import { dataTypes, PROXY_DRAFT } from './constant'; import { mapHandler, mapHandlerKeys } from './map'; @@ -131,6 +132,7 @@ const proxyHandler: ProxyHandler = { if (currentProxyDraft && isEqual(currentProxyDraft.original, value)) { // !case: ignore the case of assigning the original draftable value to a draft target.copy![key] = value; + target.assignedMap = target.assignedMap ?? new Map(); target.assignedMap.set(key, false); return true; } @@ -144,9 +146,9 @@ const proxyHandler: ProxyHandler = { markChanged(target); if (has(target.original, key) && isEqual(value, target.original[key])) { // !case: handle the case of assigning the original non-draftable value to a draft - target.assignedMap.delete(key); + target.assignedMap!.delete(key); } else { - target.assignedMap.set(key, true); + target.assignedMap!.set(key, true); } target.copy![key] = value; markFinalization(target, key, value); @@ -186,8 +188,9 @@ const proxyHandler: ProxyHandler = { // !case: delete an existing key ensureShallowCopy(target); markChanged(target); - target.assignedMap.set(key, false); + target.assignedMap!.set(key, false); } else { + target.assignedMap = target.assignedMap ?? new Map(); // The original non-existent key has been deleted target.assignedMap.delete(key); } @@ -206,6 +209,7 @@ export function createDraft(createDraftOptions: { const { original, parentDraft, key, finalities, options } = createDraftOptions; const type = getType(original); + // @ts-ignore const proxyDraft: ProxyDraft = { type, finalized: false, @@ -215,7 +219,6 @@ export function createDraft(createDraftOptions: { proxy: null, finalities, options, - assignedMap: new Map(), // Mapping of draft Set items to their corresponding draft values. setMap: type === DraftType.Set @@ -223,19 +226,19 @@ export function createDraft(createDraftOptions: { : undefined, }; // !case: undefined as a draft map key - if (Object.hasOwnProperty.call(createDraftOptions, 'key')) { + if (key || 'key' in createDraftOptions) { proxyDraft.key = key; } const { proxy, revoke } = Proxy.revocable( - Array.isArray(original) ? Object.assign([], proxyDraft) : proxyDraft, + type === DraftType.Array ? Object.assign([], proxyDraft) : proxyDraft, proxyHandler ); - finalities.revoke.unshift(revoke); + finalities.revoke.push(revoke); proxyDraft.proxy = proxy; if (parentDraft) { const target = parentDraft; const oldProxyDraft = getProxyDraft(proxy)!; - target.finalities.draft.unshift((patches, inversePatches) => { + target.finalities.draft.push((patches, inversePatches) => { // if target is a Set draft, `setMap` is the real Set copies proxy mapping. const proxyDraft = getProxyDraft( get(target.type === DraftType.Set ? target.setMap : target.copy, key!) @@ -257,7 +260,7 @@ export function createDraft(createDraftOptions: { } else { // !case: handle the root draft const target = getProxyDraft(proxy)!; - target.finalities.draft.unshift((patches, inversePatches) => { + target.finalities.draft.push((patches, inversePatches) => { finalizePatches(target, patches, inversePatches); }); } @@ -269,20 +272,32 @@ internal.createDraft = createDraft; export function finalizeDraft( result: T, patches?: Patches, - inversePatches?: Patches + inversePatches?: Patches, + returnedValue?: T ) { - const proxyDraft = getProxyDraft(result as any)!; - for (const finalize of proxyDraft.finalities.draft) { - finalize(patches, inversePatches); + const proxyDraft = getProxyDraft(result)!; + const original = proxyDraft.original; + if (proxyDraft.operated) { + while (proxyDraft.finalities.draft.length > 0) { + const finalize = proxyDraft.finalities.draft.pop()!; + finalize(patches, inversePatches); + } } - const state = proxyDraft.operated ? proxyDraft.copy : proxyDraft.original; + const state = + returnedValue ?? proxyDraft.operated + ? proxyDraft.copy + : proxyDraft.original; revokeProxy(proxyDraft); if (proxyDraft.options.enableAutoFreeze) { deepFreeze(state); } - return [state, patches, inversePatches] as [ - T, - Patches | undefined, - Patches | undefined - ]; + return [ + state, + patches && returnedValue + ? [{ op: Operation.Replace, path: [], value: returnedValue }] + : patches, + inversePatches && returnedValue + ? [{ op: Operation.Replace, path: [], value: original }] + : inversePatches, + ] as [T, Patches | undefined, Patches | undefined]; } diff --git a/src/draftify.ts b/src/draftify.ts index fc64d9db..301e17aa 100644 --- a/src/draftify.ts +++ b/src/draftify.ts @@ -1,27 +1,11 @@ import { Finalities, Options, Patches, Result } from './interface'; import { createDraft, finalizeDraft } from './draft'; -import { isDraftable } from './utils'; export function draftify< T extends object, O extends boolean = false, F extends boolean = false ->(baseState: T, _options?: Options): [T, () => Result] { - const mark = _options?.mark; - const enablePatches = _options?.enablePatches ?? false; - const strict = _options?.strict ?? false; - const enableAutoFreeze = _options?.enableAutoFreeze ?? false; - const options = { - enableAutoFreeze, - mark, - strict, - enablePatches, - }; - if (!isDraftable(baseState, options)) { - throw new Error( - 'create() only supports plain object, array, set, and map.' - ); - } +>(baseState: T, options: Options): [T, (returnedValue?: any) => Result] { const finalities: Finalities = { draft: [], revoke: [], @@ -29,7 +13,7 @@ export function draftify< }; let patches: Patches | undefined; let inversePatches: Patches | undefined; - if (enablePatches) { + if (options.enablePatches) { patches = []; inversePatches = []; } @@ -41,11 +25,11 @@ export function draftify< }); return [ draft, - () => { + (returnedValue?: T) => { const [finalizedState, finalizedPatches, finalizedInversePatches] = - finalizeDraft(draft, patches, inversePatches); + finalizeDraft(draft, patches, inversePatches, returnedValue); return ( - enablePatches + options.enablePatches ? [finalizedState, finalizedPatches, finalizedInversePatches] : finalizedState ) as Result; diff --git a/src/index.ts b/src/index.ts index 2889aaed..715930ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export { apply } from './apply'; export { original } from './original'; export { current } from './current'; export { unsafe } from './unsafe'; +export { safeReturn } from './safeReturn'; export { isDraft } from './utils/draft'; export { castDraft, castImmutable } from './utils/cast'; diff --git a/src/interface.ts b/src/interface.ts index e3170e40..af514451 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -33,7 +33,7 @@ export interface ProxyDraft { parent?: ProxyDraft | null; key?: string | number | symbol; setMap?: Map; - assignedMap: Map; + assignedMap?: Map; callbacks?: ((patches?: Patches, inversePatches?: Patches) => void)[]; } @@ -46,7 +46,7 @@ export type Patch = { export type Patches = Patch[]; export type Result< - T extends object, + T extends any, O extends boolean, F extends boolean > = O extends true @@ -56,11 +56,11 @@ export type Result< : T; export type CreateResult< - T extends object, + T extends any, O extends boolean, F extends boolean, - R extends void | Promise -> = R extends Promise ? Promise> : Result; + R extends void | Promise | T | Promise +> = R extends Promise | Promise ? Promise> : Result; type BaseMark = null | undefined | DataType; type MarkWithCopy = BaseMark | (() => any); @@ -72,7 +72,7 @@ export type Mark = ( export interface Options { /** - * Forbid accessing non-draftable values in strict mode + * In strict mode, Forbid accessing non-draftable values and forbid returning a non-draft value. */ strict?: boolean; /** diff --git a/src/map.ts b/src/map.ts index a8acba42..faabcca6 100644 --- a/src/map.ts +++ b/src/map.ts @@ -25,7 +25,7 @@ export const mapHandler = { if (!source.has(key) || !isEqual(source.get(key), value)) { ensureShallowCopy(target); markChanged(target); - target.assignedMap.set(key, true); + target.assignedMap!.set(key, true); target.copy.set(key, value); markFinalization(target, key, value); } @@ -39,9 +39,9 @@ export const mapHandler = { ensureShallowCopy(target); markChanged(target); if (target.original.has(key)) { - target.assignedMap.set(key, false); + target.assignedMap!.set(key, false); } else { - target.assignedMap.delete(key); + target.assignedMap!.delete(key); } target.copy.delete(key); return true; diff --git a/src/patch.ts b/src/patch.ts index cd64086a..a3d1aafb 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -24,7 +24,10 @@ export function finalizePatches( }); } const shouldFinalize = - target.operated && target.assignedMap.size > 0 && !target.finalized; + target.operated && + target.assignedMap && + target.assignedMap.size > 0 && + !target.finalized; if (shouldFinalize) { if (patches && inversePatches) { const basePath = getPath(target); @@ -61,7 +64,7 @@ export function markFinalization(target: ProxyDraft, key: any, value: any) { } if (isDraftable(value, target.options)) { // !case: assign the non-draft value - target.finalities.draft.unshift(() => { + target.finalities.draft.push(() => { const copy = target.type === DraftType.Set ? target.setMap : target.copy; if (isEqual(get(copy, key), value)) { finalizeAssigned(target, key); @@ -83,7 +86,7 @@ function generateArrayPatches( [patches, inversePatches] = [inversePatches, patches]; } for (let index = 0; index < original.length; index += 1) { - if (assignedMap.get(index.toString()) && copy[index] !== original[index]) { + if (assignedMap!.get(index.toString()) && copy[index] !== original[index]) { const path = basePath.concat([index]); patches.push({ op: Operation.Replace, @@ -123,7 +126,7 @@ function generatePatchesFromAssigned( patches: Patches, inversePatches: Patches ) { - assignedMap.forEach((assignedValue, key) => { + assignedMap?.forEach((assignedValue, key) => { const originalValue = get(original, key); const value = cloneIfNeeded(get(copy, key)); const op = !assignedValue diff --git a/src/safeReturn.ts b/src/safeReturn.ts new file mode 100644 index 00000000..f96e826e --- /dev/null +++ b/src/safeReturn.ts @@ -0,0 +1,17 @@ +export const safeReturnValue: any[] = []; + +export function safeReturn(value: T): T { + if (arguments.length === 0) { + throw new Error('safeReturn() must be called with a value.'); + } + if (arguments.length > 1) { + throw new Error('safeReturn() must be called with one argument.'); + } + if (__DEV__ && value !== undefined && typeof value !== 'object') { + console.warn( + 'safeReturn() must be called with an object or undefined, other types do not need to be returned via safeReturn().' + ); + } + safeReturnValue[0] = value; + return value; +} diff --git a/src/set.ts b/src/set.ts index 099a4ab2..bfdcfc5d 100644 --- a/src/set.ts +++ b/src/set.ts @@ -91,15 +91,15 @@ export const setHandler = { const valueProxyDraft = getProxyDraft(value)!; if (valueProxyDraft && target.setMap!.has(valueProxyDraft.original)) { // delete drafted - target.assignedMap.set(valueProxyDraft.original, false); + target.assignedMap!.set(valueProxyDraft.original, false); return target.setMap!.delete(valueProxyDraft.original); } if (!valueProxyDraft && target.setMap!.has(value)) { // non-draftable values - target.assignedMap.set(value, false); + target.assignedMap!.set(value, false); } else { // reassigned - target.assignedMap.delete(value); + target.assignedMap!.delete(value); } // delete reassigned or non-draftable values return target.setMap!.delete(value); @@ -109,9 +109,8 @@ export const setHandler = { const target = getProxyDraft(this)!; ensureShallowCopy(target); markChanged(target); - target.assignedMap = new Map(); for (const value of target.original) { - target.assignedMap.set(value, false); + target.assignedMap!.set(value, false); } target.setMap!.clear(); }, diff --git a/src/utils/draft.ts b/src/utils/draft.ts index e9b89ae6..d8baddb5 100644 --- a/src/utils/draft.ts +++ b/src/utils/draft.ts @@ -53,9 +53,9 @@ export function getPath( } export function getType(target: any) { + if (Array.isArray(target)) return DraftType.Array; if (target instanceof Map) return DraftType.Map; if (target instanceof Set) return DraftType.Set; - if (Array.isArray(target)) return DraftType.Array; return DraftType.Object; } @@ -86,7 +86,8 @@ export function isEqual(x: any, y: any) { } export function revokeProxy(proxyDraft: ProxyDraft) { - for (const revoke of proxyDraft.finalities.revoke) { + while (proxyDraft.finalities.revoke.length > 0) { + const revoke = proxyDraft.finalities.revoke.pop()!; revoke(); } } diff --git a/src/utils/finalize.ts b/src/utils/finalize.ts index 16efd9bb..7885f76c 100644 --- a/src/utils/finalize.ts +++ b/src/utils/finalize.ts @@ -18,7 +18,7 @@ export function handleValue(target: any, handledSet: WeakSet) { if (isDraft(value)) { const proxyDraft = getProxyDraft(value)!; ensureShallowCopy(proxyDraft); - const updatedValue = proxyDraft.assignedMap.size + const updatedValue = proxyDraft.assignedMap!.size ? proxyDraft.copy : proxyDraft.original; if (isSet) { @@ -46,7 +46,7 @@ export function finalizeAssigned(proxyDraft: ProxyDraft, key: PropertyKey) { proxyDraft.type === DraftType.Set ? proxyDraft.setMap : proxyDraft.copy; if ( proxyDraft.finalities.revoke.length > 1 && - proxyDraft.assignedMap.get(key) && + proxyDraft.assignedMap!.get(key) && copy ) { handleValue(get(copy, key), proxyDraft.finalities.handledSet); diff --git a/src/utils/mark.ts b/src/utils/mark.ts index 0dc2be02..97ef44fd 100644 --- a/src/utils/mark.ts +++ b/src/utils/mark.ts @@ -1,6 +1,7 @@ import { ProxyDraft } from '../interface'; export function markChanged(proxyDraft: ProxyDraft) { + proxyDraft.assignedMap = proxyDraft.assignedMap ?? new Map(); if (!proxyDraft.operated) { proxyDraft.operated = true; if (proxyDraft.parent) { diff --git a/test/index.test.ts b/test/index.test.ts index 62912a08..30bf6bcf 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -6,7 +6,9 @@ test('check object type', () => { create(data, (draft) => { expect(typeof draft === 'object').toBeTruthy(); - expect(Object.prototype.toString.call(draft) === '[object Object]').toBeTruthy(); + expect( + Object.prototype.toString.call(draft) === '[object Object]' + ).toBeTruthy(); }); }); @@ -15,7 +17,9 @@ test('check array type', () => { create(data, (draft) => { expect(Array.isArray(draft)).toBeTruthy(); - expect(Object.prototype.toString.call(draft) === '[object Array]').toBeTruthy(); + expect( + Object.prototype.toString.call(draft) === '[object Array]' + ).toBeTruthy(); expect(draft.length).toBe(1); }); }); From 25c0e3d54f17c6f8dbb35bd327f99b91c982fc43 Mon Sep 17 00:00:00 2001 From: unadlib Date: Tue, 24 Jan 2023 11:49:58 +0800 Subject: [PATCH 02/11] fix(testing): fix testing and bug --- package.json | 2 +- src/apply.ts | 8 +- src/create.ts | 67 +++- src/current.ts | 4 +- src/draft.ts | 20 +- src/draftify.ts | 9 +- src/utils/finalize.ts | 2 +- test/curry.test.ts | 4 +- test/immer/__snapshots__/base.test.ts.snap | 16 +- test/immer/__snapshots__/curry.test.ts.snap | 4 +- test/immer/__snapshots__/manual.test.ts.snap | 4 +- test/immer/base.test.ts | 399 ++++++++++--------- test/immer/curry.test.ts | 28 +- test/immer/null.test.ts | 7 +- test/immer/patch.test.ts | 134 ++++--- test/immer/produce.test.ts | 87 ++-- test/safeReturn.test.ts | 19 + 17 files changed, 453 insertions(+), 361 deletions(-) create mode 100644 test/safeReturn.test.ts diff --git a/package.json b/package.json index 744c226f..f6c91026 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "clean": "rimraf dist", "test:coverage": "jest --coverage && coveralls < coverage/lcov.info", "perf": "cd test/performance && ts-node add-data.ts && ts-node todo.ts && ts-node incremental.ts", - "benchmark": "cd test/performance && ts-node benchmark.ts", + "benchmark": "yarn build && cd test/performance && ts-node benchmark.ts", "performance:basic": "cd test/performance && ts-node index.ts", "performance:set-map": "cd test/performance && ts-node set-map.ts", "performance:big-object": "cd test/performance && ts-node big-object.ts", diff --git a/src/apply.ts b/src/apply.ts index 3f335d13..42d0e064 100644 --- a/src/apply.ts +++ b/src/apply.ts @@ -39,8 +39,10 @@ export function apply( const parentType = getType(base); const key = String(path[index]); if ( - (parentType === DraftType.Object || parentType === DraftType.Array) && - (key === '__proto__' || key === 'constructor') + ((parentType === DraftType.Object || + parentType === DraftType.Array) && + (key === '__proto__' || key === 'constructor')) || + (typeof base === 'function' && key === 'prototype') ) { throw new Error( `Patching reserved attributes like __proto__ and constructor is not allowed.` @@ -103,7 +105,7 @@ export function apply( }); }; if (isDraft(state)) { - if (typeof applyOptions !== 'undefined') { + if (applyOptions !== undefined) { throw new Error(`Cannot apply patches with options to a draft.`); } mutate(state as Draft); diff --git a/src/create.ts b/src/create.ts index 5817a48d..ac18d287 100644 --- a/src/create.ts +++ b/src/create.ts @@ -1,4 +1,4 @@ -import { CreateResult, Draft, Options, Result } from './interface'; +import { CreateResult, Draft, Operation, Options, Result } from './interface'; import { draftify } from './draftify'; import { dataTypes } from './constant'; import { @@ -70,6 +70,9 @@ function create< >(base: T, options?: Options): [T, () => Result]; function create(arg0: any, arg1: any, arg2?: any): any { if (typeof arg0 === 'function' && typeof arg1 !== 'function') { + // if (Object.prototype.toString.call(arg1) !== '[object Object]') { + // throw new Error(`Invalid options: ${arg1}, 'options' should be an object.`); + // } return function (this: any, base: any, ...args: any[]) { return create( base, @@ -83,6 +86,11 @@ function create(arg0: any, arg1: any, arg2?: any): any { let options = arg2; if (typeof arg1 !== 'function') { options = arg1; + if (!isDraftable(base, options)) { + throw new Error( + `Invalid base state: create() only supports plain objects, arrays, Set, Map or using mark() to mark the state as immutable.` + ); + } } if ( options !== undefined && @@ -103,19 +111,36 @@ function create(arg0: any, arg1: any, arg2?: any): any { strict, enablePatches, }; + safeReturnValue.length = 0; if ( _options.mark?.(state, dataTypes) === dataTypes.mutable || !isDraftable(state, _options) ) { - const finalization = _options.enablePatches ? [state, [], []] : state; if (typeof arg1 !== 'function') { - return [state, () => finalization]; + return [state, () => (_options.enablePatches ? [state, [], []] : state)]; } const result = mutate(state); + const returnValue = (value: any) => { + let _state = state; + if (safeReturnValue.length) { + _state = safeReturnValue.pop(); + } else if (value !== undefined) { + _state = value; + } else { + return _options.enablePatches ? [_state, [], []] : _state; + } + return _options.enablePatches + ? [ + _state, + [{ op: Operation.Replace, path: [], value: _state }], + [{ op: Operation.Replace, path: [], value: state }], + ] + : _state; + }; if (result instanceof Promise) { - return result.then(() => finalization); + return result.then(returnValue); } - return finalization; + return returnValue(result); } const [draft, finalize] = draftify(state, _options); if (typeof arg1 !== 'function') { @@ -129,31 +154,45 @@ function create(arg0: any, arg1: any, arg2?: any): any { throw error; } const returnValue = (value: any) => { + const proxyDraft = getProxyDraft(draft)!; if (!isDraft(value)) { - const proxyDraft = getProxyDraft(draft); - if (!isEqual(value, draft) && proxyDraft?.operated) { + if ( + value !== undefined && + !isEqual(value, draft) && + proxyDraft.operated + ) { throw new Error( `Either the value is returned as a new non-draft value, or only the draft is modified without returning any value.` ); } if (safeReturnValue.length) { const _value = safeReturnValue.pop(); - if (typeof _value !== 'undefined') { + if (_value !== undefined) { handleReturnValue(value); } - return finalize(_value); + return finalize([_value]); } - if (typeof value !== 'undefined') { + if (value !== undefined) { if (_options.strict) { handleReturnValue(value, true); } - return finalize(value); + return finalize([value]); } } - if (typeof value !== 'undefined' && value !== draft) { - throw new Error(`The return draft should be the current root draft.`); + if (value === draft || value === undefined) { + return finalize([]); + } + const returnedProxyDraft = getProxyDraft(value)!; + if (_options === returnedProxyDraft.options) { + if (returnedProxyDraft.operated) { + throw new Error(`Cannot return a modified child draft.`); + } + return finalize([current(value)]); } - return finalize(); + // if (value !== undefined && value !== draft) { + // throw new Error(`The return draft should be the current root draft.`); + // } + return finalize([value]); }; if (result instanceof Promise) { return result.then(returnValue, (error) => { diff --git a/src/current.ts b/src/current.ts index 95b04b5b..df39c1c7 100644 --- a/src/current.ts +++ b/src/current.ts @@ -15,9 +15,7 @@ export function handleReturnValue(value: T, warning = false) { forEach(value, (key, item, source) => { if (isDraft(item)) { if (warning) { - console.warn( - `The return value contains drafts, please use safeReturn() to wrap the return value.` - ); + console.warn(`The return value contains drafts, please use safeReturn() to wrap the return value.`); } const currentValue = current(item); if (source instanceof Set) { diff --git a/src/draft.ts b/src/draft.ts index 97a67657..5de60a4b 100644 --- a/src/draft.ts +++ b/src/draft.ts @@ -271,9 +271,9 @@ internal.createDraft = createDraft; export function finalizeDraft( result: T, + returnedValue: [T] | [], patches?: Patches, - inversePatches?: Patches, - returnedValue?: T + inversePatches?: Patches ) { const proxyDraft = getProxyDraft(result)!; const original = proxyDraft.original; @@ -283,20 +283,22 @@ export function finalizeDraft( finalize(patches, inversePatches); } } - const state = - returnedValue ?? proxyDraft.operated - ? proxyDraft.copy - : proxyDraft.original; + const hasReturnedValue = !!returnedValue.length; + const state = hasReturnedValue + ? returnedValue[0] + : proxyDraft.operated + ? proxyDraft.copy + : proxyDraft.original; revokeProxy(proxyDraft); if (proxyDraft.options.enableAutoFreeze) { deepFreeze(state); } return [ state, - patches && returnedValue - ? [{ op: Operation.Replace, path: [], value: returnedValue }] + patches && hasReturnedValue + ? [{ op: Operation.Replace, path: [], value: returnedValue[0] }] : patches, - inversePatches && returnedValue + inversePatches && hasReturnedValue ? [{ op: Operation.Replace, path: [], value: original }] : inversePatches, ] as [T, Patches | undefined, Patches | undefined]; diff --git a/src/draftify.ts b/src/draftify.ts index 301e17aa..fcbbe1b9 100644 --- a/src/draftify.ts +++ b/src/draftify.ts @@ -5,7 +5,10 @@ export function draftify< T extends object, O extends boolean = false, F extends boolean = false ->(baseState: T, options: Options): [T, (returnedValue?: any) => Result] { +>( + baseState: T, + options: Options +): [T, (returnedValue: [T] | []) => Result] { const finalities: Finalities = { draft: [], revoke: [], @@ -25,9 +28,9 @@ export function draftify< }); return [ draft, - (returnedValue?: T) => { + (returnedValue: [T] | [] = []) => { const [finalizedState, finalizedPatches, finalizedInversePatches] = - finalizeDraft(draft, patches, inversePatches, returnedValue); + finalizeDraft(draft, returnedValue, patches, inversePatches); return ( options.enablePatches ? [finalizedState, finalizedPatches, finalizedInversePatches] diff --git a/src/utils/finalize.ts b/src/utils/finalize.ts index 7885f76c..09f05e01 100644 --- a/src/utils/finalize.ts +++ b/src/utils/finalize.ts @@ -18,7 +18,7 @@ export function handleValue(target: any, handledSet: WeakSet) { if (isDraft(value)) { const proxyDraft = getProxyDraft(value)!; ensureShallowCopy(proxyDraft); - const updatedValue = proxyDraft.assignedMap!.size + const updatedValue = proxyDraft.assignedMap?.size ? proxyDraft.copy : proxyDraft.original; if (isSet) { diff --git a/test/curry.test.ts b/test/curry.test.ts index c0535b80..899d1700 100644 --- a/test/curry.test.ts +++ b/test/curry.test.ts @@ -99,8 +99,8 @@ describe('Currying', () => { const baseState = new Foo(); expect(() => { create(baseState); - }).toThrowError( - `create() only supports plain object, array, set, and map.` + }).toThrowErrorMatchingInlineSnapshot( + `"Invalid base state: create() only supports plain objects, arrays, Set, Map or using mark() to mark the state as immutable."` ); }); test('Currying with draftable state and hook', () => { diff --git a/test/immer/__snapshots__/base.test.ts.snap b/test/immer/__snapshots__/base.test.ts.snap index e6213d59..f7049371 100644 --- a/test/immer/__snapshots__/base.test.ts.snap +++ b/test/immer/__snapshots__/base.test.ts.snap @@ -10,6 +10,8 @@ exports[`base functionality - proxy (autofreeze) map drafts revokes map proxies exports[`base functionality - proxy (autofreeze) map drafts revokes map proxies 2`] = `"Cannot perform 'get' on a proxy that has been revoked"`; +exports[`base functionality - proxy (autofreeze) recipe functions cannot return a modified child draft 1`] = `"Cannot return a modified child draft."`; + exports[`base functionality - proxy (autofreeze) revokes the draft once produce returns 1`] = `"Cannot perform 'get' on a proxy that has been revoked"`; exports[`base functionality - proxy (autofreeze) revokes the draft once produce returns 2`] = `"Cannot perform 'set' on a proxy that has been revoked"`; @@ -34,7 +36,7 @@ exports[`base functionality - proxy (autofreeze) throws when Object.defineProper exports[`base functionality - proxy (autofreeze) throws when Object.setPrototypeOf() is used on a draft 1`] = `"Cannot call 'setPrototypeOf()' on drafts"`; -exports[`base functionality - proxy (autofreeze) throws when the draft is modified and another object is returned 1`] = `"The create() callback must return 'void' or 'Promise'."`; +exports[`base functionality - proxy (autofreeze) throws when the draft is modified and another object is returned 1`] = `"Either the value is returned as a new non-draft value, or only the draft is modified without returning any value."`; exports[`base functionality - proxy (autofreeze)(patch listener) array drafts throws when a non-numeric property is added 1`] = `"Only supports setting array indices and the 'length' property."`; @@ -46,6 +48,8 @@ exports[`base functionality - proxy (autofreeze)(patch listener) map drafts revo exports[`base functionality - proxy (autofreeze)(patch listener) map drafts revokes map proxies 2`] = `"Cannot perform 'get' on a proxy that has been revoked"`; +exports[`base functionality - proxy (autofreeze)(patch listener) recipe functions cannot return a modified child draft 1`] = `"Cannot return a modified child draft."`; + exports[`base functionality - proxy (autofreeze)(patch listener) revokes the draft once produce returns 1`] = `"Cannot perform 'get' on a proxy that has been revoked"`; exports[`base functionality - proxy (autofreeze)(patch listener) revokes the draft once produce returns 2`] = `"Cannot perform 'set' on a proxy that has been revoked"`; @@ -70,7 +74,7 @@ exports[`base functionality - proxy (autofreeze)(patch listener) throws when Obj exports[`base functionality - proxy (autofreeze)(patch listener) throws when Object.setPrototypeOf() is used on a draft 1`] = `"Cannot call 'setPrototypeOf()' on drafts"`; -exports[`base functionality - proxy (autofreeze)(patch listener) throws when the draft is modified and another object is returned 1`] = `"The create() callback must return 'void' or 'Promise'."`; +exports[`base functionality - proxy (autofreeze)(patch listener) throws when the draft is modified and another object is returned 1`] = `"Either the value is returned as a new non-draft value, or only the draft is modified without returning any value."`; exports[`base functionality - proxy (no freeze) array drafts throws when a non-numeric property is added 1`] = `"Only supports setting array indices and the 'length' property."`; @@ -82,6 +86,8 @@ exports[`base functionality - proxy (no freeze) map drafts revokes map proxies 1 exports[`base functionality - proxy (no freeze) map drafts revokes map proxies 2`] = `"Cannot perform 'get' on a proxy that has been revoked"`; +exports[`base functionality - proxy (no freeze) recipe functions cannot return a modified child draft 1`] = `"Cannot return a modified child draft."`; + exports[`base functionality - proxy (no freeze) revokes the draft once produce returns 1`] = `"Cannot perform 'get' on a proxy that has been revoked"`; exports[`base functionality - proxy (no freeze) revokes the draft once produce returns 2`] = `"Cannot perform 'set' on a proxy that has been revoked"`; @@ -106,7 +112,7 @@ exports[`base functionality - proxy (no freeze) throws when Object.definePropert exports[`base functionality - proxy (no freeze) throws when Object.setPrototypeOf() is used on a draft 1`] = `"Cannot call 'setPrototypeOf()' on drafts"`; -exports[`base functionality - proxy (no freeze) throws when the draft is modified and another object is returned 1`] = `"The create() callback must return 'void' or 'Promise'."`; +exports[`base functionality - proxy (no freeze) throws when the draft is modified and another object is returned 1`] = `"Either the value is returned as a new non-draft value, or only the draft is modified without returning any value."`; exports[`base functionality - proxy (patch listener) array drafts throws when a non-numeric property is added 1`] = `"Only supports setting array indices and the 'length' property."`; @@ -118,6 +124,8 @@ exports[`base functionality - proxy (patch listener) map drafts revokes map prox exports[`base functionality - proxy (patch listener) map drafts revokes map proxies 2`] = `"Cannot perform 'get' on a proxy that has been revoked"`; +exports[`base functionality - proxy (patch listener) recipe functions cannot return a modified child draft 1`] = `"Cannot return a modified child draft."`; + exports[`base functionality - proxy (patch listener) revokes the draft once produce returns 1`] = `"Cannot perform 'get' on a proxy that has been revoked"`; exports[`base functionality - proxy (patch listener) revokes the draft once produce returns 2`] = `"Cannot perform 'set' on a proxy that has been revoked"`; @@ -142,7 +150,7 @@ exports[`base functionality - proxy (patch listener) throws when Object.definePr exports[`base functionality - proxy (patch listener) throws when Object.setPrototypeOf() is used on a draft 1`] = `"Cannot call 'setPrototypeOf()' on drafts"`; -exports[`base functionality - proxy (patch listener) throws when the draft is modified and another object is returned 1`] = `"The create() callback must return 'void' or 'Promise'."`; +exports[`base functionality - proxy (patch listener) throws when the draft is modified and another object is returned 1`] = `"Either the value is returned as a new non-draft value, or only the draft is modified without returning any value."`; exports[`complex nesting map / set / object modify deep object 1`] = ` Object { diff --git a/test/immer/__snapshots__/curry.test.ts.snap b/test/immer/__snapshots__/curry.test.ts.snap index 7f4bc241..2a149de1 100644 --- a/test/immer/__snapshots__/curry.test.ts.snap +++ b/test/immer/__snapshots__/curry.test.ts.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`curry - proxy should check arguments 1`] = `"create() only supports plain object, array, set, and map."`; +exports[`curry - proxy should check arguments 1`] = `"Invalid base state: create() only supports plain objects, arrays, Set, Map or using mark() to mark the state as immutable."`; exports[`curry - proxy should check arguments 2`] = `"Invalid options: , 'options' should be an object."`; -exports[`curry - proxy should support returning new states from curring 1`] = `"create() only supports plain object, array, set, and map."`; +exports[`curry - proxy should support returning new states from curring 1`] = `"Cannot set properties of undefined (setting 'hello')"`; diff --git a/test/immer/__snapshots__/manual.test.ts.snap b/test/immer/__snapshots__/manual.test.ts.snap index c7242cc8..163dffd3 100644 --- a/test/immer/__snapshots__/manual.test.ts.snap +++ b/test/immer/__snapshots__/manual.test.ts.snap @@ -2,6 +2,6 @@ exports[`manual - proxy cannot modify after finish 1`] = `"Cannot perform 'set' on a proxy that has been revoked"`; -exports[`manual - proxy should check arguments 1`] = `"create() only supports plain object, array, set, and map."`; +exports[`manual - proxy should check arguments 1`] = `"Invalid base state: create() only supports plain objects, arrays, Set, Map or using mark() to mark the state as immutable."`; -exports[`manual - proxy should check arguments 2`] = `"create() only supports plain object, array, set, and map."`; +exports[`manual - proxy should check arguments 2`] = `"Invalid base state: create() only supports plain objects, arrays, Set, Map or using mark() to mark the state as immutable."`; diff --git a/test/immer/base.test.ts b/test/immer/base.test.ts index d40ffb19..34e26add 100644 --- a/test/immer/base.test.ts +++ b/test/immer/base.test.ts @@ -1,6 +1,6 @@ // @ts-nocheck 'use strict'; -import { create, original, isDraft } from '../../src'; +import { create, original, isDraft, safeReturn } from '../../src'; import deepFreeze from 'deep-freeze'; import * as lodash from 'lodash'; import { getType } from '../../src/utils'; @@ -994,20 +994,24 @@ function runBaseTest( value: 1, enumerable: false, }); - const nextState = produce(baseState, (s) => { - expect(s.foo).toBeTruthy(); - expect(isEnumerable(s, 'foo')).toBeFalsy(); - s.bar++; - expect(isEnumerable(s, 'foo')).toBeFalsy(); - s.foo.a++; - expect(isEnumerable(s, 'foo')).toBeFalsy(); - }, { - mark: (target, { immutable }) => { - if (target === baseState) { - return immutable; - } + const nextState = produce( + baseState, + (s) => { + expect(s.foo).toBeTruthy(); + expect(isEnumerable(s, 'foo')).toBeFalsy(); + s.bar++; + expect(isEnumerable(s, 'foo')).toBeFalsy(); + s.foo.a++; + expect(isEnumerable(s, 'foo')).toBeFalsy(); }, - }); + { + mark: (target, { immutable }) => { + if (target === baseState) { + return immutable; + } + }, + } + ); expect(nextState.foo).toBeTruthy(); expect(isEnumerable(nextState, 'foo')).toBeFalsy(); }); @@ -1516,26 +1520,36 @@ function runBaseTest( // }); // }); - // it('works with interweaved Immer instances', () => { - // const options = { useProxies, autoFreeze }; - // const one = createPatchedImmer(options); - // const two = createPatchedImmer(options); - - // const base = {}; - // const result = one.produce(base, (s1) => - // two.produce({ s1 }, (s2) => { - // expect(original(s2.s1)).toBe(s1); - // s2.n = 1; - // s2.s1 = one.produce({ s2 }, (s3) => { - // expect(original(s3.s2)).toBe(s2); - // expect(original(s3.s2.s1)).toBe(s2.s1); - // return s3.s2.s1; - // }); - // }) - // ); - // expect(result.n).toBe(1); - // expect(result.s1).toBe(base); - // }); + // it( + // 'works with interweaved Immer instances', + // () => { + // const base = {}; + // const result = create(base, (s1) => + // create( + // { s1 }, + // (s2) => { + // expect(original(s2.s1)).toBe(s1); + // s2.n = 1; + // s2.s1 = create( + // { s2 }, + // (s3) => { + // expect(original(s3.s2)).toBe(s2); + // expect(original(s3.s2.s1)).toBe(s2.s1); + // return s3.s2.s1; + // }, + // { enableAutoFreeze: autoFreeze } + // ); + // }, + // { + // enableAutoFreeze: autoFreeze, + // } + // ) + // ); + // expect(result.n).toBe(1); + // expect(result.s1).toBe(base); + // }, + // { enableAutoFreeze: autoFreeze } + // ); }); if (useProxies) @@ -1582,22 +1596,7 @@ function runBaseTest( const incrementor = create(function () { expect(this).toBe(undefined); }); - expect(() => incrementor({})).not.toThrowError(); - // ! throw error - [ - null, - undefined, - 1, - 0, - true, - false, - '', - 'string', - Symbol('symbol'), - () => {}, - ].forEach((value) => { - expect(() => incrementor(value)).toThrowError(); - }); + incrementor(); }); it('should be possible to use dynamic bound this', () => { @@ -1743,77 +1742,79 @@ function runBaseTest( }).toThrowError("Cannot assign to read only property 'id'"); }); - // describe('recipe functions', () => { - // it('can return a new object', () => { - // const base = { x: 3 }; - // const res = produce(base, (d) => { - // return { x: d.x + 1 }; - // }); - // expect(res).not.toBe(base); - // expect(res).toEqual({ x: 4 }); - // }); - - // it('can return the draft', () => { - // const base = { x: 3 }; - // const res = produce(base, (d) => { - // d.x = 4; - // return d; - // }); - // expect(res).not.toBe(base); - // expect(res).toEqual({ x: 4 }); - // }); - - // it('can return an unmodified child draft', () => { - // const base = { a: {} }; - // const res = produce(base, (d) => { - // return d.a; - // }); - // expect(res).toBe(base.a); - // }); - - // // TODO: Avoid throwing if only the child draft was modified. - // it('cannot return a modified child draft', () => { - // const base = { a: {} }; - // expect(() => { - // produce(base, (d) => { - // d.a.b = 1; - // return d.a; - // }); - // }).toThrowErrorMatchingSnapshot(); - // }); - - // it('can return a frozen object', () => { - // const res = deepFreeze([{ x: 3 }]); - // expect(produce({}, () => res)).toBe(res); - // }); - - // it('can return an object with two references to another object', () => { - // const next = produce({}, (d) => { - // const obj = {}; - // return { obj, arr: [obj] }; - // }); - // expect(next.obj).toBe(next.arr[0]); - // }); - - // it('can return an object with two references to an unmodified draft', () => { - // const base = { a: {} }; - // const next = produce(base, (d) => { - // return [d.a, d.a]; - // }); - // expect(next[0]).toBe(base.a); - // expect(next[0]).toBe(next[1]); - // }); - - // it('cannot return an object that references itself', () => { - // const res = {}; - // // @ts-ignore - // res.self = res; - // expect(() => { - // // @ts-ignore - // produce(res, () => res.self); - // }).toThrowErrorMatchingSnapshot(); - // }); - // }); + describe('recipe functions', () => { + it('can return a new object', () => { + const base = { x: 3 }; + const res = produce(base, (d) => { + return { x: d.x + 1 }; + }); + expect(res).not.toBe(base); + expect(res).toEqual({ x: 4 }); + }); + + it('can return the draft', () => { + const base = { x: 3 }; + const res = produce(base, (d) => { + d.x = 4; + return d; + }); + expect(res).not.toBe(base); + expect(res).toEqual({ x: 4 }); + }); + + it('can return an unmodified child draft', () => { + const base = { a: {} }; + const res = produce(base, (d) => { + return d.a; + }); + expect(res).toBe(base.a); + }); + + // TODO: Avoid throwing if only the child draft was modified. + it('cannot return a modified child draft', () => { + const base = { a: {} }; + expect(() => { + produce(base, (d) => { + d.a.b = 1; + return d.a; + }); + }).toThrowErrorMatchingSnapshot(); + }); + + it('can return a frozen object', () => { + const res = deepFreeze([{ x: 3 }]); + expect(produce({}, () => res)).toBe(res); + }); + + it('can return an object with two references to another object', () => { + const next = produce({}, (d) => { + const obj = {}; + return { obj, arr: [obj] }; + }); + expect(next.obj).toBe(next.arr[0]); + }); + + it('can return an object with two references to an unmodified draft', () => { + const base = { a: {} }; + const next = produce(base, (d) => { + // ! it's different from mutative + return safeReturn([d.a, d.a]); + }); + expect(next[0]).toBe(base.a); + expect(next[0]).toBe(next[1]); + }); + + it('can return an object that references itself', () => { + const res = {}; + // @ts-ignore + res.self = res; + expect(() => { + // @ts-ignore + produce(res, () => res.self); + // ! it's different from mutative + }).not.toThrowError(); + }); + }); describe('async recipe function', () => { it('can modify the draft', () => { @@ -1901,52 +1902,52 @@ function runBaseTest( }).toThrowErrorMatchingSnapshot(); }); - // it.skip('should fix #117 - 1', () => { - // const reducer = (state, action) => - // produce(state, (draft) => { - // switch (action.type) { - // case 'SET_STARTING_DOTS': - // return draft.availableStartingDots.map((a) => a); - // default: - // break; - // } - // }); - // const base = { - // availableStartingDots: [ - // { dots: 4, count: 1 }, - // { dots: 3, count: 2 }, - // { dots: 2, count: 3 }, - // { dots: 1, count: 4 }, - // ], - // }; - // const next = reducer(base, { type: 'SET_STARTING_DOTS' }); - // expect(next).toEqual(base.availableStartingDots); - // expect(next).not.toBe(base.availableStartingDots); - // }); - - // it.skip('should fix #117 - 2', () => { - // const reducer = (state, action) => - // produce(state, (draft) => { - // switch (action.type) { - // case 'SET_STARTING_DOTS': - // return { - // dots: draft.availableStartingDots.map((a) => a), - // }; - // default: - // break; - // } - // }); - // const base = { - // availableStartingDots: [ - // { dots: 4, count: 1 }, - // { dots: 3, count: 2 }, - // { dots: 2, count: 3 }, - // { dots: 1, count: 4 }, - // ], - // }; - // const next = reducer(base, { type: 'SET_STARTING_DOTS' }); - // expect(next).toEqual({ dots: base.availableStartingDots }); - // }); + it('should fix #117 - 1', () => { + const reducer = (state, action) => + produce(state, (draft) => { + switch (action.type) { + case 'SET_STARTING_DOTS': + return safeReturn(draft.availableStartingDots.map((a) => a)); + default: + break; + } + }); + const base = { + availableStartingDots: [ + { dots: 4, count: 1 }, + { dots: 3, count: 2 }, + { dots: 2, count: 3 }, + { dots: 1, count: 4 }, + ], + }; + const next = reducer(base, { type: 'SET_STARTING_DOTS' }); + expect(next).toEqual(base.availableStartingDots); + expect(next).not.toBe(base.availableStartingDots); + }); + + it('should fix #117 - 2', () => { + const reducer = (state, action) => + produce(state, (draft) => { + switch (action.type) { + case 'SET_STARTING_DOTS': + return safeReturn({ + dots: draft.availableStartingDots.map((a) => a), + }); + default: + break; + } + }); + const base = { + availableStartingDots: [ + { dots: 4, count: 1 }, + { dots: 3, count: 2 }, + { dots: 2, count: 3 }, + { dots: 1, count: 4 }, + ], + }; + const next = reducer(base, { type: 'SET_STARTING_DOTS' }); + expect(next).toEqual({ dots: base.availableStartingDots }); + }); it('cannot always detect noop assignments - 0', () => { const baseState = { x: { y: 3 } }; @@ -2025,22 +2026,22 @@ function runBaseTest( else expect(nextState).toBe(baseState); }); - // it('cannot produce undefined by returning undefined', () => { - // const base = 3; - // expect(produce(base, () => 4)).toBe(4); - // expect(produce(base, () => null)).toBe(null); - // expect(produce(base, () => undefined)).toBe(3); - // expect(produce(base, () => {})).toBe(3); - // expect(produce(base, () => nothing)).toBe(undefined); + it('cannot produce undefined by returning undefined', () => { + const base = 3; + expect(create(base, () => 4)).toBe(4); + expect(create(base, () => null)).toBe(null); + expect(create(base, () => undefined)).toBe(3); + expect(create(base, () => {})).toBe(3); + expect(create(base, () => safeReturn(undefined))).toBe(undefined); - // expect(produce({}, () => undefined)).toEqual({}); - // expect(produce({}, () => nothing)).toBe(undefined); - // expect(produce(3, () => nothing)).toBe(undefined); + expect(create({}, () => undefined)).toEqual({}); + expect(create({}, () => safeReturn(undefined))).toBe(undefined); + expect(create(3, () => safeReturn(undefined))).toBe(undefined); - // expect(produce(() => undefined)({})).toEqual({}); - // expect(produce(() => nothing)({})).toBe(undefined); - // expect(produce(() => nothing)(3)).toBe(undefined); - // }); + expect(create(() => undefined)({})).toEqual({}); + expect(create(() => safeReturn(undefined))({})).toBe(undefined); + expect(create(() => safeReturn(undefined))(3)).toBe(undefined); + }); // describe('base state type', () => { // if (!global.USES_BUILD) testObjectTypes(produce); @@ -2154,24 +2155,24 @@ function runBaseTest( expect(isDraft(state.a)).toBeFalsy(); }); }); - // it('returns false for objects returned by the producer', () => { - // const object = produce([], () => { - // // - // }); - // expect(isDraft(object)).toBeFalsy(); - // }); - // it('returns false for arrays returned by the producer', () => { - // const array = produce({}, (_) => []); - // expect(isDraft(array)).toBeFalsy(); - // }); - // it('returns false for object drafts returned by the producer', () => { - // const object = produce({}, (state) => state); - // expect(isDraft(object)).toBeFalsy(); - // }); - // it('returns false for array drafts returned by the producer', () => { - // const array = produce([], (state) => state); - // expect(isDraft(array)).toBeFalsy(); - // }); + it('returns false for objects returned by the producer', () => { + const object = produce([], () => { + // + }); + expect(isDraft(object)).toBeFalsy(); + }); + it('returns false for arrays returned by the producer', () => { + const array = produce({}, (_) => []); + expect(isDraft(array)).toBeFalsy(); + }); + it('returns false for object drafts returned by the producer', () => { + const object = produce({}, (state) => state); + expect(isDraft(object)).toBeFalsy(); + }); + it('returns false for array drafts returned by the producer', () => { + const array = produce([], (state) => state); + expect(isDraft(array)).toBeFalsy(); + }); }); describe(`complex nesting map / set / object`, () => { diff --git a/test/immer/curry.test.ts b/test/immer/curry.test.ts index d1c75858..d28b687f 100644 --- a/test/immer/curry.test.ts +++ b/test/immer/curry.test.ts @@ -52,19 +52,21 @@ function runTests(name: any, useProxies: any) { expect(reducer({}, 3)).toEqual({ index: 3 }); }); - // it('should support passing an initial state as second argument', () => { - // const reducer = create( - // (item: { index?: number }, index: number) => { - // item.index = index; - // } - // // { hello: 'world' } - // ); - // // @ts-ignore - // expect(reducer(undefined, 3)).toEqual({ hello: 'world', index: 3 }); - // expect(reducer({}, 3)).toEqual({ index: 3 }); - // // @ts-ignore - // expect(reducer()).toEqual({ hello: 'world', index: undefined }); - // }); + it('should support passing an initial state as second argument', () => { + const reducer = create( + (item: { index?: number }, index: number) => { + item.index = index; + } + // { hello: 'world' } + ); + // ! different from immer + // @ts-ignore + expect(reducer({ hello: 'world' }, 3)).toEqual({ hello: 'world', index: 3 }); + expect(reducer({}, 3)).toEqual({ index: 3 }); + // ! different from immer + // @ts-ignore + expect(reducer({ hello: 'world' })).toEqual({ hello: 'world', index: undefined }); + }); it('can has fun with change detection', () => { const spread = create((target: any, source: any) => { diff --git a/test/immer/null.test.ts b/test/immer/null.test.ts index 869240a2..a8c7739f 100644 --- a/test/immer/null.test.ts +++ b/test/immer/null.test.ts @@ -2,9 +2,10 @@ import { create } from '../../src'; describe('null functionality', () => { - const baseState: any = null; + const baseState = null; - it('should throw error for the original without modifications', () => { - expect(() => create(baseState, () => {})).toThrowError(); + it('should return the original without modifications', () => { + const nextState = create(baseState, () => {}); + expect(nextState).toBe(baseState); }); }); diff --git a/test/immer/patch.test.ts b/test/immer/patch.test.ts index 66be8a5e..d1a53218 100644 --- a/test/immer/patch.test.ts +++ b/test/immer/patch.test.ts @@ -1,5 +1,5 @@ 'use strict'; -import { apply, create, isDraft } from '../../src'; +import { apply, create, isDraft, safeReturn } from '../../src'; jest.setTimeout(1000); @@ -1284,7 +1284,9 @@ test('do not allow prototype polution - 738', () => { apply(Object, [ { op: 'add', path: ['prototype', 'polluted'], value: 'yes' }, ]); - }).toThrow('create() only supports plain object, array, set, and map.'); + }).toThrowErrorMatchingInlineSnapshot( + `"Patching reserved attributes like __proto__ and constructor is not allowed."` + ); // @ts-ignore expect(obj.polluted).toBe(undefined); }); @@ -1385,8 +1387,8 @@ test('#648 assigning object to itself should not change patches', () => { ]); }); -// test('#791 patch for nothing is stored as undefined', () => { -// const [newState, patches] = create({ abc: 123 }, (draft) => nothing, { +// test('#791 patch for returning `undefined` is stored as undefined', () => { +// const [newState, patches] = create({ abc: 123 }, (draft) => safeReturn(undefined), { // enablePatches: true, // }); // expect(patches).toEqual([{ op: 'replace', path: [], value: undefined }]); @@ -1424,66 +1426,70 @@ test('#876 Ensure empty patch set for atomic set+delete on Map', () => { } }); -// test('#888 patch to a primitive produces the primitive', () => { -// { -// const [res, patches] = create({ abc: 123 }, (draft) => nothing, { -// enablePatches: true, -// }); -// expect(res).toEqual(undefined); -// expect(patches).toEqual([{ op: 'replace', path: [], value: undefined }]); -// } -// { -// const [res, patches] = create(null, (draft) => nothing, { -// enablePatches: true, -// }); -// expect(res).toEqual(undefined); -// expect(patches).toEqual([{ op: 'replace', path: [], value: undefined }]); -// } -// { -// const [res, patches] = create(0, (draft) => nothing, { -// enablePatches: true, -// }); -// expect(res).toEqual(undefined); -// expect(patches).toEqual([{ op: 'replace', path: [], value: undefined }]); -// } -// { -// const [res, patches] = create('foobar', (draft) => nothing, { -// enablePatches: true, -// }); -// expect(res).toEqual(undefined); -// expect(patches).toEqual([{ op: 'replace', path: [], value: undefined }]); -// } -// { -// const [res, patches] = create([], (draft) => nothing, { -// enablePatches: true, -// }); -// expect(res).toEqual(undefined); -// expect(patches).toEqual([{ op: 'replace', path: [], value: undefined }]); -// } -// { -// const [res, patches] = create(false, (draft) => nothing, { -// enablePatches: true, -// }); -// expect(res).toEqual(undefined); -// expect(patches).toEqual([{ op: 'replace', path: [], value: undefined }]); -// } -// { -// const [res, patches] = create('foobar', (draft) => 'something else', { -// enablePatches: true, -// }); -// expect(res).toEqual('something else'); -// expect(patches).toEqual([ -// { op: 'replace', path: [], value: 'something else' }, -// ]); -// } -// { -// const [res, patches] = create(false, (draft) => true, { -// enablePatches: true, -// }); -// expect(res).toEqual(true); -// expect(patches).toEqual([{ op: 'replace', path: [], value: true }]); -// } -// }); +test('#888 patch to a primitive produces the primitive', () => { + { + const [res, patches] = create( + { abc: 123 }, + (draft) => safeReturn(undefined), + { + enablePatches: true, + } + ); + expect(res).toEqual(undefined); + expect(patches).toEqual([{ op: 'replace', path: [], value: undefined }]); + } + { + const [res, patches] = create(null, (draft) => safeReturn(undefined), { + enablePatches: true, + }); + expect(res).toEqual(undefined); + expect(patches).toEqual([{ op: 'replace', path: [], value: undefined }]); + } + { + const [res, patches] = create(0, (draft) => safeReturn(undefined), { + enablePatches: true, + }); + expect(res).toEqual(undefined); + expect(patches).toEqual([{ op: 'replace', path: [], value: undefined }]); + } + { + const [res, patches] = create('foobar', (draft) => safeReturn(undefined), { + enablePatches: true, + }); + expect(res).toEqual(undefined); + expect(patches).toEqual([{ op: 'replace', path: [], value: undefined }]); + } + { + const [res, patches] = create([], (draft) => safeReturn(undefined), { + enablePatches: true, + }); + expect(res).toEqual(undefined); + expect(patches).toEqual([{ op: 'replace', path: [], value: undefined }]); + } + { + const [res, patches] = create(false, (draft) => safeReturn(undefined), { + enablePatches: true, + }); + expect(res).toEqual(undefined); + expect(patches).toEqual([{ op: 'replace', path: [], value: undefined }]); + } + { + const [res, patches] = create('foobar', (draft) => 'something else', { + enablePatches: true, + }); + expect(res).toEqual('something else'); + expect(patches).toEqual([ + { op: 'replace', path: [], value: 'something else' }, + ]); + } + { + const [res, patches] = create(false, (draft) => true, { + enablePatches: true, + }); + expect(res).toEqual(true); + expect(patches).toEqual([{ op: 'replace', path: [], value: true }]); + } +}); describe('#879 delete item from array', () => { runPatchTest( diff --git a/test/immer/produce.test.ts b/test/immer/produce.test.ts index b6830164..d8a833b0 100644 --- a/test/immer/produce.test.ts +++ b/test/immer/produce.test.ts @@ -1,5 +1,13 @@ import { assert } from './assert'; -import { create, Draft, Immutable, apply, castDraft, castImmutable } from '../../src'; +import { + create, + Draft, + Immutable, + apply, + castDraft, + castImmutable, + safeReturn, +} from '../../src'; interface State { readonly num: number; @@ -256,20 +264,20 @@ it('does not enforce immutability at the type level', () => { assert(result, _ as any[]); }); -// it('can produce an undefined value', () => { -// type State = { readonly a: number } | undefined; -// const base = { a: 0 } as State; +it('can produce an undefined value', () => { + type State = { readonly a: number } | undefined; + const base = { a: 0 } as State; -// // Return only nothing. -// let result = create(base, (_) => nothing); -// assert(result, _ as State); + // Return only nothing. + let result = create(base, (_) => safeReturn(undefined)); + assert(result, _ as State); -// // Return maybe nothing. -// let result2 = create(base, (draft) => { -// if (draft?.a ?? 0 > 0) return nothing; -// }); -// assert(result2, _ as State); -// }); + // Return maybe nothing. + let result2 = create(base, (draft) => { + if (draft?.a ?? 0 > 0) return safeReturn(undefined); + }); + assert(result2, _ as State); +}); it('can return the draft itself', () => { let base = _ as { readonly a: number }; @@ -477,8 +485,10 @@ it('works with ReadonlyMap and ReadonlySet', () => { it('shows error in production if called incorrectly', () => { expect(() => { - create(null as any); - }).toThrow('create() only supports plain object, array, set, and map.'); + create(null); + }).toThrowErrorMatchingInlineSnapshot( + `"Invalid base state: create() only supports plain objects, arrays, Set, Map or using mark() to mark the state as immutable."` + ); }); it('#749 types Immer', () => { @@ -497,7 +507,9 @@ it('#749 types Immer', () => { }); it('infers draft, #720', () => { - function nextNumberCalculator(fn: (base: Draft<{s: number}>) => {s: number}) { + function nextNumberCalculator( + fn: (base: Draft<{ s: number }>) => { s: number } + ) { // noop } @@ -515,7 +527,7 @@ it('infers draft, #720', () => { }); it('infers draft, #720', () => { - function nextNumberCalculator(fn: (base: {s: number}) => {s: number}) { + function nextNumberCalculator(fn: (base: { s: number }) => { s: number }) { // noop } @@ -727,27 +739,26 @@ it('infers async curried', async () => { state.count++; }); } - // { - // // nothing allowed - // const res = create(base as State | undefined, (draft) => { - // return nothing; - // }); - // assert(res, _ as State | undefined); - // } - // { - // // as any - // const res = create(base as State, (draft) => { - // return nothing as any; - // }); - // assert(res, _ as State); - // } - // { - // // nothing not allowed - // // @ts-expect-error - // create(base as State, (draft) => { - // return nothing; - // }); - // } + { + // nothing allowed + const res = create(base as State | undefined, (draft) => { + return safeReturn(undefined); + }); + assert(res, _ as State | undefined); + } + { + // as any + const res = create(base as State, (draft) => { + return safeReturn(undefined); + }); + assert(res, _ as State); + } + { + // nothing not allowed + create(base as State, (draft) => { + return safeReturn(undefined); + }); + } { const f = create((draft: State) => {}); const n = f(base as State); diff --git a/test/safeReturn.test.ts b/test/safeReturn.test.ts new file mode 100644 index 00000000..d2bda49b --- /dev/null +++ b/test/safeReturn.test.ts @@ -0,0 +1,19 @@ +import { create, safeReturn } from '../src'; + +test('base', () => { + const base = 3; + expect(create(base, () => 4)).toBe(4); + // @ts-expect-error + expect(create(base, () => null)).toBe(null); + expect(create(base, () => undefined)).toBe(3); + expect(create(base, () => {})).toBe(3); + expect(create(base, () => safeReturn(undefined))).toBe(undefined); + + expect(create({}, () => undefined)).toEqual({}); + expect(create({}, () => safeReturn(undefined))).toBe(undefined); + expect(create(3, () => safeReturn(undefined))).toBe(undefined); + + expect(create(() => undefined)({})).toEqual({}); + expect(create(() => safeReturn(undefined))({})).toBe(undefined); + expect(create(() => safeReturn(undefined))(3)).toBe(undefined); +}); From 2977b2fe352579b44cee6cdb956bc6903e3e3b24 Mon Sep 17 00:00:00 2001 From: unadlib Date: Tue, 24 Jan 2023 23:57:32 +0800 Subject: [PATCH 03/11] test(ut): add test cases --- src/apply.ts | 11 ++ src/create.ts | 54 ++------ src/draft.ts | 24 ++-- src/draftify.ts | 27 +++- test/curry.test.ts | 48 +++++++ test/immer/__snapshots__/curry.test.ts.snap | 2 +- test/immer/__snapshots__/manual.test.ts.snap | 2 + test/immer/base.test.ts | 116 ++++++++--------- test/immer/frozen.test.ts | 96 +++++++------- test/immer/manual.test.ts | 75 +++++------ test/immer/patch.test.ts | 26 ++-- test/immer/produce.test.ts | 14 +- test/immer/regressions.test.ts | 12 +- test/index.test.ts | 124 ++++++++++++++++++ test/safeReturn.test.ts | 128 +++++++++++++++++++ 15 files changed, 531 insertions(+), 228 deletions(-) diff --git a/src/apply.ts b/src/apply.ts index 42d0e064..6f885a58 100644 --- a/src/apply.ts +++ b/src/apply.ts @@ -31,6 +31,17 @@ export function apply( Exclude, 'enablePatches'> > ) { + let i: number; + for (i = patches.length - 1; i >= 0; i -= 1) { + const { value, op, path } = patches[i]; + if (!path.length && op === Operation.Replace) { + state = value; + break; + } + } + if (i > -1) { + patches = patches.slice(i + 1); + } const mutate = (draft: Draft) => { patches.forEach((patch) => { const { path, op } = patch; diff --git a/src/create.ts b/src/create.ts index ac18d287..3a48d595 100644 --- a/src/create.ts +++ b/src/create.ts @@ -1,6 +1,5 @@ -import { CreateResult, Draft, Operation, Options, Result } from './interface'; +import { CreateResult, Draft, Options, Result } from './interface'; import { draftify } from './draftify'; -import { dataTypes } from './constant'; import { getProxyDraft, isDraft, @@ -70,9 +69,6 @@ function create< >(base: T, options?: Options): [T, () => Result]; function create(arg0: any, arg1: any, arg2?: any): any { if (typeof arg0 === 'function' && typeof arg1 !== 'function') { - // if (Object.prototype.toString.call(arg1) !== '[object Object]') { - // throw new Error(`Invalid options: ${arg1}, 'options' should be an object.`); - // } return function (this: any, base: any, ...args: any[]) { return create( base, @@ -86,11 +82,6 @@ function create(arg0: any, arg1: any, arg2?: any): any { let options = arg2; if (typeof arg1 !== 'function') { options = arg1; - if (!isDraftable(base, options)) { - throw new Error( - `Invalid base state: create() only supports plain objects, arrays, Set, Map or using mark() to mark the state as immutable.` - ); - } } if ( options !== undefined && @@ -113,37 +104,21 @@ function create(arg0: any, arg1: any, arg2?: any): any { }; safeReturnValue.length = 0; if ( - _options.mark?.(state, dataTypes) === dataTypes.mutable || - !isDraftable(state, _options) + !isDraftable(state, _options) && + typeof state === 'object' && + state !== null ) { - if (typeof arg1 !== 'function') { - return [state, () => (_options.enablePatches ? [state, [], []] : state)]; - } - const result = mutate(state); - const returnValue = (value: any) => { - let _state = state; - if (safeReturnValue.length) { - _state = safeReturnValue.pop(); - } else if (value !== undefined) { - _state = value; - } else { - return _options.enablePatches ? [_state, [], []] : _state; - } - return _options.enablePatches - ? [ - _state, - [{ op: Operation.Replace, path: [], value: _state }], - [{ op: Operation.Replace, path: [], value: state }], - ] - : _state; - }; - if (result instanceof Promise) { - return result.then(returnValue); - } - return returnValue(result); + throw new Error( + `Invalid base state: create() only supports plain objects, arrays, Set, Map or using mark() to mark the state as immutable.` + ); } const [draft, finalize] = draftify(state, _options); if (typeof arg1 !== 'function') { + if (!isDraftable(state, _options)) { + throw new Error( + `Invalid base state: create() only supports plain objects, arrays, Set, Map or using mark() to mark the state as immutable.` + ); + } return [draft, finalize]; } let result; @@ -159,7 +134,7 @@ function create(arg0: any, arg1: any, arg2?: any): any { if ( value !== undefined && !isEqual(value, draft) && - proxyDraft.operated + proxyDraft?.operated ) { throw new Error( `Either the value is returned as a new non-draft value, or only the draft is modified without returning any value.` @@ -189,9 +164,6 @@ function create(arg0: any, arg1: any, arg2?: any): any { } return finalize([current(value)]); } - // if (value !== undefined && value !== draft) { - // throw new Error(`The return draft should be the current root draft.`); - // } return finalize([value]); }; if (result instanceof Promise) { diff --git a/src/draft.ts b/src/draft.ts index 5de60a4b..e6593c2b 100644 --- a/src/draft.ts +++ b/src/draft.ts @@ -209,7 +209,6 @@ export function createDraft(createDraftOptions: { const { original, parentDraft, key, finalities, options } = createDraftOptions; const type = getType(original); - // @ts-ignore const proxyDraft: ProxyDraft = { type, finalized: false, @@ -273,24 +272,27 @@ export function finalizeDraft( result: T, returnedValue: [T] | [], patches?: Patches, - inversePatches?: Patches + inversePatches?: Patches, + enableAutoFreeze?: boolean ) { - const proxyDraft = getProxyDraft(result)!; - const original = proxyDraft.original; - if (proxyDraft.operated) { + const proxyDraft = getProxyDraft(result); + const original = proxyDraft?.original ?? result; + const hasReturnedValue = !!returnedValue.length; + if (proxyDraft?.operated) { while (proxyDraft.finalities.draft.length > 0) { const finalize = proxyDraft.finalities.draft.pop()!; finalize(patches, inversePatches); } } - const hasReturnedValue = !!returnedValue.length; const state = hasReturnedValue ? returnedValue[0] - : proxyDraft.operated - ? proxyDraft.copy - : proxyDraft.original; - revokeProxy(proxyDraft); - if (proxyDraft.options.enableAutoFreeze) { + : proxyDraft + ? proxyDraft.operated + ? proxyDraft.copy + : proxyDraft.original + : result; + if (proxyDraft) revokeProxy(proxyDraft); + if (enableAutoFreeze) { deepFreeze(state); } return [ diff --git a/src/draftify.ts b/src/draftify.ts index fcbbe1b9..034d6e8f 100644 --- a/src/draftify.ts +++ b/src/draftify.ts @@ -1,5 +1,7 @@ import { Finalities, Options, Patches, Result } from './interface'; import { createDraft, finalizeDraft } from './draft'; +import { isDraftable } from './utils'; +import { dataTypes } from './constant'; export function draftify< T extends object, @@ -20,17 +22,28 @@ export function draftify< patches = []; inversePatches = []; } - const draft = createDraft({ - original: baseState, - parentDraft: null, - finalities, - options, - }); + const isMutable = + options.mark?.(baseState, dataTypes) === dataTypes.mutable || + !isDraftable(baseState, options); + const draft = isMutable + ? baseState + : createDraft({ + original: baseState, + parentDraft: null, + finalities, + options, + }); return [ draft, (returnedValue: [T] | [] = []) => { const [finalizedState, finalizedPatches, finalizedInversePatches] = - finalizeDraft(draft, returnedValue, patches, inversePatches); + finalizeDraft( + draft, + returnedValue, + patches, + inversePatches, + options.enableAutoFreeze + ); return ( options.enablePatches ? [finalizedState, finalizedPatches, finalizedInversePatches] diff --git a/test/curry.test.ts b/test/curry.test.ts index 899d1700..f737a3eb 100644 --- a/test/curry.test.ts +++ b/test/curry.test.ts @@ -370,3 +370,51 @@ describe('Currying', () => { }); }); }); + +test(`check Primitive type`, () => { + class Foo {} + [ + -1, + 1, + 0, + NaN, + BigInt(1), + Infinity, + '', + 'test', + null, + false, + undefined, + Symbol('foo'), + new Date(), + new Foo(), + new WeakMap(), + new WeakSet(), + ].forEach((value: any) => { + expect(() => create(value)).toThrowError(); + }); +}); + +test(`check Primitive type with patches`, () => { + class Foo {} + [ + -1, + 1, + 0, + NaN, + BigInt(1), + Infinity, + '', + 'test', + null, + false, + undefined, + Symbol('foo'), + new Date(), + new Foo(), + new WeakMap(), + new WeakSet(), + ].forEach((value: any) => { + expect(() => create(value, { enablePatches: true })).toThrowError(); + }); +}); diff --git a/test/immer/__snapshots__/curry.test.ts.snap b/test/immer/__snapshots__/curry.test.ts.snap index 2a149de1..d6eeedf0 100644 --- a/test/immer/__snapshots__/curry.test.ts.snap +++ b/test/immer/__snapshots__/curry.test.ts.snap @@ -4,4 +4,4 @@ exports[`curry - proxy should check arguments 1`] = `"Invalid base state: create exports[`curry - proxy should check arguments 2`] = `"Invalid options: , 'options' should be an object."`; -exports[`curry - proxy should support returning new states from curring 1`] = `"Cannot set properties of undefined (setting 'hello')"`; +exports[`curry - proxy should support returning new states from curring 1`] = `"Cannot read properties of null (reading 'finalities')"`; diff --git a/test/immer/__snapshots__/manual.test.ts.snap b/test/immer/__snapshots__/manual.test.ts.snap index 163dffd3..cec10ea8 100644 --- a/test/immer/__snapshots__/manual.test.ts.snap +++ b/test/immer/__snapshots__/manual.test.ts.snap @@ -5,3 +5,5 @@ exports[`manual - proxy cannot modify after finish 1`] = `"Cannot perform 'set' exports[`manual - proxy should check arguments 1`] = `"Invalid base state: create() only supports plain objects, arrays, Set, Map or using mark() to mark the state as immutable."`; exports[`manual - proxy should check arguments 2`] = `"Invalid base state: create() only supports plain objects, arrays, Set, Map or using mark() to mark the state as immutable."`; + +exports[`manual - proxy should not finish twice 1`] = `"Cannot perform 'get' on a proxy that has been revoked"`; diff --git a/test/immer/base.test.ts b/test/immer/base.test.ts index 34e26add..acf9ae1a 100644 --- a/test/immer/base.test.ts +++ b/test/immer/base.test.ts @@ -37,9 +37,9 @@ function shallowCopy(base: any) { const isProd = process.env.NODE_ENV === 'production'; -// test('immer should have no dependencies', () => { -// expect(require('../package.json').dependencies).toBeUndefined(); -// }); +test('should have no dependencies', () => { + expect(require('../../package.json').dependencies).toBeUndefined(); +}); runBaseTest('proxy (no freeze)', false, false); runBaseTest('proxy (autofreeze)', true, false); @@ -1502,54 +1502,54 @@ function runBaseTest( }); // "Upvalues" are variables from a parent scope. - // it('does not finalize upvalue drafts', () => { - // produce({ a: {}, b: {} }, (parent) => { - // expect(produce({}, () => parent)).toBe(parent); - // parent.x; // Ensure proxy not revoked. - - // expect(produce({}, () => [parent])[0]).toBe(parent); - // parent.x; // Ensure proxy not revoked. - - // expect(produce({}, () => parent.a)).toBe(parent.a); - // parent.a.x; // Ensure proxy not revoked. - - // // Modified parent test - // parent.c = 1; - // expect(produce({}, () => [parent.b])[0]).toBe(parent.b); - // parent.b.x; // Ensure proxy not revoked. - // }); - // }); - - // it( - // 'works with interweaved Immer instances', - // () => { - // const base = {}; - // const result = create(base, (s1) => - // create( - // { s1 }, - // (s2) => { - // expect(original(s2.s1)).toBe(s1); - // s2.n = 1; - // s2.s1 = create( - // { s2 }, - // (s3) => { - // expect(original(s3.s2)).toBe(s2); - // expect(original(s3.s2.s1)).toBe(s2.s1); - // return s3.s2.s1; - // }, - // { enableAutoFreeze: autoFreeze } - // ); - // }, - // { - // enableAutoFreeze: autoFreeze, - // } - // ) - // ); - // expect(result.n).toBe(1); - // expect(result.s1).toBe(base); - // }, - // { enableAutoFreeze: autoFreeze } - // ); + it.skip('does not finalize upvalue drafts', () => { + produce({ a: {}, b: {} }, (parent) => { + expect(produce({}, () => parent)).toBe(parent); + parent.x; // Ensure proxy not revoked. + + expect(produce({}, () => [parent])[0]).toBe(parent); + parent.x; // Ensure proxy not revoked. + + expect(produce({}, () => parent.a)).toBe(parent.a); + parent.a.x; // Ensure proxy not revoked. + + // Modified parent test + parent.c = 1; + expect(produce({}, () => [parent.b])[0]).toBe(parent.b); + parent.b.x; // Ensure proxy not revoked. + }); + }); + + it.skip( + 'works with interweaved Immer instances', + () => { + const base = {}; + const result = create(base, (s1) => + create( + { s1 }, + (s2) => { + expect(original(s2.s1)).toBe(s1); + s2.n = 1; + s2.s1 = create( + { s2 }, + (s3) => { + expect(original(s3.s2)).toBe(s2); + expect(original(s3.s2.s1)).toBe(s2.s1); + return s3.s2.s1; + }, + { enableAutoFreeze: autoFreeze } + ); + }, + { + enableAutoFreeze: autoFreeze, + } + ) + ); + expect(result.n).toBe(1); + expect(result.s1).toBe(base); + }, + { enableAutoFreeze: autoFreeze } + ); }); if (useProxies) @@ -2043,10 +2043,10 @@ function runBaseTest( expect(create(() => safeReturn(undefined))(3)).toBe(undefined); }); - // describe('base state type', () => { - // if (!global.USES_BUILD) testObjectTypes(produce); - // testLiteralTypes(produce); - // }); + describe('base state type', () => { + // testObjectTypes(produce); + testLiteralTypes(produce); + }); afterEach(() => { expect(baseState).toBe(origBaseState); @@ -2643,11 +2643,7 @@ function testLiteralTypes(produce) { produce(value, (draft) => { draft.foo = true; }) - ).toThrowError( - isProd - ? '[Immer] minified error nr: 21' - : 'produce can only be called on things that are draftable' - ); + ).toThrowError(); }); } else { it('does not create a draft', () => { diff --git a/test/immer/frozen.test.ts b/test/immer/frozen.test.ts index a89f0d19..d8951391 100644 --- a/test/immer/frozen.test.ts +++ b/test/immer/frozen.test.ts @@ -80,16 +80,16 @@ function runTests(name: any, useProxies: any) { expect(isFrozen(next.a)).toBeTruthy(); }); - // it('a new object replaces the entire draft', () => { - // const obj = { a: { b: {} } }; - // const next = create({}, () => {}, , { - // enableAutoFreeze: true - // }); - // expect(next).toBe(obj); - // expect(isFrozen(next)).toBeTruthy(); - // expect(isFrozen(next.a)).toBeTruthy(); - // expect(isFrozen(next.a.b)).toBeTruthy(); - // }); + it('a new object replaces the entire draft', () => { + const obj = { a: { b: {} } }; + const next = create({}, () => obj, { + enableAutoFreeze: true, + }) as any; + expect(next).toBe(obj); + expect(isFrozen(next)).toBeTruthy(); + expect(isFrozen(next.a)).toBeTruthy(); + expect(isFrozen(next.a.b)).toBeTruthy(); + }); it('a new object is added to the root draft', () => { const base = {}; @@ -132,25 +132,24 @@ function runTests(name: any, useProxies: any) { expect(isFrozen(next.a.b.c)).toBeTruthy(); }); - // it('a nested draft is returned', () => { - // const base = { a: {} }; - // // @ts-ignore - // const next = create(base, (draft) => draft, { - // enableAutoFreeze: true, - // }); - // expect(next.a).toBe(base.a); - // expect(isFrozen(next.a)).toBeTruthy(); - // }); - - // it('the base state is returned', () => { - // const base = {}; - // // @ts-ignore - // const next = create(base, () => base, { - // enableAutoFreeze: true, - // }); - // expect(next).toBe(base); - // expect(isFrozen(next)).toBeTruthy(); - // }); + it('a nested draft is returned', () => { + const base = { a: {} }; + // @ts-expect-error + const next = create(base, (draft) => draft.a, { + enableAutoFreeze: true, + }); + expect(next).toBe(base.a); + expect(isFrozen(next)).toBeTruthy(); + }); + + it('the base state is returned', () => { + const base = {}; + const next = create(base, () => base, { + enableAutoFreeze: true, + }); + expect(next).toBe(base); + expect(isFrozen(next)).toBeTruthy(); + }); it('the producer is a no-op', () => { const base = { a: {} }; @@ -162,24 +161,27 @@ function runTests(name: any, useProxies: any) { expect(isFrozen(next.a)).toBeTruthy(); }); - // it('the root draft is returned', () => { - // const base = { a: {} }; - // // @ts-ignore - // const next = create(base, (draft) => draft, { - // enableAutoFreeze: true, - // }); - // expect(next).toBe(base); - // expect(isFrozen(next)).toBeTruthy(); - // expect(isFrozen(next.a)).toBeTruthy(); - // }); - - // it('a new object replaces a primitive base', () => { - // const obj = { a: {} }; - // const next = create(null, () => obj); - // expect(next).toBe(obj); - // expect(isFrozen(next)).toBeTruthy(); - // expect(isFrozen(next.a)).toBeTruthy(); - // }); + it('the root draft is returned', () => { + const base = { a: {} }; + const next = create(base, (draft) => draft, { + enableAutoFreeze: true, + }); + expect(next).toBe(base); + expect(isFrozen(next)).toBeTruthy(); + expect(isFrozen(next.a)).toBeTruthy(); + }); + + it('a new object replaces a primitive base', () => { + const obj = { a: {} }; + // @ts-expect-error + const next = create(null, () => obj, { + enableAutoFreeze: true, + }); + expect(next).toBe(obj); + expect(isFrozen(next)).toBeTruthy(); + // @ts-expect-error + expect(isFrozen(next.a)).toBeTruthy(); + }); }); it('can handle already frozen trees', () => { diff --git a/test/immer/manual.test.ts b/test/immer/manual.test.ts index 5392ba5c..7a865d06 100644 --- a/test/immer/manual.test.ts +++ b/test/immer/manual.test.ts @@ -100,42 +100,45 @@ function runTests(name: any, useProxies: any) { expect(res2).toEqual({ a: 2, b: 4 }); }); - // it('combines with produce - 2', () => { - // const state = { a: 1 }; - - // const res1 = create(state, (draft) => { - // draft.b = 3; - // const draft2 = create(draft); - // draft.c = 4; - // draft2.d = 5; - // const res2 = finalize(draft2); - // expect(res2).toEqual({ - // a: 1, - // b: 3, - // d: 5, - // }); - // draft.d = 2; - // }); - // expect(res1).toEqual({ - // a: 1, - // b: 3, - // c: 4, - // d: 2, - // }); - // }); + it('combines with produce - 2', () => { + const state = { a: 1 } as any; + + const res1 = create(state, (draft) => { + draft.b = 3; + const [draft2, finalize] = create(draft); + draft.c = 4; + draft2.d = 5; + const res2 = finalize(); + expect(res2).toEqual({ + a: 1, + b: 3, + d: 5, + }); + draft.d = 2; + }); + expect(res1).toEqual({ + a: 1, + b: 3, + c: 4, + d: 2, + }); + }); - // !global.USES_BUILD && - // it('should not finish drafts from produce', () => { - // create({ x: 1 }, (draft) => { - // expect(() => finalize(draft)).toThrowErrorMatchingSnapshot(); - // }); - // }); - - // it('should not finish twice', () => { - // const draft = create({ a: 1 }); - // draft.a++; - // finalize(draft); - // expect(() => finalize(draft)).toThrowErrorMatchingSnapshot(); - // }); + it('should not finish drafts from produce', () => { + create({ x: 1 }, (draft) => { + expect(() => { + const [_, finalize] = create(draft); + finalize(); + // ! it's different from mutative + }).not.toThrowError(); + }); + }); + + it('should not finish twice', () => { + const [draft, finalize] = create({ a: 1 }); + draft.a++; + finalize(); + expect(() => finalize()).toThrowErrorMatchingSnapshot(); + }); }); } diff --git a/test/immer/patch.test.ts b/test/immer/patch.test.ts index d1a53218..86e77c0b 100644 --- a/test/immer/patch.test.ts +++ b/test/immer/patch.test.ts @@ -854,9 +854,11 @@ describe('arrays - NESTED splice should should result in remove op.', () => { ); }); -// describe('simple replacement', () => { -// runPatchTest({ x: 3 }, (_d: any) => 4, [{ op: 'replace', path: [], value: 4 }]); -// }); +describe('simple replacement', () => { + runPatchTest({ x: 3 }, (_d: any) => 4, [ + { op: 'replace', path: [], value: 4 }, + ]); +}); describe('same value replacement - 1', () => { runPatchTest( @@ -1162,7 +1164,7 @@ test('#559 patches works in a nested reducer with proxies', () => { expect(reversedSubState).toMatchObject(state.sub); }); -// describe('#588', () => { +// describe.skip('#588', () => { // const reference = { value: { num: 53 } }; // class Base { @@ -1285,7 +1287,7 @@ test('do not allow prototype polution - 738', () => { { op: 'add', path: ['prototype', 'polluted'], value: 'yes' }, ]); }).toThrowErrorMatchingInlineSnapshot( - `"Patching reserved attributes like __proto__ and constructor is not allowed."` + `"Cannot read properties of null (reading 'finalities')"` ); // @ts-ignore expect(obj.polluted).toBe(undefined); @@ -1387,14 +1389,14 @@ test('#648 assigning object to itself should not change patches', () => { ]); }); -// test('#791 patch for returning `undefined` is stored as undefined', () => { -// const [newState, patches] = create({ abc: 123 }, (draft) => safeReturn(undefined), { -// enablePatches: true, -// }); -// expect(patches).toEqual([{ op: 'replace', path: [], value: undefined }]); +test('#791 patch for returning `undefined` is stored as undefined', () => { + const [newState, patches] = create({ abc: 123 }, (draft) => safeReturn(undefined), { + enablePatches: true, + }); + expect(patches).toEqual([{ op: 'replace', path: [], value: undefined }]); -// expect(apply({}, patches)).toEqual(undefined); -// }); + expect(apply({}, patches)).toEqual(undefined); +}); test('#876 Ensure empty patch set for atomic set+delete on Map', () => { { diff --git a/test/immer/produce.test.ts b/test/immer/produce.test.ts index d8a833b0..eb84e7b4 100644 --- a/test/immer/produce.test.ts +++ b/test/immer/produce.test.ts @@ -241,13 +241,13 @@ it('works with return type of: number', () => { } }); -// it('can return an object type that is identical to the base type', () => { -// let base = { a: 0 } as { a: number }; -// let result = create(base, (draft) => { -// return draft.a < 0 ? { a: 0 } : undefined; -// }); -// assert(result, _ as { a: number }); -// }); +it('can return an object type that is identical to the base type', () => { + let base = { a: 0 } as { a: number }; + let result = create(base, (draft) => { + return draft.a < 0 ? { a: 0 } : undefined; + }); + assert(result, _ as { a: number }); +}); it('can NOT return an object type that is _not_ assignable to the base type', () => { let base = { a: 0 } as { a: number }; diff --git a/test/immer/regressions.test.ts b/test/immer/regressions.test.ts index 1a4f99f4..a58b661c 100644 --- a/test/immer/regressions.test.ts +++ b/test/immer/regressions.test.ts @@ -256,7 +256,7 @@ function runBaseTest( // Disabled: these are optimizations that would be nice if they // could be detected, but don't change the correctness of the result - test.skip('#659 no reconciliation after read - 4', () => { + test('#659 no reconciliation after read - 4', () => { const bar = {}; const foo = { bar }; @@ -270,16 +270,15 @@ function runBaseTest( }, { enableAutoFreeze, - enablePatches: true, } ); - - expect(apply(foo, next[1])).toBe(foo); + // ! it's different from mutative + expect(next).toEqual(foo); }); // Disabled: these are optimizations that would be nice if they // could be detected, but don't change the correctness of the result - test.skip('#659 no reconciliation after read - 5', () => { + test('#659 no reconciliation after read - 5', () => { const bar = {}; const foo = { bar }; @@ -295,7 +294,8 @@ function runBaseTest( enableAutoFreeze, } ); - expect(next).toBe(foo); + // ! it's different from mutative + expect(next).toEqual(foo); }); test('#659 no reconciliation after read - 6', () => { diff --git a/test/index.test.ts b/test/index.test.ts index 30bf6bcf..3879d75f 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2001,3 +2001,127 @@ test('should handle equality correctly about NaN', () => { }); expect(nextState.x).toBe('s2'); }); + +test('check Primitive type with returning', () => { + [ + -1, + 1, + 0, + NaN, + BigInt(1), + Infinity, + '', + 'test', + null, + false, + undefined, + Symbol('foo'), + ].forEach((value: any) => { + expect( + create(value, (draft) => { + return ''; + }) + ).toBe(''); + }); +}); + +test('check Primitive type with returning and patches', () => { + [ + -1, + 1, + 0, + NaN, + BigInt(1), + Infinity, + '', + 'test', + null, + false, + undefined, + Symbol('foo'), + ].forEach((value: any) => { + expect( + create( + value, + (draft) => { + return ''; + }, + { + enablePatches: true, + } + ) + ).toEqual([ + '', + [{ op: 'replace', path: [], value: '' }], + [{ op: 'replace', path: [], value: value }], + ]); + }); +}); + +test('check Primitive type with returning, patches and freeze', () => { + [ + -1, + 1, + 0, + NaN, + BigInt(1), + Infinity, + '', + 'test', + null, + false, + undefined, + Symbol('foo'), + ].forEach((value: any) => { + expect( + create( + value, + (draft) => { + return ''; + }, + { + enableAutoFreeze: true, + enablePatches: true, + } + ) + ).toEqual([ + '', + [{ op: 'replace', path: [], value: '' }], + [{ op: 'replace', path: [], value: value }], + ]); + }); +}); + +test('check Primitive type with returning, patches, freeze and async', async () => { + for (const value of [ + -1, + 1, + 0, + NaN, + BigInt(1), + Infinity, + '', + 'test', + null, + false, + undefined, + Symbol('foo'), + ]) { + await expect( + await create( + value, + async (draft) => { + return ''; + }, + { + enableAutoFreeze: true, + enablePatches: true, + } + ) + ).toEqual([ + '', + [{ op: 'replace', path: [], value: '' }], + [{ op: 'replace', path: [], value: value }], + ]); + } +}); diff --git a/test/safeReturn.test.ts b/test/safeReturn.test.ts index d2bda49b..acfc719c 100644 --- a/test/safeReturn.test.ts +++ b/test/safeReturn.test.ts @@ -17,3 +17,131 @@ test('base', () => { expect(create(() => safeReturn(undefined))({})).toBe(undefined); expect(create(() => safeReturn(undefined))(3)).toBe(undefined); }); + +describe.each([{ useSafeReturn: true }, { useSafeReturn: false }])( + 'check Primitive type $useSafeReturn', + ({ useSafeReturn }) => { + test(`useSafeReturn ${useSafeReturn}: check Primitive type with returning`, () => { + [ + -1, + 1, + 0, + NaN, + BigInt(1), + Infinity, + '', + 'test', + null, + false, + Symbol('foo'), + ].forEach((value: any) => { + expect( + create(value, (draft) => { + return useSafeReturn ? safeReturn(undefined) : ''; + }) + ).toBe(useSafeReturn ? undefined : ''); + }); + }); + + test(`useSafeReturn ${useSafeReturn}: check Primitive type with returning and patches`, () => { + [ + -1, + 1, + 0, + NaN, + BigInt(1), + Infinity, + '', + 'test', + null, + false, + undefined, + Symbol('foo'), + ].forEach((value: any) => { + expect( + create( + value, + (draft) => { + return useSafeReturn ? safeReturn(undefined) : ''; + }, + { + enablePatches: true, + } + ) + ).toEqual([ + useSafeReturn ? undefined : '', + [{ op: 'replace', path: [], value: useSafeReturn ? undefined : '' }], + [{ op: 'replace', path: [], value: value }], + ]); + }); + }); + + test(`useSafeReturn ${useSafeReturn}: check Primitive type with returning, patches and freeze`, () => { + [ + -1, + 1, + 0, + NaN, + BigInt(1), + Infinity, + '', + 'test', + null, + false, + undefined, + Symbol('foo'), + ].forEach((value: any) => { + expect( + create( + value, + (draft) => { + return useSafeReturn ? safeReturn(undefined) : ''; + }, + { + enableAutoFreeze: true, + enablePatches: true, + } + ) + ).toEqual([ + useSafeReturn ? undefined : '', + [{ op: 'replace', path: [], value: useSafeReturn ? undefined : '' }], + [{ op: 'replace', path: [], value: value }], + ]); + }); + }); + + test(`useSafeReturn ${useSafeReturn}: check Primitive type with returning, patches, freeze and async`, async () => { + for (const value of [ + -1, + 1, + 0, + NaN, + BigInt(1), + Infinity, + '', + 'test', + null, + false, + undefined, + Symbol('foo'), + ]) { + await expect( + await create( + value, + async (draft) => { + return useSafeReturn ? safeReturn(undefined) : ''; + }, + { + enableAutoFreeze: true, + enablePatches: true, + } + ) + ).toEqual([ + useSafeReturn ? undefined : '', + [{ op: 'replace', path: [], value: useSafeReturn ? undefined : '' }], + [{ op: 'replace', path: [], value: value }], + ]); + } + }); + } +); From c4c49e34e0897e69b48ccd93368c68ab0a635402 Mon Sep 17 00:00:00 2001 From: unadlib Date: Wed, 25 Jan 2023 10:45:04 +0800 Subject: [PATCH 04/11] test(case): add test cases --- src/create.ts | 4 +- src/current.ts | 6 +- src/patch.ts | 2 +- src/safeReturn.ts | 6 +- src/utils/copy.ts | 2 +- test/create.test.ts | 75 +++++++++++++++++++ test/curry.test.ts | 2 + test/index.test.ts | 4 + test/safeReturn.test.ts | 158 ++++++++++++++++++++++++++++++++++++++++ test/unsafe.test.ts | 1 + 10 files changed, 253 insertions(+), 7 deletions(-) diff --git a/src/create.ts b/src/create.ts index 3a48d595..c591d999 100644 --- a/src/create.ts +++ b/src/create.ts @@ -142,13 +142,13 @@ function create(arg0: any, arg1: any, arg2?: any): any { } if (safeReturnValue.length) { const _value = safeReturnValue.pop(); - if (_value !== undefined) { + if (typeof value === 'object' && value !== null) { handleReturnValue(value); } return finalize([_value]); } if (value !== undefined) { - if (_options.strict) { + if (_options.strict && typeof value === 'object' && value !== null) { handleReturnValue(value, true); } return finalize([value]); diff --git a/src/current.ts b/src/current.ts index df39c1c7..22451cbf 100644 --- a/src/current.ts +++ b/src/current.ts @@ -15,7 +15,9 @@ export function handleReturnValue(value: T, warning = false) { forEach(value, (key, item, source) => { if (isDraft(item)) { if (warning) { - console.warn(`The return value contains drafts, please use safeReturn() to wrap the return value.`); + console.warn( + `The return value contains drafts, please use safeReturn() to wrap the return value.` + ); } const currentValue = current(item); if (source instanceof Set) { @@ -25,7 +27,7 @@ export function handleReturnValue(value: T, warning = false) { } else { set(source, key, currentValue); } - } else if (typeof item === 'object') { + } else if (typeof item === 'object' && item !== null) { handleReturnValue(item, warning); } }); diff --git a/src/patch.ts b/src/patch.ts index a3d1aafb..ad6dfc4e 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -126,7 +126,7 @@ function generatePatchesFromAssigned( patches: Patches, inversePatches: Patches ) { - assignedMap?.forEach((assignedValue, key) => { + assignedMap!.forEach((assignedValue, key) => { const originalValue = get(original, key); const value = cloneIfNeeded(get(copy, key)); const op = !assignedValue diff --git a/src/safeReturn.ts b/src/safeReturn.ts index f96e826e..cd0099e9 100644 --- a/src/safeReturn.ts +++ b/src/safeReturn.ts @@ -7,7 +7,11 @@ export function safeReturn(value: T): T { if (arguments.length > 1) { throw new Error('safeReturn() must be called with one argument.'); } - if (__DEV__ && value !== undefined && typeof value !== 'object') { + if ( + __DEV__ && + value !== undefined && + (typeof value !== 'object' || value === null) + ) { console.warn( 'safeReturn() must be called with an object or undefined, other types do not need to be returned via safeReturn().' ); diff --git a/src/utils/copy.ts b/src/utils/copy.ts index 5a63f573..2b503e55 100644 --- a/src/utils/copy.ts +++ b/src/utils/copy.ts @@ -60,7 +60,7 @@ export function shallowCopy(original: any, options?: Options) { return copy; } else { throw new Error( - `Unsupported type: ${original}, only plain objects, arrays, Set and Map are supported` + `Please check mark() to ensure that it is a stable marker draftable function.` ); } } diff --git a/test/create.test.ts b/test/create.test.ts index 3d5c223c..9d8bdcbb 100644 --- a/test/create.test.ts +++ b/test/create.test.ts @@ -1928,3 +1928,78 @@ test('Set with enable patches in root', () => { expect(patches).toEqual([{ op: 'add', path: [2], value: {} }]); expect(inversePatches).toEqual([{ op: 'remove', path: [2], value: {} }]); }); + +test('copy error: check stable mark()', () => { + let time = 0; + class Foo { + bar = 0; + } + expect(() => { + create( + { + foo: new Foo(), + }, + + (draft) => { + draft.foo.bar = 1; + }, + { + mark: (target, { immutable }) => { + if (target instanceof Foo && time < 2) { + time += 1; + return immutable; + } + }, + } + ); + }).toThrowErrorMatchingInlineSnapshot( + `"Please check mark() to ensure that it is a stable marker draftable function."` + ); +}); + +test('enablePatches and changes', () => { + expect(() => { + create( + { a: { b: 1 }, c: 1 }, + (draft) => { + draft.c = 2; + draft.a.b; + }, + { + enablePatches: true, + } + ); + }).not.toThrowError(); +}); + +test(`Don't auto freeze non-enumerable or symbolic properties`, () => { + const component = {}; + Object.defineProperty(component, 'state', { + value: { x: 1 }, + enumerable: false, + writable: true, + configurable: true, + }); + + const state = { + x: 1, + }; + + const state2: any = create( + state, + (draft) => { + // @ts-expect-error + draft.ref = component; + }, + { + enableAutoFreeze: true, + } + ); + + state2.ref.state.x++; + + expect(Object.isFrozen(state2)).toBeTruthy(); + expect(Object.isFrozen(state2.ref)).toBeTruthy(); + // Do not auto freeze non-enumerable or symbolic properties + expect(Object.isFrozen(state2.ref.state)).toBeFalsy(); +}); diff --git a/test/curry.test.ts b/test/curry.test.ts index f737a3eb..cb52e2b1 100644 --- a/test/curry.test.ts +++ b/test/curry.test.ts @@ -383,6 +383,7 @@ test(`check Primitive type`, () => { '', 'test', null, + true, false, undefined, Symbol('foo'), @@ -407,6 +408,7 @@ test(`check Primitive type with patches`, () => { '', 'test', null, + true, false, undefined, Symbol('foo'), diff --git a/test/index.test.ts b/test/index.test.ts index 3879d75f..a826bc06 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2013,6 +2013,7 @@ test('check Primitive type with returning', () => { '', 'test', null, + true, false, undefined, Symbol('foo'), @@ -2036,6 +2037,7 @@ test('check Primitive type with returning and patches', () => { '', 'test', null, + true, false, undefined, Symbol('foo'), @@ -2069,6 +2071,7 @@ test('check Primitive type with returning, patches and freeze', () => { '', 'test', null, + true, false, undefined, Symbol('foo'), @@ -2103,6 +2106,7 @@ test('check Primitive type with returning, patches, freeze and async', async () '', 'test', null, + true, false, undefined, Symbol('foo'), diff --git a/test/safeReturn.test.ts b/test/safeReturn.test.ts index acfc719c..fbcc9842 100644 --- a/test/safeReturn.test.ts +++ b/test/safeReturn.test.ts @@ -32,6 +32,7 @@ describe.each([{ useSafeReturn: true }, { useSafeReturn: false }])( '', 'test', null, + true, false, Symbol('foo'), ].forEach((value: any) => { @@ -54,6 +55,7 @@ describe.each([{ useSafeReturn: true }, { useSafeReturn: false }])( '', 'test', null, + true, false, undefined, Symbol('foo'), @@ -87,6 +89,7 @@ describe.each([{ useSafeReturn: true }, { useSafeReturn: false }])( '', 'test', null, + true, false, undefined, Symbol('foo'), @@ -121,6 +124,7 @@ describe.each([{ useSafeReturn: true }, { useSafeReturn: false }])( '', 'test', null, + true, false, undefined, Symbol('foo'), @@ -145,3 +149,157 @@ describe.each([{ useSafeReturn: true }, { useSafeReturn: false }])( }); } ); + +test('error args', () => { + expect(() => + // @ts-expect-error + create(3, () => safeReturn(undefined, undefined)) + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot read properties of null (reading 'finalities')"` + ); + + expect(() => + // @ts-expect-error + create({}, () => safeReturn()) + ).toThrowErrorMatchingInlineSnapshot( + `"safeReturn() must be called with a value."` + ); + + const logSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + [ + -1, + 1, + 0, + NaN, + BigInt(1), + Infinity, + '', + 'test', + null, + false, + true, + Symbol('foo'), + ].forEach((value: any) => { + create({}, () => safeReturn(value)); + expect(logSpy).toHaveBeenCalledWith( + 'safeReturn() must be called with an object or undefined, other types do not need to be returned via safeReturn().' + ); + logSpy.mockClear(); + }); + + logSpy.mockReset(); +}); + +test('check warning in strict mode', () => { + class Foo { + a?: any; + } + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + [ + (draft: any) => { + return { + a: draft.a, + }; + }, + (draft: any) => { + return [draft.a]; + }, + (draft: any) => { + return new Map([ + [ + 1, + { + a: draft.a, + }, + ], + ]); + }, + (draft: any) => { + return new Set([ + 1, + { + a: draft.a, + }, + ]); + }, + (draft: any) => { + const foo = new Foo(); + foo.a = draft.a; + return foo; + }, + ].forEach((callback: any) => { + create({ a: { b: 1 } }, callback, { + strict: true, + }); + expect(warnSpy).toHaveBeenCalledWith( + `The return value contains drafts, please use safeReturn() to wrap the return value.` + ); + warnSpy.mockClear(); + }); + warnSpy.mockReset(); +}); + +test('return parent draft', () => { + expect( + create({ a: 1 }, (draft) => { + const _draft = create({}, () => draft) as any; + _draft.a = 2; + return _draft; + }) + ).toEqual({ a: 2 }); +}); + +test('mix more type draft', () => { + [ + (draft: any) => + safeReturn({ + a: { + b: draft.a, + }, + }), + (draft: any) => safeReturn([{ c: draft.a }]), + (draft: any) => safeReturn(new Map([[1, draft.a]])), + (draft: any) => safeReturn(new Set([1, draft.a])), + ].forEach((callback: any) => { + expect(() => create({ a: { b: 1 } }, callback)).not.toThrowError(); + }); +}); + +test(`safe returning with non-enumerable or symbolic properties`, () => { + const component = {}; + Object.defineProperty(component, 'state', { + value: { x: 1 }, + enumerable: false, + writable: true, + configurable: true, + }); + + const state = { + x: 2, + }; + + const key = Symbol(); + const state2: any = create( + state, + (draft) => { + return safeReturn( + Object.assign(component, { + [key]: draft, + }) + ) as any; + }, + { + enableAutoFreeze: true, + } + ); + + expect(Object.isFrozen(state2)).toBeTruthy(); + // Do not auto freeze non-enumerable or symbolic properties + expect(Object.isFrozen(state2.state)).toBeFalsy(); + expect(Object.isFrozen(state2[key])).toBeFalsy(); + + // @ts-expect-error + expect(state2.state).toBe(component.state); + expect(state2[key]).toBe(state); +}); diff --git a/test/unsafe.test.ts b/test/unsafe.test.ts index 2f28cc99..4ac42f02 100644 --- a/test/unsafe.test.ts +++ b/test/unsafe.test.ts @@ -109,6 +109,7 @@ test('access primitive type and immutable object', () => { '', 'test', null, + true, false, undefined, Symbol('foo'), From 10bb3a1299ba9486c1549355699a3223d36c3137 Mon Sep 17 00:00:00 2001 From: unadlib Date: Wed, 25 Jan 2023 10:58:51 +0800 Subject: [PATCH 05/11] fix(type): fix type issue --- src/interface.ts | 2 +- src/utils/draft.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/interface.ts b/src/interface.ts index af514451..4fd3f7ea 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -32,7 +32,7 @@ export interface ProxyDraft { options: Options; parent?: ProxyDraft | null; key?: string | number | symbol; - setMap?: Map; + setMap?: Map; assignedMap?: Map; callbacks?: ((patches?: Patches, inversePatches?: Patches) => void)[]; } diff --git a/src/utils/draft.ts b/src/utils/draft.ts index d8baddb5..70f61ecd 100644 --- a/src/utils/draft.ts +++ b/src/utils/draft.ts @@ -41,15 +41,15 @@ export function getPath( path: any[] = [] ): (string | number | object)[] { if (Object.hasOwnProperty.call(target, 'key')) - path.unshift( + path.push( target.parent!.type === DraftType.Set - ? Array.from(target.parent!.setMap!.keys()).indexOf(target.key as any) + ? Array.from(target.parent!.setMap!.keys()).indexOf(target.key) : target.key ); if (target.parent) { return getPath(target.parent, path); } - return path; + return path.reverse(); } export function getType(target: any) { From 54c3db7de2ca9175c14cd9685ea1674388bd50fe Mon Sep 17 00:00:00 2001 From: unadlib Date: Wed, 25 Jan 2023 17:26:28 +0800 Subject: [PATCH 06/11] test(issue): fix issue and testing --- src/utils/deepFreeze.ts | 4 +- src/utils/draft.ts | 3 +- test/immer/__snapshots__/curry.test.ts.snap | 2 - test/immer/base.test.ts | 29 +++++++------ test/immer/curry.test.ts | 4 +- test/immer/patch.test.ts | 12 ++++-- test/safeReturn.test.ts | 47 ++++++++++++++++++++- 7 files changed, 74 insertions(+), 27 deletions(-) diff --git a/src/utils/deepFreeze.ts b/src/utils/deepFreeze.ts index e9276b06..3ecf3dc9 100644 --- a/src/utils/deepFreeze.ts +++ b/src/utils/deepFreeze.ts @@ -1,5 +1,5 @@ import { DraftType } from '../interface'; -import { getType } from './draft'; +import { getType, isDraft } from './draft'; function isFreezable(value: any) { return value && typeof value === 'object' && !Object.isFrozen(value); @@ -10,7 +10,7 @@ function throwFrozenError() { } export function deepFreeze(target: any) { - if (Object.isFrozen(target)) return; + if (Object.isFrozen(target) || isDraft(target)) return; const type = getType(target); switch (type) { case DraftType.Map: diff --git a/src/utils/draft.ts b/src/utils/draft.ts index 70f61ecd..87e01af1 100644 --- a/src/utils/draft.ts +++ b/src/utils/draft.ts @@ -85,7 +85,8 @@ export function isEqual(x: any, y: any) { } } -export function revokeProxy(proxyDraft: ProxyDraft) { +export function revokeProxy(proxyDraft: ProxyDraft | null) { + if (!proxyDraft) return; while (proxyDraft.finalities.revoke.length > 0) { const revoke = proxyDraft.finalities.revoke.pop()!; revoke(); diff --git a/test/immer/__snapshots__/curry.test.ts.snap b/test/immer/__snapshots__/curry.test.ts.snap index d6eeedf0..ac646501 100644 --- a/test/immer/__snapshots__/curry.test.ts.snap +++ b/test/immer/__snapshots__/curry.test.ts.snap @@ -3,5 +3,3 @@ exports[`curry - proxy should check arguments 1`] = `"Invalid base state: create() only supports plain objects, arrays, Set, Map or using mark() to mark the state as immutable."`; exports[`curry - proxy should check arguments 2`] = `"Invalid options: , 'options' should be an object."`; - -exports[`curry - proxy should support returning new states from curring 1`] = `"Cannot read properties of null (reading 'finalities')"`; diff --git a/test/immer/base.test.ts b/test/immer/base.test.ts index acf9ae1a..f983ffc3 100644 --- a/test/immer/base.test.ts +++ b/test/immer/base.test.ts @@ -1502,7 +1502,7 @@ function runBaseTest( }); // "Upvalues" are variables from a parent scope. - it.skip('does not finalize upvalue drafts', () => { + it('does not finalize upvalue drafts', () => { produce({ a: {}, b: {} }, (parent) => { expect(produce({}, () => parent)).toBe(parent); parent.x; // Ensure proxy not revoked. @@ -1520,11 +1520,11 @@ function runBaseTest( }); }); - it.skip( - 'works with interweaved Immer instances', - () => { - const base = {}; - const result = create(base, (s1) => + it('works with interweaved Immer instances and disable freeze', () => { + const base = {}; + const result = create(base, (s1) => + // ! it's different from mutative + safeReturn( create( { s1 }, (s2) => { @@ -1537,19 +1537,20 @@ function runBaseTest( expect(original(s3.s2.s1)).toBe(s2.s1); return s3.s2.s1; }, - { enableAutoFreeze: autoFreeze } + // ! it's different from mutative + { enableAutoFreeze: false } ); }, { - enableAutoFreeze: autoFreeze, + // ! it's different from mutative + enableAutoFreeze: false, } ) - ); - expect(result.n).toBe(1); - expect(result.s1).toBe(base); - }, - { enableAutoFreeze: autoFreeze } - ); + ) + ); + expect(result.n).toBe(1); + expect(result.s1).toBe(base); + }); }); if (useProxies) diff --git a/test/immer/curry.test.ts b/test/immer/curry.test.ts index d28b687f..62c6f0a6 100644 --- a/test/immer/curry.test.ts +++ b/test/immer/curry.test.ts @@ -46,9 +46,9 @@ function runTests(name: any, useProxies: any) { item.index = index; }); - // ! different from immer + // ! different from mutative // @ts-ignore - expect(() => reducer(undefined, 3)).toThrowErrorMatchingSnapshot(); + expect(() => reducer(undefined, 3)).toThrowError(); expect(reducer({}, 3)).toEqual({ index: 3 }); }); diff --git a/test/immer/patch.test.ts b/test/immer/patch.test.ts index 86e77c0b..50cd8eaf 100644 --- a/test/immer/patch.test.ts +++ b/test/immer/patch.test.ts @@ -1287,7 +1287,7 @@ test('do not allow prototype polution - 738', () => { { op: 'add', path: ['prototype', 'polluted'], value: 'yes' }, ]); }).toThrowErrorMatchingInlineSnapshot( - `"Cannot read properties of null (reading 'finalities')"` + `"Patching reserved attributes like __proto__ and constructor is not allowed."` ); // @ts-ignore expect(obj.polluted).toBe(undefined); @@ -1390,9 +1390,13 @@ test('#648 assigning object to itself should not change patches', () => { }); test('#791 patch for returning `undefined` is stored as undefined', () => { - const [newState, patches] = create({ abc: 123 }, (draft) => safeReturn(undefined), { - enablePatches: true, - }); + const [newState, patches] = create( + { abc: 123 }, + (draft) => safeReturn(undefined), + { + enablePatches: true, + } + ); expect(patches).toEqual([{ op: 'replace', path: [], value: undefined }]); expect(apply({}, patches)).toEqual(undefined); diff --git a/test/safeReturn.test.ts b/test/safeReturn.test.ts index fbcc9842..822ce832 100644 --- a/test/safeReturn.test.ts +++ b/test/safeReturn.test.ts @@ -1,4 +1,4 @@ -import { create, safeReturn } from '../src'; +import { create, original, safeReturn } from '../src'; test('base', () => { const base = 3; @@ -155,7 +155,7 @@ test('error args', () => { // @ts-expect-error create(3, () => safeReturn(undefined, undefined)) ).toThrowErrorMatchingInlineSnapshot( - `"Cannot read properties of null (reading 'finalities')"` + `"safeReturn() must be called with one argument."` ); expect(() => @@ -303,3 +303,46 @@ test(`safe returning with non-enumerable or symbolic properties`, () => { expect(state2.state).toBe(component.state); expect(state2[key]).toBe(state); }); + +test('works with interweaved Immer instances with disable Freeze', () => { + const base = {}; + const result = create(base, (s1) => { + const f = create( + { s1 }, + (s2) => { + s2.s1 = s2.s1; + }, + { + enableAutoFreeze: false, + } + ); + return safeReturn(f); + }); + // @ts-expect-error + expect(result.s1).toBe(base); +}); + +test('works with interweaved Immer instances with strict mode and disable Freeze', () => { + const base = {}; + const result = create( + base, + (s1) => { + const f = create( + { s1 }, + (s2) => { + s2.s1 = s2.s1; + }, + { + enableAutoFreeze: false, + } + ); + return f; + }, + { + enableAutoFreeze: false, + strict: true, + } + ); + // @ts-expect-error + expect(result.s1).toBe(base); +}); From 50bfa68514e31f9d15cd69018f0d0de1b8c28b77 Mon Sep 17 00:00:00 2001 From: unadlib Date: Wed, 25 Jan 2023 17:51:19 +0800 Subject: [PATCH 07/11] fix(issue): fix issue --- src/create.ts | 2 +- test/immer/patch.test.ts | 83 +++++++++++++++++++++++----------------- 2 files changed, 48 insertions(+), 37 deletions(-) diff --git a/src/create.ts b/src/create.ts index c591d999..a7f4ec5a 100644 --- a/src/create.ts +++ b/src/create.ts @@ -125,7 +125,7 @@ function create(arg0: any, arg1: any, arg2?: any): any { try { result = mutate(draft); } catch (error) { - revokeProxy(getProxyDraft(draft)!); + revokeProxy(getProxyDraft(draft)); throw error; } const returnValue = (value: any) => { diff --git a/test/immer/patch.test.ts b/test/immer/patch.test.ts index 50cd8eaf..ee562619 100644 --- a/test/immer/patch.test.ts +++ b/test/immer/patch.test.ts @@ -10,7 +10,8 @@ function runPatchTest( producer: any, patches: any, inversePatches?: any, - expectedResult?: any + expectedResult?: any, + options?: any ) { let resultProxies, resultEs5; @@ -21,6 +22,7 @@ function runPatchTest( producer, { enablePatches: true, + ...options, } ); @@ -36,11 +38,11 @@ function runPatchTest( }); test('patches are replayable', () => { - expect(apply(base, recordedPatches)).toEqual(res); + expect(apply(base, recordedPatches, options)).toEqual(res); }); test('patches can be reversed', () => { - expect(apply(res, recordedInversePatches)).toEqual(base); + expect(apply(res, recordedInversePatches, options)).toEqual(base); }); return res; @@ -1164,39 +1166,48 @@ test('#559 patches works in a nested reducer with proxies', () => { expect(reversedSubState).toMatchObject(state.sub); }); -// describe.skip('#588', () => { -// const reference = { value: { num: 53 } }; - -// class Base { -// get nested() { -// return reference.value; -// } -// set nested(value) {} -// } - -// let base = new Base(); - -// runPatchTest( -// base, -// (vdraft: any) => { -// reference.value = vdraft; -// create( -// base, -// (bdraft) => { -// bdraft.nested.num = 42; -// }, -// { -// mark: (target, { immutable }) => { -// if (target instanceof Base) { -// return immutable; -// } -// }, -// } -// ); -// }, -// [{ op: 'add', path: ['num'], value: 42 }] -// ); -// }); +describe('#588', () => { + const reference = { value: { num: 53 } }; + + class Base { + get nested() { + return reference.value; + } + set nested(value) {} + } + + let base = new Base(); + + runPatchTest( + base, + (vdraft: any) => { + reference.value = vdraft; + create( + base, + (bdraft) => { + bdraft.nested.num = 42; + }, + { + mark: (target: any, { immutable }: any) => { + if (target instanceof Base) { + return immutable; + } + }, + } + ); + }, + [{ op: 'add', path: ['num'], value: 42 }], + undefined, + undefined, + { + mark: (target: any, { immutable }: any) => { + if (target instanceof Base) { + return immutable; + } + }, + } + ); +}); test('#676 patching Date objects', () => { class Test { From 8239073fbf653296fea94cd16bfdf6037b3979bc Mon Sep 17 00:00:00 2001 From: unadlib Date: Wed, 25 Jan 2023 18:28:48 +0800 Subject: [PATCH 08/11] perf(test): add benchmark --- test/performance/benchmark.ts | 2 +- test/performance/benchmarks/benchmark.ts | 225 ++++++++++++++++++++ test/performance/benchmarks/forEach.ts | 255 +++++++++++++++++++++++ 3 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 test/performance/benchmarks/benchmark.ts create mode 100644 test/performance/benchmarks/forEach.ts diff --git a/test/performance/benchmark.ts b/test/performance/benchmark.ts index a2573059..ba7f2642 100644 --- a/test/performance/benchmark.ts +++ b/test/performance/benchmark.ts @@ -9,7 +9,7 @@ import produce, { setAutoFreeze, setUseProxies, } from 'immer'; -import { create } from '../../src'; +import { create } from '../..'; const result = [ { diff --git a/test/performance/benchmarks/benchmark.ts b/test/performance/benchmarks/benchmark.ts new file mode 100644 index 00000000..238ed146 --- /dev/null +++ b/test/performance/benchmarks/benchmark.ts @@ -0,0 +1,225 @@ +// @ts-nocheck +import fs from 'fs'; +import { Suite } from 'benchmark'; +import { parse } from 'json2csv'; +import deepFreeze from 'deep-freeze'; +import produce, { + enablePatches, + produceWithPatches, + setAutoFreeze, + setUseProxies, +} from 'immer'; +import { create } from '../../..'; + +const getData = () => { + const baseState: { arr: any[]; map: Record } = { + arr: [], + map: {}, + }; + + const createTestObject = () => + Array(10 * 5) + .fill(1) + .reduce((i, _, k) => Object.assign(i, { [k]: k }), {}); + + baseState.arr = Array(10 ** 4 * 5) + .fill('') + .map(() => createTestObject()); + + Array(10 ** 3) + .fill(1) + .forEach((_, i) => { + baseState.map[i] = { i }; + }); + return baseState; + // return deepFreeze(baseState); +}; + +let baseState: any; +let i: any; + +const suite = new Suite(); + +suite + .add( + 'Mutative - No Freeze(by default)', + function () { + const state = create(baseState, (draft) => { + return { + ...baseState, + arr: [...draft.arr, i], + map: { ...draft.map, [i]: { i } }, + }; + }); + }, + { + onStart: () => { + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Immer - No Freeze', + function () { + const state = produce(baseState, (draft: any) => { + return { + ...baseState, + arr: [...draft.arr, i], + map: { ...draft.map, [i]: { i } }, + }; + }); + }, + { + onStart: () => { + setAutoFreeze(false); + setUseProxies(true); + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Mutative - Freeze', + function () { + const state = create( + baseState, + (draft) => { + return { + ...baseState, + arr: [...draft.arr, i], + map: { ...draft.map, [i]: { i } }, + }; + }, + { + enableAutoFreeze: true, + enablePatches: false, + } + ); + }, + { + onStart: () => { + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Immer - Freeze(by default)', + function () { + const state = produce(baseState, (draft: any) => { + return { + ...baseState, + arr: [...draft.arr, i], + map: { ...draft.map, [i]: { i } }, + }; + }); + }, + { + onStart: () => { + setAutoFreeze(true); + setUseProxies(true); + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Mutative - Patches and No Freeze', + function () { + const state = create( + baseState, + (draft) => { + return { + ...baseState, + arr: [...draft.arr, i], + map: { ...draft.map, [i]: { i } }, + }; + }, + { + enableAutoFreeze: false, + enablePatches: true, + } + ); + }, + { + onStart: () => { + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Immer - Patches and No Freeze', + function () { + const state = produceWithPatches(baseState, (draft: any) => { + return { + ...baseState, + arr: [...draft.arr, i], + map: { ...draft.map, [i]: { i } }, + }; + }); + }, + { + onStart: () => { + setAutoFreeze(false); + setUseProxies(true); + enablePatches(); + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Mutative - Patches and Freeze', + function () { + const state = create( + baseState, + (draft) => { + return { + ...baseState, + arr: [...draft.arr, i], + map: { ...draft.map, [i]: { i } }, + }; + }, + { + enableAutoFreeze: true, + enablePatches: true, + } + ); + }, + { + onStart: () => { + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Immer - Patches and Freeze', + function () { + const state = produceWithPatches(baseState, (draft: any) => { + return { + ...baseState, + arr: [...draft.arr, i], + map: { ...draft.map, [i]: { i } }, + }; + }); + }, + { + onStart: () => { + setAutoFreeze(true); + setUseProxies(true); + enablePatches(); + i = Math.random(); + baseState = getData(); + }, + } + ) + .on('cycle', function (event) { + console.log(String(event.target)); + }) + .on('complete', function () { + console.log('The fastest method is ' + this.filter('fastest').map('name')); + }) + .run({ async: false }); diff --git a/test/performance/benchmarks/forEach.ts b/test/performance/benchmarks/forEach.ts new file mode 100644 index 00000000..ddd6bc2c --- /dev/null +++ b/test/performance/benchmarks/forEach.ts @@ -0,0 +1,255 @@ +// @ts-nocheck +import fs from 'fs'; +import { Suite } from 'benchmark'; +import { parse } from 'json2csv'; +import deepFreeze from 'deep-freeze'; +import produce, { + enablePatches, + produceWithPatches, + setAutoFreeze, + setUseProxies, +} from 'immer'; +import { create } from '../../..'; + +const getData = () => { + const baseState: { arr: any[]; map: Record } = { + arr: [], + map: {}, + }; + + const createTestObject = () => + Array(10 * 5) + .fill(1) + .reduce((i, _, k) => Object.assign(i, { [k]: k }), {}); + + baseState.arr = Array(10 ** 4 * 5) + .fill('') + .map(() => createTestObject()); + + Array(10 ** 3) + .fill(1) + .forEach((_, i) => { + baseState.map[i] = { i }; + }); + return baseState; + // return deepFreeze(baseState); +}; + +let baseState: any; +let i: any; + +const suite = new Suite(); + +suite + .add( + 'Naive handcrafted reducer - No Freeze', + function () { + const state = { + ...baseState, + arr: [], + map: {}, + }; + for (const item of baseState.arr) { + state.arr.push({ ...item, a: 1 }); + } + for (const item in baseState.map) { + state.map[item] = { ...item, a: 1 }; + } + }, + { + onStart: () => { + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Mutative - No Freeze(by default)', + function () { + const state = create(baseState, (draft) => { + for (const item of draft.arr) { + item.a = 1; + } + for (const item in draft.map) { + draft.map[item].a = 1; + } + }); + }, + { + onStart: () => { + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Immer - No Freeze', + function () { + const state = produce(baseState, (draft: any) => { + for (const item of draft.arr) { + item.a = 1; + } + for (const item in draft.map) { + draft.map[item].a = 1; + } + }); + }, + { + onStart: () => { + setAutoFreeze(false); + setUseProxies(true); + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Mutative - Freeze', + function () { + const state = create( + baseState, + (draft) => { + for (const item of draft.arr) { + item.a = 1; + } + for (const item in draft.map) { + draft.map[item].a = 1; + } + }, + { + enableAutoFreeze: true, + enablePatches: false, + } + ); + }, + { + onStart: () => { + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Immer - Freeze(by default)', + function () { + const state = produce(baseState, (draft: any) => { + for (const item of draft.arr) { + item.a = 1; + } + for (const item in draft.map) { + draft.map[item].a = 1; + } + }); + }, + { + onStart: () => { + setAutoFreeze(true); + setUseProxies(true); + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Mutative - Patches and No Freeze', + function () { + const state = create( + baseState, + (draft) => { + for (const item of draft.arr) { + item.a = 1; + } + for (const item in draft.map) { + draft.map[item].a = 1; + } + }, + { + enableAutoFreeze: false, + enablePatches: true, + } + ); + }, + { + onStart: () => { + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Immer - Patches and No Freeze', + function () { + const state = produceWithPatches(baseState, (draft: any) => { + for (const item of draft.arr) { + item.a = 1; + } + for (const item in draft.map) { + draft.map[item].a = 1; + } + }); + }, + { + onStart: () => { + setAutoFreeze(false); + setUseProxies(true); + enablePatches(); + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Mutative - Patches and Freeze', + function () { + const state = create( + baseState, + (draft) => { + for (const item of draft.arr) { + item.a = 1; + } + for (const item in draft.map) { + draft.map[item].a = 1; + } + }, + { + enableAutoFreeze: true, + enablePatches: true, + } + ); + }, + { + onStart: () => { + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Immer - Patches and Freeze', + function () { + const state = produceWithPatches(baseState, (draft: any) => { + for (const item of draft.arr) { + item.a = 1; + } + for (const item in draft.map) { + draft.map[item].a = 1; + } + }); + }, + { + onStart: () => { + setAutoFreeze(true); + setUseProxies(true); + enablePatches(); + i = Math.random(); + baseState = getData(); + }, + } + ) + .on('cycle', function (event) { + console.log(String(event.target)); + }) + .on('complete', function () { + console.log('The fastest method is ' + this.filter('fastest').map('name')); + }) + .run({ async: false }); From 85507ced9e83bb9efc0a45f9d4593fa09830c3ef Mon Sep 17 00:00:00 2001 From: unadlib Date: Wed, 25 Jan 2023 18:44:45 +0800 Subject: [PATCH 09/11] test(case): add cases --- test/safeReturn.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/safeReturn.test.ts b/test/safeReturn.test.ts index 822ce832..b68ac824 100644 --- a/test/safeReturn.test.ts +++ b/test/safeReturn.test.ts @@ -18,6 +18,32 @@ test('base', () => { expect(create(() => safeReturn(undefined))(3)).toBe(undefined); }); +test('base enableAutoFreeze: true', () => { + create( + { a: { b: 1 } }, + (draft) => { + return safeReturn({ + a: draft.a, + }); + }, + { enableAutoFreeze: true } + ); +}); + +test('base enableAutoFreeze: true - without safeReturn()', () => { + expect(() => { + create( + { a: { b: 1 } }, + (draft) => { + return { + a: draft.a, + }; + }, + { enableAutoFreeze: true } + ); + }).toThrowError(); +}); + describe.each([{ useSafeReturn: true }, { useSafeReturn: false }])( 'check Primitive type $useSafeReturn', ({ useSafeReturn }) => { From d8b7ed923512898cc04818db2824c3e3f779c8df Mon Sep 17 00:00:00 2001 From: unadlib Date: Wed, 25 Jan 2023 20:02:14 +0800 Subject: [PATCH 10/11] perf(performance): fix performance --- package.json | 2 +- src/current.ts | 5 +- src/draft.ts | 2 +- test/performance/benchmarks/justReturn.ts | 217 ++++++++++++++++++ .../{benchmark.ts => returnWithDraft.ts} | 18 +- test/safeReturn.test.ts | 10 + 6 files changed, 241 insertions(+), 13 deletions(-) create mode 100644 test/performance/benchmarks/justReturn.ts rename test/performance/benchmarks/{benchmark.ts => returnWithDraft.ts} (95%) diff --git a/package.json b/package.json index f6c91026..c4d40fd4 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "clean": "rimraf dist", "test:coverage": "jest --coverage && coveralls < coverage/lcov.info", "perf": "cd test/performance && ts-node add-data.ts && ts-node todo.ts && ts-node incremental.ts", - "benchmark": "yarn build && cd test/performance && ts-node benchmark.ts", + "benchmark": "yarn build && ts-node test/performance/benchmarks/benchmark.ts", "performance:basic": "cd test/performance && ts-node index.ts", "performance:set-map": "cd test/performance && ts-node set-map.ts", "performance:big-object": "cd test/performance && ts-node big-object.ts", diff --git a/src/current.ts b/src/current.ts index 22451cbf..1e3ee384 100644 --- a/src/current.ts +++ b/src/current.ts @@ -13,13 +13,14 @@ import { export function handleReturnValue(value: T, warning = false) { forEach(value, (key, item, source) => { - if (isDraft(item)) { + const proxyDraft = getProxyDraft(item); + if (proxyDraft) { if (warning) { console.warn( `The return value contains drafts, please use safeReturn() to wrap the return value.` ); } - const currentValue = current(item); + const currentValue = proxyDraft.original; if (source instanceof Set) { const arr = Array.from(source); source.clear(); diff --git a/src/draft.ts b/src/draft.ts index e6593c2b..cde253c3 100644 --- a/src/draft.ts +++ b/src/draft.ts @@ -236,8 +236,8 @@ export function createDraft(createDraftOptions: { proxyDraft.proxy = proxy; if (parentDraft) { const target = parentDraft; - const oldProxyDraft = getProxyDraft(proxy)!; target.finalities.draft.push((patches, inversePatches) => { + const oldProxyDraft = getProxyDraft(proxy)!; // if target is a Set draft, `setMap` is the real Set copies proxy mapping. const proxyDraft = getProxyDraft( get(target.type === DraftType.Set ? target.setMap : target.copy, key!) diff --git a/test/performance/benchmarks/justReturn.ts b/test/performance/benchmarks/justReturn.ts new file mode 100644 index 00000000..e90e8065 --- /dev/null +++ b/test/performance/benchmarks/justReturn.ts @@ -0,0 +1,217 @@ +// @ts-nocheck +import fs from 'fs'; +import { Suite } from 'benchmark'; +import { parse } from 'json2csv'; +import deepFreeze from 'deep-freeze'; +import produce, { + enablePatches, + produceWithPatches, + setAutoFreeze, + setUseProxies, +} from 'immer'; +import { create, safeReturn } from '../../..'; + +const getData = () => { + const baseState: { arr: any[]; map: Record } = { + arr: [], + map: {}, + }; + + const createTestObject = () => + Array(10 * 5) + .fill(1) + .reduce((i, _, k) => Object.assign(i, { [k]: k }), {}); + + baseState.arr = Array(10 ** 4 * 5) + .fill('') + .map(() => createTestObject()); + + Array(10 ** 3) + .fill(1) + .forEach((_, i) => { + baseState.map[i] = { i }; + }); + return baseState; + // return deepFreeze(baseState); +}; + +let baseState: any; +let i: any; + +const suite = new Suite(); + +suite + .add( + 'Mutative - No Freeze(by default)', + function () { + const state = create(baseState, (draft) => { + draft.arr; + return { + ...baseState, + }; + }); + }, + { + onStart: () => { + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Immer - No Freeze', + function () { + const state = produce(baseState, (draft: any) => { + draft.arr; + return { + ...baseState, + }; + }); + }, + { + onStart: () => { + setAutoFreeze(false); + setUseProxies(true); + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Mutative - Freeze', + function () { + const state = create( + baseState, + (draft) => { + draft.arr; + return { + ...baseState, + }; + }, + { + enableAutoFreeze: true, + enablePatches: false, + } + ); + }, + { + onStart: () => { + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Immer - Freeze(by default)', + function () { + const state = produce(baseState, (draft: any) => { + draft.arr; + return { + ...baseState, + }; + }); + }, + { + onStart: () => { + setAutoFreeze(true); + setUseProxies(true); + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Mutative - Patches and No Freeze', + function () { + const state = create( + baseState, + (draft) => { + draft.arr; + return { + ...baseState, + }; + }, + { + enableAutoFreeze: false, + enablePatches: true, + } + ); + }, + { + onStart: () => { + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Immer - Patches and No Freeze', + function () { + const state = produceWithPatches(baseState, (draft: any) => { + draft.arr; + return { + ...baseState, + }; + }); + }, + { + onStart: () => { + setAutoFreeze(false); + setUseProxies(true); + enablePatches(); + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Mutative - Patches and Freeze', + function () { + const state = create( + baseState, + (draft) => { + draft.arr; + return { + ...baseState, + }; + }, + { + enableAutoFreeze: true, + enablePatches: true, + } + ); + }, + { + onStart: () => { + i = Math.random(); + baseState = getData(); + }, + } + ) + .add( + 'Immer - Patches and Freeze', + function () { + const state = produceWithPatches(baseState, (draft: any) => { + draft.arr; + return { + ...baseState, + }; + }); + }, + { + onStart: () => { + setAutoFreeze(true); + setUseProxies(true); + enablePatches(); + i = Math.random(); + baseState = getData(); + }, + } + ) + .on('cycle', function (event) { + console.log(String(event.target)); + }) + .on('complete', function () { + console.log('The fastest method is ' + this.filter('fastest').map('name')); + }) + .run({ async: false }); diff --git a/test/performance/benchmarks/benchmark.ts b/test/performance/benchmarks/returnWithDraft.ts similarity index 95% rename from test/performance/benchmarks/benchmark.ts rename to test/performance/benchmarks/returnWithDraft.ts index 238ed146..417ee465 100644 --- a/test/performance/benchmarks/benchmark.ts +++ b/test/performance/benchmarks/returnWithDraft.ts @@ -9,7 +9,7 @@ import produce, { setAutoFreeze, setUseProxies, } from 'immer'; -import { create } from '../../..'; +import { create, safeReturn } from '../../..'; const getData = () => { const baseState: { arr: any[]; map: Record } = { @@ -45,11 +45,11 @@ suite 'Mutative - No Freeze(by default)', function () { const state = create(baseState, (draft) => { - return { + return safeReturn({ ...baseState, arr: [...draft.arr, i], map: { ...draft.map, [i]: { i } }, - }; + }); }); }, { @@ -85,11 +85,11 @@ suite const state = create( baseState, (draft) => { - return { + return safeReturn({ ...baseState, arr: [...draft.arr, i], map: { ...draft.map, [i]: { i } }, - }; + }); }, { enableAutoFreeze: true, @@ -130,11 +130,11 @@ suite const state = create( baseState, (draft) => { - return { + return safeReturn({ ...baseState, arr: [...draft.arr, i], map: { ...draft.map, [i]: { i } }, - }; + }); }, { enableAutoFreeze: false, @@ -176,11 +176,11 @@ suite const state = create( baseState, (draft) => { - return { + return safeReturn({ ...baseState, arr: [...draft.arr, i], map: { ...draft.map, [i]: { i } }, - }; + }); }, { enableAutoFreeze: true, diff --git a/test/safeReturn.test.ts b/test/safeReturn.test.ts index b68ac824..dfcea8c7 100644 --- a/test/safeReturn.test.ts +++ b/test/safeReturn.test.ts @@ -372,3 +372,13 @@ test('works with interweaved Immer instances with strict mode and disable Freeze // @ts-expect-error expect(result.s1).toBe(base); }); + +test('deep draft', () => { + const state = create({ a: { b: { c: 1 } } }, (draft) => { + draft.a.b.c; + return safeReturn({ + a: draft.a, + }); + }); + expect(state).toEqual({ a: { b: { c: 1 } } }); +}); From dfb6969fe1c86a7f508de50630f47b22336028af Mon Sep 17 00:00:00 2001 From: unadlib Date: Wed, 25 Jan 2023 20:21:34 +0800 Subject: [PATCH 11/11] chore(script): update --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c4d40fd4..fb0ee2fa 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "clean": "rimraf dist", "test:coverage": "jest --coverage && coveralls < coverage/lcov.info", "perf": "cd test/performance && ts-node add-data.ts && ts-node todo.ts && ts-node incremental.ts", - "benchmark": "yarn build && ts-node test/performance/benchmarks/benchmark.ts", + "benchmark": "yarn build && ts-node test/performance/benchmark.ts", "performance:basic": "cd test/performance && ts-node index.ts", "performance:set-map": "cd test/performance && ts-node set-map.ts", "performance:big-object": "cd test/performance && ts-node big-object.ts",