Skip to content

2.0.0

Latest

Choose a tag to compare

@unadlib unadlib released this 12 Oct 17:27
· 43 commits to main since this release

Mutative v2 keeps the v1 API and makes updates faster and more correct. Across 97 benchmarked workloads, it is 4.3x faster than Mutative 1.3.0 on geometric mean, and 3.6x faster than Immer 11.1.18 with matched settings, or 6.6x with each library's defaults. It also fixes many cases in which v1 produced wrong states or patches, and production bundles leave out the development checks. Some of these changes can affect existing code: read the breaking changes below and the migration guide before you upgrade.

Highlights

  • Faster updates. Against Mutative 1.3.0, v2 was faster in 535 of 614 measured cases, within 5% in 70 and slower in 9. Against Immer 11.1.18, it was faster in 595 of 614 cases. The performance summary has every workload, the method and its limits.
  • Native array methods. shift, unshift, splice and reverse move the elements of a plain array directly on the draft's copy, and indexOf, lastIndexOf and includes search it natively. Removing the first of 10,000 rows takes 7.04 µs, against 7,408 µs in Immer, and the patches of such moves replay in both directions.
  • Searches without drafts. current() of a changed array copies the current array instead of reading each element through the draft, so current(draft.list).findIndex(…) searches a large array without creating a draft for each element.
  • Correct patches and states. The patches of moved drafts, of Sets whose items changed, of arrays that shrink and grow again, of -0 and of Map keys that are arrays now reproduce the next state, and Map and Set drafts freeze and iterate like built-in Maps and Sets. See fixes that change results.
  • Safer apply(). Patches cannot change the prototypes of objects: apply() also rejects a path that ends in __proto__, and reads Set items only at their indices.
  • Production builds for bundlers. The ESM entry compares process.env.NODE_ENV at each development check, so production bundles leave out the development checks and warnings, and production errors are short codes that link to the errors page.
  • Better types. Async recipes with an explicit state type return a Promise, apply() types frozen and in-place results, and a curried recipe that annotates its draft gives a typed producer.
  • A new website. mutative.js.org has new guides, a comparison with Immer, migration guides from Immer and from v1, an errors page, and llms.txt.

Breaking changes

The migration guide describes each change and what to do about it.

Build output and package entries

  • Every build targets ES2018 instead of ES2015. Engines that support Proxy but not ES2018 syntax, such as Chrome 49–59, Firefox 18–54, Safari 10–11.0 and Edge 12–18, need Mutative transpiled by your build.
  • The ESM entry compares process.env.NODE_ENV at each development check instead of always running development code. Bundles that target Node.js without defining NODE_ENV, such as esbuild with platform: 'node', now include both CommonJS builds; define NODE_ENV or keep Mutative external.
  • In Node.js, import resolves a module that re-exports the CommonJS build, so import and require share one instance and both follow NODE_ENV. Previously, import always ran the development ESM build as a separate instance.
  • dist/mutative.esm.mjs and dist/mutative.esm.js read process.env.NODE_ENV when they load, so loading them without a bundler, from a <script type="module"> with a raw CDN URL or in another runtime without process, throws process is not defined. Load dist/mutative.esm.production.min.mjs instead, or the UMD build that the unpkg and jsdelivr fields point to.
  • Production builds throw Minified Mutative error #<code> with a link to the errors page instead of the full message. Code that matches error messages in production must match the codes instead.

Patches

  • The patches of a nested draft come before those of its parents, the order Immer uses. Applying them gives the same state as before.
  • A patch value that was a draft is the object that the next state holds instead of a deep copy, and it is frozen with the state under enableAutoFreeze. apply() still copies patch values; copy a patch value before you change it.
  • A changed draft that leaves its key, because sort() or an assignment moved it or another value replaced it there, emits no patches under its old path; the patches of its parent carry its value.
  • A changed item of a Set that also added or removed items is carried by the Set's remove and add patches instead of patches under its position.
  • Assigning the original object of a draft to the key that holds the draft is recorded like any other assignment.

Arrays

  • shift, unshift, splice and reverse run natively on the copy of a plain array, and indexOf, lastIndexOf and includes search it natively. For data arrays the results are those of v1, but an accessor property on an array index is read as a data value, so its getter may run a different number of times.
  • current() of a changed array copies the current array. Base elements that a native method moved and that hold drafts of an outer create() call are left as they are.

Returned values

  • With enableAutoFreeze, production builds no longer search frozen objects in a value that a recipe returns for drafts, and development builds throw when they find one there. Never put a draft in a frozen object or in a value it holds.

Development builds

  • Strict mode warns once when a recipe leaves 1,000 or more drafts unchanged, as a search through a large draft array does.
  • create() warns once when it receives a draft with a recipe, since it drafts a copy of the draft's current state; see create() on a draft.
  • With enablePatches: { pathAsArray: false }, a patch path that would hold a Map key that is not a string, or a symbol key, throws. Production builds still generate such patches, as v1 did.
  • Passing a draft to the set() method of a Map draft throws, since the next state would keep the revoked draft as a key.
  • With enableAutoFreeze, a circular reference is reported only for an object that holds itself.
  • In strict mode, rawReturn() of a value without drafts no longer prints contradictory warnings.

TypeScript

  • create() with an explicit state type and an async recipe, such as create<State>(base, async (draft) => { … }), and its curried form return Promise<State>; v1 typed the result as State.
  • apply() accepts enableAutoFreeze: true and then returns Immutable<State>; with mutable typed as boolean, it returns State | void.
  • A curried producer whose recipe annotates its draft and returns a new state, such as create((draft: State) => ({ ...draft, count: 0 })), returns State; v1 typed such a call as a manual draft.
  • With an explicit state type, a recipe that may return a Promise gives State | Promise<State>; v1 typed the result as State.

Fixes that change results

v1 gave other results in these cases; the migration guide describes each of them.

  • Sets: with enablePatches, every changed item of a Set keeps its changes; a Set that receives an unchanged draft holds the original object; a Set draft that the recipe moves elsewhere keeps its changes; and a draft of a Set subclass changes its copy, so the subclass's own add, has, delete and clear take effect during the recipe, and its add must store the value that it receives.
  • Assigning undefined to a key that delete, shift, unshift or a shrinking splice removed adds the key back.
  • Assigning to a key that a draft holds as an own data property sets that property, as in JavaScript, also when a prototype defines a setter of that name. v1 called the setter through the draft instead, and assigning an own __proto__ key, as JSON.parse() creates one, threw. A class instance that a mark makes immutable is copied by assigning its own properties, so such a setter now runs on that copy instead of through the draft; see copying marked instances.
  • 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().
  • current() returns a snapshot of a draft whose state holds a plain Set with drafts, and snapshots an assigned object that a mark makes draftable together with the drafts it holds.
  • With enableAutoFreeze, Map and Set instances are frozen too, and later producers skip them.
  • Map and Set draft iterators behave like built-in iterators, with iterator helpers where the engine has them. The value and entry iterators of a Map draft, and all iterators of a Set draft, cannot yield values after their producer ends.
  • apply() copies the symbol keys and own __proto__ keys of patch values, rejects a path that ends in __proto__, reads Set items only at their indices, uses Map keys as they are, and with mutable: true throws for a patch that replaces the root state.
  • An object that a mark makes draftable keeps an own __proto__ key as a property of its copy.
  • Patches keep a Map key that is an array as one path segment, record changes between 0 and -0, emit no patch for an element that stays NaN, and replace the indices that an array exposes again after it shrank and grew.
  • A nested unsafe() call no longer ends the access of the outer call.
  • A producer that fails after its recipe returned revokes its drafts, as a recipe that throws does.
  • A draft of an array whose Symbol.isConcatSpreadable is false copies its elements.

Other changes

  • Bundle size. The fast paths and native array methods add about 2 kB. In a browser bundle with NODE_ENV set to production, create alone is 7.9 kB with Brotli, against 5.8 kB with 1.3.0, and create, apply, current and original together are 8.5 kB, against 6.4 kB. Patches, Maps, Sets and array methods still need no plugins.
  • Requirements. Node.js 14.0 or later, as before; the package checks compile the type declarations with TypeScript 5.0.
  • Tooling. The repository moved to pnpm, Vitest, tsdown, oxlint and oxfmt. CI checks the packed package with publint and attw, the bundle sizes against a baseline, and the performance budgets of the benchmark suite.

What's Changed

  • fix(apply): fix apply with patches non-string keys by @unadlib in #151
  • build(rollup): add build analyzer by @SukkaW in #164
  • refactor: separating and minifying errors via error codes by @SukkaW in #167
  • Chore/toolchain migration by @unadlib in #169
  • build: migrate to tsdown with package and size regression checks by @unadlib in #170
  • test: reproduce Immer array methods patch failures on 11.1.18 by @unadlib in #172
  • perf: add versioned benchmarks and CI regression budgets by @unadlib in #173
  • perf: draft fast paths for copies, traps, finalization, and patch generation by @unadlib in #174
  • test: pin Immer 11.1.18 failures that Mutative handles by @unadlib in #175
  • fix: unchanged drafts in Sets and undefined assignments to removed keys by @unadlib in #176
  • perf: run array methods natively on the draft copy by @unadlib in #75
  • Replace the legacy benchmarks with an extended perf-testing suite by @unadlib in #177
  • test: run the exhaustive array method audit against Mutative by @unadlib in #179
  • perf: make current() of changed arrays cheap and recommend it for read-only scans by @unadlib in #180
  • fix: accurate rawReturn() warnings and no warning state in production builds by @unadlib in #181
  • build: strip development code from production ESM bundles and minify the remaining errors by @unadlib in #182
  • perf: frozen returns, Set reads, frozen array copies and moved-element edits, plus a v1 to v2 migration guide by @unadlib in #183
  • fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings by @unadlib in #184
  • fix: copy Set drafts of an outer create() call and warn about create() on a draft by @unadlib in #186
  • docs: recommend helpers that change a draft in place for create() on a draft by @unadlib in #188
  • fix: reject Map keys that are not strings and symbol keys in string patch paths in development builds by @unadlib in #189
  • fix: keep inserted base elements as they are and patch regrown indices of array drafts by @unadlib in #190
  • fix: make the Node.js ESM entry work on Node.js 14.0 by @unadlib in #191
  • fix: harden apply() patch paths and fix mark copies, current() snapshots and async recipe types by @unadlib in #192
  • docs: migrate the website from Docusaurus to Fumadocs by @unadlib in #193
  • perf: budget the scenarios added in #177 and #180, and compare the fastest processes of each build by @unadlib in #194
  • perf: change the copy of a Set draft directly, and keep the changes of a Set draft that left its key by @unadlib in #195
  • perf: read the type of a draft from its state when applying patches by @unadlib in #196
  • docs: list the differences from Immer and describe patch behavior by @unadlib in #197
  • perf: add benchmark scenarios for Set reads, Map iteration, moved rows and unchanged collections by @unadlib in #198
  • perf: rerun the full benchmark on main and refresh the published results by @unadlib in #199
  • perf: find a moved array element's original index from the offset of its move by @unadlib in #200
  • fix: harden apply() for Set positions and mutable root replacement, and fix development checks and recipe types by @unadlib in #201
  • fix: bound the search for repeated moved array elements and type maybe-async recipes of primitive states by @unadlib in #202
  • docs: name the state types that keep async results typed as the state, fix the JSON Patch note and state the TypeScript versions by @unadlib in #203
  • fix: type curried recipes whose draft is annotated with a type parameter by @unadlib in #204
  • perf: rerun the full benchmark and refresh the published results by @unadlib in #205

New Contributors

Full Changelog: v1.3.0...v2.0.0