Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
da06175
fix: keep the changes of a Set draft that left its key
unadlib Oct 8, 2026
24cc0ee
perf: change the copy of a Set draft directly instead of mapping all …
unadlib Oct 8, 2026
199cf1a
build: refresh the size baseline and limits for Set drafts that chang…
unadlib Oct 8, 2026
138cd18
docs: note that a Set draft moved to another place keeps its changes
unadlib Oct 8, 2026
29a6fdc
docs: refresh the benchmark figures with the Set scenarios measured a…
unadlib Oct 8, 2026
604af2e
perf: keep the items of a rebuilt Set draft on its item map instead o…
unadlib Oct 9, 2026
632d5b2
fix: never rebuild a Set draft before its own producer finalizes it
unadlib Oct 9, 2026
ba9b907
perf: rebuild a Set draft once for the drafts of its own producer tha…
unadlib Oct 9, 2026
e6bb148
docs: note that a draft of a Set subclass uses the methods that the s…
unadlib Oct 9, 2026
8577ad8
build: refresh the size baseline for Set drafts that their own produc…
unadlib Oct 9, 2026
f7dca13
docs: refresh the Set figures with the scenarios measured again on th…
unadlib Oct 9, 2026
9dd7249
fix: find an item that is a revoked draft with has() of a Set draft
unadlib Oct 9, 2026
21c6a64
fix: keep the patch positions of a Set subclass draft that orders its…
unadlib Oct 9, 2026
2ee6b47
docs: note how a draft of a Set subclass orders its items and what it…
unadlib Oct 9, 2026
47e0d0b
perf: rebuild a Set draft that changed only through its drafted items…
unadlib Oct 9, 2026
d50f163
build: raise the size limits and refresh the baseline for Set subclas…
unadlib Oct 9, 2026
adb385a
docs: refresh the Set figures with the scenarios measured again after…
unadlib Oct 9, 2026
a094ca4
test: cover a nested producer that copies a draft of a Set subclass
unadlib Oct 9, 2026
b8f009e
perf: find the position of a Set item for its patch path without copy…
unadlib Oct 9, 2026
434cbf2
build: refresh the size baseline for the position search of Set patch…
unadlib Oct 9, 2026
4d5546d
docs: refresh the Set figures with the scenarios measured again after…
unadlib Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)?**

Expand All @@ -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.

Expand All @@ -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 | 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 | — |
Expand All @@ -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.8x | 10.9x | 211x | 442x |
| 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 |

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:

Expand Down Expand Up @@ -792,6 +792,8 @@ 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 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.
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,19 +61,19 @@
"name": "production CJS, all exports",
"path": "dist/mutative.cjs.production.min.js",
"import": "*",
"limit": "8.4 KB"
"limit": "8.6 KB"
},
{
"name": "ESM, create",
"path": "dist/mutative.esm.mjs",
"import": "{ create }",
"limit": "7.4 KB"
"limit": "7.5 KB"
},
{
"name": "ESM, all exports",
"path": "dist/mutative.esm.mjs",
"import": "*",
"limit": "8.3 KB"
"limit": "8.4 KB"
}
],
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion perf-testing/reports/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Loading
Loading