From da06175acf89ab8579dc0cfd8569b999e6d85f18 Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 03:28:04 +0800 Subject: [PATCH 01/21] fix: keep the changes of a Set draft that left its key --- src/utils/finalize.ts | 3 ++ test/set-moved.test.ts | 98 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 test/set-moved.test.ts diff --git a/src/utils/finalize.ts b/src/utils/finalize.ts index 8e9aabe3..65b9377c 100644 --- a/src/utils/finalize.ts +++ b/src/utils/finalize.ts @@ -129,6 +129,9 @@ export function finalizeNode( // Fast path: the node is still at its own key. Otherwise another draft // may have been moved here, e.g. by `reverse()`, and is finalized instead. const proxyDraft = draft === node.proxy ? node : getProxyDraft(draft); + // A draft that left its key reaches its new place as a value, through a + // callback or a value assigned there, so a Set finalizes its items here. + if (proxyDraft !== node) finalizeSetValue(node); if (proxyDraft) { // assign the updated value to the copy object const updatedValue = proxyDraft.operated diff --git a/test/set-moved.test.ts b/test/set-moved.test.ts new file mode 100644 index 00000000..731d49e9 --- /dev/null +++ b/test/set-moved.test.ts @@ -0,0 +1,98 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { apply, create } from '../src'; + +describe('a Set draft that left its key keeps its changes', () => { + const makeBase = () => ({ + a: { set: new Set([{ value: 1 }, 2]) }, + list: [] as any[], + map: new Map(), + other: new Set([0]), + }); + const change = (set: Set) => { + for (const item of set) if (typeof item === 'object') item.value = 2; + set.add(3); + set.delete(2); + }; + const expected = [{ value: 2 }, 3]; + + test.each([ + [ + 'another key', + (draft: any) => { + draft.b = draft.a.set; + }, + (state: any) => state.b, + ], + [ + 'an array', + (draft: any) => { + draft.list.push(draft.a.set); + }, + (state: any) => state.list[0], + ], + [ + 'a Map', + (draft: any) => { + draft.map.set('set', draft.a.set); + }, + (state: any) => state.map.get('set'), + ], + [ + 'another Set', + (draft: any) => { + draft.other.add(draft.a.set); + }, + (state: any) => [...state.other][1], + ], + [ + 'a new object', + (draft: any) => { + draft.b = { set: draft.a.set }; + }, + (state: any) => state.b.set, + ], + [ + 'a new Set', + (draft: any) => { + draft.b = new Set([draft.a.set]); + }, + (state: any) => [...state.b][0], + ], + ])('moved to %s', (_, move, read) => { + for (const enablePatches of [false, true]) { + const base = makeBase(); + const result: any = create( + base, + (draft) => { + change(draft.a.set); + move(draft); + draft.a.set = new Set(); + }, + { enablePatches } + ); + const state = enablePatches ? result[0] : result; + expect([...read(state)]).toEqual(expected); + expect([...state.a.set]).toEqual([]); + expect([...base.a.set]).toEqual([{ value: 1 }, 2]); + if (enablePatches) { + expect(apply(base, result[1])).toEqual(state); + } + } + }); + + test('removed from its parent Set and added to another', () => { + const base = { + outer: new Set([new Set([{ value: 1 }])]), + target: new Set(), + }; + const state = create(base, (draft) => { + const [inner] = draft.outer; + for (const item of inner) item.value = 2; + inner.add(3); + draft.outer.clear(); + draft.target.add(inner); + }); + expect([...state.outer]).toEqual([]); + expect([...[...state.target][0]]).toEqual([{ value: 2 }, 3]); + }); +}); From 24cc0eebcf13d564dfc89eed9d0b5b0fc1f65786 Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 03:28:33 +0800 Subject: [PATCH 02/21] perf: change the copy of a Set draft directly instead of mapping all its items --- src/current.ts | 7 ++- src/draft.ts | 5 +- src/interface.ts | 4 +- src/set.ts | 74 ++++++++++++------------- src/utils/draft.ts | 16 +++++- src/utils/finalize.ts | 24 ++++++--- test/set-copy.test.ts | 123 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 203 insertions(+), 50 deletions(-) create mode 100644 test/set-copy.test.ts diff --git a/src/current.ts b/src/current.ts index e9beccaf..db5e66b1 100644 --- a/src/current.ts +++ b/src/current.ts @@ -16,10 +16,10 @@ import { isEqual, latest, set, + setItemValue, shallowCopy, } from './utils'; import { checksReads } from './unsafe'; -import { getSetMap } from './set'; import { die, ErrorCode } from './error'; // Only development builds read `containsDraft`, so production builds keep no @@ -119,7 +119,10 @@ function getCurrent(target: any, rootOptions?: Options) { : new Map(target) : type === DraftType.Set ? proxyDraft - ? Array.from(getSetMap(proxyDraft).values()) + ? // The items in order, with the drafts that stand for some. + Array.from(latest(proxyDraft) as Set, (item) => + setItemValue(proxyDraft, item) + ) : Array.from(target as Set) : shallowCopy(array ? latest(array) : target, options); } diff --git a/src/draft.ts b/src/draft.ts index df4df1d3..ac8d1ece 100644 --- a/src/draft.ts +++ b/src/draft.ts @@ -381,9 +381,10 @@ export function createDraft( proxy: null, finalities, options, - // Mapping of draft Set items to their corresponding draft values, built - // once a Set draft is changed or iterated; see `getSetMap`. + // The drafts of a Set draft's items and the objects it added, and its + // items in order once finalization rebuilt it; see `src/set.ts`. setMap: undefined, + setItems: undefined, assignedMap: undefined, callbacks: undefined, children: undefined, diff --git a/src/interface.ts b/src/interface.ts index 1625f1bd..4501e576 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -55,7 +55,9 @@ export interface ProxyDraft { options: Options & { updatedValues?: WeakMap }; parent: ProxyDraft | null; key: string | number | symbol | undefined; - setMap: Map | undefined; + setMap: Map | undefined; + // The items of a Set draft in order, kept by its first rebuild. + setItems: any[] | undefined; assignedMap: Map | undefined; callbacks: FinalizeCallback[] | undefined; // Child drafts created by this draft, by key. diff --git a/src/set.ts b/src/set.ts index e23856d1..3260980f 100644 --- a/src/set.ts +++ b/src/set.ts @@ -5,19 +5,19 @@ import { assertDraftActive, ensureShallowCopy, getProxyDraft, + getSetMap, isDraftable, + latest, markChanged, markFinalization, + setItemValue, } from './utils'; import { checkReadable } from './unsafe'; -/** - * The item mapping of a Set draft. Reads that leave the Set unchanged, such - * as `has` and `size`, answer from the original instead of building it. - */ -export function getSetMap(target: ProxyDraft): Map { - return (target.setMap ??= new Map(target.original.entries())); -} +// A Set draft adds and deletes items in its copy, which holds the items in +// order. `setMap` maps the original items that an iterator drafted to their +// drafts, and the objects that the recipe added, drafts among them, to +// themselves. Finalization replaces the drafts with their final values. const getNextIterator = ( @@ -30,7 +30,7 @@ const getNextIterator = if (result.done) return result; assertDraftActive(target); const key = result.value as any; - let value = target.setMap!.get(key); + let value = setItemValue(target, key); const currentDraft = getProxyDraft(value); const mutable = target.options.mark?.(value, dataTypes) === dataTypes.mutable; @@ -55,7 +55,7 @@ const getNextIterator = target.finalities, target.options ); - target.setMap!.set(key, proxy); + getSetMap(target).set(key, proxy); value = proxy; } else if (currentDraft) { // drafted @@ -69,18 +69,16 @@ const getNextIterator = export const setHandler = { get size() { - const target: ProxyDraft = getProxyDraft(this)!; - return (target.setMap ?? target.original).size; + return latest(getProxyDraft(this)!).size; }, has(value: any) { - const target = getProxyDraft(this)!; - const items: Map | Set = target.setMap ?? target.original; - // reassigned or non-draftable values - if (items.has(value)) return true; - const valueProxyDraft = getProxyDraft(value)!; - // drafted - if (valueProxyDraft && items.has(valueProxyDraft.original)) return true; - return false; + const items: Set = latest(getProxyDraft(this)!); + const valueProxyDraft = getProxyDraft(value); + // The value itself, or the item that a draft of it stands for. + return ( + items.has(value) || + (!!valueProxyDraft && items.has(valueProxyDraft.original)) + ); }, add(value: any) { const target = getProxyDraft(this)!; @@ -88,7 +86,11 @@ export const setHandler = { ensureShallowCopy(target); markChanged(target); target.assignedMap!.set(value, true); - getSetMap(target).set(value, value); + target.copy!.add(value); + // An added draft is replaced by its final value when finalizing, and an + // added object is finalized for the drafts it may hold. + if (value && typeof value === 'object') + getSetMap(target).set(value, value); markFinalization(target, value, value); } return this; @@ -100,22 +102,21 @@ export const setHandler = { const target = getProxyDraft(this)!; ensureShallowCopy(target); markChanged(target); - const setMap = getSetMap(target); - const valueProxyDraft = getProxyDraft(value)!; - if (valueProxyDraft && setMap.has(valueProxyDraft.original)) { - // delete drafted - target.assignedMap!.set(valueProxyDraft.original, false); - return setMap.delete(valueProxyDraft.original); - } - if (!valueProxyDraft && setMap.has(value)) { - // non-draftable values - target.assignedMap!.set(value, false); - } else { + const copy: Set = target.copy!; + const valueProxyDraft = getProxyDraft(value); + // A draft of an item stands for the item; an added draft is an item. + const item = + valueProxyDraft && copy.has(valueProxyDraft.original) + ? valueProxyDraft.original + : value; + if (valueProxyDraft && item === value) { // reassigned target.assignedMap!.delete(value); + } else { + target.assignedMap!.set(item, false); } - // delete reassigned or non-draftable values - return setMap.delete(value); + if (target.setMap) target.setMap.delete(item); + return copy.delete(item); }, clear() { if (!this.size) return; @@ -125,12 +126,13 @@ export const setHandler = { for (const value of target.original) { target.assignedMap!.set(value, false); } - getSetMap(target).clear(); + if (target.setMap) target.setMap.clear(); + target.copy!.clear(); }, values(): IterableIterator { const target = getProxyDraft(this)!; ensureShallowCopy(target); - const iterator = getSetMap(target).keys(); + const iterator = target.copy!.values(); return { __proto__: iteratorPrototype, next: getNextIterator(target, iterator, true), @@ -139,7 +141,7 @@ export const setHandler = { entries(): IterableIterator<[any, any]> { const target = getProxyDraft(this)!; ensureShallowCopy(target); - const iterator = getSetMap(target).keys(); + const iterator = target.copy!.values(); return { __proto__: iteratorPrototype, next: getNextIterator( diff --git a/src/utils/draft.ts b/src/utils/draft.ts index e041c4e6..5a5c71d7 100644 --- a/src/utils/draft.ts +++ b/src/utils/draft.ts @@ -5,6 +5,17 @@ export function latest(proxyDraft: ProxyDraft): T { return proxyDraft.copy ?? proxyDraft.original; } +// The drafts of a Set draft's items and the objects it added; see `src/set.ts`. +export function getSetMap(target: ProxyDraft): Map { + return target.setMap || (target.setMap = new Map()); +} + +// The value that an item of a Set draft stands for. +export function setItemValue(target: ProxyDraft, item: any) { + const setMap = target.setMap; + return setMap && setMap.has(item) ? setMap.get(item) : item; +} + /** * Check if the value is a draft */ @@ -80,9 +91,10 @@ export function getPath( // which patches can follow only while the Set itself is unchanged. // Once the recipe added or removed items, the Set's own patches carry // every changed item by value, so patches under this path would be - // applied to whatever sits at the position in the base Set. + // applied to whatever sits at the position in the base Set. An + // unchanged Set holds its items in their original order. if (parent.assignedMap!.size > 0) return null; - key = Array.from(parent.setMap!.keys()).indexOf(key); + key = Array.from(parent.original as Set).indexOf(key); } else if (get(parentCopy, key) !== target.proxy) { // The child left its key: it was moved, deleted or replaced, possibly // by another draft of a shared object. The parent's patches carry its diff --git a/src/utils/finalize.ts b/src/utils/finalize.ts index 65b9377c..23233536 100644 --- a/src/utils/finalize.ts +++ b/src/utils/finalize.ts @@ -8,6 +8,7 @@ import { isDraftable, isEqual, set, + setItemValue, } from './draft'; import { forEach } from './forEach'; @@ -74,14 +75,23 @@ export type GeneratePatches = ( inversePatches: Patches ) => void; +// The copy of a Set draft holds its items in order, so only items that a draft +// stands for may need their final value: the Set is rebuilt in order once one +// of them differs from the item itself. The items are kept, as a draft of an +// outer producer that a nested producer's Set holds is finalized later and +// rebuilds the Set again. export function finalizeSetValue(target: ProxyDraft) { - // A Set draft whose items never changed may have no item mapping. - if (target.type === DraftType.Set && target.copy && target.setMap) { - target.copy.clear(); - target.setMap!.forEach((value) => { - target.copy!.add(getValue(value)); - }); - } + // Only Set drafts have `setMap`. + const setMap = target.setMap; + const items = + setMap && + (target.setItems || + (Array.from(setMap).some(([item, value]) => getValue(value) !== item) && + (target.setItems = Array.from(target.copy as Set)))); + if (!items) return; + const copy: Set = target.copy; + copy.clear(); + items.forEach((item) => copy.add(getValue(setItemValue(target, item)))); } export function finalizePatches( diff --git a/test/set-copy.test.ts b/test/set-copy.test.ts new file mode 100644 index 00000000..88ee4aad --- /dev/null +++ b/test/set-copy.test.ts @@ -0,0 +1,123 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { apply, create } from '../src'; + +// Calls of a spied Set method made on the given Set. +const callsOn = (spy: { mock: { contexts: unknown[] } }, set: Set) => + spy.mock.contexts.filter((context) => context === set).length; + +describe('Set drafts change their copy', () => { + test('adding and deleting items neither indexes nor rebuilds the Set', () => { + const base = { ids: new Set([1, 2, 3]) }; + // A rebuild clears the copy first; the item mapping was built from entries. + const clear = vi.spyOn(Set.prototype, 'clear'); + const entries = vi.spyOn(Set.prototype, 'entries'); + try { + const state = create(base, (draft) => { + draft.ids.add(4); + draft.ids.delete(1); + expect(draft.ids.has(4)).toBe(true); + expect(draft.ids.has(1)).toBe(false); + expect([...draft.ids]).toEqual([2, 3, 4]); + }); + expect([...state.ids]).toEqual([2, 3, 4]); + expect(callsOn(clear, state.ids)).toBe(0); + expect(entries).not.toHaveBeenCalled(); + } finally { + clear.mockRestore(); + entries.mockRestore(); + } + expect([...base.ids]).toEqual([1, 2, 3]); + }); + + test('items read through an iterator rebuild the Set only once one changed', () => { + const first = { id: 1, value: 0 }; + const second = { id: 2, value: 0 }; + const base = { items: new Set([first, second]) }; + const clear = vi.spyOn(Set.prototype, 'clear'); + try { + const state = create(base, (draft) => { + for (const item of draft.items) expect(item.value).toBe(0); + draft.items.add(3); + }); + expect([...state.items]).toEqual([first, second, 3]); + expect([...state.items][0]).toBe(first); + expect(callsOn(clear, state.items)).toBe(0); + + const changed = create(base, (draft) => { + for (const item of draft.items) if (item.id === 1) item.value = 1; + draft.items.add(3); + }); + expect([...changed.items]).toEqual([{ id: 1, value: 1 }, second, 3]); + expect([...changed.items][1]).toBe(second); + expect(callsOn(clear, changed.items)).toBe(1); + } finally { + clear.mockRestore(); + } + expect(first.value).toBe(0); + }); + + test('a changed item keeps its position among added and deleted items', () => { + const items = [{ id: 1 }, { id: 2 }, { id: 3 }].map((item) => ({ + ...item, + value: 0, + })); + const base = { items: new Set(items) }; + const [state, patches, inversePatches] = create( + base, + (draft) => { + draft.items.add('a'); + const second = [...draft.items].find( + (item) => typeof item === 'object' && item.id === 2 + ); + second.value = 1; + // Moved to the end, as a native Set does. + draft.items.delete(second); + draft.items.add(second); + draft.items.delete(items[0]); + draft.items.add('b'); + }, + { enablePatches: true } + ); + expect([...state.items]).toEqual([items[2], 'a', { id: 2, value: 1 }, 'b']); + expect(apply(base, patches)).toEqual(state); + expect(apply(state, inversePatches)).toEqual(base); + }); +}); + +test('a Set holds the final value of a draft that an outer producer finalizes later', () => { + const outerBase = { item: { value: 1 } }; + const innerBase = { set: new Set([{ value: 0 }]) }; + let inner: typeof innerBase | undefined; + const outer = create(outerBase, (draft) => { + inner = create(innerBase, (innerDraft) => { + for (const item of innerDraft.set) item.value = 1; + innerDraft.set.add(draft.item); + }); + draft.item.value = 2; + }); + expect([...inner!.set]).toEqual([{ value: 1 }, { value: 2 }]); + expect([...inner!.set][1]).toBe(outer.item); +}); + +test('patches of a Set item keep its position after another draft rebuilt the Set', () => { + const base = { list: [new Set([{ value: 1 }, 'x']), new Set([2])] }; + const [state, patches, inversePatches] = create( + base, + (draft) => { + const [item] = draft.list[0]; + item.value = 2; + expect(draft.list[1].size).toBe(1); + draft.list[1] = draft.list[0]; + }, + { enablePatches: true } + ); + expect([...state.list[0]]).toEqual([{ value: 2 }, 'x']); + expect(state.list[1]).toBe(state.list[0]); + expect(patches).toContainEqual({ + op: 'replace', + path: ['list', 0, 0, 'value'], + value: 2, + }); + expect(apply(base, patches)).toEqual(state); + expect(apply(state, inversePatches)).toEqual(base); +}); From 199cf1aa64ad0584420a70d93d0fd2793711d4f9 Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 03:29:42 +0800 Subject: [PATCH 03/21] build: refresh the size baseline and limits for Set drafts that change their copy --- package.json | 4 +- scripts/build-size-baseline.json | 82 ++++++++++++++++---------------- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/package.json b/package.json index 9d45d748..d4441591 100644 --- a/package.json +++ b/package.json @@ -61,13 +61,13 @@ "name": "production CJS, all exports", "path": "dist/mutative.cjs.production.min.js", "import": "*", - "limit": "8.4 KB" + "limit": "8.5 KB" }, { "name": "ESM, create", "path": "dist/mutative.esm.mjs", "import": "{ create }", - "limit": "7.4 KB" + "limit": "7.5 KB" }, { "name": "ESM, all exports", diff --git a/scripts/build-size-baseline.json b/scripts/build-size-baseline.json index 00ba583d..5b851caa 100644 --- a/scripts/build-size-baseline.json +++ b/scripts/build-size-baseline.json @@ -1,7 +1,7 @@ { "reference": { - "revision": "068192438cb329e8a9fce1f8faba0ea0fed9f03f", - "description": "Native array methods keep the identity of base elements that a recipe inserts into a moved array, and array patches compare indices that a length change exposes again", + "revision": "24cc0eebcf13d564dfc89eed9d0b5b0fc1f65786", + "description": "Set drafts add and delete items in their copy and map only the items that drafts stand for, and a Set draft that left its key keeps its changes", "node": "v24.16.0", "commands": [ "pnpm install --frozen-lockfile", @@ -15,69 +15,69 @@ "esbuild": "0.24.0", "measurements": { "artifact/mutative.cjs.production.min.js": { - "raw": 26722, - "gzip": 8929, - "brotli": 8184 + "raw": 26746, + "gzip": 8980, + "brotli": 8234 }, "artifact/mutative.umd.production.min.js": { - "raw": 26877, - "gzip": 8989, - "brotli": 8247 + "raw": 26900, + "gzip": 9038, + "brotli": 8294 }, "artifact/mutative.esm.production.min.mjs": { - "raw": 26701, - "gzip": 8949, - "brotli": 8223 + "raw": 26718, + "gzip": 8999, + "brotli": 8263 }, "artifact/mutative.cjs.development.js": { - "raw": 72560, - "gzip": 17343, - "brotli": 15076 + "raw": 72015, + "gzip": 17312, + "brotli": 15067 }, "artifact/mutative.umd.development.js": { - "raw": 74813, - "gzip": 17548, - "brotli": 15238 + "raw": 74253, + "gzip": 17511, + "brotli": 15213 }, "artifact/mutative.esm.js": { - "raw": 73412, - "gzip": 17462, - "brotli": 15145 + "raw": 72867, + "gzip": 17426, + "brotli": 15138 }, "artifact/mutative.esm.mjs": { - "raw": 73413, - "gzip": 17463, - "brotli": 15137 + "raw": 72868, + "gzip": 17428, + "brotli": 15142 }, "consumer/import/create": { - "raw": 24068, - "gzip": 8365, - "brotli": 7679 + "raw": 24104, + "gzip": 8436, + "brotli": 7744 }, "consumer/import/patches": { - "raw": 25971, - "gzip": 8985, - "brotli": 8247 + "raw": 25997, + "gzip": 9037, + "brotli": 8299 }, "consumer/import/all": { - "raw": 26756, - "gzip": 9242, - "brotli": 8467 + "raw": 26782, + "gzip": 9303, + "brotli": 8528 }, "consumer/require/create": { - "raw": 26245, - "gzip": 9233, - "brotli": 8462 + "raw": 26267, + "gzip": 9300, + "brotli": 8521 }, "consumer/require/patches": { - "raw": 26313, - "gzip": 9269, - "brotli": 8528 + "raw": 26335, + "gzip": 9337, + "brotli": 8558 }, "consumer/require/all": { - "raw": 26210, - "gzip": 9213, - "brotli": 8441 + "raw": 26232, + "gzip": 9279, + "brotli": 8514 } } } From 138cd18fe3e23f18bbacd69981bb46a73c2df50c Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 03:29:58 +0800 Subject: [PATCH 04/21] docs: note that a Set draft moved to another place keeps its changes --- README.md | 1 + website/content/docs/advanced-guides/migration-v2.mdx | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 10ab200a..8edff989 100644 --- a/README.md +++ b/README.md @@ -792,6 +792,7 @@ Mutative v2 keeps the v1 API. The changes below, made since v1.3.0, can affect e - With `enablePatches`, every changed item of a Set keeps its changes. In v1, when two or more items of a Set that was not the root changed below their first level, the state kept the change of only one of them. - A Set that receives an unchanged draft holds the original object, as the rest of the state does, instead of the draft's shallow copy. +- A Set draft that the recipe moves to another place, such as another key, an array, a Map, another Set or a new object, keeps its added, deleted and changed items there when its old place gets another value or is removed; v1 kept its original items. - Assigning `undefined` to a key that `delete`, `shift`, `unshift` or a shrinking `splice` removed from a draft adds the key back; v1 left a hole or kept the shorter length. - Under a `mark` that returns `mutable`, a value that the recipe assigned or moved is read back as assigned, through the draft and in `current()`; v1 returned the original value. - `current()` of a draft whose state holds a plain Set with drafts returns a snapshot; v1 threw. diff --git a/website/content/docs/advanced-guides/migration-v2.mdx b/website/content/docs/advanced-guides/migration-v2.mdx index 786c64ca..87d6864c 100644 --- a/website/content/docs/advanced-guides/migration-v2.mdx +++ b/website/content/docs/advanced-guides/migration-v2.mdx @@ -45,6 +45,7 @@ Mutative v2 keeps the v1 API. The changes below, made since v1.3.0, can affect e - With `enablePatches`, every changed item of a Set keeps its changes. In v1, when two or more items of a Set that was not the root changed below their first level, the state kept the change of only one of them. - A Set that receives an unchanged draft holds the original object, as the rest of the state does, instead of the draft's shallow copy. +- A Set draft that the recipe moves to another place, such as another key, an array, a Map, another Set or a new object, keeps its added, deleted and changed items there when its old place gets another value or is removed; v1 kept its original items. - Assigning `undefined` to a key that `delete`, `shift`, `unshift` or a shrinking `splice` removed from a draft adds the key back; v1 left a hole or kept the shorter length. - Under a `mark` that returns `mutable`, a value that the recipe assigned or moved is read back as assigned, through the draft and in `current()`; v1 returned the original value. - `current()` of a draft whose state holds a plain Set with drafts returns a snapshot; v1 threw. From 29a6fdc00163ec58ae7727e31e79d84cd3ed12ed Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 03:41:01 +0800 Subject: [PATCH 05/21] docs: refresh the benchmark figures with the Set scenarios measured again --- README.md | 22 +++--- perf-testing/reports/README.md | 2 +- perf-testing/reports/SUMMARY.md | 76 +++++++++++++------ website/app/layout.tsx | 2 +- website/components/home/benchmarks.tsx | 8 +- website/components/home/hero.tsx | 2 +- .../extra-topics/comparison-with-immer.mdx | 4 +- .../docs/getting-started/performance.mdx | 22 +++--- website/content/docs/intro.mdx | 2 +- 9 files changed, 85 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 8edff989..5543ff60 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ [![NPM Downloads](https://img.shields.io/npm/dm/mutative)](https://npmtrends.com/mutative) ![license](https://img.shields.io/npm/l/mutative) -**Mutative** - A JavaScript library for efficient immutable updates. In the [benchmark](./perf-testing/reports/SUMMARY.md), it was about 3.4x faster than Immer with the same settings and 6x faster with each library's defaults across 93 workloads. +**Mutative** - A JavaScript library for efficient immutable updates. In the [benchmark](./perf-testing/reports/SUMMARY.md), it was about 3.6x faster than Immer with the same settings and 6.6x faster with each library's defaults across 93 workloads. -In that benchmark the gap widened on large arrays, where Mutative moved elements up to 1,213x faster than Immer. When copying dominates, such as updating objects with thousands of keys or inserting at the front of a large array, Mutative is even faster than hand-written spread reducers. +In that benchmark the gap widened on large arrays, where Mutative moved elements up to 1,213x faster than Immer. When copying dominates, such as updating objects with thousands of keys, inserting at the front of a large array or adding an item to a large Set, Mutative is even faster than hand-written reducers. **How does Mutative compare with the spread operation (hand-written reducers)?** @@ -35,7 +35,7 @@ This is why Mutative was created. The [benchmark suite](./perf-testing/README.md) times 93 workloads: Immer's own performance tests, array methods, reads, Map and Set values, object records, class instances, a deep path, patch application, returned values, and searches. It compares Mutative with Immer 11.1.18 and with reducers written by hand, after checking every result against those reducers. The [performance summary](./perf-testing/reports/SUMMARY.md) has the complete results, the method, and their limits. -With matched settings, both freezing or both not and both generating patches or both not, Mutative was faster than Immer in 536 of 566 measured cases, 3.4x on geometric mean. With each library's defaults, Mutative without auto-freeze and Immer with it, Mutative was faster in 141 of 145 cases, 6.0x on geometric mean. +With matched settings, both freezing or both not and both generating patches or both not, Mutative was faster than Immer in 548 of 566 measured cases, 3.6x on geometric mean. With each library's defaults, Mutative without auto-freeze and Immer with it, Mutative was faster in 142 of 145 cases, 6.6x on geometric mean. Times are microseconds per update, medians of three runs on an Apple M1 Max with Node.js 24.16.0, each library in a process of its own; lower is better. Mutative, the first Immer column, and the hand-written reducers run without auto-freeze; the second Immer column shows Immer's default. @@ -50,7 +50,7 @@ Times are microseconds per update, medians of three runs on an Apple M1 Max with | Update one of 10,000 records | 10,000 | 1,175 | 2,109 | 3,135 | 2,113 | | Insert into a 1,000-property object | — | 51.0 | 146 | 231 | 129 | | Update a Map value | 10,000 | 517 | 521 | 730 | 518 | -| Add a number to a Set | 10,000 | 951 | 905 | 975 | 61.2 | +| Add a number to a Set | 10,000 | 33.2 | 938 | 1,011 | 65.6 | | Update a class instance | 10,000 | 2.92 | 3.43 | 668 | 1.43 | | Update a value ten levels deep | — | 3.47 | 4.46 | 7.83 | 0.75 | | Apply patches to 10% of rows | 10,000 | 1,440 | 1,182 | 2,110 | — | @@ -59,26 +59,26 @@ Times are microseconds per update, medians of three runs on an Apple M1 Max with | Return a new state | 10,000 | 2,594 | 5,628 | 0.79 | 0.02 | | Return it with rawReturn() | 10,000 | 0.26 | 5,699 | 0.79 | 0.02 | -Each library ran each workload in a process of its own. With all of them in one process every library ran slower, and not by the same amount: Mutative and Immer took 1.1 times as long on geometric mean, and the hand-written reducers 1.7 times. The search removes the first row with `shift()`, then finds the last row in a snapshot of the array and updates it. Immer has no `rawReturn()` and returns the same plain value in the last two rows; with auto-freeze, Mutative skips a returned state that is already frozen, as Immer does, taking 0.53 µs. Immer was faster in 9 of the 566 matched cases: when applying patches that replace nested values, by 16-22%; when adding a number to a Set or deleting one without auto-freeze or patches, by 5-12%; with auto-freeze, when updating a class instance with 1,000 fields; and in one concatenation with auto-freeze and patches, by 8%. +Each library ran each workload in a process of its own. With all of them in one process every library ran slower, and not by the same amount: Mutative and Immer took 1.1 times as long on geometric mean, and the hand-written reducers 1.7 times. The search removes the first row with `shift()`, then finds the last row in a snapshot of the array and updates it. Immer has no `rawReturn()` and returns the same plain value in the last two rows; with auto-freeze, Mutative skips a returned state that is already frozen, as Immer does, taking 0.53 µs. Immer was faster in 6 of the 566 matched cases: when applying patches that replace nested values, by 16-22%; with auto-freeze, when updating a class instance with 1,000 fields; and in one concatenation with auto-freeze and patches, by 8%. The Set workloads were measured again after Set drafts began to change their copy directly; adding or deleting a Set item became 3-50x faster. Run `pnpm benchmark:immer` to measure the suite; the [benchmark guide](./perf-testing/README.md) describes its options. ### Large arrays -At 1,000 and 10,000 rows the gap grows. Against Immer without its array-method plugin, Mutative was faster in 182 of 200 cases at these sizes, 4.4x on geometric mean, and faster in every case that moves elements: +At 1,000 and 10,000 rows the gap grows. Against Immer without its array-method plugin, Mutative was faster in 190 of 200 cases at these sizes, 4.9x on geometric mean, and faster in every case that moves elements: | Auto-freeze | Patches | All workloads, 1,000 rows | All workloads, 10,000 rows | Moves, 1,000 rows | Moves, 10,000 rows | | ----------- | ------- | ------------------------: | -------------------------: | ----------------: | -----------------: | -| off | off | 7.3x | 9.4x | 211x | 442x | -| off | on | 4.2x | 4.9x | 7.1x | 7.5x | -| on | off | 3.4x | 3.7x | 32x | 40x | -| on | on | 2.6x | 2.8x | 6.6x | 7.1x | +| off | off | 8.7x | 10.9x | 211x | 442x | +| off | on | 4.5x | 5.2x | 7.1x | 7.5x | +| on | off | 3.8x | 4.2x | 32x | 40x | +| on | on | 2.8x | 3.0x | 6.6x | 7.1x | Each value is the geometric mean of Immer's time over Mutative's; moves are `shift`, `unshift`, `splice` insertion, and `reverse`. Mutative moves elements natively on its copy, while Immer moves each one through its draft proxy: removing the first of 10,000 rows took 7.10 µs against 8,605 µs, 1,213x. With patches, both libraries emit one patch per moved index, which bounds the gain to 5-10x. The [performance summary](./perf-testing/reports/SUMMARY.md) breaks these results down by scenario. ### With patches -With patches on and auto-freeze off, Mutative was faster than Immer in 132 of 138 cases and within 5% in the other 6, 3.2x on geometric mean, and faster than Mutative 1.3.0 in 114 and within 5% in the rest, 4.0x. In every array case measured it was faster than both: 4.4x Immer and 13x Mutative 1.3.0 on geometric mean. Patches cost little when an update changes a few paths: pushing a row and inserting a property at 10,000 rows took 63.3 µs with patches against 62.8 µs without. Moving elements emits one patch per moved index in every library, so removing the first of 10,000 rows produces 10,000 forward and 10,000 inverse patches. +With patches on and auto-freeze off, Mutative was faster than Immer in 136 of 138 cases and within 5% in the other 2, 3.4x on geometric mean, and faster than Mutative 1.3.0 in 120 and within 5% in the rest, 4.2x. In every array case measured it was faster than both: 4.4x Immer and 13x Mutative 1.3.0 on geometric mean. Patches cost little when an update changes a few paths: pushing a row and inserting a property at 10,000 rows took 63.3 µs with patches against 62.8 µs without. Moving elements emits one patch per moved index in every library, so removing the first of 10,000 rows produces 10,000 forward and 10,000 inverse patches. Times are microseconds per update with patches on and auto-freeze off: diff --git a/perf-testing/reports/README.md b/perf-testing/reports/README.md index b568578e..26bf9407 100644 --- a/perf-testing/reports/README.md +++ b/perf-testing/reports/README.md @@ -1,6 +1,6 @@ # Performance evidence and archives -This directory contains two maintained documents: this archive index and the [current performance summary](./SUMMARY.md). The summary records the October 6 and 7 cross-library measurements of `main`, and its [history](./SUMMARY.md#history) summarizes earlier batches, including the October 5 base/head matrix of PR #184, whose paired CI measurements are uploaded by the [PR checks](https://github.com/unadlib/mutative/pull/184/checks). Benchmark source, tests, pinned versions, [`budgets.json`](../budgets.json), and the [build-size baseline](../../scripts/build-size-baseline.json) remain versioned. Historical measurements are not inputs to CI: the performance jobs measure their actual base/head checkouts and upload their own results. +This directory contains two maintained documents: this archive index and the [current performance summary](./SUMMARY.md). The summary records the October 6 and 7 cross-library measurements of `main`, with the Set scenarios measured again on October 8 for [PR #195](https://github.com/unadlib/mutative/pull/195), and its [history](./SUMMARY.md#history) summarizes earlier batches, including the October 5 base/head matrix of PR #184, whose paired CI measurements are uploaded by the [PR checks](https://github.com/unadlib/mutative/pull/184/checks). Benchmark source, tests, pinned versions, [`budgets.json`](../budgets.json), and the [build-size baseline](../../scripts/build-size-baseline.json) remain versioned. Historical measurements are not inputs to CI: the performance jobs measure their actual base/head checkouts and upload their own results. ## Retention policy diff --git a/perf-testing/reports/SUMMARY.md b/perf-testing/reports/SUMMARY.md index 6c98ccdf..58c08666 100644 --- a/perf-testing/reports/SUMMARY.md +++ b/perf-testing/reports/SUMMARY.md @@ -2,6 +2,8 @@ Measurements from 2026-10-06 and 2026-10-07 UTC of the source of `main` at [`2ab58fc`](https://github.com/unadlib/mutative/commit/2ab58fce1210b2b4724fcecce10ff0e1a864ddce). Since the source of the previous batch, `e6b6563`, `main` has added the cheap `current()` of changed arrays of [PR #180](https://github.com/unadlib/mutative/pull/180), the returned-value, frozen-array, Set and moved-element improvements of [PR #183](https://github.com/unadlib/mutative/pull/183), and the draft lifecycle, patch, Map and Set fixes of [PR #184](https://github.com/unadlib/mutative/pull/184); PRs #181, #182, #186 and #189 changed development builds, packaging and rarely used paths. The candidate and the pinned npm baseline both report Mutative 1.3.0. Immer is pinned to 11.1.18 and Mitata to 1.0.34. The main comparison runs Immer without its array-method plugin; a separate dataset below enables `enableArrayMethods`. The raw datasets of this batch were not archived; the identities and commands below reproduce them, and the [archive index](./README.md) describes the retention policy for batches that are. +The Set scenarios were measured again on 2026-10-08 UTC, after [PR #195](https://github.com/unadlib/mutative/pull/195) made Set drafts change their copy directly instead of mapping all their items: `set-add`, `set-delete`, `set-update` and `set-update-10pct` at 100 rows and `set-add` and `set-update` at 1,000 and 10,000 rows, every library in processes of its own, three runs each (312 trials from 96 processes), and the memory of `set-add` at every size. The results below use those cells in place of the earlier ones, except where [shared and isolated processes](#shared-and-isolated-processes) compares the cells of October 6 and 7; [Set drafts](#set-drafts) compares the change with `main`. + This batch remeasures every scenario and replaces the cross-library comparison of 2026-10-03 and 2026-10-04, which the [history](#history) summarizes. Unlike earlier batches, every timing below comes from processes that each run one scenario with one library. With all libraries in one process, as earlier batches measured every scenario but the wide-object ones, each library ran slower by a different amount, which favored the candidate against Immer and hid much of the hand-written reducer's lead; [shared and isolated processes](#shared-and-isolated-processes) compares the two. The harness is unchanged since the previous batch except for the three `search-*` scenarios of PR #180, which now also run at 1,000 and 10,000 rows and with Immer's array-method plugin. ## Scope and method @@ -24,10 +26,10 @@ Cells faster / within 5% / slower, and the geometric mean of comparator time ove | Comparator | 100 rows | 1,000 and 10,000 rows | All | | --- | ---: | ---: | ---: | -| Immer 11.1.18 | 354 / 8 / 4 of 366, 2.92 | 182 / 13 / 5 of 200, 4.39 | 536 / 21 / 9 of 566, 3.37 | -| Immer 11.1.18 with its defaults | 92 / 0 / 1 of 93, 5.95 | 49 / 1 / 2 of 52, 6.04 | 141 / 1 / 3 of 145, 5.98 | -| Pinned npm Mutative 1.3.0 | 323 / 42 / 1 of 366, 3.20 | 147 / 53 / 0 of 200, 4.36 | 470 / 95 / 1 of 566, 3.57 | -| Hand-written reducer | 7 / 0 / 83 of 90, 0.11 | 9 / 2 / 37 of 48, 0.10 | 16 / 2 / 120 of 138, 0.11 | +| Immer 11.1.18 | 358 / 6 / 2 of 366, 3.05 | 190 / 6 / 4 of 200, 4.88 | 548 / 12 / 6 of 566, 3.60 | +| Immer 11.1.18 with its defaults | 92 / 0 / 1 of 93, 6.30 | 50 / 0 / 2 of 52, 7.09 | 142 / 0 / 3 of 145, 6.57 | +| Pinned npm Mutative 1.3.0 | 332 / 33 / 1 of 366, 3.34 | 163 / 37 / 0 of 200, 4.85 | 495 / 70 / 1 of 566, 3.81 | +| Hand-written reducer | 7 / 0 / 83 of 90, 0.11 | 11 / 2 / 35 of 48, 0.12 | 18 / 2 / 118 of 138, 0.12 | Against Immer by workload group, faster / within 5% / slower and geometric mean: @@ -39,7 +41,7 @@ Against Immer by workload group, faster / within 5% / slower and geometric mean: | Small state | 12 / 0 / 0, 1.66 | — | — | | Mutation density | 12 / 0 / 0, 2.10 | 4 / 0 / 0, 1.90 | 4 / 0 / 0, 1.92 | | Array methods | 132 / 0 / 0, 5.75 | 20 / 0 / 0, 23.77 | 20 / 0 / 0, 31.12 | -| Map and Set | 31 / 3 / 2, 1.61 | 10 / 6 / 0, 1.69 | 10 / 5 / 1, 1.90 | +| Map and Set | 35 / 1 / 0, 2.47 | 14 / 2 / 0, 3.36 | 14 / 2 / 0, 3.59 | | Object records | 11 / 1 / 0, 1.26 | 8 / 0 / 0, 2.34 | 8 / 0 / 0, 1.61 | | Class instances | 6 / 2 / 0, 1.74 | 6 / 0 / 2, 1.61 | 6 / 2 / 0, 1.64 | | Deep path | 4 / 0 / 0, 1.54 | — | — | @@ -48,7 +50,7 @@ Against Immer by workload group, faster / within 5% / slower and geometric mean: | Returned values | 12 / 0 / 0, 3.83 | 12 / 0 / 0, 5.60 | 12 / 0 / 0, 8.85 | | Searches | 12 / 0 / 0, 5.14 | 12 / 0 / 0, 8.33 | 12 / 0 / 0, 9.47 | -The 9 cells slower than Immer are `apply-update-10pct` with freeze off at every size (0.82–0.86), `set-add` and `set-delete` with freeze and patches off at 100 rows (6.44 µs and 6.43 µs against 5.89 µs and 5.75 µs), `set-add` likewise at 10,000 rows (951 µs against 905 µs), `class-wide-update` with freeze on at 1,000 rows (411 µs and 415 µs against 239 µs and 238 µs), and `concat` with freeze and patches on at 100 rows (22.5 µs against 20.8 µs, a cell whose three candidate processes measured 19.2–22.8 µs); see the limits below. With each library's defaults, the candidate is slower only in `return-replace`, at every size: without auto-freeze it searches the returned state for drafts, which Immer's auto-freeze skips because the state is frozen (27.0 µs, 256 µs and 2.59 ms against 0.78–0.79 µs). The one cell slower than Mutative 1.3.0 is the same `concat` cell, against 18.3 µs. +The 6 cells slower than Immer are `apply-update-10pct` with freeze off at every size (0.82–0.86), `class-wide-update` with freeze on at 1,000 rows (411 µs and 415 µs against 239 µs and 238 µs), and `concat` with freeze and patches on at 100 rows (22.5 µs against 20.8 µs, a cell whose three candidate processes measured 19.2–22.8 µs); see the limits below. With each library's defaults, the candidate is slower only in `return-replace`, at every size: without auto-freeze it searches the returned state for drafts, which Immer's auto-freeze skips because the state is frozen (27.0 µs, 256 µs and 2.59 ms against 0.78–0.79 µs). The one cell slower than Mutative 1.3.0 is the same `concat` cell, against 18.3 µs. ### Large arrays @@ -56,14 +58,14 @@ Against Immer without its array-method plugin, by size and mode, over the 26 sca | Rows | Freeze | Patches | All scenarios | Geometric mean | Moves, geometric mean | Moves, range | | ---: | --- | --- | ---: | ---: | ---: | ---: | -| 1,000 | off | off | 23 / 2 / 1 | 7.33 | 211 | 72.0–492 | -| 1,000 | off | on | 22 / 2 / 0 | 4.18 | 7.1 | 6.1–7.5 | -| 1,000 | on | off | 24 / 1 / 1 | 3.38 | 32.4 | 18.5–46.9 | -| 1,000 | on | on | 22 / 1 / 1 | 2.58 | 6.6 | 6.1–6.9 | -| 10,000 | off | off | 23 / 1 / 2 | 9.36 | 442 | 176–1,213 | -| 10,000 | off | on | 22 / 2 / 0 | 4.85 | 7.5 | 5.0–9.7 | -| 10,000 | on | off | 24 / 2 / 0 | 3.73 | 39.7 | 22.2–53.0 | -| 10,000 | on | on | 22 / 2 / 0 | 2.82 | 7.1 | 5.1–9.0 | +| 1,000 | off | off | 24 / 1 / 1 | 8.70 | 211 | 72.0–492 | +| 1,000 | off | on | 23 / 1 / 0 | 4.49 | 7.1 | 6.1–7.5 | +| 1,000 | on | off | 25 / 0 / 1 | 3.84 | 32.4 | 18.5–46.9 | +| 1,000 | on | on | 23 / 0 / 1 | 2.76 | 6.6 | 6.1–6.9 | +| 10,000 | off | off | 24 / 1 / 1 | 10.86 | 442 | 176–1,213 | +| 10,000 | off | on | 23 / 1 / 0 | 5.19 | 7.5 | 5.0–9.7 | +| 10,000 | on | off | 25 / 1 / 0 | 4.22 | 39.7 | 22.2–53.0 | +| 10,000 | on | on | 23 / 1 / 0 | 3.00 | 7.1 | 5.1–9.0 | Immer time over candidate time for the moves at 10,000 rows, by freeze and patch mode: @@ -75,11 +77,11 @@ Immer time over candidate time for the moves at 10,000 rows, by freeze and patch | array-reverse-nested | 551 | 8.8 | 40.5 | 8.1 | | array-reverse-primitive | 302 | 5.0 | 53.0 | 5.1 | -The gap widens with size. With freeze and patches off, the moves were 211 times faster at 1,000 rows and 442 times at 10,000 on geometric mean: the candidate moves elements natively on its copy, while Immer moves each element through its draft proxy. Removing the first of 10,000 rows took 7.10 µs against 8,605 µs. With patches, both libraries emit one patch per moved index, which bounds the gain to 5–10 times. At these sizes Immer was faster in `apply-update-10pct`, in `class-wide-update` with freeze on at 1,000 rows and in `set-add` without freeze or patches at 10,000 rows, and within 5% in Map updates without freezing, Set additions and `class-wide-update` with freeze on at 10,000 rows; the limits below discuss each. +The gap widens with size. With freeze and patches off, the moves were 211 times faster at 1,000 rows and 442 times at 10,000 on geometric mean: the candidate moves elements natively on its copy, while Immer moves each element through its draft proxy. Removing the first of 10,000 rows took 7.10 µs against 8,605 µs. With patches, both libraries emit one patch per moved index, which bounds the gain to 5–10 times. At these sizes Immer was faster in `apply-update-10pct` and in `class-wide-update` with freeze on at 1,000 rows, and within 5% in Map updates without freezing and `class-wide-update` with freeze on at 10,000 rows; the limits below discuss each. ### Freeze off, patches on -With patches on and freeze off, the candidate was faster than Immer in 132 of 138 cells and within 5% in the other 6: Set additions and deletions at 100 rows, Map updates at 1,000 and 10,000 rows, and Set additions at 1,000 and 10,000 rows (geometric mean 3.24). It was faster than Mutative 1.3.0 in 114 and within 5% in 24 (4.00). In the 67 array-related cells, array methods, searches and the upstream array workloads at every size, it was faster than both in every cell: 4.35 times Immer and 12.56 times Mutative 1.3.0 on geometric mean. +With patches on and freeze off, the candidate was faster than Immer in 136 of 138 cells and within 5% in the other 2, the Map updates at 1,000 and 10,000 rows (geometric mean 3.39). It was faster than Mutative 1.3.0 in 120 and within 5% in 18 (4.19). In the 67 array-related cells, array methods, searches and the upstream array workloads at every size, it was faster than both in every cell: 4.35 times Immer and 12.56 times Mutative 1.3.0 on geometric mean. Patches cost little when an update changes a few paths. They added a median 1% to the candidate's time at 1,000 and 10,000 rows and 20% at 100 rows. The former `benchmark:base` workload, `push-and-insert` at 10,000 rows, took 62.8 µs without patches and 63.3 µs with them; Mutative 1.3.0 went from 67.0 µs to 208 µs, and Immer from 162 µs to 364 µs. Moving elements is the exception: both libraries emit one patch per moved index, so `shift` at 10,000 rows produces 10,000 forward and 10,000 inverse patches in each. The candidate's time grows from 7.10 µs to 1,071 µs, against 10,376 µs for Immer producing the same patches. @@ -166,6 +168,26 @@ The `search-*` scenarios find the last row of an array of nested rows and update A snapshot search was faster than the draft search at every size: 1.7 times at 100 rows, 7.9 times at 1,000 and 12 times at 10,000 with freeze off. The draft search drafts every row it visits, while `current()` copies the changed array from its current elements. After `shift()`, the search at 10,000 rows took 257 µs, against 772 µs when PR #180 was measured: updating the moved last row now finds its original index by searching the original array instead of building a map of all 10,000 original indices (PR #183). Immer's `current()` walks every unfrozen object, so with freeze off its snapshot searches are slower than its draft search. +### Set drafts + +[PR #195](https://github.com/unadlib/mutative/pull/195) changed how a Set draft tracks its items. Its first change or iteration used to build a Map of all items, every change went through that Map, and finalization cleared the copy and added every item back from it, even when no item had to be replaced. A Set draft now adds and deletes items in its copy and maps only the items that drafts stand for, and finalization rebuilds the Set only once one of them changed. Mutative alone in processes of its own, the source of `main` at `e6af4b0` and that of the PR in alternating order, three runs each, µs per update: + +| Scenario | Items | Freeze | Patches | `main` | PR #195 | PR/`main` | +| --- | ---: | --- | --- | ---: | ---: | ---: | +| set-add | 100 | off | off | 6.56 | 0.70 | 0.11 | +| set-add | 100 | on | off | 6.95 | 1.11 | 0.16 | +| set-add | 100 | off | on | 8.45 | 2.60 | 0.31 | +| set-delete | 100 | off | off | 6.49 | 0.69 | 0.11 | +| set-add | 1,000 | off | off | 67.6 | 1.29 | 0.02 | +| set-add | 10,000 | off | off | 984 | 32.7 | 0.03 | +| set-add | 10,000 | on | off | 1,006 | 62.7 | 0.06 | +| set-add | 10,000 | off | on | 1,404 | 454 | 0.32 | +| set-update | 100 | off | off | 8.31 | 4.93 | 0.59 | +| set-update | 10,000 | off | off | 1,056 | 652 | 0.62 | +| set-update-10pct | 100 | off | off | 10.96 | 8.13 | 0.74 | + +Over all 32 cells, four Set scenarios at 100 rows and two at 1,000 and 10,000 rows in every mode, the PR took 0.32 of the time of `main` on geometric mean and was faster in each. Additions and deletions no longer copy the items into a Map and back, so without patches they now cost little more than copying the Set: `set-add` at 10,000 items allocated 329 KiB per update instead of 2,883 KiB. With patches, a Set that added or deleted items still compares all items to emit its own patches, which bounds that gain to about 3 times, and a changed item still rebuilds the Set in order. Against the other libraries the candidate was faster than Immer in every Set cell, 3.0–50.7 times, and faster than the hand-written reducer in `set-add` at 1,000 and 10,000 rows, where `Set.prototype.difference` copies the Set faster than the reducer's `new Set()`. + ### Selected times Microseconds per complete scenario, medians of three runs. @@ -181,7 +203,7 @@ Microseconds per complete scenario, medians of three runs. | update-largeObject1 | 100 | on | off | 132 | 138 | 231 | — | 1.75 | | object-update | 100 | off | off | 3.16 | 6.12 | 3.45 | 2.14 | 1.09 | | map-update | 100 | off | off | 4.55 | 5.19 | 4.95 | 3.66 | 1.09 | -| set-update | 100 | off | off | 8.21 | 8.85 | 41.9 | 2.08 | 5.10 | +| set-update | 100 | off | off | 4.92 | 9.10 | 42.9 | 2.14 | 8.72 | | class-update | 100 | on | off | 3.86 | 5.22 | 9.24 | — | 2.40 | | apply-array-ops | 100 | off | off | 4.06 | 116 | 101 | — | 24.83 | | return-filter | 100 | off | off | 23.8 | 26.2 | 32.6 | 0.19 | 1.37 | @@ -195,7 +217,7 @@ Microseconds per complete scenario, medians of three runs. | array-reverse-nested | 10,000 | off | off | 13.7 | 8,627 | 7,555 | 9.68 | 551.48 | | array-reverse-nested | 10,000 | off | on | 1,074 | 277,427 | 9,416 | — | 8.76 | | read-index | 10,000 | off | off | 5,094 | 6,293 | 11,773 | 55.5 | 2.31 | -| set-update | 10,000 | off | off | 1,016 | 1,024 | 5,892 | 400 | 5.80 | +| set-update | 10,000 | off | off | 654 | 1,069 | 6,176 | 416 | 9.45 | | object-update | 10,000 | off | off | 1,175 | 1,189 | 2,109 | 2,113 | 1.79 | | apply-reverse | 10,000 | off | off | 12,590 | 13,801 | 13,864 | — | 1.10 | | push-and-insert | 10,000 | off | on | 63.3 | 208 | 364 | — | 5.76 | @@ -204,11 +226,11 @@ Microseconds per complete scenario, medians of three runs. ### Against a hand-written reducer -The hand-written reducer is faster than the candidate in 120 of 138 cells, and across all 138 the candidate takes 9.5 times as long on geometric mean: the reducer copies only what each update changes and has no draft to create, track, or finalize. The candidate is faster in 16 cells. Fifteen copy wide objects: objects with 1,000 and 3,000 properties, records with 1,000 or 10,000 keys, and class instances with 100 or more fields, by 1.5–2.7 times. One inserts at the front of an array of 10,000 rows, where the reducer's spread copies every element and `unshift` runs natively on the draft's copy: 15.9 µs against 28.3 µs; at 100 and 1,000 rows the reducer was faster. Copying a Map of 1,000 or 10,000 entries dominates `map-update` in both, which are within 5%. The hand-written reducer is the same spread-based code that validates every scenario; faster hand-written code exists for many of them. +The hand-written reducer is faster than the candidate in 118 of 138 cells, and across all 138 the candidate takes 8.6 times as long on geometric mean: the reducer copies only what each update changes and has no draft to create, track, or finalize. The candidate is faster in 18 cells. Fifteen copy wide objects: objects with 1,000 and 3,000 properties, records with 1,000 or 10,000 keys, and class instances with 100 or more fields, by 1.5–2.7 times. One inserts at the front of an array of 10,000 rows, where the reducer's spread copies every element and `unshift` runs natively on the draft's copy: 15.9 µs against 28.3 µs; at 100 and 1,000 rows the reducer was faster. Two add a number to a Set of 1,000 or 10,000 numbers, which the candidate copies with `Set.prototype.difference`, faster than the reducer's `new Set()`: 1.30 µs against 4.16 µs and 33.2 µs against 65.6 µs; at 100 numbers the reducer was faster, 0.45 µs against 0.70 µs. Copying a Map of 1,000 or 10,000 entries dominates `map-update` in both, which are within 5%. The hand-written reducer is the same spread-based code that validates every scenario; faster hand-written code exists for many of them. ### Shared and isolated processes -Earlier batches ran every scenario except the wide-object ones with all four libraries together, in one process per run and a second one for the Map and Set scenarios. This batch measured every cell both ways, and in a process of its own every library was faster, by different amounts. Over all 566 cells, the shared-process times were longer on geometric mean by 1.08 times for the candidate, 1.15 times for Mutative 1.3.0 and 1.12 times for Immer, and over the 138 cells of the hand-written reducer by 1.67 times; at the 95th percentile by 1.26, 1.42, 1.45 and 5.67 times. In a shared process the recipes, the hand-written reducer and the libraries handle the objects of every scenario, so V8 optimizes them for many object shapes at once; in a process of its own each handles the shapes of one scenario. Small code gains the most: the hand-written reducer summed 100 rows in 0.14 µs alone and in 0.71 µs beside the others. Immer gained more than the candidate, most of all in Set updates: adding a number to a 100-item Set took Immer 5.9 µs alone and 14.0 µs beside the others, and the candidate 6.4 µs and 6.5 µs. +Earlier batches ran every scenario except the wide-object ones with all four libraries together, in one process per run and a second one for the Map and Set scenarios. This batch measured every cell both ways, and in a process of its own every library was faster, by different amounts. This comparison uses the cells of October 6 and 7, before the Set scenarios were measured again. Over all 566 cells, the shared-process times were longer on geometric mean by 1.08 times for the candidate, 1.15 times for Mutative 1.3.0 and 1.12 times for Immer, and over the 138 cells of the hand-written reducer by 1.67 times; at the 95th percentile by 1.26, 1.42, 1.45 and 5.67 times. In a shared process the recipes, the hand-written reducer and the libraries handle the objects of every scenario, so V8 optimizes them for many object shapes at once; in a process of its own each handles the shapes of one scenario. Small code gains the most: the hand-written reducer summed 100 rows in 0.14 µs alone and in 0.71 µs beside the others. Immer gained more than the candidate, most of all in Set updates: adding a number to a 100-item Set took Immer 5.9 µs alone and 14.0 µs beside the others, and the candidate 6.4 µs and 6.5 µs. | Comparator | All in shared processes | Isolated processes | | --- | ---: | ---: | @@ -223,14 +245,14 @@ Inserting a property into the 1,000-property object also depended on the order o ### Memory -Sampled allocation of the candidate was below Mutative 1.3.0 in 105 of 114 memory cells. The exceptions are `noop-empty` in three modes and `small-object-update` with patches, by at most 0.2 KiB; `set-add` without freezing at 1,000 elements and with it at 10,000, by 1.2% and less than 0.1%; and `map-update` at 10,000 entries, by 0.4% with patches and without freezing, and by 20–29% with auto-freeze (3.7 MiB against 2.9–3.1 MiB). That last difference depends on what a worker runs before it measures: in workers that validate only the measured patch mode, the current source and that of the previous batch both allocated 2.9 MiB, and the cell's time equals that of 1.3.0 (692 µs against 691 µs). It was below Immer in 65 of 114 cells: lower for reads, array moves, returned filters, and patch application; higher for Map and Set drafts (up to 2.3 times), the push-and-insert fixture (1.1–2.6 times), the 1,000-key record (113 against 16 KiB), and by a few KiB for no-op, small-object, record, and class-instance producers. Objects with more than 128 keys are copied into dictionary-mode objects, which also retain 48 KiB per output at 1,000 keys, against 8 KiB for the other libraries. +Sampled allocation of the candidate was below Mutative 1.3.0 in 107 of 114 memory cells. The exceptions are `noop-empty` in three modes and `small-object-update` with patches, by at most 0.2 KiB; and `map-update` at 10,000 entries, by 0.4% with patches and without freezing, and by 20–29% with auto-freeze (3.7 MiB against 2.9–3.1 MiB). That last difference depends on what a worker runs before it measures: in workers that validate only the measured patch mode, the current source and that of the previous batch both allocated 2.9 MiB, and the cell's time equals that of 1.3.0 (692 µs against 691 µs). It was below Immer in 77 of 114 cells: lower for reads, array moves, Set additions, returned filters, and patch application; higher for Map drafts (up to 2.3 times), the push-and-insert fixture (1.1–2.6 times), the 1,000-key record (113 against 16 KiB), and by a few KiB for no-op, small-object, record, and class-instance producers. Objects with more than 128 keys are copied into dictionary-mode objects, which also retain 48 KiB per output at 1,000 keys, against 8 KiB for the other libraries. | Scenario | Rows | Patches | Candidate | Mutative 1.3.0 | Immer | Hand-written | | --- | ---: | --- | ---: | ---: | ---: | ---: | | read-index | 100 | off | 98.6 | 150.1 | 192.3 | 1.7 | | push-and-insert | 100 | off | 115.2 | 1,365.6 | 66.0 | 19.3 | | object-update | 1,000 | off | 112.9 | 1,311.7 | 15.9 | 10.9 | -| set-add | 10,000 | off | 2,882.9 | 2,883.8 | 1,613.6 | 325.4 | +| set-add | 10,000 | off | 328.7 | 2,883.5 | 1,613.1 | 325.9 | | array-reverse-nested | 10,000 | off | 89.0 | 11,077.6 | 12,840.4 | 83.4 | | array-reverse-nested | 10,000 | on | 3,028.5 | 268,460.6 | 15,102.0 | — | @@ -283,7 +305,7 @@ In 10 cells every process was slower than every process of the previous batch: ` ## Tradeoffs and limits - Returned values: without auto-freeze, the candidate searches every object of a returned value for drafts, as Immer does: `return-replace` took 2.59 ms at 10,000 rows, against 5.63 ms for Immer without auto-freeze. Immer's default auto-freeze skips frozen values (0.79 µs), and so do production builds of the candidate with auto-freeze (0.53 µs). Wrapping the value in `rawReturn()` skips the search (0.26 µs). -- Set drafts copy the Set on the first change and rebuild it when finalizing: `set-add` took about 95 ns per element at 10,000 elements, against 6 ns for a hand-written copy and 90 ns for Immer, which was 5% faster there without freezing or patches. Adding a number to a 100-item Set or deleting one took 6.4 µs against 5.8–5.9 µs for Immer. Copying the Map dominates `map-update` at 10,000 entries in every library, about 520 µs. +- A Set draft copies the Set on its first change and rebuilds it in order once an item that the recipe read through an iterator changed: `set-update` took 654 µs at 10,000 items, against 416 µs for the hand-written reducer, which copies the Set once. With patches, a Set that added or deleted items compares every original item with its copy and every item of the copy with the original: `set-add` took 456 µs with patches at 10,000 items, against 33.2 µs without. Copying the Map dominates `map-update` at 10,000 entries in every library, about 520 µs. - With auto-freeze, Immer updated a class instance with 1,000 fields faster: 239 µs against 411 µs. Without freezing the candidate is faster, 98 µs against 206 µs, so the difference lies in freezing the candidate's copy, which it makes property by property with descriptors where Immer 11 uses `Object.assign`. - Applying patches that replace nested values was 16–22% slower than Immer; structural patches are faster, as `apply-array-ops` shows (25 times). - Even in processes of their own, some cells varied between processes of the same build by up to about 1.5 times, for example `remove-reuse` with freeze on (127–191 µs) and Immer's `map-read` at 10,000 rows; `class-wide-update` without freezing at 1,000 rows took either about 95 µs or about 200 µs in both Mutative builds. Medians of three processes absorb one outlier, not two; compare such cells through geometric means rather than one by one. @@ -318,6 +340,13 @@ Pinned v1 production: 15ad9df11178c64f80e66797ffe784a4eeedfb03759ca59ef61a64f414 Immer production: 30fec64eb16c235f5fe771ed7e4be08eb0f67cb46c44917424a6e643f3d677b9 ``` +The Set scenarios of 2026-10-08 measured the production artifact of PR #195, which its head builds unchanged, and compared it with `main` at `e6af4b0`: + +```text +PR #195 production: 5f7d95816155e6761b68a3843ffb0dc36f5169626ad6715f6568d8a748da10b0 +main production: b70312b6faedc295e645a4d81050ebb982878414fe7d4c15e7e5f55b7c27e7c5 +``` + ## Reproduce Use Node 24.16.0 and the frozen lockfile. Run the following sequentially from the repository root; timings will vary. The [benchmark guide](../README.md) explains workload semantics and options. @@ -371,6 +400,7 @@ MUTATIVE_PERF_BUNDLE="perf-testing/dist/$NAME/immutability-benchmarks.mjs" node Earlier versions of this summary, in Git history, describe each round in detail. Cell counts compare the candidate with the named comparator: faster, within 5%, and slower. +- 2026-10-08, PR #195: Set drafts add and delete items in their copy and map only the items that drafts stand for, and a Set draft that left its key keeps its changes, which 1.3.0 also lost. Against `main` at `e6af4b0`, Mutative alone, the PR took 0.32 of the time over 32 Set cells; `set-add` at 10,000 items took 32.7 µs instead of 984 µs. The Set scenarios, measured again with every library in processes of its own, replace the earlier cells: 548 / 12 / 6 of 566 against Immer (geometric mean 3.60), 142 / 0 / 3 of 145 with each library's defaults (6.57), 495 / 70 / 1 against 1.3.0 (3.81), and 18 / 2 / 118 against the hand-written reducer. The production CJS artifact grew from 8,201 to 8,234 Brotli bytes. - 2026-10-05, PR #184 review fixes: the fixed source at `6b08f57` against `main` at `ba8d0cd`, Mutative only, all 93 scenarios at 100 rows in both freeze and patch modes, three runs each: fixed/main geometric mean 0.982 over 366 cells, 42 cells more than 5% faster and 6 more than 5% slower; the slower wide-object cells measured 0.77–1.02 in isolated processes. Map iteration with the iterator lifetime checks took 1–2.4% longer than without them and about 70% less than `main` for primitive entries. An update beside an unchanged frozen 10,000-entry Map or Set took 0.44 µs instead of 66.7 µs and 26.1 µs. The production CJS artifact grew from 8,079 to 8,106 Brotli bytes. - 2026-10-04, PR #180 at `e70aeed`: `current()` copies changed array drafts from their current array and leaves moved base elements as they are, and strict mode warns once in development builds when a recipe leaves 1,000 or more drafts unchanged. The three `search-*` scenarios were added: a `current()` snapshot search at 10,000 rows took 209 µs instead of 3,262 µs, and 772 µs instead of 7,490 µs after `shift()`. Gate 48 of 48. The production CJS artifact grew from 8,031 to 8,104 Brotli bytes against `main`. - 2026-10-03 and 2026-10-04, the source of `main` at `e6b6563` with the harness of PR #177, 90 scenarios at 100 rows and 23 at 1,000 and 10,000, in shared processes except for the wide-object scenarios: 508 / 11 / 11 of 530 cells against Immer (geometric mean 3.07), 133 / 0 / 3 of 136 with each library's defaults (6.01), 417 / 111 / 2 against pinned 1.3.0 (3.16), and 18 / 2 / 109 of 129 against the hand-written reducer (0.17). Immer was faster in `return-replace` with freeze on (2.9 ms against 0.9 µs at 10,000 rows), `apply-update-10pct` and `class-wide-update` with freeze on at 1,000 rows. With Immer's `enableArrayMethods`, 229 / 5 / 46 of 280 cells (1.63). Allocation below 1.3.0 in 104 of 114 memory cells; gate 48 of 48. diff --git a/website/app/layout.tsx b/website/app/layout.tsx index 08adeaf8..53a40f86 100644 --- a/website/app/layout.tsx +++ b/website/app/layout.tsx @@ -15,7 +15,7 @@ export const metadata: Metadata = { template: '%s | Mutative', }, description: - "Efficient immutable updates, about 3.4x faster than Immer with matched settings and 6x faster with each library's defaults.", + "Efficient immutable updates, about 3.6x faster than Immer with matched settings and 6.6x faster with each library's defaults.", }), }; diff --git a/website/components/home/benchmarks.tsx b/website/components/home/benchmarks.tsx index 2dab4442..44ad229c 100644 --- a/website/components/home/benchmarks.tsx +++ b/website/components/home/benchmarks.tsx @@ -5,14 +5,14 @@ import { SectionHeading } from './section-heading'; // The results of the performance page in the docs. const stats = [ { - value: '3.4x', + value: '3.6x', label: 'Faster than Immer with matched settings', - detail: 'Faster in 536 of 566 measured cases', + detail: 'Faster in 548 of 566 measured cases', }, { - value: '6.0x', + value: '6.6x', label: "Faster than Immer with each library's defaults", - detail: 'Faster in 141 of 145 measured cases', + detail: 'Faster in 142 of 145 measured cases', }, { value: '93', diff --git a/website/components/home/hero.tsx b/website/components/home/hero.tsx index 770d5ed4..993ddf97 100644 --- a/website/components/home/hero.tsx +++ b/website/components/home/hero.tsx @@ -42,7 +42,7 @@ export function Hero() { > - About 3.4x faster than Immer + About 3.6x faster than Immer across 93 workloads diff --git a/website/content/docs/extra-topics/comparison-with-immer.mdx b/website/content/docs/extra-topics/comparison-with-immer.mdx index 96d8582f..7865617e 100644 --- a/website/content/docs/extra-topics/comparison-with-immer.mdx +++ b/website/content/docs/extra-topics/comparison-with-immer.mdx @@ -26,9 +26,9 @@ The [Immer array methods audit](https://github.com/unadlib/mutative/blob/main/te > Mutative passed all of Immer's test cases. -With matched settings, both freezing or both not and both generating patches or both not, Mutative was faster than Immer 11.1.18 in 536 of 566 measured cases, 3.4x on geometric mean. With each library's defaults, Mutative without auto-freeze and Immer with it, Mutative was faster in 141 of 145 cases, 6.0x on geometric mean. Immer was faster when applying patches that replace nested values, when adding or deleting Set items without auto-freeze or patches, with auto-freeze when updating class instances with 1,000 fields, and in one concatenation with auto-freeze and patches. +With matched settings, both freezing or both not and both generating patches or both not, Mutative was faster than Immer 11.1.18 in 548 of 566 measured cases, 3.6x on geometric mean. With each library's defaults, Mutative without auto-freeze and Immer with it, Mutative was faster in 142 of 145 cases, 6.6x on geometric mean. Immer was faster when applying patches that replace nested values, with auto-freeze when updating class instances with 1,000 fields, and in one concatenation with auto-freeze and patches. -With patches on and auto-freeze off, Mutative was faster in 132 of 138 cases and never slower, and faster in every array case: 4.4x Immer and 13x Mutative 1.3.0 on geometric mean. The comparisons run Immer without its `enableArrayMethods()` plugin. In Immer 11.1.18 the plugin returns raw base objects from `shift`, `pop` and `splice`, so editing them changes the previous state, and its patches can fail to replay; the audit above reproduces these failures. +With patches on and auto-freeze off, Mutative was faster in 136 of 138 cases and never slower, and faster in every array case: 4.4x Immer and 13x Mutative 1.3.0 on geometric mean. The comparisons run Immer without its `enableArrayMethods()` plugin. In Immer 11.1.18 the plugin returns raw base objects from `shift`, `pop` and `splice`, so editing them changes the previous state, and its patches can fail to replay; the audit above reproduces these failures. Immer 11.0.0 ported the finalization callbacks of Mutative, which replace a recursive traversal of the draft tree, in [immerjs/immer#1183](https://github.com/immerjs/immer/pull/1183). Immer 11.1.18, which these measurements use, includes them. diff --git a/website/content/docs/getting-started/performance.mdx b/website/content/docs/getting-started/performance.mdx index 6f3b832d..7febe563 100644 --- a/website/content/docs/getting-started/performance.mdx +++ b/website/content/docs/getting-started/performance.mdx @@ -2,7 +2,7 @@ title: Performance --- -Mutative is about 3.4x faster than Immer with matched settings and 6x faster with each library's defaults, across 93 benchmarked workloads. A hand-written reducer that copies only what it changes is faster still in most of them. +Mutative is about 3.6x faster than Immer with matched settings and 6.6x faster with each library's defaults, across 93 benchmarked workloads. A hand-written reducer that copies only what it changes is faster still in most of them. ## Benchmark suite @@ -12,7 +12,7 @@ The [benchmark suite](https://github.com/unadlib/mutative/tree/main/perf-testing > Mutative passed all of Immer's test cases. -With matched settings, both freezing or both not and both generating patches or both not, Mutative was faster than Immer in 536 of 566 measured cases, 3.4x on geometric mean. With each library's defaults, Mutative without auto-freeze and Immer with it, Mutative was faster in 141 of 145 cases, 6.0x on geometric mean. +With matched settings, both freezing or both not and both generating patches or both not, Mutative was faster than Immer in 548 of 566 measured cases, 3.6x on geometric mean. With each library's defaults, Mutative without auto-freeze and Immer with it, Mutative was faster in 142 of 145 cases, 6.6x on geometric mean. Times are microseconds per update, medians of three runs on an Apple M1 Max with Node.js 24.16.0, each library in a process of its own; lower is better. Mutative, the first Immer column, and the hand-written reducers run without auto-freeze; the second Immer column shows Immer's default. @@ -27,7 +27,7 @@ Times are microseconds per update, medians of three runs on an Apple M1 Max with | Update one of 10,000 records | 10,000 | 1,175 | 2,109 | 3,135 | 2,113 | | Insert into a 1,000-property object | — | 51.0 | 146 | 231 | 129 | | Update a Map value | 10,000 | 517 | 521 | 730 | 518 | -| Add a number to a Set | 10,000 | 951 | 905 | 975 | 61.2 | +| Add a number to a Set | 10,000 | 33.2 | 938 | 1,011 | 65.6 | | Update a class instance | 10,000 | 2.92 | 3.43 | 668 | 1.43 | | Update a value ten levels deep | — | 3.47 | 4.46 | 7.83 | 0.75 | | Apply patches to 10% of rows | 10,000 | 1,440 | 1,182 | 2,110 | — | @@ -36,24 +36,24 @@ Times are microseconds per update, medians of three runs on an Apple M1 Max with | Return a new state | 10,000 | 2,594 | 5,628 | 0.79 | 0.02 | | Return it with rawReturn() | 10,000 | 0.26 | 5,699 | 0.79 | 0.02 | -Each library ran each workload in a process of its own. With all of them in one process every library ran slower, and not by the same amount: Mutative and Immer took 1.1 times as long on geometric mean, and the hand-written reducers 1.7 times. The search removes the first row with `shift()`, then finds the last row in a snapshot of the array and updates it. Immer has no `rawReturn()` and returns the same plain value in the last two rows; with auto-freeze, Mutative skips a returned state that is already frozen, as Immer does, taking 0.53 µs. Immer was faster in 9 of the 566 matched cases: when applying patches that replace nested values, by 16-22%; when adding a number to a Set or deleting one without auto-freeze or patches, by 5-12%; with auto-freeze, when updating a class instance with 1,000 fields; and in one concatenation with auto-freeze and patches, by 8%. +Each library ran each workload in a process of its own. With all of them in one process every library ran slower, and not by the same amount: Mutative and Immer took 1.1 times as long on geometric mean, and the hand-written reducers 1.7 times. The search removes the first row with `shift()`, then finds the last row in a snapshot of the array and updates it. Immer has no `rawReturn()` and returns the same plain value in the last two rows; with auto-freeze, Mutative skips a returned state that is already frozen, as Immer does, taking 0.53 µs. Immer was faster in 6 of the 566 matched cases: when applying patches that replace nested values, by 16-22%; with auto-freeze, when updating a class instance with 1,000 fields; and in one concatenation with auto-freeze and patches, by 8%. The Set workloads were measured again after Set drafts began to change their copy directly; adding or deleting a Set item became 3-50x faster. ## Large arrays -At 1,000 and 10,000 rows the gap grows. Against Immer without its array-method plugin, Mutative was faster in 182 of 200 cases at these sizes, 4.4x on geometric mean, and faster in every case that moves elements: +At 1,000 and 10,000 rows the gap grows. Against Immer without its array-method plugin, Mutative was faster in 190 of 200 cases at these sizes, 4.9x on geometric mean, and faster in every case that moves elements: | Auto-freeze | Patches | All workloads, 1,000 rows | All workloads, 10,000 rows | Moves, 1,000 rows | Moves, 10,000 rows | | --- | --- | ---: | ---: | ---: | ---: | -| off | off | 7.3x | 9.4x | 211x | 442x | -| off | on | 4.2x | 4.9x | 7.1x | 7.5x | -| on | off | 3.4x | 3.7x | 32x | 40x | -| on | on | 2.6x | 2.8x | 6.6x | 7.1x | +| off | off | 8.7x | 10.9x | 211x | 442x | +| off | on | 4.5x | 5.2x | 7.1x | 7.5x | +| on | off | 3.8x | 4.2x | 32x | 40x | +| on | on | 2.8x | 3.0x | 6.6x | 7.1x | Each value is the geometric mean of Immer's time over Mutative's; moves are `shift`, `unshift`, `splice` insertion, and `reverse`. Mutative moves elements natively on its copy, while Immer moves each one through its draft proxy: removing the first of 10,000 rows took 7.10 µs against 8,605 µs, 1,213x. With patches, both libraries emit one patch per moved index, which bounds the gain to 5-10x. The [performance summary](https://github.com/unadlib/mutative/blob/main/perf-testing/reports/SUMMARY.md) breaks these results down by scenario. ## With patches -With patches on and auto-freeze off, Mutative was faster than Immer in 132 of 138 cases and within 5% in the other 6, 3.2x on geometric mean, and faster than Mutative 1.3.0 in 114 and within 5% in the rest, 4.0x. In every array case measured it was faster than both: 4.4x Immer and 13x Mutative 1.3.0 on geometric mean. Patches cost little when an update changes a few paths: pushing a row and inserting a property at 10,000 rows took 63.3 µs with patches against 62.8 µs without. Moving elements emits one patch per moved index in every library, so removing the first of 10,000 rows produces 10,000 forward and 10,000 inverse patches. +With patches on and auto-freeze off, Mutative was faster than Immer in 136 of 138 cases and within 5% in the other 2, 3.4x on geometric mean, and faster than Mutative 1.3.0 in 120 and within 5% in the rest, 4.2x. In every array case measured it was faster than both: 4.4x Immer and 13x Mutative 1.3.0 on geometric mean. Patches cost little when an update changes a few paths: pushing a row and inserting a property at 10,000 rows took 63.3 µs with patches against 62.8 µs without. Moving elements emits one patch per moved index in every library, so removing the first of 10,000 rows produces 10,000 forward and 10,000 inverse patches. Times are microseconds per update with patches on and auto-freeze off: @@ -74,7 +74,7 @@ Immer's optional `enableArrayMethods()` plugin also runs array methods on the dr ## Mutative vs hand-written reducers -A hand-written reducer that copies only what it changes is faster than any library that drafts state, and it was faster than Mutative in 120 of the suite's 138 cases. Mutative was faster when copying wide objects, such as records with 1,000 keys or class instances with 100 fields, by 1.5-2.7x, and when inserting at the front of 10,000 rows: 16 µs instead of 28 µs. +A hand-written reducer that copies only what it changes is faster than any library that drafts state, and it was faster than Mutative in 118 of the suite's 138 cases. Mutative was faster when copying wide objects, such as records with 1,000 keys or class instances with 100 fields, by 1.5-2.7x, when inserting at the front of 10,000 rows: 16 µs instead of 28 µs, and when adding a number to a Set of 1,000 or 10,000 numbers, which it copies faster than `new Set()` does: 33 µs instead of 66 µs at 10,000. Spreads also become slow when code copies the same structure again and again. Mutative copies each object at most once per update, however many changes a recipe makes. diff --git a/website/content/docs/intro.mdx b/website/content/docs/intro.mdx index f43f72ca..4f2df7b6 100644 --- a/website/content/docs/intro.mdx +++ b/website/content/docs/intro.mdx @@ -2,7 +2,7 @@ title: Introduction --- -**Mutative** - A JavaScript library for efficient immutable updates, about 3.4x faster than Immer with matched settings and 6x faster with each library's defaults, across [93 benchmarked workloads](/docs/getting-started/performance). +**Mutative** - A JavaScript library for efficient immutable updates, about 3.6x faster than Immer with matched settings and 6.6x faster with each library's defaults, across [93 benchmarked workloads](/docs/getting-started/performance). ## What is Mutative? From 604af2ee5d209df17d8c1c967617919d225bba6e Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 08:26:06 +0800 Subject: [PATCH 06/21] perf: keep the items of a rebuilt Set draft on its item map instead of on every draft --- src/draft.ts | 5 ++--- src/interface.ts | 6 +++--- src/utils/finalize.ts | 22 +++++++++++++--------- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/draft.ts b/src/draft.ts index ac8d1ece..cefa4751 100644 --- a/src/draft.ts +++ b/src/draft.ts @@ -381,10 +381,9 @@ export function createDraft( proxy: null, finalities, options, - // The drafts of a Set draft's items and the objects it added, and its - // items in order once finalization rebuilt it; see `src/set.ts`. + // The drafts of a Set draft's items and the objects it added; see + // `src/set.ts`. setMap: undefined, - setItems: undefined, assignedMap: undefined, callbacks: undefined, children: undefined, diff --git a/src/interface.ts b/src/interface.ts index 4501e576..6507cce4 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -55,9 +55,9 @@ export interface ProxyDraft { options: Options & { updatedValues?: WeakMap }; parent: ProxyDraft | null; key: string | number | symbol | undefined; - setMap: Map | undefined; - // The items of a Set draft in order, kept by its first rebuild. - setItems: any[] | undefined; + // The drafts of a Set draft's items and the objects it added, with its items + // in order once finalization rebuilt it. + setMap: (Map & { items?: any[] }) | undefined; assignedMap: Map | undefined; callbacks: FinalizeCallback[] | undefined; // Child drafts created by this draft, by key. diff --git a/src/utils/finalize.ts b/src/utils/finalize.ts index 23233536..6eba0d53 100644 --- a/src/utils/finalize.ts +++ b/src/utils/finalize.ts @@ -77,18 +77,22 @@ export type GeneratePatches = ( // The copy of a Set draft holds its items in order, so only items that a draft // stands for may need their final value: the Set is rebuilt in order once one -// of them differs from the item itself. The items are kept, as a draft of an -// outer producer that a nested producer's Set holds is finalized later and -// rebuilds the Set again. +// of them differs from the item itself. The first rebuild keeps the items on +// `setMap`, as a draft of an outer producer that a nested producer's Set holds +// is finalized later and rebuilds the Set again. export function finalizeSetValue(target: ProxyDraft) { // Only Set drafts have `setMap`. const setMap = target.setMap; - const items = - setMap && - (target.setItems || - (Array.from(setMap).some(([item, value]) => getValue(value) !== item) && - (target.setItems = Array.from(target.copy as Set)))); - if (!items) return; + let items = setMap && setMap.items; + if (!items) { + let differs = false; + if (setMap) + setMap.forEach((value, item) => { + if (getValue(value) !== item) differs = true; + }); + if (!differs) return; + items = setMap!.items = Array.from(target.copy as Set); + } const copy: Set = target.copy; copy.clear(); items.forEach((item) => copy.add(getValue(setItemValue(target, item)))); From 632d5b2fb7ca29d08b12a3a1445fad986c4a073f Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 08:35:22 +0800 Subject: [PATCH 07/21] fix: never rebuild a Set draft before its own producer finalizes it --- src/utils/finalize.ts | 16 ++++++++++------ test/set-copy.test.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/utils/finalize.ts b/src/utils/finalize.ts index 6eba0d53..e5f6039c 100644 --- a/src/utils/finalize.ts +++ b/src/utils/finalize.ts @@ -78,15 +78,16 @@ export type GeneratePatches = ( // The copy of a Set draft holds its items in order, so only items that a draft // stands for may need their final value: the Set is rebuilt in order once one // of them differs from the item itself. The first rebuild keeps the items on -// `setMap`, as a draft of an outer producer that a nested producer's Set holds -// is finalized later and rebuilds the Set again. -export function finalizeSetValue(target: ProxyDraft) { +// `setMap`. With `again`, only a Set rebuilt before is rebuilt, for a draft of +// an outer producer that a nested producer's Set holds; other Sets may still +// be changing, and are rebuilt when their own producer finalizes them. +export function finalizeSetValue(target: ProxyDraft, again?: boolean) { // Only Set drafts have `setMap`. const setMap = target.setMap; let items = setMap && setMap.items; if (!items) { let differs = false; - if (setMap) + if (setMap && !again) setMap.forEach((value, item) => { if (getValue(value) !== item) differs = true; }); @@ -151,7 +152,10 @@ export function finalizeNode( const updatedValue = proxyDraft.operated ? proxyDraft.copy : proxyDraft.original; - finalizeSetValue(proxyDraft); + // A draft of another producer, which a recipe placed here, is + // finalized by that producer. + if (proxyDraft.finalities === node.finalities) + finalizeSetValue(proxyDraft); finalizePatches(proxyDraft, generatePatches, patches, inversePatches); if (__DEV__ && parent.options.enableAutoFreeze) { parent.options.updatedValues = @@ -194,7 +198,7 @@ export function markFinalization(target: ProxyDraft, key: any, value: any) { const updatedValue = proxyDraft.operated ? proxyDraft.copy : proxyDraft.original; - finalizeSetValue(target); + finalizeSetValue(target, true); if (__DEV__ && target.options.enableAutoFreeze) { target.options.updatedValues = target.options.updatedValues ?? new WeakMap(); diff --git a/test/set-copy.test.ts b/test/set-copy.test.ts index 88ee4aad..f7d3865a 100644 --- a/test/set-copy.test.ts +++ b/test/set-copy.test.ts @@ -121,3 +121,37 @@ test('patches of a Set item keep its position after another draft rebuilt the Se expect(apply(base, patches)).toEqual(state); expect(apply(state, inversePatches)).toEqual(base); }); + +describe('a Set draft and drafts of a nested producer', () => { + test('keeps the changes made after a nested producer finalized a draft that the Set holds', () => { + const first = { value: 0 }; + const base = { set: new Set([first, 7]) }; + const innerBase = { item: { value: 1 } }; + const state = create(base, (draft) => { + for (const item of draft.set) + if (typeof item === 'object') item.value = 1; + create(innerBase, (innerDraft) => { + innerDraft.item.value = 2; + draft.set.add(innerDraft.item); + }); + expect(draft.set.has(first)).toBe(true); + draft.set.delete(7); + draft.set.add(5); + }); + expect([...state.set]).toEqual([{ value: 1 }, { value: 2 }, 5]); + }); + + test('keeps the changes made after a nested producer held the Set draft', () => { + const base = { set: new Set([{ value: 0 }]) }; + const innerBase = { holder: { set: new Set([1]) } }; + const state = create(base, (draft) => { + for (const item of draft.set) item.value = 1; + create(innerBase, (innerDraft) => { + innerDraft.holder.set.add(2); + innerDraft.holder.set = draft.set; + }); + draft.set.add(5); + }); + expect([...state.set]).toEqual([{ value: 1 }, 5]); + }); +}); From ba9b907d23e7ffd39ffaec1cc3dad4d7fcd1ada1 Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 08:37:06 +0800 Subject: [PATCH 08/21] perf: rebuild a Set draft once for the drafts of its own producer that it holds --- src/utils/finalize.ts | 5 ++++- test/set-copy.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/utils/finalize.ts b/src/utils/finalize.ts index e5f6039c..6aee5bfe 100644 --- a/src/utils/finalize.ts +++ b/src/utils/finalize.ts @@ -198,7 +198,10 @@ export function markFinalization(target: ProxyDraft, key: any, value: any) { const updatedValue = proxyDraft.operated ? proxyDraft.copy : proxyDraft.original; - finalizeSetValue(target, true); + // The drafts of a Set's own producer are done changing when it rebuilds + // the Set; a draft of another producer may change after that. + if (proxyDraft.finalities !== target.finalities) + finalizeSetValue(target, true); if (__DEV__ && target.options.enableAutoFreeze) { target.options.updatedValues = target.options.updatedValues ?? new WeakMap(); diff --git a/test/set-copy.test.ts b/test/set-copy.test.ts index f7d3865a..e5f56c75 100644 --- a/test/set-copy.test.ts +++ b/test/set-copy.test.ts @@ -155,3 +155,38 @@ describe('a Set draft and drafts of a nested producer', () => { expect([...state.set]).toEqual([{ value: 1 }, 5]); }); }); + +test('drafts added to a Set rebuild it once', () => { + const base = { + ids: new Set([1, 2]), + list: [{ value: 0 }, { value: 1 }, { value: 2 }], + }; + // Drafts are finalized in reverse order of creation, so the Set draft is + // finalized after the drafts it holds in the first recipe and before them + // in the second. + const recipes = [ + (draft: typeof base) => { + const { ids } = draft; + for (const item of draft.list) { + item.value += 1; + ids.add(item); + } + }, + (draft: typeof base) => { + for (const item of draft.list) item.value += 1; + for (const item of draft.list) draft.ids.add(item); + }, + ]; + const clear = vi.spyOn(Set.prototype, 'clear'); + try { + for (const recipe of recipes) { + const state = create(base, recipe); + expect([...state.ids]).toEqual([1, 2, ...state.list]); + expect([...state.ids][2]).toBe(state.list[0]); + expect(state.list).toEqual([{ value: 1 }, { value: 2 }, { value: 3 }]); + expect(callsOn(clear, state.ids)).toBe(1); + } + } finally { + clear.mockRestore(); + } +}); From e6bb148ac68062154dd1800e18adc2aa5637c758 Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 08:41:14 +0800 Subject: [PATCH 09/21] docs: note that a draft of a Set subclass uses the methods that the subclass overrides --- README.md | 1 + test/set-copy.test.ts | 20 +++++++++++++++++++ .../docs/advanced-guides/migration-v2.mdx | 1 + 3 files changed, 22 insertions(+) diff --git a/README.md b/README.md index 5543ff60..da22ea91 100644 --- a/README.md +++ b/README.md @@ -793,6 +793,7 @@ Mutative v2 keeps the v1 API. The changes below, made since v1.3.0, can affect e - With `enablePatches`, every changed item of a Set keeps its changes. In v1, when two or more items of a Set that was not the root changed below their first level, the state kept the change of only one of them. - A Set that receives an unchanged draft holds the original object, as the rest of the state does, instead of the draft's shallow copy. - A Set draft that the recipe moves to another place, such as another key, an array, a Map, another Set or a new object, keeps its added, deleted and changed items there when its old place gets another value or is removed; v1 kept its original items. +- A draft of a Set subclass works on its copy, an instance of the subclass created from the original's `values()`, as Map drafts do: the methods that the subclass overrides, such as `add`, `has`, `delete` or `values`, take effect during the recipe, and the next state keeps the items in the order of the copy. v1 kept the items of a changed Set in a separate map in their original order, which bypassed those methods until it rebuilt the Set after the recipe. - Assigning `undefined` to a key that `delete`, `shift`, `unshift` or a shrinking `splice` removed from a draft adds the key back; v1 left a hole or kept the shorter length. - Under a `mark` that returns `mutable`, a value that the recipe assigned or moved is read back as assigned, through the draft and in `current()`; v1 returned the original value. - `current()` of a draft whose state holds a plain Set with drafts returns a snapshot; v1 threw. diff --git a/test/set-copy.test.ts b/test/set-copy.test.ts index e5f56c75..cc3fd7fb 100644 --- a/test/set-copy.test.ts +++ b/test/set-copy.test.ts @@ -82,6 +82,26 @@ describe('Set drafts change their copy', () => { expect(apply(base, patches)).toEqual(state); expect(apply(state, inversePatches)).toEqual(base); }); + + test('a draft of a Set subclass uses the methods that the subclass overrides', () => { + class LowerCaseSet extends Set { + add(value: string) { + return super.add(value.toLowerCase()); + } + has(value: string) { + return super.has(value.toLowerCase()); + } + } + const base = { tags: new LowerCaseSet(['a']) }; + const state = create(base, (draft) => { + draft.tags.add('B'); + expect(draft.tags.has('b')).toBe(true); + draft.tags.add('A'); + expect([...draft.tags]).toEqual(['a', 'b']); + }); + expect(state.tags).toBeInstanceOf(LowerCaseSet); + expect([...state.tags]).toEqual(['a', 'b']); + }); }); test('a Set holds the final value of a draft that an outer producer finalizes later', () => { diff --git a/website/content/docs/advanced-guides/migration-v2.mdx b/website/content/docs/advanced-guides/migration-v2.mdx index 87d6864c..7ba6b641 100644 --- a/website/content/docs/advanced-guides/migration-v2.mdx +++ b/website/content/docs/advanced-guides/migration-v2.mdx @@ -46,6 +46,7 @@ Mutative v2 keeps the v1 API. The changes below, made since v1.3.0, can affect e - With `enablePatches`, every changed item of a Set keeps its changes. In v1, when two or more items of a Set that was not the root changed below their first level, the state kept the change of only one of them. - A Set that receives an unchanged draft holds the original object, as the rest of the state does, instead of the draft's shallow copy. - A Set draft that the recipe moves to another place, such as another key, an array, a Map, another Set or a new object, keeps its added, deleted and changed items there when its old place gets another value or is removed; v1 kept its original items. +- A draft of a Set subclass works on its copy, an instance of the subclass created from the original's `values()`, as Map drafts do: the methods that the subclass overrides, such as `add`, `has`, `delete` or `values`, take effect during the recipe, and the next state keeps the items in the order of the copy. v1 kept the items of a changed Set in a separate map in their original order, which bypassed those methods until it rebuilt the Set after the recipe. - Assigning `undefined` to a key that `delete`, `shift`, `unshift` or a shrinking `splice` removed from a draft adds the key back; v1 left a hole or kept the shorter length. - Under a `mark` that returns `mutable`, a value that the recipe assigned or moved is read back as assigned, through the draft and in `current()`; v1 returned the original value. - `current()` of a draft whose state holds a plain Set with drafts returns a snapshot; v1 threw. From 8577ad88f45c631dd2fe63e31aebc2b5bc347c98 Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 08:42:42 +0800 Subject: [PATCH 10/21] build: refresh the size baseline for Set drafts that their own producer rebuilds --- scripts/build-size-baseline.json | 82 ++++++++++++++++---------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/scripts/build-size-baseline.json b/scripts/build-size-baseline.json index 5b851caa..172173b1 100644 --- a/scripts/build-size-baseline.json +++ b/scripts/build-size-baseline.json @@ -1,7 +1,7 @@ { "reference": { - "revision": "24cc0eebcf13d564dfc89eed9d0b5b0fc1f65786", - "description": "Set drafts add and delete items in their copy and map only the items that drafts stand for, and a Set draft that left its key keeps its changes", + "revision": "ba9b907d23e7ffd39ffaec1cc3dad4d7fcd1ada1", + "description": "Set drafts add and delete items in their copy, map only the items that drafts stand for and are rebuilt once their own producer finalizes them, and a Set draft that left its key keeps its changes", "node": "v24.16.0", "commands": [ "pnpm install --frozen-lockfile", @@ -15,69 +15,69 @@ "esbuild": "0.24.0", "measurements": { "artifact/mutative.cjs.production.min.js": { - "raw": 26746, - "gzip": 8980, - "brotli": 8234 + "raw": 26809, + "gzip": 8990, + "brotli": 8237 }, "artifact/mutative.umd.production.min.js": { - "raw": 26900, - "gzip": 9038, - "brotli": 8294 + "raw": 26963, + "gzip": 9050, + "brotli": 8304 }, "artifact/mutative.esm.production.min.mjs": { - "raw": 26718, - "gzip": 8999, - "brotli": 8263 + "raw": 26781, + "gzip": 9012, + "brotli": 8272 }, "artifact/mutative.cjs.development.js": { - "raw": 72015, - "gzip": 17312, - "brotli": 15067 + "raw": 72185, + "gzip": 17335, + "brotli": 15085 }, "artifact/mutative.umd.development.js": { - "raw": 74253, - "gzip": 17511, - "brotli": 15213 + "raw": 74429, + "gzip": 17537, + "brotli": 15251 }, "artifact/mutative.esm.js": { - "raw": 72867, - "gzip": 17426, - "brotli": 15138 + "raw": 73037, + "gzip": 17445, + "brotli": 15146 }, "artifact/mutative.esm.mjs": { - "raw": 72868, - "gzip": 17428, - "brotli": 15142 + "raw": 73038, + "gzip": 17447, + "brotli": 15148 }, "consumer/import/create": { - "raw": 24104, - "gzip": 8436, - "brotli": 7744 + "raw": 24163, + "gzip": 8441, + "brotli": 7750 }, "consumer/import/patches": { - "raw": 25997, - "gzip": 9037, - "brotli": 8299 + "raw": 26056, + "gzip": 9050, + "brotli": 8310 }, "consumer/import/all": { - "raw": 26782, - "gzip": 9303, - "brotli": 8528 + "raw": 26841, + "gzip": 9316, + "brotli": 8535 }, "consumer/require/create": { - "raw": 26267, - "gzip": 9300, - "brotli": 8521 + "raw": 26326, + "gzip": 9311, + "brotli": 8534 }, "consumer/require/patches": { - "raw": 26335, - "gzip": 9337, - "brotli": 8558 + "raw": 26394, + "gzip": 9349, + "brotli": 8571 }, "consumer/require/all": { - "raw": 26232, - "gzip": 9279, - "brotli": 8514 + "raw": 26291, + "gzip": 9289, + "brotli": 8517 } } } From f7dca1302f0110243d00f392dab1ec433b7ac601 Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 09:07:37 +0800 Subject: [PATCH 11/21] docs: refresh the Set figures with the scenarios measured again on the final source --- README.md | 2 +- perf-testing/reports/SUMMARY.md | 56 +++++++++---------- .../docs/getting-started/performance.mdx | 2 +- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index da22ea91..6ea20198 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Times are microseconds per update, medians of three runs on an Apple M1 Max with | Update one of 10,000 records | 10,000 | 1,175 | 2,109 | 3,135 | 2,113 | | Insert into a 1,000-property object | — | 51.0 | 146 | 231 | 129 | | Update a Map value | 10,000 | 517 | 521 | 730 | 518 | -| Add a number to a Set | 10,000 | 33.2 | 938 | 1,011 | 65.6 | +| Add a number to a Set | 10,000 | 32.0 | 916 | 987 | 64.3 | | Update a class instance | 10,000 | 2.92 | 3.43 | 668 | 1.43 | | Update a value ten levels deep | — | 3.47 | 4.46 | 7.83 | 0.75 | | Apply patches to 10% of rows | 10,000 | 1,440 | 1,182 | 2,110 | — | diff --git a/perf-testing/reports/SUMMARY.md b/perf-testing/reports/SUMMARY.md index 58c08666..dedd1e70 100644 --- a/perf-testing/reports/SUMMARY.md +++ b/perf-testing/reports/SUMMARY.md @@ -2,7 +2,7 @@ Measurements from 2026-10-06 and 2026-10-07 UTC of the source of `main` at [`2ab58fc`](https://github.com/unadlib/mutative/commit/2ab58fce1210b2b4724fcecce10ff0e1a864ddce). Since the source of the previous batch, `e6b6563`, `main` has added the cheap `current()` of changed arrays of [PR #180](https://github.com/unadlib/mutative/pull/180), the returned-value, frozen-array, Set and moved-element improvements of [PR #183](https://github.com/unadlib/mutative/pull/183), and the draft lifecycle, patch, Map and Set fixes of [PR #184](https://github.com/unadlib/mutative/pull/184); PRs #181, #182, #186 and #189 changed development builds, packaging and rarely used paths. The candidate and the pinned npm baseline both report Mutative 1.3.0. Immer is pinned to 11.1.18 and Mitata to 1.0.34. The main comparison runs Immer without its array-method plugin; a separate dataset below enables `enableArrayMethods`. The raw datasets of this batch were not archived; the identities and commands below reproduce them, and the [archive index](./README.md) describes the retention policy for batches that are. -The Set scenarios were measured again on 2026-10-08 UTC, after [PR #195](https://github.com/unadlib/mutative/pull/195) made Set drafts change their copy directly instead of mapping all their items: `set-add`, `set-delete`, `set-update` and `set-update-10pct` at 100 rows and `set-add` and `set-update` at 1,000 and 10,000 rows, every library in processes of its own, three runs each (312 trials from 96 processes), and the memory of `set-add` at every size. The results below use those cells in place of the earlier ones, except where [shared and isolated processes](#shared-and-isolated-processes) compares the cells of October 6 and 7; [Set drafts](#set-drafts) compares the change with `main`. +The Set scenarios were measured again on 2026-10-09 UTC, after [PR #195](https://github.com/unadlib/mutative/pull/195) made Set drafts change their copy directly instead of mapping all their items: `set-add`, `set-delete`, `set-update` and `set-update-10pct` at 100 rows and `set-add` and `set-update` at 1,000 and 10,000 rows, every library in processes of its own, three runs each (312 trials from 96 processes), and the memory of `set-add` at every size. The results below use those cells in place of the earlier ones, except where [shared and isolated processes](#shared-and-isolated-processes) compares the cells of October 6 and 7; [Set drafts](#set-drafts) compares the change with `main`. This batch remeasures every scenario and replaces the cross-library comparison of 2026-10-03 and 2026-10-04, which the [history](#history) summarizes. Unlike earlier batches, every timing below comes from processes that each run one scenario with one library. With all libraries in one process, as earlier batches measured every scenario but the wide-object ones, each library ran slower by a different amount, which favored the candidate against Immer and hid much of the hand-written reducer's lead; [shared and isolated processes](#shared-and-isolated-processes) compares the two. The harness is unchanged since the previous batch except for the three `search-*` scenarios of PR #180, which now also run at 1,000 and 10,000 rows and with Immer's array-method plugin. @@ -27,7 +27,7 @@ Cells faster / within 5% / slower, and the geometric mean of comparator time ove | Comparator | 100 rows | 1,000 and 10,000 rows | All | | --- | ---: | ---: | ---: | | Immer 11.1.18 | 358 / 6 / 2 of 366, 3.05 | 190 / 6 / 4 of 200, 4.88 | 548 / 12 / 6 of 566, 3.60 | -| Immer 11.1.18 with its defaults | 92 / 0 / 1 of 93, 6.30 | 50 / 0 / 2 of 52, 7.09 | 142 / 0 / 3 of 145, 6.57 | +| Immer 11.1.18 with its defaults | 92 / 0 / 1 of 93, 6.30 | 50 / 0 / 2 of 52, 7.10 | 142 / 0 / 3 of 145, 6.58 | | Pinned npm Mutative 1.3.0 | 332 / 33 / 1 of 366, 3.34 | 163 / 37 / 0 of 200, 4.85 | 495 / 70 / 1 of 566, 3.81 | | Hand-written reducer | 7 / 0 / 83 of 90, 0.11 | 11 / 2 / 35 of 48, 0.12 | 18 / 2 / 118 of 138, 0.12 | @@ -41,7 +41,7 @@ Against Immer by workload group, faster / within 5% / slower and geometric mean: | Small state | 12 / 0 / 0, 1.66 | — | — | | Mutation density | 12 / 0 / 0, 2.10 | 4 / 0 / 0, 1.90 | 4 / 0 / 0, 1.92 | | Array methods | 132 / 0 / 0, 5.75 | 20 / 0 / 0, 23.77 | 20 / 0 / 0, 31.12 | -| Map and Set | 35 / 1 / 0, 2.47 | 14 / 2 / 0, 3.36 | 14 / 2 / 0, 3.59 | +| Map and Set | 35 / 1 / 0, 2.48 | 14 / 2 / 0, 3.39 | 14 / 2 / 0, 3.62 | | Object records | 11 / 1 / 0, 1.26 | 8 / 0 / 0, 2.34 | 8 / 0 / 0, 1.61 | | Class instances | 6 / 2 / 0, 1.74 | 6 / 0 / 2, 1.61 | 6 / 2 / 0, 1.64 | | Deep path | 4 / 0 / 0, 1.54 | — | — | @@ -58,12 +58,12 @@ Against Immer without its array-method plugin, by size and mode, over the 26 sca | Rows | Freeze | Patches | All scenarios | Geometric mean | Moves, geometric mean | Moves, range | | ---: | --- | --- | ---: | ---: | ---: | ---: | -| 1,000 | off | off | 24 / 1 / 1 | 8.70 | 211 | 72.0–492 | +| 1,000 | off | off | 24 / 1 / 1 | 8.71 | 211 | 72.0–492 | | 1,000 | off | on | 23 / 1 / 0 | 4.49 | 7.1 | 6.1–7.5 | | 1,000 | on | off | 25 / 0 / 1 | 3.84 | 32.4 | 18.5–46.9 | | 1,000 | on | on | 23 / 0 / 1 | 2.76 | 6.6 | 6.1–6.9 | -| 10,000 | off | off | 24 / 1 / 1 | 10.86 | 442 | 176–1,213 | -| 10,000 | off | on | 23 / 1 / 0 | 5.19 | 7.5 | 5.0–9.7 | +| 10,000 | off | off | 24 / 1 / 1 | 10.89 | 442 | 176–1,213 | +| 10,000 | off | on | 23 / 1 / 0 | 5.20 | 7.5 | 5.0–9.7 | | 10,000 | on | off | 25 / 1 / 0 | 4.22 | 39.7 | 22.2–53.0 | | 10,000 | on | on | 23 / 1 / 0 | 3.00 | 7.1 | 5.1–9.0 | @@ -81,7 +81,7 @@ The gap widens with size. With freeze and patches off, the moves were 211 times ### Freeze off, patches on -With patches on and freeze off, the candidate was faster than Immer in 136 of 138 cells and within 5% in the other 2, the Map updates at 1,000 and 10,000 rows (geometric mean 3.39). It was faster than Mutative 1.3.0 in 120 and within 5% in 18 (4.19). In the 67 array-related cells, array methods, searches and the upstream array workloads at every size, it was faster than both in every cell: 4.35 times Immer and 12.56 times Mutative 1.3.0 on geometric mean. +With patches on and freeze off, the candidate was faster than Immer in 136 of 138 cells and within 5% in the other 2, the Map updates at 1,000 and 10,000 rows (geometric mean 3.40). It was faster than Mutative 1.3.0 in 120 and within 5% in 18 (4.19). In the 67 array-related cells, array methods, searches and the upstream array workloads at every size, it was faster than both in every cell: 4.35 times Immer and 12.56 times Mutative 1.3.0 on geometric mean. Patches cost little when an update changes a few paths. They added a median 1% to the candidate's time at 1,000 and 10,000 rows and 20% at 100 rows. The former `benchmark:base` workload, `push-and-insert` at 10,000 rows, took 62.8 µs without patches and 63.3 µs with them; Mutative 1.3.0 went from 67.0 µs to 208 µs, and Immer from 162 µs to 364 µs. Moving elements is the exception: both libraries emit one patch per moved index, so `shift` at 10,000 rows produces 10,000 forward and 10,000 inverse patches in each. The candidate's time grows from 7.10 µs to 1,071 µs, against 10,376 µs for Immer producing the same patches. @@ -174,19 +174,19 @@ A snapshot search was faster than the draft search at every size: 1.7 times at 1 | Scenario | Items | Freeze | Patches | `main` | PR #195 | PR/`main` | | --- | ---: | --- | --- | ---: | ---: | ---: | -| set-add | 100 | off | off | 6.56 | 0.70 | 0.11 | -| set-add | 100 | on | off | 6.95 | 1.11 | 0.16 | -| set-add | 100 | off | on | 8.45 | 2.60 | 0.31 | -| set-delete | 100 | off | off | 6.49 | 0.69 | 0.11 | -| set-add | 1,000 | off | off | 67.6 | 1.29 | 0.02 | -| set-add | 10,000 | off | off | 984 | 32.7 | 0.03 | -| set-add | 10,000 | on | off | 1,006 | 62.7 | 0.06 | -| set-add | 10,000 | off | on | 1,404 | 454 | 0.32 | -| set-update | 100 | off | off | 8.31 | 4.93 | 0.59 | -| set-update | 10,000 | off | off | 1,056 | 652 | 0.62 | -| set-update-10pct | 100 | off | off | 10.96 | 8.13 | 0.74 | - -Over all 32 cells, four Set scenarios at 100 rows and two at 1,000 and 10,000 rows in every mode, the PR took 0.32 of the time of `main` on geometric mean and was faster in each. Additions and deletions no longer copy the items into a Map and back, so without patches they now cost little more than copying the Set: `set-add` at 10,000 items allocated 329 KiB per update instead of 2,883 KiB. With patches, a Set that added or deleted items still compares all items to emit its own patches, which bounds that gain to about 3 times, and a changed item still rebuilds the Set in order. Against the other libraries the candidate was faster than Immer in every Set cell, 3.0–50.7 times, and faster than the hand-written reducer in `set-add` at 1,000 and 10,000 rows, where `Set.prototype.difference` copies the Set faster than the reducer's `new Set()`. +| set-add | 100 | off | off | 6.54 | 0.69 | 0.11 | +| set-add | 100 | on | off | 6.87 | 1.12 | 0.16 | +| set-add | 100 | off | on | 8.39 | 2.60 | 0.31 | +| set-delete | 100 | off | off | 6.42 | 0.70 | 0.11 | +| set-add | 1,000 | off | off | 67.6 | 1.30 | 0.02 | +| set-add | 10,000 | off | off | 988 | 33.9 | 0.03 | +| set-add | 10,000 | on | off | 1,008 | 63.8 | 0.06 | +| set-add | 10,000 | off | on | 1,407 | 454 | 0.32 | +| set-update | 100 | off | off | 8.26 | 4.71 | 0.57 | +| set-update | 10,000 | off | off | 1,067 | 644 | 0.60 | +| set-update-10pct | 100 | off | off | 10.97 | 7.97 | 0.73 | + +Over all 32 cells, four Set scenarios at 100 rows and two at 1,000 and 10,000 rows in every mode, the PR took 0.31 of the time of `main` on geometric mean and was faster in each. Additions and deletions no longer copy the items into a Map and back, so without patches they now cost little more than copying the Set: `set-add` at 10,000 items allocated 325 KiB per update, against 2,881 KiB for Mutative 1.3.0. With patches, a Set that added or deleted items still compares all items to emit its own patches, which bounds that gain to about 3 times, and a changed item still rebuilds the Set in order. Against the other libraries the candidate was faster than Immer in every Set cell, 3.0–50.6 times, and faster than the hand-written reducer in `set-add` at 1,000 and 10,000 rows, where `Set.prototype.difference` copies the Set faster than the reducer's `new Set()`. ### Selected times @@ -203,7 +203,7 @@ Microseconds per complete scenario, medians of three runs. | update-largeObject1 | 100 | on | off | 132 | 138 | 231 | — | 1.75 | | object-update | 100 | off | off | 3.16 | 6.12 | 3.45 | 2.14 | 1.09 | | map-update | 100 | off | off | 4.55 | 5.19 | 4.95 | 3.66 | 1.09 | -| set-update | 100 | off | off | 4.92 | 9.10 | 42.9 | 2.14 | 8.72 | +| set-update | 100 | off | off | 4.66 | 9.00 | 42.1 | 2.11 | 9.03 | | class-update | 100 | on | off | 3.86 | 5.22 | 9.24 | — | 2.40 | | apply-array-ops | 100 | off | off | 4.06 | 116 | 101 | — | 24.83 | | return-filter | 100 | off | off | 23.8 | 26.2 | 32.6 | 0.19 | 1.37 | @@ -217,7 +217,7 @@ Microseconds per complete scenario, medians of three runs. | array-reverse-nested | 10,000 | off | off | 13.7 | 8,627 | 7,555 | 9.68 | 551.48 | | array-reverse-nested | 10,000 | off | on | 1,074 | 277,427 | 9,416 | — | 8.76 | | read-index | 10,000 | off | off | 5,094 | 6,293 | 11,773 | 55.5 | 2.31 | -| set-update | 10,000 | off | off | 654 | 1,069 | 6,176 | 416 | 9.45 | +| set-update | 10,000 | off | off | 622 | 1,049 | 6,138 | 404 | 9.87 | | object-update | 10,000 | off | off | 1,175 | 1,189 | 2,109 | 2,113 | 1.79 | | apply-reverse | 10,000 | off | off | 12,590 | 13,801 | 13,864 | — | 1.10 | | push-and-insert | 10,000 | off | on | 63.3 | 208 | 364 | — | 5.76 | @@ -226,7 +226,7 @@ Microseconds per complete scenario, medians of three runs. ### Against a hand-written reducer -The hand-written reducer is faster than the candidate in 118 of 138 cells, and across all 138 the candidate takes 8.6 times as long on geometric mean: the reducer copies only what each update changes and has no draft to create, track, or finalize. The candidate is faster in 18 cells. Fifteen copy wide objects: objects with 1,000 and 3,000 properties, records with 1,000 or 10,000 keys, and class instances with 100 or more fields, by 1.5–2.7 times. One inserts at the front of an array of 10,000 rows, where the reducer's spread copies every element and `unshift` runs natively on the draft's copy: 15.9 µs against 28.3 µs; at 100 and 1,000 rows the reducer was faster. Two add a number to a Set of 1,000 or 10,000 numbers, which the candidate copies with `Set.prototype.difference`, faster than the reducer's `new Set()`: 1.30 µs against 4.16 µs and 33.2 µs against 65.6 µs; at 100 numbers the reducer was faster, 0.45 µs against 0.70 µs. Copying a Map of 1,000 or 10,000 entries dominates `map-update` in both, which are within 5%. The hand-written reducer is the same spread-based code that validates every scenario; faster hand-written code exists for many of them. +The hand-written reducer is faster than the candidate in 118 of 138 cells, and across all 138 the candidate takes 8.6 times as long on geometric mean: the reducer copies only what each update changes and has no draft to create, track, or finalize. The candidate is faster in 18 cells. Fifteen copy wide objects: objects with 1,000 and 3,000 properties, records with 1,000 or 10,000 keys, and class instances with 100 or more fields, by 1.5–2.7 times. One inserts at the front of an array of 10,000 rows, where the reducer's spread copies every element and `unshift` runs natively on the draft's copy: 15.9 µs against 28.3 µs; at 100 and 1,000 rows the reducer was faster. Two add a number to a Set of 1,000 or 10,000 numbers, which the candidate copies with `Set.prototype.difference`, faster than the reducer's `new Set()`: 1.27 µs against 4.06 µs and 32.0 µs against 64.3 µs; at 100 numbers the reducer was faster, 0.44 µs against 0.70 µs. Copying a Map of 1,000 or 10,000 entries dominates `map-update` in both, which are within 5%. The hand-written reducer is the same spread-based code that validates every scenario; faster hand-written code exists for many of them. ### Shared and isolated processes @@ -252,7 +252,7 @@ Sampled allocation of the candidate was below Mutative 1.3.0 in 107 of 114 memor | read-index | 100 | off | 98.6 | 150.1 | 192.3 | 1.7 | | push-and-insert | 100 | off | 115.2 | 1,365.6 | 66.0 | 19.3 | | object-update | 1,000 | off | 112.9 | 1,311.7 | 15.9 | 10.9 | -| set-add | 10,000 | off | 328.7 | 2,883.5 | 1,613.1 | 325.9 | +| set-add | 10,000 | off | 325.0 | 2,881.1 | 1,605.9 | 321.7 | | array-reverse-nested | 10,000 | off | 89.0 | 11,077.6 | 12,840.4 | 83.4 | | array-reverse-nested | 10,000 | on | 3,028.5 | 268,460.6 | 15,102.0 | — | @@ -305,7 +305,7 @@ In 10 cells every process was slower than every process of the previous batch: ` ## Tradeoffs and limits - Returned values: without auto-freeze, the candidate searches every object of a returned value for drafts, as Immer does: `return-replace` took 2.59 ms at 10,000 rows, against 5.63 ms for Immer without auto-freeze. Immer's default auto-freeze skips frozen values (0.79 µs), and so do production builds of the candidate with auto-freeze (0.53 µs). Wrapping the value in `rawReturn()` skips the search (0.26 µs). -- A Set draft copies the Set on its first change and rebuilds it in order once an item that the recipe read through an iterator changed: `set-update` took 654 µs at 10,000 items, against 416 µs for the hand-written reducer, which copies the Set once. With patches, a Set that added or deleted items compares every original item with its copy and every item of the copy with the original: `set-add` took 456 µs with patches at 10,000 items, against 33.2 µs without. Copying the Map dominates `map-update` at 10,000 entries in every library, about 520 µs. +- A Set draft copies the Set on its first change and rebuilds it in order once an item that the recipe read through an iterator changed: `set-update` took 622 µs at 10,000 items, against 404 µs for the hand-written reducer, which copies the Set once. With patches, a Set that added or deleted items compares every original item with its copy and every item of the copy with the original: `set-add` took 446 µs with patches at 10,000 items, against 32.0 µs without. Copying the Map dominates `map-update` at 10,000 entries in every library, about 520 µs. - With auto-freeze, Immer updated a class instance with 1,000 fields faster: 239 µs against 411 µs. Without freezing the candidate is faster, 98 µs against 206 µs, so the difference lies in freezing the candidate's copy, which it makes property by property with descriptors where Immer 11 uses `Object.assign`. - Applying patches that replace nested values was 16–22% slower than Immer; structural patches are faster, as `apply-array-ops` shows (25 times). - Even in processes of their own, some cells varied between processes of the same build by up to about 1.5 times, for example `remove-reuse` with freeze on (127–191 µs) and Immer's `map-read` at 10,000 rows; `class-wide-update` without freezing at 1,000 rows took either about 95 µs or about 200 µs in both Mutative builds. Medians of three processes absorb one outlier, not two; compare such cells through geometric means rather than one by one. @@ -340,10 +340,10 @@ Pinned v1 production: 15ad9df11178c64f80e66797ffe784a4eeedfb03759ca59ef61a64f414 Immer production: 30fec64eb16c235f5fe771ed7e4be08eb0f67cb46c44917424a6e643f3d677b9 ``` -The Set scenarios of 2026-10-08 measured the production artifact of PR #195, which its head builds unchanged, and compared it with `main` at `e6af4b0`: +The Set scenarios of 2026-10-09 measured the production artifact of PR #195, which its head builds unchanged, and compared it with `main` at `e6af4b0`: ```text -PR #195 production: 5f7d95816155e6761b68a3843ffb0dc36f5169626ad6715f6568d8a748da10b0 +PR #195 production: 1e4f18978a918493d1614108a8579d23ac59c562fc2465bfdde480b285bd15a7 main production: b70312b6faedc295e645a4d81050ebb982878414fe7d4c15e7e5f55b7c27e7c5 ``` @@ -400,7 +400,7 @@ MUTATIVE_PERF_BUNDLE="perf-testing/dist/$NAME/immutability-benchmarks.mjs" node Earlier versions of this summary, in Git history, describe each round in detail. Cell counts compare the candidate with the named comparator: faster, within 5%, and slower. -- 2026-10-08, PR #195: Set drafts add and delete items in their copy and map only the items that drafts stand for, and a Set draft that left its key keeps its changes, which 1.3.0 also lost. Against `main` at `e6af4b0`, Mutative alone, the PR took 0.32 of the time over 32 Set cells; `set-add` at 10,000 items took 32.7 µs instead of 984 µs. The Set scenarios, measured again with every library in processes of its own, replace the earlier cells: 548 / 12 / 6 of 566 against Immer (geometric mean 3.60), 142 / 0 / 3 of 145 with each library's defaults (6.57), 495 / 70 / 1 against 1.3.0 (3.81), and 18 / 2 / 118 against the hand-written reducer. The production CJS artifact grew from 8,201 to 8,234 Brotli bytes. +- 2026-10-09, PR #195: Set drafts add and delete items in their copy, map only the items that drafts stand for and are rebuilt once, by their own producer, and a Set draft that left its key keeps its changes, which 1.3.0 also lost. Against `main` at `e6af4b0`, Mutative alone, the PR took 0.31 of the time over 32 Set cells; `set-add` at 10,000 items took 33.9 µs instead of 988 µs. The Set scenarios, measured again with every library in processes of its own, replace the earlier cells: 548 / 12 / 6 of 566 against Immer (geometric mean 3.60), 142 / 0 / 3 of 145 with each library's defaults (6.58), 495 / 70 / 1 against 1.3.0 (3.81), and 18 / 2 / 118 against the hand-written reducer. The production CJS artifact grew from 8,201 to 8,237 Brotli bytes. - 2026-10-05, PR #184 review fixes: the fixed source at `6b08f57` against `main` at `ba8d0cd`, Mutative only, all 93 scenarios at 100 rows in both freeze and patch modes, three runs each: fixed/main geometric mean 0.982 over 366 cells, 42 cells more than 5% faster and 6 more than 5% slower; the slower wide-object cells measured 0.77–1.02 in isolated processes. Map iteration with the iterator lifetime checks took 1–2.4% longer than without them and about 70% less than `main` for primitive entries. An update beside an unchanged frozen 10,000-entry Map or Set took 0.44 µs instead of 66.7 µs and 26.1 µs. The production CJS artifact grew from 8,079 to 8,106 Brotli bytes. - 2026-10-04, PR #180 at `e70aeed`: `current()` copies changed array drafts from their current array and leaves moved base elements as they are, and strict mode warns once in development builds when a recipe leaves 1,000 or more drafts unchanged. The three `search-*` scenarios were added: a `current()` snapshot search at 10,000 rows took 209 µs instead of 3,262 µs, and 772 µs instead of 7,490 µs after `shift()`. Gate 48 of 48. The production CJS artifact grew from 8,031 to 8,104 Brotli bytes against `main`. - 2026-10-03 and 2026-10-04, the source of `main` at `e6b6563` with the harness of PR #177, 90 scenarios at 100 rows and 23 at 1,000 and 10,000, in shared processes except for the wide-object scenarios: 508 / 11 / 11 of 530 cells against Immer (geometric mean 3.07), 133 / 0 / 3 of 136 with each library's defaults (6.01), 417 / 111 / 2 against pinned 1.3.0 (3.16), and 18 / 2 / 109 of 129 against the hand-written reducer (0.17). Immer was faster in `return-replace` with freeze on (2.9 ms against 0.9 µs at 10,000 rows), `apply-update-10pct` and `class-wide-update` with freeze on at 1,000 rows. With Immer's `enableArrayMethods`, 229 / 5 / 46 of 280 cells (1.63). Allocation below 1.3.0 in 104 of 114 memory cells; gate 48 of 48. diff --git a/website/content/docs/getting-started/performance.mdx b/website/content/docs/getting-started/performance.mdx index 7febe563..6237cd42 100644 --- a/website/content/docs/getting-started/performance.mdx +++ b/website/content/docs/getting-started/performance.mdx @@ -27,7 +27,7 @@ Times are microseconds per update, medians of three runs on an Apple M1 Max with | Update one of 10,000 records | 10,000 | 1,175 | 2,109 | 3,135 | 2,113 | | Insert into a 1,000-property object | — | 51.0 | 146 | 231 | 129 | | Update a Map value | 10,000 | 517 | 521 | 730 | 518 | -| Add a number to a Set | 10,000 | 33.2 | 938 | 1,011 | 65.6 | +| Add a number to a Set | 10,000 | 32.0 | 916 | 987 | 64.3 | | Update a class instance | 10,000 | 2.92 | 3.43 | 668 | 1.43 | | Update a value ten levels deep | — | 3.47 | 4.46 | 7.83 | 0.75 | | Apply patches to 10% of rows | 10,000 | 1,440 | 1,182 | 2,110 | — | From 9dd72496e444c67d986b0dcb7f3504cd72e4842d Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 18:32:51 +0800 Subject: [PATCH 12/21] fix: find an item that is a revoked draft with has() of a Set draft --- src/set.ts | 8 +++++--- test/set-copy.test.ts | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/set.ts b/src/set.ts index 3260980f..1650379e 100644 --- a/src/set.ts +++ b/src/set.ts @@ -73,11 +73,13 @@ export const setHandler = { }, has(value: any) { const items: Set = latest(getProxyDraft(this)!); - const valueProxyDraft = getProxyDraft(value); - // The value itself, or the item that a draft of it stands for. + let valueProxyDraft: ProxyDraft | null; + // The value itself, which may be a draft that its producer revoked, or the + // item that a draft of it stands for. return ( items.has(value) || - (!!valueProxyDraft && items.has(valueProxyDraft.original)) + (!!(valueProxyDraft = getProxyDraft(value)) && + items.has(valueProxyDraft.original)) ); }, add(value: any) { diff --git a/test/set-copy.test.ts b/test/set-copy.test.ts index cc3fd7fb..b609fadb 100644 --- a/test/set-copy.test.ts +++ b/test/set-copy.test.ts @@ -210,3 +210,18 @@ test('drafts added to a Set rebuild it once', () => { clear.mockRestore(); } }); + +test('a Set draft finds an item that is a draft of a finished nested producer', () => { + const state = create(new Set(), (draft) => { + let item: any; + create({ item: { value: 1 } }, (innerDraft) => { + item = innerDraft.item; + innerDraft.item.value = 2; + draft.add(item); + }); + expect(draft.has(item)).toBe(true); + draft.add(item); + expect(draft.size).toBe(1); + }); + expect([...state]).toEqual([{ value: 2 }]); +}); From 21c6a64d7fa2bfabc21bdf6876ca10920e3c58ec Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 18:33:44 +0800 Subject: [PATCH 13/21] fix: keep the patch positions of a Set subclass draft that orders its items otherwise --- src/current.ts | 3 ++- src/set.ts | 5 +++-- src/utils/copy.ts | 10 ++++++++-- src/utils/draft.ts | 12 +++++++++++- src/utils/finalize.ts | 3 ++- test/set-copy.test.ts | 32 ++++++++++++++++++++++++++++++++ 6 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/current.ts b/src/current.ts index db5e66b1..cf5f8c62 100644 --- a/src/current.ts +++ b/src/current.ts @@ -14,6 +14,7 @@ import { isDraft, isDraftable, isEqual, + iterateSet, latest, set, setItemValue, @@ -120,7 +121,7 @@ function getCurrent(target: any, rootOptions?: Options) { : type === DraftType.Set ? proxyDraft ? // The items in order, with the drafts that stand for some. - Array.from(latest(proxyDraft) as Set, (item) => + Array.from(iterateSet(latest(proxyDraft)), (item) => setItemValue(proxyDraft, item) ) : Array.from(target as Set) diff --git a/src/set.ts b/src/set.ts index 1650379e..339699de 100644 --- a/src/set.ts +++ b/src/set.ts @@ -7,6 +7,7 @@ import { getProxyDraft, getSetMap, isDraftable, + iterateSet, latest, markChanged, markFinalization, @@ -134,7 +135,7 @@ export const setHandler = { values(): IterableIterator { const target = getProxyDraft(this)!; ensureShallowCopy(target); - const iterator = target.copy!.values(); + const iterator = iterateSet(target.copy!); return { __proto__: iteratorPrototype, next: getNextIterator(target, iterator, true), @@ -143,7 +144,7 @@ export const setHandler = { entries(): IterableIterator<[any, any]> { const target = getProxyDraft(this)!; ensureShallowCopy(target); - const iterator = target.copy!.values(); + const iterator = iterateSet(target.copy!); return { __proto__: iteratorPrototype, next: getNextIterator( diff --git a/src/utils/copy.ts b/src/utils/copy.ts index 8b0dddf3..bd14bc07 100644 --- a/src/utils/copy.ts +++ b/src/utils/copy.ts @@ -1,6 +1,6 @@ import type { Options, ProxyDraft } from '../interface'; import { dataTypes } from '../constant'; -import { getValue, isDraft, isDraftable } from './draft'; +import { getValue, isDraft, isDraftable, iterateSet } from './draft'; import { isBaseMapInstance, isBaseSetInstance, isPlainArray } from './proto'; import { die, ErrorCode } from '../error'; @@ -128,7 +128,13 @@ export function shallowCopy(original: any, options?: Options) { } else if (original instanceof Set) { if (!isBaseSetInstance(original)) { const SubClass = Object.getPrototypeOf(original).constructor; - return new SubClass(original.values()); + const copy = new SubClass(original.values()); + // The copy of a Set draft holds the original items in their order, + // whatever the subclass's constructor or `add` does with them. + Set.prototype.clear.call(copy); + for (const item of iterateSet(original)) + Set.prototype.add.call(copy, item); + return copy; } // The Set draft of an outer create() call has no Set internals. return !isDraft(original) && Set.prototype.difference diff --git a/src/utils/draft.ts b/src/utils/draft.ts index 5a5c71d7..46ee4cc3 100644 --- a/src/utils/draft.ts +++ b/src/utils/draft.ts @@ -16,6 +16,16 @@ export function setItemValue(target: ProxyDraft, item: any) { return setMap && setMap.has(item) ? setMap.get(item) : item; } +const setValues = Set.prototype.values; + +// The items of a Set in the order in which it holds them, also for a subclass +// that iterates them otherwise, so that the positions in patch paths are those +// that the draft iterates. A draft of an outer producer iterates them so +// itself. +export function iterateSet(set: Set): IterableIterator { + return isDraft(set) ? set.values() : setValues.call(set); +} + /** * Check if the value is a draft */ @@ -94,7 +104,7 @@ export function getPath( // applied to whatever sits at the position in the base Set. An // unchanged Set holds its items in their original order. if (parent.assignedMap!.size > 0) return null; - key = Array.from(parent.original as Set).indexOf(key); + key = Array.from(iterateSet(parent.original)).indexOf(key); } else if (get(parentCopy, key) !== target.proxy) { // The child left its key: it was moved, deleted or replaced, possibly // by another draft of a shared object. The parent's patches carry its diff --git a/src/utils/finalize.ts b/src/utils/finalize.ts index 6aee5bfe..1ee735f9 100644 --- a/src/utils/finalize.ts +++ b/src/utils/finalize.ts @@ -7,6 +7,7 @@ import { isDraft, isDraftable, isEqual, + iterateSet, set, setItemValue, } from './draft'; @@ -92,7 +93,7 @@ export function finalizeSetValue(target: ProxyDraft, again?: boolean) { if (getValue(value) !== item) differs = true; }); if (!differs) return; - items = setMap!.items = Array.from(target.copy as Set); + items = setMap!.items = Array.from(iterateSet(target.copy)); } const copy: Set = target.copy; copy.clear(); diff --git a/test/set-copy.test.ts b/test/set-copy.test.ts index b609fadb..826add35 100644 --- a/test/set-copy.test.ts +++ b/test/set-copy.test.ts @@ -225,3 +225,35 @@ test('a Set draft finds an item that is a draft of a finished nested producer', }); expect([...state]).toEqual([{ value: 2 }]); }); + +test('patch paths keep the positions of the items of a Set subclass that orders them otherwise', () => { + class ReverseSet extends Set { + constructor(items: Iterable = []) { + super([...items].reverse()); + } + } + class ReverseValuesSet extends Set { + values() { + return [...super.values()].reverse().values(); + } + } + for (const Subclass of [ReverseSet, ReverseValuesSet]) { + const base = new Subclass([ + { id: 'a', value: 1 }, + { id: 'b', value: 2 }, + ]); + const [state, patches, inversePatches] = create( + base, + (draft) => { + for (const item of draft) if (item.id === 'a') item.value = 3; + }, + { enablePatches: true } + ); + expect(state).toBeInstanceOf(Subclass); + expect([...state].map((item) => item.id)).toEqual( + [...base].map((item) => item.id) + ); + expect([...apply(base, patches)]).toEqual([...state]); + expect([...apply(state, inversePatches)]).toEqual([...base]); + } +}); From 2ee6b47e5a74ebaa43b87ab3b42a740bcaeaff6b Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 18:34:03 +0800 Subject: [PATCH 14/21] docs: note how a draft of a Set subclass orders its items and what its add() must store --- README.md | 2 +- website/content/docs/advanced-guides/migration-v2.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6ea20198..752fefed 100644 --- a/README.md +++ b/README.md @@ -793,7 +793,7 @@ Mutative v2 keeps the v1 API. The changes below, made since v1.3.0, can affect e - With `enablePatches`, every changed item of a Set keeps its changes. In v1, when two or more items of a Set that was not the root changed below their first level, the state kept the change of only one of them. - A Set that receives an unchanged draft holds the original object, as the rest of the state does, instead of the draft's shallow copy. - A Set draft that the recipe moves to another place, such as another key, an array, a Map, another Set or a new object, keeps its added, deleted and changed items there when its old place gets another value or is removed; v1 kept its original items. -- A draft of a Set subclass works on its copy, an instance of the subclass created from the original's `values()`, as Map drafts do: the methods that the subclass overrides, such as `add`, `has`, `delete` or `values`, take effect during the recipe, and the next state keeps the items in the order of the copy. v1 kept the items of a changed Set in a separate map in their original order, which bypassed those methods until it rebuilt the Set after the recipe. +- A draft of a Set subclass changes its copy, an instance of the subclass, as Map drafts do: the subclass's own `add`, `has`, `delete` and `clear` take effect during the recipe, while the draft iterates the items in the order in which the Set holds them. v1 kept the items of a changed Set in a separate map, which bypassed those methods until it rebuilt the Set after the recipe. As a Map subclass's `set` already had to in v1, a Set subclass's `add` must store the value that it receives: an `add` that stores a copy of a draft, or of an object that holds drafts, leaves those drafts in the next state, where reading them throws. - Assigning `undefined` to a key that `delete`, `shift`, `unshift` or a shrinking `splice` removed from a draft adds the key back; v1 left a hole or kept the shorter length. - Under a `mark` that returns `mutable`, a value that the recipe assigned or moved is read back as assigned, through the draft and in `current()`; v1 returned the original value. - `current()` of a draft whose state holds a plain Set with drafts returns a snapshot; v1 threw. diff --git a/website/content/docs/advanced-guides/migration-v2.mdx b/website/content/docs/advanced-guides/migration-v2.mdx index 7ba6b641..d4ba16b6 100644 --- a/website/content/docs/advanced-guides/migration-v2.mdx +++ b/website/content/docs/advanced-guides/migration-v2.mdx @@ -46,7 +46,7 @@ Mutative v2 keeps the v1 API. The changes below, made since v1.3.0, can affect e - With `enablePatches`, every changed item of a Set keeps its changes. In v1, when two or more items of a Set that was not the root changed below their first level, the state kept the change of only one of them. - A Set that receives an unchanged draft holds the original object, as the rest of the state does, instead of the draft's shallow copy. - A Set draft that the recipe moves to another place, such as another key, an array, a Map, another Set or a new object, keeps its added, deleted and changed items there when its old place gets another value or is removed; v1 kept its original items. -- A draft of a Set subclass works on its copy, an instance of the subclass created from the original's `values()`, as Map drafts do: the methods that the subclass overrides, such as `add`, `has`, `delete` or `values`, take effect during the recipe, and the next state keeps the items in the order of the copy. v1 kept the items of a changed Set in a separate map in their original order, which bypassed those methods until it rebuilt the Set after the recipe. +- A draft of a Set subclass changes its copy, an instance of the subclass, as Map drafts do: the subclass's own `add`, `has`, `delete` and `clear` take effect during the recipe, while the draft iterates the items in the order in which the Set holds them. v1 kept the items of a changed Set in a separate map, which bypassed those methods until it rebuilt the Set after the recipe. As a Map subclass's `set` already had to in v1, a Set subclass's `add` must store the value that it receives: an `add` that stores a copy of a draft, or of an object that holds drafts, leaves those drafts in the next state, where reading them throws. - Assigning `undefined` to a key that `delete`, `shift`, `unshift` or a shrinking `splice` removed from a draft adds the key back; v1 left a hole or kept the shorter length. - Under a `mark` that returns `mutable`, a value that the recipe assigned or moved is read back as assigned, through the draft and in `current()`; v1 returned the original value. - `current()` of a draft whose state holds a plain Set with drafts returns a snapshot; v1 threw. From 47e0d0b39d733b58dd78590292fe7c49e9442209 Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 18:35:09 +0800 Subject: [PATCH 15/21] perf: rebuild a Set draft that changed only through its drafted items without searching them first --- src/utils/draft.ts | 7 ++++--- src/utils/finalize.ts | 32 ++++++++++++++++++++++---------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/utils/draft.ts b/src/utils/draft.ts index 46ee4cc3..145b82fb 100644 --- a/src/utils/draft.ts +++ b/src/utils/draft.ts @@ -10,10 +10,11 @@ export function getSetMap(target: ProxyDraft): Map { return target.setMap || (target.setMap = new Map()); } -// The value that an item of a Set draft stands for. +// The value that an item of a Set draft stands for; `setMap` holds no +// `undefined` value. export function setItemValue(target: ProxyDraft, item: any) { - const setMap = target.setMap; - return setMap && setMap.has(item) ? setMap.get(item) : item; + const value = target.setMap && target.setMap.get(item); + return value === undefined ? item : value; } const setValues = Set.prototype.values; diff --git a/src/utils/finalize.ts b/src/utils/finalize.ts index 1ee735f9..29260135 100644 --- a/src/utils/finalize.ts +++ b/src/utils/finalize.ts @@ -9,7 +9,6 @@ import { isEqual, iterateSet, set, - setItemValue, } from './draft'; import { forEach } from './forEach'; @@ -83,21 +82,34 @@ export type GeneratePatches = ( // an outer producer that a nested producer's Set holds; other Sets may still // be changing, and are rebuilt when their own producer finalizes them. export function finalizeSetValue(target: ProxyDraft, again?: boolean) { - // Only Set drafts have `setMap`. + // Only Set drafts have `setMap`, and an unchanged draft stands for its + // original. const setMap = target.setMap; - let items = setMap && setMap.items; + if (!setMap || !target.operated) return; + let items = setMap.items; if (!items) { - let differs = false; - if (setMap && !again) - setMap.forEach((value, item) => { - if (getValue(value) !== item) differs = true; - }); + if (again) return; + // Without added or deleted items, an item that the Set drafted changed, + // unless the recipe added and deleted a draft; rebuilding then changes + // nothing. + let differs = target.assignedMap!.size === 0; + if (!differs) { + for (const [item, value] of setMap) { + if (getValue(value) !== item) { + differs = true; + break; + } + } + } if (!differs) return; - items = setMap!.items = Array.from(iterateSet(target.copy)); + items = setMap.items = Array.from(iterateSet(target.copy)); } const copy: Set = target.copy; copy.clear(); - items.forEach((item) => copy.add(getValue(setItemValue(target, item)))); + items.forEach((item) => { + const value = setMap.get(item); + copy.add(value === undefined ? item : getValue(value)); + }); } export function finalizePatches( From d50f16369b8dd9056df637c11f23513b618095f1 Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 18:36:53 +0800 Subject: [PATCH 16/21] build: raise the size limits and refresh the baseline for Set subclass drafts that keep their order --- package.json | 4 +- scripts/build-size-baseline.json | 82 ++++++++++++++++---------------- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/package.json b/package.json index d4441591..7ea3213a 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "name": "production CJS, all exports", "path": "dist/mutative.cjs.production.min.js", "import": "*", - "limit": "8.5 KB" + "limit": "8.6 KB" }, { "name": "ESM, create", @@ -73,7 +73,7 @@ "name": "ESM, all exports", "path": "dist/mutative.esm.mjs", "import": "*", - "limit": "8.3 KB" + "limit": "8.4 KB" } ], "repository": { diff --git a/scripts/build-size-baseline.json b/scripts/build-size-baseline.json index 172173b1..eb0f3f44 100644 --- a/scripts/build-size-baseline.json +++ b/scripts/build-size-baseline.json @@ -1,7 +1,7 @@ { "reference": { - "revision": "ba9b907d23e7ffd39ffaec1cc3dad4d7fcd1ada1", - "description": "Set drafts add and delete items in their copy, map only the items that drafts stand for and are rebuilt once their own producer finalizes them, and a Set draft that left its key keeps its changes", + "revision": "47e0d0b39d733b58dd78590292fe7c49e9442209", + "description": "Set drafts add and delete items in their copy, map only the items that drafts stand for and are rebuilt once, by their own producer, a Set subclass draft keeps its items in their order, and a Set draft that left its key keeps its changes", "node": "v24.16.0", "commands": [ "pnpm install --frozen-lockfile", @@ -15,69 +15,69 @@ "esbuild": "0.24.0", "measurements": { "artifact/mutative.cjs.production.min.js": { - "raw": 26809, - "gzip": 8990, - "brotli": 8237 + "raw": 27086, + "gzip": 9072, + "brotli": 8322 }, "artifact/mutative.umd.production.min.js": { - "raw": 26963, - "gzip": 9050, - "brotli": 8304 + "raw": 27243, + "gzip": 9137, + "brotli": 8381 }, "artifact/mutative.esm.production.min.mjs": { - "raw": 26781, - "gzip": 9012, - "brotli": 8272 + "raw": 27061, + "gzip": 9099, + "brotli": 8350 }, "artifact/mutative.cjs.development.js": { - "raw": 72185, - "gzip": 17335, - "brotli": 15085 + "raw": 72637, + "gzip": 17466, + "brotli": 15192 }, "artifact/mutative.umd.development.js": { - "raw": 74429, - "gzip": 17537, - "brotli": 15251 + "raw": 74896, + "gzip": 17665, + "brotli": 15353 }, "artifact/mutative.esm.js": { - "raw": 73037, - "gzip": 17445, - "brotli": 15146 + "raw": 73489, + "gzip": 17581, + "brotli": 15258 }, "artifact/mutative.esm.mjs": { - "raw": 73038, - "gzip": 17447, - "brotli": 15148 + "raw": 73490, + "gzip": 17582, + "brotli": 15256 }, "consumer/import/create": { - "raw": 24163, - "gzip": 8441, - "brotli": 7750 + "raw": 24418, + "gzip": 8518, + "brotli": 7830 }, "consumer/import/patches": { - "raw": 26056, - "gzip": 9050, - "brotli": 8310 + "raw": 26311, + "gzip": 9141, + "brotli": 8378 }, "consumer/import/all": { - "raw": 26841, - "gzip": 9316, - "brotli": 8535 + "raw": 27096, + "gzip": 9395, + "brotli": 8607 }, "consumer/require/create": { - "raw": 26326, - "gzip": 9311, - "brotli": 8534 + "raw": 26598, + "gzip": 9393, + "brotli": 8617 }, "consumer/require/patches": { - "raw": 26394, - "gzip": 9349, - "brotli": 8571 + "raw": 26666, + "gzip": 9432, + "brotli": 8649 }, "consumer/require/all": { - "raw": 26291, - "gzip": 9289, - "brotli": 8517 + "raw": 26563, + "gzip": 9372, + "brotli": 8591 } } } From adb385aa0d0bf4d6c26439576de9efc18fb5b404 Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 18:58:53 +0800 Subject: [PATCH 17/21] docs: refresh the Set figures with the scenarios measured again after the rebuild and subclass changes --- README.md | 6 +- perf-testing/reports/SUMMARY.md | 66 +++++++++---------- .../docs/getting-started/performance.mdx | 6 +- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 752fefed..a4280a9d 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Times are microseconds per update, medians of three runs on an Apple M1 Max with | Update one of 10,000 records | 10,000 | 1,175 | 2,109 | 3,135 | 2,113 | | Insert into a 1,000-property object | — | 51.0 | 146 | 231 | 129 | | Update a Map value | 10,000 | 517 | 521 | 730 | 518 | -| Add a number to a Set | 10,000 | 32.0 | 916 | 987 | 64.3 | +| Add a number to a Set | 10,000 | 34.5 | 938 | 1,006 | 66.8 | | Update a class instance | 10,000 | 2.92 | 3.43 | 668 | 1.43 | | Update a value ten levels deep | — | 3.47 | 4.46 | 7.83 | 0.75 | | Apply patches to 10% of rows | 10,000 | 1,440 | 1,182 | 2,110 | — | @@ -69,9 +69,9 @@ At 1,000 and 10,000 rows the gap grows. Against Immer without its array-method p | Auto-freeze | Patches | All workloads, 1,000 rows | All workloads, 10,000 rows | Moves, 1,000 rows | Moves, 10,000 rows | | ----------- | ------- | ------------------------: | -------------------------: | ----------------: | -----------------: | -| off | off | 8.7x | 10.9x | 211x | 442x | +| off | off | 8.8x | 10.9x | 211x | 442x | | off | on | 4.5x | 5.2x | 7.1x | 7.5x | -| on | off | 3.8x | 4.2x | 32x | 40x | +| on | off | 3.9x | 4.2x | 32x | 40x | | on | on | 2.8x | 3.0x | 6.6x | 7.1x | Each value is the geometric mean of Immer's time over Mutative's; moves are `shift`, `unshift`, `splice` insertion, and `reverse`. Mutative moves elements natively on its copy, while Immer moves each one through its draft proxy: removing the first of 10,000 rows took 7.10 µs against 8,605 µs, 1,213x. With patches, both libraries emit one patch per moved index, which bounds the gain to 5-10x. The [performance summary](./perf-testing/reports/SUMMARY.md) breaks these results down by scenario. diff --git a/perf-testing/reports/SUMMARY.md b/perf-testing/reports/SUMMARY.md index dedd1e70..3e9bd969 100644 --- a/perf-testing/reports/SUMMARY.md +++ b/perf-testing/reports/SUMMARY.md @@ -26,9 +26,9 @@ Cells faster / within 5% / slower, and the geometric mean of comparator time ove | Comparator | 100 rows | 1,000 and 10,000 rows | All | | --- | ---: | ---: | ---: | -| Immer 11.1.18 | 358 / 6 / 2 of 366, 3.05 | 190 / 6 / 4 of 200, 4.88 | 548 / 12 / 6 of 566, 3.60 | -| Immer 11.1.18 with its defaults | 92 / 0 / 1 of 93, 6.30 | 50 / 0 / 2 of 52, 7.10 | 142 / 0 / 3 of 145, 6.58 | -| Pinned npm Mutative 1.3.0 | 332 / 33 / 1 of 366, 3.34 | 163 / 37 / 0 of 200, 4.85 | 495 / 70 / 1 of 566, 3.81 | +| Immer 11.1.18 | 358 / 6 / 2 of 366, 3.06 | 190 / 6 / 4 of 200, 4.92 | 548 / 12 / 6 of 566, 3.62 | +| Immer 11.1.18 with its defaults | 92 / 0 / 1 of 93, 6.33 | 50 / 0 / 2 of 52, 7.16 | 142 / 0 / 3 of 145, 6.62 | +| Pinned npm Mutative 1.3.0 | 332 / 33 / 1 of 366, 3.36 | 163 / 37 / 0 of 200, 4.89 | 495 / 70 / 1 of 566, 3.83 | | Hand-written reducer | 7 / 0 / 83 of 90, 0.11 | 11 / 2 / 35 of 48, 0.12 | 18 / 2 / 118 of 138, 0.12 | Against Immer by workload group, faster / within 5% / slower and geometric mean: @@ -41,7 +41,7 @@ Against Immer by workload group, faster / within 5% / slower and geometric mean: | Small state | 12 / 0 / 0, 1.66 | — | — | | Mutation density | 12 / 0 / 0, 2.10 | 4 / 0 / 0, 1.90 | 4 / 0 / 0, 1.92 | | Array methods | 132 / 0 / 0, 5.75 | 20 / 0 / 0, 23.77 | 20 / 0 / 0, 31.12 | -| Map and Set | 35 / 1 / 0, 2.48 | 14 / 2 / 0, 3.39 | 14 / 2 / 0, 3.62 | +| Map and Set | 35 / 1 / 0, 2.60 | 14 / 2 / 0, 3.62 | 14 / 2 / 0, 3.72 | | Object records | 11 / 1 / 0, 1.26 | 8 / 0 / 0, 2.34 | 8 / 0 / 0, 1.61 | | Class instances | 6 / 2 / 0, 1.74 | 6 / 0 / 2, 1.61 | 6 / 2 / 0, 1.64 | | Deep path | 4 / 0 / 0, 1.54 | — | — | @@ -58,14 +58,14 @@ Against Immer without its array-method plugin, by size and mode, over the 26 sca | Rows | Freeze | Patches | All scenarios | Geometric mean | Moves, geometric mean | Moves, range | | ---: | --- | --- | ---: | ---: | ---: | ---: | -| 1,000 | off | off | 24 / 1 / 1 | 8.71 | 211 | 72.0–492 | -| 1,000 | off | on | 23 / 1 / 0 | 4.49 | 7.1 | 6.1–7.5 | -| 1,000 | on | off | 25 / 0 / 1 | 3.84 | 32.4 | 18.5–46.9 | -| 1,000 | on | on | 23 / 0 / 1 | 2.76 | 6.6 | 6.1–6.9 | -| 10,000 | off | off | 24 / 1 / 1 | 10.89 | 442 | 176–1,213 | -| 10,000 | off | on | 23 / 1 / 0 | 5.20 | 7.5 | 5.0–9.7 | -| 10,000 | on | off | 25 / 1 / 0 | 4.22 | 39.7 | 22.2–53.0 | -| 10,000 | on | on | 23 / 1 / 0 | 3.00 | 7.1 | 5.1–9.0 | +| 1,000 | off | off | 24 / 1 / 1 | 8.82 | 211 | 72.0–492 | +| 1,000 | off | on | 23 / 1 / 0 | 4.55 | 7.1 | 6.1–7.5 | +| 1,000 | on | off | 25 / 0 / 1 | 3.88 | 32.4 | 18.5–46.9 | +| 1,000 | on | on | 23 / 0 / 1 | 2.78 | 6.6 | 6.1–6.9 | +| 10,000 | off | off | 24 / 1 / 1 | 10.92 | 442 | 176–1,213 | +| 10,000 | off | on | 23 / 1 / 0 | 5.23 | 7.5 | 5.0–9.7 | +| 10,000 | on | off | 25 / 1 / 0 | 4.24 | 39.7 | 22.2–53.0 | +| 10,000 | on | on | 23 / 1 / 0 | 3.02 | 7.1 | 5.1–9.0 | Immer time over candidate time for the moves at 10,000 rows, by freeze and patch mode: @@ -81,7 +81,7 @@ The gap widens with size. With freeze and patches off, the moves were 211 times ### Freeze off, patches on -With patches on and freeze off, the candidate was faster than Immer in 136 of 138 cells and within 5% in the other 2, the Map updates at 1,000 and 10,000 rows (geometric mean 3.40). It was faster than Mutative 1.3.0 in 120 and within 5% in 18 (4.19). In the 67 array-related cells, array methods, searches and the upstream array workloads at every size, it was faster than both in every cell: 4.35 times Immer and 12.56 times Mutative 1.3.0 on geometric mean. +With patches on and freeze off, the candidate was faster than Immer in 136 of 138 cells and within 5% in the other 2, the Map updates at 1,000 and 10,000 rows (geometric mean 3.42). It was faster than Mutative 1.3.0 in 120 and within 5% in 18 (4.21). In the 67 array-related cells, array methods, searches and the upstream array workloads at every size, it was faster than both in every cell: 4.35 times Immer and 12.56 times Mutative 1.3.0 on geometric mean. Patches cost little when an update changes a few paths. They added a median 1% to the candidate's time at 1,000 and 10,000 rows and 20% at 100 rows. The former `benchmark:base` workload, `push-and-insert` at 10,000 rows, took 62.8 µs without patches and 63.3 µs with them; Mutative 1.3.0 went from 67.0 µs to 208 µs, and Immer from 162 µs to 364 µs. Moving elements is the exception: both libraries emit one patch per moved index, so `shift` at 10,000 rows produces 10,000 forward and 10,000 inverse patches in each. The candidate's time grows from 7.10 µs to 1,071 µs, against 10,376 µs for Immer producing the same patches. @@ -174,19 +174,19 @@ A snapshot search was faster than the draft search at every size: 1.7 times at 1 | Scenario | Items | Freeze | Patches | `main` | PR #195 | PR/`main` | | --- | ---: | --- | --- | ---: | ---: | ---: | -| set-add | 100 | off | off | 6.54 | 0.69 | 0.11 | -| set-add | 100 | on | off | 6.87 | 1.12 | 0.16 | -| set-add | 100 | off | on | 8.39 | 2.60 | 0.31 | -| set-delete | 100 | off | off | 6.42 | 0.70 | 0.11 | -| set-add | 1,000 | off | off | 67.6 | 1.30 | 0.02 | -| set-add | 10,000 | off | off | 988 | 33.9 | 0.03 | -| set-add | 10,000 | on | off | 1,008 | 63.8 | 0.06 | -| set-add | 10,000 | off | on | 1,407 | 454 | 0.32 | -| set-update | 100 | off | off | 8.26 | 4.71 | 0.57 | -| set-update | 10,000 | off | off | 1,067 | 644 | 0.60 | -| set-update-10pct | 100 | off | off | 10.97 | 7.97 | 0.73 | - -Over all 32 cells, four Set scenarios at 100 rows and two at 1,000 and 10,000 rows in every mode, the PR took 0.31 of the time of `main` on geometric mean and was faster in each. Additions and deletions no longer copy the items into a Map and back, so without patches they now cost little more than copying the Set: `set-add` at 10,000 items allocated 325 KiB per update, against 2,881 KiB for Mutative 1.3.0. With patches, a Set that added or deleted items still compares all items to emit its own patches, which bounds that gain to about 3 times, and a changed item still rebuilds the Set in order. Against the other libraries the candidate was faster than Immer in every Set cell, 3.0–50.6 times, and faster than the hand-written reducer in `set-add` at 1,000 and 10,000 rows, where `Set.prototype.difference` copies the Set faster than the reducer's `new Set()`. +| set-add | 100 | off | off | 6.62 | 0.72 | 0.11 | +| set-add | 100 | on | off | 6.92 | 1.13 | 0.16 | +| set-add | 100 | off | on | 8.56 | 2.62 | 0.31 | +| set-delete | 100 | off | off | 6.56 | 0.70 | 0.11 | +| set-add | 1,000 | off | off | 67.6 | 1.31 | 0.02 | +| set-add | 10,000 | off | off | 987 | 35.6 | 0.04 | +| set-add | 10,000 | on | off | 1,002 | 62.6 | 0.06 | +| set-add | 10,000 | off | on | 1,397 | 459 | 0.33 | +| set-update | 100 | off | off | 8.24 | 3.42 | 0.41 | +| set-update | 10,000 | off | off | 1,057 | 548 | 0.52 | +| set-update-10pct | 100 | off | off | 11.19 | 7.09 | 0.63 | + +Over all 32 cells, four Set scenarios at 100 rows and two at 1,000 and 10,000 rows in every mode, the PR took 0.28 of the time of `main` on geometric mean and was faster in each. Additions and deletions no longer copy the items into a Map and back, so without patches they now cost little more than copying the Set: `set-add` at 10,000 items allocated 325 KiB per update, against 2,878 KiB for Mutative 1.3.0. With patches, a Set that added or deleted items still compares all items to emit its own patches, which bounds that gain to about 3 times, and a changed item still rebuilds the Set in order. Against the other libraries the candidate was faster than Immer in every Set cell, 3.0–50.7 times, and faster than the hand-written reducer in `set-add` at 1,000 and 10,000 rows, where `Set.prototype.difference` copies the Set faster than the reducer's `new Set()`. ### Selected times @@ -203,7 +203,7 @@ Microseconds per complete scenario, medians of three runs. | update-largeObject1 | 100 | on | off | 132 | 138 | 231 | — | 1.75 | | object-update | 100 | off | off | 3.16 | 6.12 | 3.45 | 2.14 | 1.09 | | map-update | 100 | off | off | 4.55 | 5.19 | 4.95 | 3.66 | 1.09 | -| set-update | 100 | off | off | 4.66 | 9.00 | 42.1 | 2.11 | 9.03 | +| set-update | 100 | off | off | 3.37 | 9.04 | 43.3 | 2.15 | 12.83 | | class-update | 100 | on | off | 3.86 | 5.22 | 9.24 | — | 2.40 | | apply-array-ops | 100 | off | off | 4.06 | 116 | 101 | — | 24.83 | | return-filter | 100 | off | off | 23.8 | 26.2 | 32.6 | 0.19 | 1.37 | @@ -217,7 +217,7 @@ Microseconds per complete scenario, medians of three runs. | array-reverse-nested | 10,000 | off | off | 13.7 | 8,627 | 7,555 | 9.68 | 551.48 | | array-reverse-nested | 10,000 | off | on | 1,074 | 277,427 | 9,416 | — | 8.76 | | read-index | 10,000 | off | off | 5,094 | 6,293 | 11,773 | 55.5 | 2.31 | -| set-update | 10,000 | off | off | 622 | 1,049 | 6,138 | 404 | 9.87 | +| set-update | 10,000 | off | off | 550 | 1,070 | 6,203 | 413 | 11.27 | | object-update | 10,000 | off | off | 1,175 | 1,189 | 2,109 | 2,113 | 1.79 | | apply-reverse | 10,000 | off | off | 12,590 | 13,801 | 13,864 | — | 1.10 | | push-and-insert | 10,000 | off | on | 63.3 | 208 | 364 | — | 5.76 | @@ -226,7 +226,7 @@ Microseconds per complete scenario, medians of three runs. ### Against a hand-written reducer -The hand-written reducer is faster than the candidate in 118 of 138 cells, and across all 138 the candidate takes 8.6 times as long on geometric mean: the reducer copies only what each update changes and has no draft to create, track, or finalize. The candidate is faster in 18 cells. Fifteen copy wide objects: objects with 1,000 and 3,000 properties, records with 1,000 or 10,000 keys, and class instances with 100 or more fields, by 1.5–2.7 times. One inserts at the front of an array of 10,000 rows, where the reducer's spread copies every element and `unshift` runs natively on the draft's copy: 15.9 µs against 28.3 µs; at 100 and 1,000 rows the reducer was faster. Two add a number to a Set of 1,000 or 10,000 numbers, which the candidate copies with `Set.prototype.difference`, faster than the reducer's `new Set()`: 1.27 µs against 4.06 µs and 32.0 µs against 64.3 µs; at 100 numbers the reducer was faster, 0.44 µs against 0.70 µs. Copying a Map of 1,000 or 10,000 entries dominates `map-update` in both, which are within 5%. The hand-written reducer is the same spread-based code that validates every scenario; faster hand-written code exists for many of them. +The hand-written reducer is faster than the candidate in 118 of 138 cells, and across all 138 the candidate takes 8.5 times as long on geometric mean: the reducer copies only what each update changes and has no draft to create, track, or finalize. The candidate is faster in 18 cells. Fifteen copy wide objects: objects with 1,000 and 3,000 properties, records with 1,000 or 10,000 keys, and class instances with 100 or more fields, by 1.5–2.7 times. One inserts at the front of an array of 10,000 rows, where the reducer's spread copies every element and `unshift` runs natively on the draft's copy: 15.9 µs against 28.3 µs; at 100 and 1,000 rows the reducer was faster. Two add a number to a Set of 1,000 or 10,000 numbers, which the candidate copies with `Set.prototype.difference`, faster than the reducer's `new Set()`: 1.31 µs against 4.17 µs and 34.5 µs against 66.8 µs; at 100 numbers the reducer was faster, 0.44 µs against 0.70 µs. Copying a Map of 1,000 or 10,000 entries dominates `map-update` in both, which are within 5%. The hand-written reducer is the same spread-based code that validates every scenario; faster hand-written code exists for many of them. ### Shared and isolated processes @@ -252,7 +252,7 @@ Sampled allocation of the candidate was below Mutative 1.3.0 in 107 of 114 memor | read-index | 100 | off | 98.6 | 150.1 | 192.3 | 1.7 | | push-and-insert | 100 | off | 115.2 | 1,365.6 | 66.0 | 19.3 | | object-update | 1,000 | off | 112.9 | 1,311.7 | 15.9 | 10.9 | -| set-add | 10,000 | off | 325.0 | 2,881.1 | 1,605.9 | 321.7 | +| set-add | 10,000 | off | 325.2 | 2,877.5 | 1,606.8 | 321.7 | | array-reverse-nested | 10,000 | off | 89.0 | 11,077.6 | 12,840.4 | 83.4 | | array-reverse-nested | 10,000 | on | 3,028.5 | 268,460.6 | 15,102.0 | — | @@ -305,7 +305,7 @@ In 10 cells every process was slower than every process of the previous batch: ` ## Tradeoffs and limits - Returned values: without auto-freeze, the candidate searches every object of a returned value for drafts, as Immer does: `return-replace` took 2.59 ms at 10,000 rows, against 5.63 ms for Immer without auto-freeze. Immer's default auto-freeze skips frozen values (0.79 µs), and so do production builds of the candidate with auto-freeze (0.53 µs). Wrapping the value in `rawReturn()` skips the search (0.26 µs). -- A Set draft copies the Set on its first change and rebuilds it in order once an item that the recipe read through an iterator changed: `set-update` took 622 µs at 10,000 items, against 404 µs for the hand-written reducer, which copies the Set once. With patches, a Set that added or deleted items compares every original item with its copy and every item of the copy with the original: `set-add` took 446 µs with patches at 10,000 items, against 32.0 µs without. Copying the Map dominates `map-update` at 10,000 entries in every library, about 520 µs. +- A Set draft copies the Set on its first change and rebuilds it in order once an item that the recipe read through an iterator changed: `set-update` took 550 µs at 10,000 items, against 413 µs for the hand-written reducer, which copies the Set once. With patches, a Set that added or deleted items compares every original item with its copy and every item of the copy with the original: `set-add` took 455 µs with patches at 10,000 items, against 34.5 µs without. Copying the Map dominates `map-update` at 10,000 entries in every library, about 520 µs. - With auto-freeze, Immer updated a class instance with 1,000 fields faster: 239 µs against 411 µs. Without freezing the candidate is faster, 98 µs against 206 µs, so the difference lies in freezing the candidate's copy, which it makes property by property with descriptors where Immer 11 uses `Object.assign`. - Applying patches that replace nested values was 16–22% slower than Immer; structural patches are faster, as `apply-array-ops` shows (25 times). - Even in processes of their own, some cells varied between processes of the same build by up to about 1.5 times, for example `remove-reuse` with freeze on (127–191 µs) and Immer's `map-read` at 10,000 rows; `class-wide-update` without freezing at 1,000 rows took either about 95 µs or about 200 µs in both Mutative builds. Medians of three processes absorb one outlier, not two; compare such cells through geometric means rather than one by one. @@ -343,7 +343,7 @@ Immer production: 30fec64eb16c235f5fe771ed7e4be08eb0f67cb46c44917424a6e643f3 The Set scenarios of 2026-10-09 measured the production artifact of PR #195, which its head builds unchanged, and compared it with `main` at `e6af4b0`: ```text -PR #195 production: 1e4f18978a918493d1614108a8579d23ac59c562fc2465bfdde480b285bd15a7 +PR #195 production: c97341095e0064c5c5db38a6c26a107db55c078184b30639b5c0733e84960490 main production: b70312b6faedc295e645a4d81050ebb982878414fe7d4c15e7e5f55b7c27e7c5 ``` @@ -400,7 +400,7 @@ MUTATIVE_PERF_BUNDLE="perf-testing/dist/$NAME/immutability-benchmarks.mjs" node Earlier versions of this summary, in Git history, describe each round in detail. Cell counts compare the candidate with the named comparator: faster, within 5%, and slower. -- 2026-10-09, PR #195: Set drafts add and delete items in their copy, map only the items that drafts stand for and are rebuilt once, by their own producer, and a Set draft that left its key keeps its changes, which 1.3.0 also lost. Against `main` at `e6af4b0`, Mutative alone, the PR took 0.31 of the time over 32 Set cells; `set-add` at 10,000 items took 33.9 µs instead of 988 µs. The Set scenarios, measured again with every library in processes of its own, replace the earlier cells: 548 / 12 / 6 of 566 against Immer (geometric mean 3.60), 142 / 0 / 3 of 145 with each library's defaults (6.58), 495 / 70 / 1 against 1.3.0 (3.81), and 18 / 2 / 118 against the hand-written reducer. The production CJS artifact grew from 8,201 to 8,237 Brotli bytes. +- 2026-10-09, PR #195: Set drafts add and delete items in their copy, map only the items that drafts stand for and are rebuilt once, by their own producer, drafts of Set subclasses keep their items in order, and a Set draft that left its key keeps its changes, which 1.3.0 also lost. Against `main` at `e6af4b0`, Mutative alone, the PR took 0.28 of the time over 32 Set cells; `set-add` at 10,000 items took 35.6 µs instead of 987 µs. The Set scenarios, measured again with every library in processes of its own, replace the earlier cells: 548 / 12 / 6 of 566 against Immer (geometric mean 3.62), 142 / 0 / 3 of 145 with each library's defaults (6.62), 495 / 70 / 1 against 1.3.0 (3.83), and 18 / 2 / 118 against the hand-written reducer. The production CJS artifact grew from 8,201 to 8,322 Brotli bytes. - 2026-10-05, PR #184 review fixes: the fixed source at `6b08f57` against `main` at `ba8d0cd`, Mutative only, all 93 scenarios at 100 rows in both freeze and patch modes, three runs each: fixed/main geometric mean 0.982 over 366 cells, 42 cells more than 5% faster and 6 more than 5% slower; the slower wide-object cells measured 0.77–1.02 in isolated processes. Map iteration with the iterator lifetime checks took 1–2.4% longer than without them and about 70% less than `main` for primitive entries. An update beside an unchanged frozen 10,000-entry Map or Set took 0.44 µs instead of 66.7 µs and 26.1 µs. The production CJS artifact grew from 8,079 to 8,106 Brotli bytes. - 2026-10-04, PR #180 at `e70aeed`: `current()` copies changed array drafts from their current array and leaves moved base elements as they are, and strict mode warns once in development builds when a recipe leaves 1,000 or more drafts unchanged. The three `search-*` scenarios were added: a `current()` snapshot search at 10,000 rows took 209 µs instead of 3,262 µs, and 772 µs instead of 7,490 µs after `shift()`. Gate 48 of 48. The production CJS artifact grew from 8,031 to 8,104 Brotli bytes against `main`. - 2026-10-03 and 2026-10-04, the source of `main` at `e6b6563` with the harness of PR #177, 90 scenarios at 100 rows and 23 at 1,000 and 10,000, in shared processes except for the wide-object scenarios: 508 / 11 / 11 of 530 cells against Immer (geometric mean 3.07), 133 / 0 / 3 of 136 with each library's defaults (6.01), 417 / 111 / 2 against pinned 1.3.0 (3.16), and 18 / 2 / 109 of 129 against the hand-written reducer (0.17). Immer was faster in `return-replace` with freeze on (2.9 ms against 0.9 µs at 10,000 rows), `apply-update-10pct` and `class-wide-update` with freeze on at 1,000 rows. With Immer's `enableArrayMethods`, 229 / 5 / 46 of 280 cells (1.63). Allocation below 1.3.0 in 104 of 114 memory cells; gate 48 of 48. diff --git a/website/content/docs/getting-started/performance.mdx b/website/content/docs/getting-started/performance.mdx index 6237cd42..afef36c6 100644 --- a/website/content/docs/getting-started/performance.mdx +++ b/website/content/docs/getting-started/performance.mdx @@ -27,7 +27,7 @@ Times are microseconds per update, medians of three runs on an Apple M1 Max with | Update one of 10,000 records | 10,000 | 1,175 | 2,109 | 3,135 | 2,113 | | Insert into a 1,000-property object | — | 51.0 | 146 | 231 | 129 | | Update a Map value | 10,000 | 517 | 521 | 730 | 518 | -| Add a number to a Set | 10,000 | 32.0 | 916 | 987 | 64.3 | +| Add a number to a Set | 10,000 | 34.5 | 938 | 1,006 | 66.8 | | Update a class instance | 10,000 | 2.92 | 3.43 | 668 | 1.43 | | Update a value ten levels deep | — | 3.47 | 4.46 | 7.83 | 0.75 | | Apply patches to 10% of rows | 10,000 | 1,440 | 1,182 | 2,110 | — | @@ -44,9 +44,9 @@ At 1,000 and 10,000 rows the gap grows. Against Immer without its array-method p | Auto-freeze | Patches | All workloads, 1,000 rows | All workloads, 10,000 rows | Moves, 1,000 rows | Moves, 10,000 rows | | --- | --- | ---: | ---: | ---: | ---: | -| off | off | 8.7x | 10.9x | 211x | 442x | +| off | off | 8.8x | 10.9x | 211x | 442x | | off | on | 4.5x | 5.2x | 7.1x | 7.5x | -| on | off | 3.8x | 4.2x | 32x | 40x | +| on | off | 3.9x | 4.2x | 32x | 40x | | on | on | 2.8x | 3.0x | 6.6x | 7.1x | Each value is the geometric mean of Immer's time over Mutative's; moves are `shift`, `unshift`, `splice` insertion, and `reverse`. Mutative moves elements natively on its copy, while Immer moves each one through its draft proxy: removing the first of 10,000 rows took 7.10 µs against 8,605 µs, 1,213x. With patches, both libraries emit one patch per moved index, which bounds the gain to 5-10x. The [performance summary](https://github.com/unadlib/mutative/blob/main/perf-testing/reports/SUMMARY.md) breaks these results down by scenario. From a094ca4db40010075d8df57cda02c97a5d3d05b5 Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 19:00:21 +0800 Subject: [PATCH 18/21] test: cover a nested producer that copies a draft of a Set subclass --- test/nested-create.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/nested-create.test.ts b/test/nested-create.test.ts index f65224e4..375fb70c 100644 --- a/test/nested-create.test.ts +++ b/test/nested-create.test.ts @@ -14,6 +14,21 @@ describe('drafts whose original is a draft of an outer create() call', () => { expect(state).toBe(base); }); + test('a draft of a Set subclass is copied through the outer draft', () => { + class TagSet extends Set {} + const base = { tags: new TagSet(['a', 'b']) }; + const state = create(base, (draft) => { + const result = create({ tags: draft.tags }, (innerDraft) => { + innerDraft.tags.add('c'); + innerDraft.tags.delete('a'); + }); + expect(result.tags).toBeInstanceOf(TagSet); + expect([...result.tags]).toEqual(['b', 'c']); + expect([...draft.tags]).toEqual(['a', 'b']); + }); + expect(state).toBe(base); + }); + test('a Map draft is copied through the outer draft', () => { const base = { map: new Map([['a', 1]]) }; const state = create(base, (draft) => { From b8f009e2b1a82b57fdd0bb919bac3f6f6ae6de88 Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 19:15:17 +0800 Subject: [PATCH 19/21] perf: find the position of a Set item for its patch path without copying the Set --- src/utils/draft.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/utils/draft.ts b/src/utils/draft.ts index 145b82fb..f290d33f 100644 --- a/src/utils/draft.ts +++ b/src/utils/draft.ts @@ -105,7 +105,13 @@ export function getPath( // applied to whatever sits at the position in the base Set. An // unchanged Set holds its items in their original order. if (parent.assignedMap!.size > 0) return null; - key = Array.from(iterateSet(parent.original)).indexOf(key); + // Counted without copying the Set, as each changed item searches it. + let index = 0; + for (const item of iterateSet(parent.original)) { + if (item === key) break; + index += 1; + } + key = index; } else if (get(parentCopy, key) !== target.proxy) { // The child left its key: it was moved, deleted or replaced, possibly // by another draft of a shared object. The parent's patches carry its From 434cbf210ef8d44c4f3ddbc72326e9da09012851 Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 19:15:54 +0800 Subject: [PATCH 20/21] build: refresh the size baseline for the position search of Set patch paths --- scripts/build-size-baseline.json | 80 ++++++++++++++++---------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/scripts/build-size-baseline.json b/scripts/build-size-baseline.json index eb0f3f44..e4396c46 100644 --- a/scripts/build-size-baseline.json +++ b/scripts/build-size-baseline.json @@ -1,6 +1,6 @@ { "reference": { - "revision": "47e0d0b39d733b58dd78590292fe7c49e9442209", + "revision": "b8f009e2b1a82b57fdd0bb919bac3f6f6ae6de88", "description": "Set drafts add and delete items in their copy, map only the items that drafts stand for and are rebuilt once, by their own producer, a Set subclass draft keeps its items in their order, and a Set draft that left its key keeps its changes", "node": "v24.16.0", "commands": [ @@ -15,69 +15,69 @@ "esbuild": "0.24.0", "measurements": { "artifact/mutative.cjs.production.min.js": { - "raw": 27086, - "gzip": 9072, - "brotli": 8322 + "raw": 27109, + "gzip": 9083, + "brotli": 8334 }, "artifact/mutative.umd.production.min.js": { - "raw": 27243, - "gzip": 9137, - "brotli": 8381 + "raw": 27266, + "gzip": 9150, + "brotli": 8389 }, "artifact/mutative.esm.production.min.mjs": { - "raw": 27061, - "gzip": 9099, - "brotli": 8350 + "raw": 27084, + "gzip": 9110, + "brotli": 8358 }, "artifact/mutative.cjs.development.js": { - "raw": 72637, - "gzip": 17466, - "brotli": 15192 + "raw": 72711, + "gzip": 17489, + "brotli": 15219 }, "artifact/mutative.umd.development.js": { - "raw": 74896, - "gzip": 17665, - "brotli": 15353 + "raw": 74975, + "gzip": 17688, + "brotli": 15367 }, "artifact/mutative.esm.js": { - "raw": 73489, - "gzip": 17581, - "brotli": 15258 + "raw": 73563, + "gzip": 17603, + "brotli": 15302 }, "artifact/mutative.esm.mjs": { - "raw": 73490, - "gzip": 17582, - "brotli": 15256 + "raw": 73564, + "gzip": 17604, + "brotli": 15296 }, "consumer/import/create": { - "raw": 24418, - "gzip": 8518, - "brotli": 7830 + "raw": 24439, + "gzip": 8529, + "brotli": 7838 }, "consumer/import/patches": { - "raw": 26311, - "gzip": 9141, - "brotli": 8378 + "raw": 26332, + "gzip": 9153, + "brotli": 8393 }, "consumer/import/all": { - "raw": 27096, - "gzip": 9395, - "brotli": 8607 + "raw": 27117, + "gzip": 9407, + "brotli": 8621 }, "consumer/require/create": { - "raw": 26598, - "gzip": 9393, - "brotli": 8617 + "raw": 26619, + "gzip": 9403, + "brotli": 8627 }, "consumer/require/patches": { - "raw": 26666, - "gzip": 9432, - "brotli": 8649 + "raw": 26687, + "gzip": 9442, + "brotli": 8660 }, "consumer/require/all": { - "raw": 26563, - "gzip": 9372, - "brotli": 8591 + "raw": 26584, + "gzip": 9382, + "brotli": 8603 } } } From 4d5546dd7d4389de341cd1fae71ada978894a93e Mon Sep 17 00:00:00 2001 From: unadlib Date: Fri, 9 Oct 2026 19:36:23 +0800 Subject: [PATCH 21/21] docs: refresh the Set figures with the scenarios measured again after the patch path search change --- README.md | 4 +- perf-testing/reports/SUMMARY.md | 52 +++++++++---------- .../docs/getting-started/performance.mdx | 4 +- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index a4280a9d..914142e0 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Times are microseconds per update, medians of three runs on an Apple M1 Max with | Update one of 10,000 records | 10,000 | 1,175 | 2,109 | 3,135 | 2,113 | | Insert into a 1,000-property object | — | 51.0 | 146 | 231 | 129 | | Update a Map value | 10,000 | 517 | 521 | 730 | 518 | -| Add a number to a Set | 10,000 | 34.5 | 938 | 1,006 | 66.8 | +| Add a number to a Set | 10,000 | 34.1 | 929 | 1,004 | 66.0 | | Update a class instance | 10,000 | 2.92 | 3.43 | 668 | 1.43 | | Update a value ten levels deep | — | 3.47 | 4.46 | 7.83 | 0.75 | | Apply patches to 10% of rows | 10,000 | 1,440 | 1,182 | 2,110 | — | @@ -70,7 +70,7 @@ At 1,000 and 10,000 rows the gap grows. Against Immer without its array-method p | Auto-freeze | Patches | All workloads, 1,000 rows | All workloads, 10,000 rows | Moves, 1,000 rows | Moves, 10,000 rows | | ----------- | ------- | ------------------------: | -------------------------: | ----------------: | -----------------: | | off | off | 8.8x | 10.9x | 211x | 442x | -| off | on | 4.5x | 5.2x | 7.1x | 7.5x | +| off | on | 4.6x | 5.2x | 7.1x | 7.5x | | on | off | 3.9x | 4.2x | 32x | 40x | | on | on | 2.8x | 3.0x | 6.6x | 7.1x | diff --git a/perf-testing/reports/SUMMARY.md b/perf-testing/reports/SUMMARY.md index 3e9bd969..931e575a 100644 --- a/perf-testing/reports/SUMMARY.md +++ b/perf-testing/reports/SUMMARY.md @@ -28,7 +28,7 @@ Cells faster / within 5% / slower, and the geometric mean of comparator time ove | --- | ---: | ---: | ---: | | Immer 11.1.18 | 358 / 6 / 2 of 366, 3.06 | 190 / 6 / 4 of 200, 4.92 | 548 / 12 / 6 of 566, 3.62 | | Immer 11.1.18 with its defaults | 92 / 0 / 1 of 93, 6.33 | 50 / 0 / 2 of 52, 7.16 | 142 / 0 / 3 of 145, 6.62 | -| Pinned npm Mutative 1.3.0 | 332 / 33 / 1 of 366, 3.36 | 163 / 37 / 0 of 200, 4.89 | 495 / 70 / 1 of 566, 3.83 | +| Pinned npm Mutative 1.3.0 | 332 / 33 / 1 of 366, 3.36 | 163 / 37 / 0 of 200, 4.89 | 495 / 70 / 1 of 566, 3.84 | | Hand-written reducer | 7 / 0 / 83 of 90, 0.11 | 11 / 2 / 35 of 48, 0.12 | 18 / 2 / 118 of 138, 0.12 | Against Immer by workload group, faster / within 5% / slower and geometric mean: @@ -41,7 +41,7 @@ Against Immer by workload group, faster / within 5% / slower and geometric mean: | Small state | 12 / 0 / 0, 1.66 | — | — | | Mutation density | 12 / 0 / 0, 2.10 | 4 / 0 / 0, 1.90 | 4 / 0 / 0, 1.92 | | Array methods | 132 / 0 / 0, 5.75 | 20 / 0 / 0, 23.77 | 20 / 0 / 0, 31.12 | -| Map and Set | 35 / 1 / 0, 2.60 | 14 / 2 / 0, 3.62 | 14 / 2 / 0, 3.72 | +| Map and Set | 35 / 1 / 0, 2.61 | 14 / 2 / 0, 3.65 | 14 / 2 / 0, 3.73 | | Object records | 11 / 1 / 0, 1.26 | 8 / 0 / 0, 2.34 | 8 / 0 / 0, 1.61 | | Class instances | 6 / 2 / 0, 1.74 | 6 / 0 / 2, 1.61 | 6 / 2 / 0, 1.64 | | Deep path | 4 / 0 / 0, 1.54 | — | — | @@ -58,10 +58,10 @@ Against Immer without its array-method plugin, by size and mode, over the 26 sca | Rows | Freeze | Patches | All scenarios | Geometric mean | Moves, geometric mean | Moves, range | | ---: | --- | --- | ---: | ---: | ---: | ---: | -| 1,000 | off | off | 24 / 1 / 1 | 8.82 | 211 | 72.0–492 | -| 1,000 | off | on | 23 / 1 / 0 | 4.55 | 7.1 | 6.1–7.5 | +| 1,000 | off | off | 24 / 1 / 1 | 8.84 | 211 | 72.0–492 | +| 1,000 | off | on | 23 / 1 / 0 | 4.56 | 7.1 | 6.1–7.5 | | 1,000 | on | off | 25 / 0 / 1 | 3.88 | 32.4 | 18.5–46.9 | -| 1,000 | on | on | 23 / 0 / 1 | 2.78 | 6.6 | 6.1–6.9 | +| 1,000 | on | on | 23 / 0 / 1 | 2.79 | 6.6 | 6.1–6.9 | | 10,000 | off | off | 24 / 1 / 1 | 10.92 | 442 | 176–1,213 | | 10,000 | off | on | 23 / 1 / 0 | 5.23 | 7.5 | 5.0–9.7 | | 10,000 | on | off | 25 / 1 / 0 | 4.24 | 39.7 | 22.2–53.0 | @@ -81,7 +81,7 @@ The gap widens with size. With freeze and patches off, the moves were 211 times ### Freeze off, patches on -With patches on and freeze off, the candidate was faster than Immer in 136 of 138 cells and within 5% in the other 2, the Map updates at 1,000 and 10,000 rows (geometric mean 3.42). It was faster than Mutative 1.3.0 in 120 and within 5% in 18 (4.21). In the 67 array-related cells, array methods, searches and the upstream array workloads at every size, it was faster than both in every cell: 4.35 times Immer and 12.56 times Mutative 1.3.0 on geometric mean. +With patches on and freeze off, the candidate was faster than Immer in 136 of 138 cells and within 5% in the other 2, the Map updates at 1,000 and 10,000 rows (geometric mean 3.42). It was faster than Mutative 1.3.0 in 120 and within 5% in 18 (4.22). In the 67 array-related cells, array methods, searches and the upstream array workloads at every size, it was faster than both in every cell: 4.35 times Immer and 12.56 times Mutative 1.3.0 on geometric mean. Patches cost little when an update changes a few paths. They added a median 1% to the candidate's time at 1,000 and 10,000 rows and 20% at 100 rows. The former `benchmark:base` workload, `push-and-insert` at 10,000 rows, took 62.8 µs without patches and 63.3 µs with them; Mutative 1.3.0 went from 67.0 µs to 208 µs, and Immer from 162 µs to 364 µs. Moving elements is the exception: both libraries emit one patch per moved index, so `shift` at 10,000 rows produces 10,000 forward and 10,000 inverse patches in each. The candidate's time grows from 7.10 µs to 1,071 µs, against 10,376 µs for Immer producing the same patches. @@ -174,19 +174,19 @@ A snapshot search was faster than the draft search at every size: 1.7 times at 1 | Scenario | Items | Freeze | Patches | `main` | PR #195 | PR/`main` | | --- | ---: | --- | --- | ---: | ---: | ---: | -| set-add | 100 | off | off | 6.62 | 0.72 | 0.11 | -| set-add | 100 | on | off | 6.92 | 1.13 | 0.16 | -| set-add | 100 | off | on | 8.56 | 2.62 | 0.31 | -| set-delete | 100 | off | off | 6.56 | 0.70 | 0.11 | -| set-add | 1,000 | off | off | 67.6 | 1.31 | 0.02 | -| set-add | 10,000 | off | off | 987 | 35.6 | 0.04 | -| set-add | 10,000 | on | off | 1,002 | 62.6 | 0.06 | -| set-add | 10,000 | off | on | 1,397 | 459 | 0.33 | -| set-update | 100 | off | off | 8.24 | 3.42 | 0.41 | -| set-update | 10,000 | off | off | 1,057 | 548 | 0.52 | -| set-update-10pct | 100 | off | off | 11.19 | 7.09 | 0.63 | - -Over all 32 cells, four Set scenarios at 100 rows and two at 1,000 and 10,000 rows in every mode, the PR took 0.28 of the time of `main` on geometric mean and was faster in each. Additions and deletions no longer copy the items into a Map and back, so without patches they now cost little more than copying the Set: `set-add` at 10,000 items allocated 325 KiB per update, against 2,878 KiB for Mutative 1.3.0. With patches, a Set that added or deleted items still compares all items to emit its own patches, which bounds that gain to about 3 times, and a changed item still rebuilds the Set in order. Against the other libraries the candidate was faster than Immer in every Set cell, 3.0–50.7 times, and faster than the hand-written reducer in `set-add` at 1,000 and 10,000 rows, where `Set.prototype.difference` copies the Set faster than the reducer's `new Set()`. +| set-add | 100 | off | off | 6.51 | 0.70 | 0.11 | +| set-add | 100 | on | off | 6.87 | 1.11 | 0.16 | +| set-add | 100 | off | on | 8.38 | 2.59 | 0.31 | +| set-delete | 100 | off | off | 6.40 | 0.69 | 0.11 | +| set-add | 1,000 | off | off | 66.9 | 1.29 | 0.02 | +| set-add | 10,000 | off | off | 987 | 33.2 | 0.03 | +| set-add | 10,000 | on | off | 1,000 | 62.6 | 0.06 | +| set-add | 10,000 | off | on | 1,396 | 452 | 0.32 | +| set-update | 100 | off | off | 8.27 | 3.39 | 0.41 | +| set-update | 10,000 | off | off | 1,053 | 542 | 0.52 | +| set-update-10pct | 100 | off | off | 10.90 | 6.98 | 0.64 | + +Over all 32 cells, four Set scenarios at 100 rows and two at 1,000 and 10,000 rows in every mode, the PR took 0.28 of the time of `main` on geometric mean and was faster in each. Additions and deletions no longer copy the items into a Map and back, so without patches they now cost little more than copying the Set: `set-add` at 10,000 items allocated 325 KiB per update, against 2,878 KiB for Mutative 1.3.0. With patches, a Set that added or deleted items still compares all items to emit its own patches, which bounds that gain to about 3 times, and a changed item still rebuilds the Set in order. Against the other libraries the candidate was faster than Immer in every Set cell, 2.9–51.1 times, and faster than the hand-written reducer in `set-add` at 1,000 and 10,000 rows, where `Set.prototype.difference` copies the Set faster than the reducer's `new Set()`. ### Selected times @@ -203,7 +203,7 @@ Microseconds per complete scenario, medians of three runs. | update-largeObject1 | 100 | on | off | 132 | 138 | 231 | — | 1.75 | | object-update | 100 | off | off | 3.16 | 6.12 | 3.45 | 2.14 | 1.09 | | map-update | 100 | off | off | 4.55 | 5.19 | 4.95 | 3.66 | 1.09 | -| set-update | 100 | off | off | 3.37 | 9.04 | 43.3 | 2.15 | 12.83 | +| set-update | 100 | off | off | 3.39 | 8.98 | 42.0 | 2.14 | 12.37 | | class-update | 100 | on | off | 3.86 | 5.22 | 9.24 | — | 2.40 | | apply-array-ops | 100 | off | off | 4.06 | 116 | 101 | — | 24.83 | | return-filter | 100 | off | off | 23.8 | 26.2 | 32.6 | 0.19 | 1.37 | @@ -217,7 +217,7 @@ Microseconds per complete scenario, medians of three runs. | array-reverse-nested | 10,000 | off | off | 13.7 | 8,627 | 7,555 | 9.68 | 551.48 | | array-reverse-nested | 10,000 | off | on | 1,074 | 277,427 | 9,416 | — | 8.76 | | read-index | 10,000 | off | off | 5,094 | 6,293 | 11,773 | 55.5 | 2.31 | -| set-update | 10,000 | off | off | 550 | 1,070 | 6,203 | 413 | 11.27 | +| set-update | 10,000 | off | off | 544 | 1,057 | 6,119 | 411 | 11.24 | | object-update | 10,000 | off | off | 1,175 | 1,189 | 2,109 | 2,113 | 1.79 | | apply-reverse | 10,000 | off | off | 12,590 | 13,801 | 13,864 | — | 1.10 | | push-and-insert | 10,000 | off | on | 63.3 | 208 | 364 | — | 5.76 | @@ -226,7 +226,7 @@ Microseconds per complete scenario, medians of three runs. ### Against a hand-written reducer -The hand-written reducer is faster than the candidate in 118 of 138 cells, and across all 138 the candidate takes 8.5 times as long on geometric mean: the reducer copies only what each update changes and has no draft to create, track, or finalize. The candidate is faster in 18 cells. Fifteen copy wide objects: objects with 1,000 and 3,000 properties, records with 1,000 or 10,000 keys, and class instances with 100 or more fields, by 1.5–2.7 times. One inserts at the front of an array of 10,000 rows, where the reducer's spread copies every element and `unshift` runs natively on the draft's copy: 15.9 µs against 28.3 µs; at 100 and 1,000 rows the reducer was faster. Two add a number to a Set of 1,000 or 10,000 numbers, which the candidate copies with `Set.prototype.difference`, faster than the reducer's `new Set()`: 1.31 µs against 4.17 µs and 34.5 µs against 66.8 µs; at 100 numbers the reducer was faster, 0.44 µs against 0.70 µs. Copying a Map of 1,000 or 10,000 entries dominates `map-update` in both, which are within 5%. The hand-written reducer is the same spread-based code that validates every scenario; faster hand-written code exists for many of them. +The hand-written reducer is faster than the candidate in 118 of 138 cells, and across all 138 the candidate takes 8.5 times as long on geometric mean: the reducer copies only what each update changes and has no draft to create, track, or finalize. The candidate is faster in 18 cells. Fifteen copy wide objects: objects with 1,000 and 3,000 properties, records with 1,000 or 10,000 keys, and class instances with 100 or more fields, by 1.5–2.7 times. One inserts at the front of an array of 10,000 rows, where the reducer's spread copies every element and `unshift` runs natively on the draft's copy: 15.9 µs against 28.3 µs; at 100 and 1,000 rows the reducer was faster. Two add a number to a Set of 1,000 or 10,000 numbers, which the candidate copies with `Set.prototype.difference`, faster than the reducer's `new Set()`: 1.28 µs against 4.10 µs and 34.1 µs against 66.0 µs; at 100 numbers the reducer was faster, 0.44 µs against 0.70 µs. Copying a Map of 1,000 or 10,000 entries dominates `map-update` in both, which are within 5%. The hand-written reducer is the same spread-based code that validates every scenario; faster hand-written code exists for many of them. ### Shared and isolated processes @@ -252,7 +252,7 @@ Sampled allocation of the candidate was below Mutative 1.3.0 in 107 of 114 memor | read-index | 100 | off | 98.6 | 150.1 | 192.3 | 1.7 | | push-and-insert | 100 | off | 115.2 | 1,365.6 | 66.0 | 19.3 | | object-update | 1,000 | off | 112.9 | 1,311.7 | 15.9 | 10.9 | -| set-add | 10,000 | off | 325.2 | 2,877.5 | 1,606.8 | 321.7 | +| set-add | 10,000 | off | 325.4 | 2,878.1 | 1,606.8 | 321.7 | | array-reverse-nested | 10,000 | off | 89.0 | 11,077.6 | 12,840.4 | 83.4 | | array-reverse-nested | 10,000 | on | 3,028.5 | 268,460.6 | 15,102.0 | — | @@ -305,7 +305,7 @@ In 10 cells every process was slower than every process of the previous batch: ` ## Tradeoffs and limits - Returned values: without auto-freeze, the candidate searches every object of a returned value for drafts, as Immer does: `return-replace` took 2.59 ms at 10,000 rows, against 5.63 ms for Immer without auto-freeze. Immer's default auto-freeze skips frozen values (0.79 µs), and so do production builds of the candidate with auto-freeze (0.53 µs). Wrapping the value in `rawReturn()` skips the search (0.26 µs). -- A Set draft copies the Set on its first change and rebuilds it in order once an item that the recipe read through an iterator changed: `set-update` took 550 µs at 10,000 items, against 413 µs for the hand-written reducer, which copies the Set once. With patches, a Set that added or deleted items compares every original item with its copy and every item of the copy with the original: `set-add` took 455 µs with patches at 10,000 items, against 34.5 µs without. Copying the Map dominates `map-update` at 10,000 entries in every library, about 520 µs. +- A Set draft copies the Set on its first change and rebuilds it in order once an item that the recipe read through an iterator changed: `set-update` took 544 µs at 10,000 items, against 411 µs for the hand-written reducer, which copies the Set once. With patches, a Set that added or deleted items compares every original item with its copy and every item of the copy with the original: `set-add` took 448 µs with patches at 10,000 items, against 34.1 µs without. Copying the Map dominates `map-update` at 10,000 entries in every library, about 520 µs. - With auto-freeze, Immer updated a class instance with 1,000 fields faster: 239 µs against 411 µs. Without freezing the candidate is faster, 98 µs against 206 µs, so the difference lies in freezing the candidate's copy, which it makes property by property with descriptors where Immer 11 uses `Object.assign`. - Applying patches that replace nested values was 16–22% slower than Immer; structural patches are faster, as `apply-array-ops` shows (25 times). - Even in processes of their own, some cells varied between processes of the same build by up to about 1.5 times, for example `remove-reuse` with freeze on (127–191 µs) and Immer's `map-read` at 10,000 rows; `class-wide-update` without freezing at 1,000 rows took either about 95 µs or about 200 µs in both Mutative builds. Medians of three processes absorb one outlier, not two; compare such cells through geometric means rather than one by one. @@ -343,7 +343,7 @@ Immer production: 30fec64eb16c235f5fe771ed7e4be08eb0f67cb46c44917424a6e643f3 The Set scenarios of 2026-10-09 measured the production artifact of PR #195, which its head builds unchanged, and compared it with `main` at `e6af4b0`: ```text -PR #195 production: c97341095e0064c5c5db38a6c26a107db55c078184b30639b5c0733e84960490 +PR #195 production: 74adeacf19aaa8df15d94751824c3d8f12dd821f854eb8a9f137ee9daff41d05 main production: b70312b6faedc295e645a4d81050ebb982878414fe7d4c15e7e5f55b7c27e7c5 ``` @@ -400,7 +400,7 @@ MUTATIVE_PERF_BUNDLE="perf-testing/dist/$NAME/immutability-benchmarks.mjs" node Earlier versions of this summary, in Git history, describe each round in detail. Cell counts compare the candidate with the named comparator: faster, within 5%, and slower. -- 2026-10-09, PR #195: Set drafts add and delete items in their copy, map only the items that drafts stand for and are rebuilt once, by their own producer, drafts of Set subclasses keep their items in order, and a Set draft that left its key keeps its changes, which 1.3.0 also lost. Against `main` at `e6af4b0`, Mutative alone, the PR took 0.28 of the time over 32 Set cells; `set-add` at 10,000 items took 35.6 µs instead of 987 µs. The Set scenarios, measured again with every library in processes of its own, replace the earlier cells: 548 / 12 / 6 of 566 against Immer (geometric mean 3.62), 142 / 0 / 3 of 145 with each library's defaults (6.62), 495 / 70 / 1 against 1.3.0 (3.83), and 18 / 2 / 118 against the hand-written reducer. The production CJS artifact grew from 8,201 to 8,322 Brotli bytes. +- 2026-10-09, PR #195: Set drafts add and delete items in their copy, map only the items that drafts stand for and are rebuilt once, by their own producer, drafts of Set subclasses keep their items in order, and a Set draft that left its key keeps its changes, which 1.3.0 also lost. Against `main` at `e6af4b0`, Mutative alone, the PR took 0.28 of the time over 32 Set cells; `set-add` at 10,000 items took 33.2 µs instead of 987 µs. The Set scenarios, measured again with every library in processes of its own, replace the earlier cells: 548 / 12 / 6 of 566 against Immer (geometric mean 3.62), 142 / 0 / 3 of 145 with each library's defaults (6.62), 495 / 70 / 1 against 1.3.0 (3.84), and 18 / 2 / 118 against the hand-written reducer. The production CJS artifact grew from 8,201 to 8,334 Brotli bytes. - 2026-10-05, PR #184 review fixes: the fixed source at `6b08f57` against `main` at `ba8d0cd`, Mutative only, all 93 scenarios at 100 rows in both freeze and patch modes, three runs each: fixed/main geometric mean 0.982 over 366 cells, 42 cells more than 5% faster and 6 more than 5% slower; the slower wide-object cells measured 0.77–1.02 in isolated processes. Map iteration with the iterator lifetime checks took 1–2.4% longer than without them and about 70% less than `main` for primitive entries. An update beside an unchanged frozen 10,000-entry Map or Set took 0.44 µs instead of 66.7 µs and 26.1 µs. The production CJS artifact grew from 8,079 to 8,106 Brotli bytes. - 2026-10-04, PR #180 at `e70aeed`: `current()` copies changed array drafts from their current array and leaves moved base elements as they are, and strict mode warns once in development builds when a recipe leaves 1,000 or more drafts unchanged. The three `search-*` scenarios were added: a `current()` snapshot search at 10,000 rows took 209 µs instead of 3,262 µs, and 772 µs instead of 7,490 µs after `shift()`. Gate 48 of 48. The production CJS artifact grew from 8,031 to 8,104 Brotli bytes against `main`. - 2026-10-03 and 2026-10-04, the source of `main` at `e6b6563` with the harness of PR #177, 90 scenarios at 100 rows and 23 at 1,000 and 10,000, in shared processes except for the wide-object scenarios: 508 / 11 / 11 of 530 cells against Immer (geometric mean 3.07), 133 / 0 / 3 of 136 with each library's defaults (6.01), 417 / 111 / 2 against pinned 1.3.0 (3.16), and 18 / 2 / 109 of 129 against the hand-written reducer (0.17). Immer was faster in `return-replace` with freeze on (2.9 ms against 0.9 µs at 10,000 rows), `apply-update-10pct` and `class-wide-update` with freeze on at 1,000 rows. With Immer's `enableArrayMethods`, 229 / 5 / 46 of 280 cells (1.63). Allocation below 1.3.0 in 104 of 114 memory cells; gate 48 of 48. diff --git a/website/content/docs/getting-started/performance.mdx b/website/content/docs/getting-started/performance.mdx index afef36c6..19c83970 100644 --- a/website/content/docs/getting-started/performance.mdx +++ b/website/content/docs/getting-started/performance.mdx @@ -27,7 +27,7 @@ Times are microseconds per update, medians of three runs on an Apple M1 Max with | Update one of 10,000 records | 10,000 | 1,175 | 2,109 | 3,135 | 2,113 | | Insert into a 1,000-property object | — | 51.0 | 146 | 231 | 129 | | Update a Map value | 10,000 | 517 | 521 | 730 | 518 | -| Add a number to a Set | 10,000 | 34.5 | 938 | 1,006 | 66.8 | +| Add a number to a Set | 10,000 | 34.1 | 929 | 1,004 | 66.0 | | Update a class instance | 10,000 | 2.92 | 3.43 | 668 | 1.43 | | Update a value ten levels deep | — | 3.47 | 4.46 | 7.83 | 0.75 | | Apply patches to 10% of rows | 10,000 | 1,440 | 1,182 | 2,110 | — | @@ -45,7 +45,7 @@ At 1,000 and 10,000 rows the gap grows. Against Immer without its array-method p | Auto-freeze | Patches | All workloads, 1,000 rows | All workloads, 10,000 rows | Moves, 1,000 rows | Moves, 10,000 rows | | --- | --- | ---: | ---: | ---: | ---: | | off | off | 8.8x | 10.9x | 211x | 442x | -| off | on | 4.5x | 5.2x | 7.1x | 7.5x | +| off | on | 4.6x | 5.2x | 7.1x | 7.5x | | on | off | 3.9x | 4.2x | 32x | 40x | | on | on | 2.8x | 3.0x | 6.6x | 7.1x |