Repository navigation
[Roadmap] Mutative v2 #168
Description
Goal
Mutative v2 will focus on correctness, performance, smaller package output, and a better developer experience.
Correct by default. Faster when safe. Smaller and easier to adopt.
Plan
Core semantics
- Improve shared-reference handling in nested
create()calls. For Nested create() on draft element shares references with original base object #160,create()on a draft still drafts a copy of it: development builds warn once about it, and the docs show the problem and recommend helpers that change a draft in place (fix: copy Set drafts of an outer create() call and warn about create() on a draft #186, docs: recommend helpers that change a draft in place for create() on a draft #188), after the alignment with Immer in fix: nested create() calls on drafts work like Immer's produce #185 and thecloneDraftBaseoption in feat: add the cloneDraftBase option for create() on a draft #187 were closed. Copying a Set draft of an outercreate()call no longer throws (fix: copy Set drafts of an outer create() call and warn about create() on a draft #186). - Align with Immer where appropriate and document intentional differences.
- Emit child patches before parent patches and share finalized objects in patch values, as Immer does (perf: draft fast paths for copies, traps, finalization, and patch generation #174).
- Pin the Immer 11.1.18 failures that Mutative handles, including those of its array-method plugin (test: reproduce Immer array methods patch failures on 11.1.18 #172, test: pin Immer 11.1.18 failures that Mutative handles #175).
- Document the intentional differences in one place: the Comparison with Immer page lists them, checked against Immer 11.1.18 in 97 cases, with an API table and the behaviors that both libraries share (docs: list the differences from Immer and describe patch behavior #197).
- Document two limits: an iteration over a Map draft that starts before the recipe's first change walks the base Map, and Set patches remove objects by identity, so an undo after a redo cannot remove an object that
apply()added (fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184). Immer 11.1.18 behaves the same in both (docs: list the differences from Immer and describe patch behavior #197). - Document three limits that 1.3.0 already had, two of them shared with Immer: the ES2025 Set methods such as
union()compare the objects of a Set draft as its iterator returns them, a manualfinalize()that throws leaves its drafts writable, and an async recipe from another realm needs a wrapper from this realm (fix: harden apply() patch paths and fix mark copies, current() snapshots and async recipe types #192). - Document three more limits that 1.3.0 already had:
apply()appends the items that patches add to a Set, so a Set that it changes can hold its items in another order and the patches of a later recipe can find other items, as in Immer; a draft of onecreate()call stored in the draft of another can stay in the result revoked; and a copy of an instance that amarkmakes draftable assigns its enumerable, writable and configurable own properties, so one that shadows a setter of its class runs it, as Immer's default copy does (fix: harden apply() for Set positions and mutable root replacement, and fix development checks and recipe types #201).
- Ensure enabling patches never changes the final state (perf: add versioned benchmarks and CI regression budgets #173, Replace the legacy benchmarks with an extended perf-testing suite #177, test: run the exhaustive array method audit against Mutative #179, fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184).
- With patches enabled, every changed item of a Set keeps its change. Resolving the patch path of a changed item iterated the Set through its draft, and the iterator drafted already finalized items again, so only the last finalized item kept its change; the bug dates from 1.3.0 (fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184).
- Verify that patches reproduce the result and inverse patches restore the original state.
- CI checks every benchmark workload against one reference result in each patch mode it supports, including forward and inverse replay (perf: add versioned benchmarks and CI regression budgets #173, Replace the legacy benchmarks with an extended perf-testing suite #177).
- Unit tests replay array patches across native moves, removed elements, holes, and shared references (perf: run array methods natively on the draft copy #75, fix: unchanged drafts in Sets and undefined assignments to removed keys #176).
- Run the exhaustive 4,913-sequence array audit against Mutative too, on the native and proxy paths and in every freeze mode (test: run the exhaustive array method audit against Mutative #179).
- A changed draft that left its key, moved by
sort()or an assignment or replaced there, no longer emits patches under its old path; its parent's patches carry its value. 20,000 random recipes replay in both directions, against 7 forward and 38 inverse failures onmain(fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184). - An original object assigned over a draft is recorded like any assignment, changed items of a Set that added or removed items are carried by the Set's
removeandaddpatches, an array Map key stays one path segment,0→-0is a patch, andapply()copies keep symbol keys and an own__proto__key (fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184). - With string paths (
pathAsArray: false), development builds throw when a patch path would hold a Map key that is not a string, or a symbol key, which a JSON Pointer cannot name: applying such a patch writes another key, for example'1'instead of1. Production builds stay byte-identical and generate such patches as 1.3.0 did; the docs say to keep array paths for such keys (fix: reject Map keys that are not strings and symbol keys in string patch paths in development builds #189). - Patches cover the indices that a longer length exposes again. A native move dropped the record of an index that
pop()removed, sopop(),reverse()andlength = 3lost the third element in both directions, a v2 regression found by the pre-release review; on the proxy path,length = 1thenlength = 3lost elements since 1.3.0. Such indices are now marked when an array grows back below its original length, and 32,000 sequences of three operations replay in both directions on the native and proxy paths (fix: keep inserted base elements as they are and patch regrown indices of array drafts #190). -
apply()uses the Map keys of patch paths as they are; it converted them to strings, which threw for an object without a prototype (fix: harden apply() patch paths and fix mark copies, current() snapshots and async recipe types #192). -
apply()withmutable: truethrows the new error 21 for a patch that replaces the root state with another value; since 1.3.0 it ignored the replacement and applied the later patches to that patch's value, so the state stayed as it was (fix: harden apply() for Set positions and mutable root replacement, and fix development checks and recipe types #201).
- Add coverage for shared references, nested drafts, sparse arrays, and complex array mutations (perf: draft fast paths for copies, traps, finalization, and patch generation #174, perf: run array methods natively on the draft copy #75, test: pin Immer 11.1.18 failures that Mutative handles #175, fix: unchanged drafts in Sets and undefined assignments to removed keys #176); line, branch, and function coverage of
srcis 100%. - Optimize plain-object copying for both small objects and wide objects (perf: draft fast paths for copies, traps, finalization, and patch generation #174).
- Reduce draft creation and access overhead through a consistent internal state layout and efficient child-draft caching (perf: draft fast paths for copies, traps, finalization, and patch generation #174).
- Optimize finalization and patch generation to avoid unnecessary deep copies, callbacks, and full-array scans (perf: draft fast paths for copies, traps, finalization, and patch generation #174).
- Preserve getter, setter, symbol-key, and own
__proto__semantics during optimized reads and copies (perf: draft fast paths for copies, traps, finalization, and patch generation #174). - Ensure
current()correctly snapshots plain Sets containing drafts while preserving unchanged references (perf: draft fast paths for copies, traps, finalization, and patch generation #174, test: pin Immer 11.1.18 failures that Mutative handles #175). - Make
current()of a changed array cheap: copy it from the draft's current array instead of through the proxy, and leave the base elements that native methods moved as they are (perf: make current() of changed arrays cheap and recommend it for read-only scans #180). A snapshot search is now faster than the draft search at every size, 1.5x at 100 rows and 10.8x at 10,000. - Read values under a
markfrom the draft's current copy. The get trap read the original object, so a value the recipe assigned or moved was read back stale through the draft and bycurrent(); found in the review of perf: make current() of changed arrays cheap and recommend it for read-only scans #180 and fixed there. - In strict mode, warn once in development builds when a recipe leaves 1,000 or more drafts unchanged, as a search through a large draft array does (perf: make current() of changed arrays cheap and recommend it for read-only scans #180).
- In strict mode, warn about a
rawReturn()value only when it holds drafts. A draft-free value printed two contradictory warnings, andrawReturn(1)printed three (fix: accurate rawReturn() warnings and no warning state in production builds #181). - Freeze Map and Set instances under auto-freeze after installing their mutator guards, so later producers skip them and a collection that holds itself no longer overflows the stack in production builds (fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184).
- Give Map and Set drafts iterators that inherit
%IteratorPrototype%, continue where they stopped and offer the iterator helpers, and stop yielding values once their producer has ended (fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184). - Revoke the drafts of a producer that fails after its recipe returned, including a failure while inspecting the returned value, and release the array method cache (fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184).
- Restore the outer
unsafe()access after a nested call, and copy arrays whoseSymbol.isConcatSpreadableis false element by element (fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184). - Finalize unchanged drafts held in Sets to their originals, and keep
undefinedassignments to keys removed from the copy (fix: unchanged drafts in Sets and undefined assignments to removed keys #176). - Keep
__proto__keys from changing prototypes.apply()rejects a patch whose path ends in__proto__on an object or array in every mode, also for a path key that converts to it, such as a boxed string; withmutable: truesuch a patch replaced the prototype since 1.3.0. Copies for marks keep an own__proto__key as a property, as the other copies already did (fix: harden apply() patch paths and fix mark copies, current() snapshots and async recipe types #192). A patch path that passes a Set finds an item only at its index and converts its key once, so__proto__there no longer reachesArray.prototype, which such a patch could change since 1.3.0 (fix: harden apply() for Set positions and mutable root replacement, and fix development checks and recipe types #201). The README and theapply()page say that these checks apply to the paths of objects and arrays, that Map keys of these names are ordinary keys, and thatapply()does not guard against prototypes that other code has already changed (fix: bound the search for repeated moved array elements and type maybe-async recipes of primitive states #202). - Snapshot an object that the recipe assigned and that a
markmakes draftable incurrent(); since 1.3.0 the snapshot showed later changes of the drafts in it (fix: harden apply() patch paths and fix mark copies, current() snapshots and async recipe types #192). - Type an async recipe with an explicit state type,
create<State>(base, async (draft) => …), and its curried form as returning a Promise, and type the results ofapply()withenableAutoFreezeand withmutableor freeze options of typeboolean; calls without type arguments resolve as before (fix: harden apply() patch paths and fix mark copies, current() snapshots and async recipe types #192). A curried producer whose recipe annotates its draft and returns a new state is typed as a producer instead of a manual draft (fix: harden apply() for Set positions and mutable root replacement, and fix development checks and recipe types #201), and so is one whose draft is annotated with a type parameter of a generic function (fix: type curried recipes whose draft is annotated with a type parameter #204). With an explicit state type, a recipe that may or may not return a Promise givesState | Promise<State>(fix: harden apply() for Set positions and mutable root replacement, and fix development checks and recipe types #201), also for a primitive state type (fix: bound the search for repeated moved array elements and type maybe-async recipes of primitive states #202). An explicit state type that is a type parameter of a generic function, or that includes Promises, such asobjectorunknown, still types such results as the state, as in 1.3.0; the docs say so, and that leaving out the type argument in generic code gives a Promise (docs: name the state types that keep async results typed as the state, fix the JSON Patch note and state the TypeScript versions #203). The docs also say how the type ofenablePatchesdecides the result type: options typed{ enablePatches: true }that may be missing still give the tuple, so the flag is typedbooleanthere (fix: bound the search for repeated moved array elements and type maybe-async recipes of primitive states #202). - Keep the changes of a Set draft that the recipe moves to another place, such as another key, an array, a Map, another Set or a new object, when its old place gets another value or is removed; since 1.3.0 the state held that Set without its added, deleted and changed items (perf: change the copy of a Set draft directly, and keep the changes of a Set draft that left its key #195).
- In development builds, report a circular reference under auto-freeze only for an object that holds itself. A changed draft that holds its original, as
draft.prev = original(draft)makes it, or that links to a shared object through another path threw since 1.3.0, while production builds froze the state; without the bookkeeping, development builds with auto-freeze run 10–17% faster and the production bundle is 25 B smaller (fix: harden apply() for Set positions and mutable root replacement, and fix development checks and recipe types #201). - Throw in development builds when a recipe passes a draft to the
set()method of a Map draft: Map keys are used as they are, so the next state kept a revoked draft as a key, and with auto-freezecreate()threw aTypeError. Production builds keep such a key, as 1.3.0 did, and the FAQ names the alternatives (fix: harden apply() for Set positions and mutable root replacement, and fix development checks and recipe types #201).
Array performance
- Enable specialized array fast paths with patches enabled and disabled; native moves emit patches that replay in both directions (perf: run array methods natively on the draft copy #75). This replaces the conservative implementation planned for patches.
- Optimize high-impact operations such as
shift(),unshift(),splice(), andreverse(), which now run natively on the draft's copy (perf: run array methods natively on the draft copy #75). - Ensure optimized and conservative paths produce identical results for data arrays, under the contract stated in the README FAQ; index accessors are outside it (perf: run array methods natively on the draft copy #75).
- Hand out a base element that the recipe inserts or assigns as it is after native moves, as the proxy path and 1.3.0 do:
unshift(base[1])followed by a read, a search orshift()drafted it, a v2 regression found by the pre-release review. One rule decides reads, searches and removals, the native methods keep the records of assignments as the proxy path does, and every sequence of three of 20 operations matches the proxy path by value and by identity (fix: keep inserted base elements as they are and patch regrown indices of array drafts #190).
- Hand out a base element that the recipe inserts or assigns as it is after native moves, as the proxy path and 1.3.0 do:
- Prevent performance regressions in sequential reads and full-array traversal.
- With auto-freeze, copy frozen, sealed and non-extensible plain arrays with a spread, since
concatleaves V8's fast path for them; 27 of the 33 freeze-on array scenarios at 100 rows are 5–11% faster (perf: frozen returns, Set reads, frozen array copies and moved-element edits, plus a v1 to v2 migration guide #183).
Benchmarks
- Build reproducible benchmarks for Mutative v1, Mutative v2, and a pinned Immer version (perf: add versioned benchmarks and CI regression budgets #173).
- Measure patch-enabled and patch-disabled modes separately (perf: add versioned benchmarks and CI regression budgets #173).
- Cover mutation-heavy, read-heavy, small-state, and large-state workloads (perf: add versioned benchmarks and CI regression budgets #173).
- Track execution time, memory usage, and common array operations (perf: add versioned benchmarks and CI regression budgets #173).
- Add performance regression budgets to CI (perf: add versioned benchmarks and CI regression budgets #173).
- Compare the fastest of the five processes of each build in the timing budgets. About one process in six on GitHub runners ran the array moves 20–38% slower throughout, for base and candidate builds alike, so the medians of five paired ratios failed when three or four slow processes fell to the candidate: 4 of 113 runs from September 30 to October 8, among them one between byte-identical builds in docs: migrate the website from Docusaurus to Fumadocs #193 at 1.30–1.32. Compared by their fastest processes, all of them pass, at most 1.08 (perf: budget the scenarios added in #177 and #180, and compare the fastest processes of each build #194).
- Extend the suite to 90 workloads with Map and Set values, object records, class instances, patch application, returned values, and a hand-written reducer, and remove the legacy benchmark scripts (Replace the legacy benchmarks with an extended perf-testing suite #177).
- Add the
search-draft,search-currentandsearch-current-shiftedscenarios, 93 workloads, with their measurements recorded in the performance summary (perf: make current() of changed arrays cheap and recommend it for read-only scans #180). - Add CI budgets for the 26 scenarios added in Replace the legacy benchmarks with an extended perf-testing suite #177 and perf: make current() of changed arrays cheap and recommend it for read-only scans #180: timing budgets for all of them, 98 cells, and memory budgets for the eight heaviest, in four budget groups whose jobs run in parallel, 6–8 minutes each. Before they were added, identical builds compared on 30 GitHub runners passed every run, at most 1.11 for timing, and no split of a run's processes between the two builds exceeded 1.26. Smaller cases have no memory budget, since the retained heap of
deep-updatewith patches measured 13–36 KiB instead of 1.8 KiB per output in 23 of 120 processes (perf: budget the scenarios added in #177 and #180, and compare the fastest processes of each build #194). - Add scenarios for read-only Set access, for editing elements that a native method moved, for Map iteration and for producers beside unchanged frozen collections, which perf: frozen returns, Set reads, frozen array copies and moved-element edits, plus a v1 to v2 migration guide #183 and fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184 measured with micro-benchmarks only. perf: add benchmark scenarios for Set reads, Map iteration, moved rows and unchanged collections #198 added
set-read,map-forEach,shift-and-updateandmap-set-beside, 97 workloads; built from the commit before each improvement, each took at least 2.3 times as long in one or more modes, andset-read62–65 times in every mode. CI budgets them,shift-and-updateincoreand the Map and Set scenarios in two jobs instead of one,mapsandsets; before that, identical builds compared on 30 GitHub runners passed every run, the new cells at most 1.04. Their measurements join the published results: Mutative was faster than Immer in 595 of 614 cells, 3.6x on geometric mean, and in 153 of 157 with each library's defaults, 6.7x (perf: add benchmark scenarios for Set reads, Map iteration, moved rows and unchanged collections #198).
Performance follow-ups
Measured in #177, #180 and #184; see Tradeoffs and limits.
- Returned values: with auto-freeze, production builds no longer search frozen objects in a returned value for drafts, and development builds throw when a draft sits under one. Returning a state built from frozen data took 2.9 ms against Immer's 0.9 µs at 10,000 rows and now takes 0.56 µs; without auto-freeze, returned values are searched as before (perf: frozen returns, Set reads, frozen array copies and moved-element edits, plus a v1 to v2 migration guide #183).
- Set drafts: avoid building and rebuilding a value map of the whole Set, about 100 ns per element at 10,000 elements against 7 ns for a hand-written copy (perf: frozen returns, Set reads, frozen array copies and moved-element edits, plus a v1 to v2 migration guide #183, perf: change the copy of a Set draft directly, and keep the changes of a Set draft that left its key #195).
-
hasandsizeanswer from the original Set until it changes or is iterated: a read-onlyhastakes 0.65 µs instead of 522 µs at 10,000 elements (perf: frozen returns, Set reads, frozen array copies and moved-element edits, plus a v1 to v2 migration guide #183). - Changes go to the Set's copy, which holds its items in order, and a map holds only the items that an iterator drafted and the objects that the recipe added; finalization rebuilds the Set once, by its own producer, and only when one of them has a different final value. Adding a number to a Set of 10,000 numbers takes 34 µs, against 929 µs for Immer and 66 µs for the hand-written reducer; Mutative alone, the 32 Set cells take 0.28 of the time of
main, and the performance page lists 6 cases where Immer is faster instead of 9. With patches, a Set that added or deleted items still compares all of its items to emit its own patches (perf: change the copy of a Set draft directly, and keep the changes of a Set draft that left its key #195).
-
- Applying patches that replace nested values: with auto-freeze off,
apply-update-10pctwas 16–22% slower than Immer at 100, 1,000 and 10,000 rows, 3 of the 6 cells where Immer was faster.apply()asked each draft on a patch path for its type twice, through thegetPrototypeOftrap of its proxy, and now reads the type from the draft's state:apply-update-10pcttakes 0.49–0.61 of the time ofmainand is 1.6–2.0 times as fast as Immer, the 14 patch application cells take 0.68 of the time, and Immer is faster in 3 cases instead of 6. Objects of the application on a patch path are typed as before (perf: read the type of a draft from its state when applying patches #196). - Moved array elements: reading a moved element of a large array built a map of original indices, about 400 µs of the 772 µs that
search-current-shiftedtook at 10,000 rows (perf: make current() of changed arrays cheap and recommend it for read-only scans #180). The first eight lookups now search the original array instead: an edit aftershift()takes 23 µs instead of 410 µs at 10,000 rows, andsearch-current-shifted305 µs instead of 846 µs in the same run (perf: frozen returns, Set reads, frozen array copies and moved-element edits, plus a v1 to v2 migration guide #183). Removing an element that sits at its original index no longer searches the original array:apply-array-opstakes 23 µs instead of 39 µs at 10,000 rows (fix: keep inserted base elements as they are and patch regrown indices of array drafts #190). V8 searches frozen arrays backward about 15 times as slowly, so eight such searches of a frozen array cost more than the map they deferred; lookups now check first where the offset of the lastshift(),unshift()orsplice()puts the element and search forward from there, which finds the same index: eight edits aftershift()of 10,000 frozen rows take 208 µs instead of 2,050 µs, and an edit aftershift()of 10,000 rows without auto-freeze 8.9 µs instead of 15.9 µs (perf: find a moved array element's original index from the offset of its move #200). Following a repeated element forward copy by copy then cost one search per later copy:shift()and eight reads of 10,000 rows of two objects in turn took 581 µs instead of 10.3 µs. After 16 copies, each search now starts twice as far ahead as the one before, and the backward search starts at the first one that finds no copy: 13.6 µs, while arrays with copies in their first 100 rows keep the time of perf: find a moved array element's original index from the offset of its move #200; a long run of copies costs more on a frozen base, 655 µs instead of 330 µs when the first half of 10,000 rows repeats two objects (fix: bound the search for repeated moved array elements and type maybe-async recipes of primitive states #202). - Frozen Map and Set instances: with auto-freeze, every producer walked every reachable Map and Set again, since the instances were never frozen. A producer beside an unchanged 10,000-entry Map takes 0.44 µs instead of 66.7 µs, and beside a 10,000-item Set 0.44 µs instead of 26.1 µs (fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184).
- Map iteration:
forEach,values()andentries()calledget()through the proxy for every entry. They read entries directly now:forEachover 10,000 primitive entries takes 279 µs instead of 932 µs, of which the iterator lifetime check costs 1–2% (fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184).
Build system and bundle size
- Migrate the build system from Rollup to tsdown (build: migrate to tsdown with package and size regression checks #170).
- Preserve existing ESM, CommonJS, UMD, development, and production outputs (build: migrate to tsdown with package and size regression checks #170).
- Preserve package exports and TypeScript declaration compatibility (build: migrate to tsdown with package and size regression checks #170).
- Configure the build target and platform explicitly (build: migrate to tsdown with package and size regression checks #170).
- Validate package output with
publintandattw(build: migrate to tsdown with package and size regression checks #170). - Add size regression checks against a reviewed baseline (build: migrate to tsdown with package and size regression checks #170).
- Reduce bundle size. Error codes made it smaller (refactor: separating and minifying errors via error codes #167), but the draft fast paths, the native array methods and the
current()array copy grew the production CommonJS bundle from 6.1 kB in 1.3.0 to 8.1 kB Brotli (perf: draft fast paths for copies, traps, finalization, and patch generation #174, perf: run array methods natively on the draft copy #75, perf: make current() of changed arrays cheap and recommend it for read-only scans #180); it is 7.9 kB after fix: accurate rawReturn() warnings and no warning state in production builds #181 and build: strip development code from production ESM bundles and minify the remaining errors #182, 8.1 kB after the fast paths of perf: frozen returns, Set reads, frozen array copies and moved-element edits, plus a v1 to v2 migration guide #183, and 8,106 B after fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184, 27 B more than perf: frozen returns, Set reads, frozen array copies and moved-element edits, plus a v1 to v2 migration guide #183: the Set patch fixes and the dropped patch path validation saved 17 B, and the iterator lifetime checks with the failed-producer cleanup added 44 B. The Set copy fix of fix: copy Set drafts of an outer create() call and warn about create() on a draft #186 adds 8 B, to 8,114 B, and fix: reject Map keys that are not strings and symbol keys in string patch paths in development builds #189 leaves the production artifacts byte-identical. fix: keep inserted base elements as they are and patch regrown indices of array drafts #190 adds 70 B, to 8,184 B, and fix: harden apply() patch paths and fix mark copies, current() snapshots and async recipe types #192 17 B, to 8,201 B. The Set drafts of perf: change the copy of a Set draft directly, and keep the changes of a Set draft that left its key #195 add 133 B, to 8,334 B, 11 B of them for the moved Set fix. perf: read the type of a draft from its state when applying patches #196 adds 33 B, to 8,367 B, perf: find a moved array element's original index from the offset of its move #200 72 B, to 8,439 B, and fix: harden apply() for Set positions and mutable root replacement, and fix development checks and recipe types #201 10 B, to 8,449 B: removing the bookkeeping of the development cycle check saved 25 B, and the Set path and mutable root checks ofapply()added 29 B and 6 B. fix: bound the search for repeated moved array elements and type maybe-async recipes of primitive states #202 adds 23 B, to 8,472 B, for the search of repeated moved elements.size-limitmeasures 8.64 / 7.58 / 8.52 kB against caps of 8.7 / 7.7 / 8.6 kB: perf: change the copy of a Set draft directly, and keep the changes of a Set draft that left its key #195 raised them from 8.4 / 7.4 / 8.3 kB, perf: read the type of a draft from its state when applying patches #196 the cap of all ESM exports to 8.5 kB, perf: find a moved array element's original index from the offset of its move #200 the caps of all CommonJS exports and of the ESMcreateimport to 8.7 and 7.7 kB, and fix: bound the search for repeated moved array elements and type maybe-async recipes of primitive states #202 the cap of all ESM exports to 8.6 kB.- Keep the warning state of returned values out of production builds (fix: accurate rawReturn() warnings and no warning state in production builds #181).
- Minify the eight production errors that still carried full messages, as error codes 13–20 (build: strip development code from production ESM bundles and minify the remaining errors #182).
- Strip development code from production ESM bundles; ESM consumers shrink by 0.9–1.1 kB (build: strip development code from production ESM bundles and minify the remaining errors #182).
- Measure what consumers import with
size-limit: all exports of the production CommonJS artifact, andcreateand all exports of the ESM entry. It used to count only what module initialization keeps (build: strip development code from production ESM bundles and minify the remaining errors #182). - Drop the unreachable patch path validation and its error code 12, which stays reserved so that codes 13–20 keep their numbers (fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184).
- Ship production ESM artifacts (build: strip development code from production ESM bundles and minify the remaining errors #182). Every development check in
mutative.esm.jsandmutative.esm.mjscomparesprocess.env.NODE_ENV, so bundlers drop the development code in production. Node.js resolves the CommonJS entry through thenodecondition and reads the variable once, andmutative.esm.production.min.mjsserves browsers without a bundler. - Make the watch-build check wait until both rebuilt outputs carry the edited value instead of counting completion messages, which let a stale output pass on Node.js 22 (fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184).
- Verify the final packed npm artifact before release (build: migrate to tsdown with package and size regression checks #170). The publish workflow also type-checks the source and the tests, including the type tests, before it builds (fix: harden apply() for Set positions and mutable root replacement, and fix development checks and recipe types #201).
- Compile production and consumer builds to ES2018 to preserve native object spread and validate bundle-size budgets (perf: draft fast paths for copies, traps, finalization, and patch generation #174).
- Publish reproducible performance evidence with source and artifact hashes (perf: draft fast paths for copies, traps, finalization, and patch generation #174, perf: run array methods natively on the draft copy #75, Replace the legacy benchmarks with an extended perf-testing suite #177, fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184, perf: change the copy of a Set draft directly, and keep the changes of a Set draft that left its key #195, perf: read the type of a draft from its state when applying patches #196, perf: add benchmark scenarios for Set reads, Map iteration, moved rows and unchanged collections #198, perf: rerun the full benchmark on main and refresh the published results #199, perf: find a moved array element's original index from the offset of its move #200, fix: bound the search for repeated moved array elements and type maybe-async recipes of primitive states #202); the complete 2026-10-01 measurements are kept in a verified external archive, and the performance summary records the fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184 comparison of head against
main, the Set measurements of perf: change the copy of a Set draft directly, and keep the changes of a Set draft that left its key #195, the patch application measurements of perf: read the type of a draft from its state when applying patches #196, the four scenarios of perf: add benchmark scenarios for Set reads, Map iteration, moved rows and unchanged collections #198, the full batch of perf: rerun the full benchmark on main and refresh the published results #199 and the moved-element measurements of perf: find a moved array element's original index from the offset of its move #200 and fix: bound the search for repeated moved array elements and type maybe-async recipes of primitive states #202 with their production CommonJS hashes.
Tooling
- Migrate from Yarn to pnpm and regenerate the lockfile (Chore/toolchain migration #169).
- Migrate the test suite from Jest to Vitest (Chore/toolchain migration #169).
- Preserve test behavior, coverage, mocks, snapshots, and timer semantics (Chore/toolchain migration #169).
- Replace ESLint and Prettier with oxlint and oxfmt, and check lint and formatting in CI (Chore/toolchain migration #169).
- Update CI and the build environment for tsdown (build: migrate to tsdown with package and size regression checks #170).
- Keep tooling migrations separate from runtime changes.
Website and documentation
- Refactor the website. docs: migrate the website from Docusaurus to Fumadocs #193 moved it from Docusaurus 3.3 to Fumadocs 16 on Next.js 16, still exported statically for GitHub Pages: pages keep their URLs and the docs their heading ids, the blog keeps its RSS and Atom feeds, and moved pages redirect. It adds search,
llms.txtand the Markdown of each page, fails the build on broken links, builds the website in CI, and redesigns the home page with a code comparison, the benchmark results, the projects that replaced Immer with Mutative, the features and the ecosystem. - Add first-class Chinese language support. The disabled
zh-CNlocale of the Docusaurus site held only its tutorial and an empty blog post, and docs: migrate the website from Docusaurus to Fumadocs #193 removed it. - Publish the benchmark methodology and current results in the README, on the website, and in
perf-testing(perf: add versioned benchmarks and CI regression budgets #173, Replace the legacy benchmarks with an extended perf-testing suite #177, perf: make current() of changed arrays cheap and recommend it for read-only scans #180). The summary gained a per-PR section with a complete 366-cell matrix, three runs each, and focused diagnostics (fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184). The home page shows the results, and the Comparison with Immer page says that the measured Immer 11.1.18 includes the finalization callbacks that Immer 11.0.0 ported from Mutative (docs: migrate the website from Docusaurus to Fumadocs #193). Theperf-testingREADME describes the CI budget groups, the fastest-process comparison and the noise measured on GitHub runners (perf: budget the scenarios added in #177 and #180, and compare the fastest processes of each build #194, perf: add benchmark scenarios for Set reads, Map iteration, moved rows and unchanged collections #198). The Set scenarios were measured again for perf: change the copy of a Set draft directly, and keep the changes of a Set draft that left its key #195 and the patch application scenarios for perf: read the type of a draft from its state when applying patches #196, the four scenarios of perf: add benchmark scenarios for Set reads, Map iteration, moved rows and unchanged collections #198 were measured the same way, and the README, the website and the summary report those cells. perf: rerun the full benchmark on main and refresh the published results #199 replaced them with one batch of every scenario onmainatce0c25e, and perf: find a moved array element's original index from the offset of its move #200 measured the cells ofshift-and-updateagain. - Rerun and publish the benchmarks for the final v2 build. Run them last, once the final runtime change is merged, so that the README, the website and the performance summary match the released build. perf: rerun the full benchmark on main and refresh the published results #199 reran the full suite on
mainatce0c25ein 7 h 13 min, every library in isolated processes with the shared-process, process-order, memory, plugin and budget-gate controls, and replaced every published figure with that one batch: Mutative was faster than Immer in 596 of 614 cells, 3.6x on geometric mean, and in 154 of 157 with each library's defaults, 6.6x, and Immer is faster only inclass-wide-updatewith auto-freeze at 1,000 rows. It also comparesrawReturn()without auto-freeze with an ordinary return in Immer with auto-freeze, 3.0x at every size. A runtime change merged later needs its cells measured again (perf: rerun the full benchmark on main and refresh the published results #199), as perf: find a moved array element's original index from the offset of its move #200 did forshift-and-update, which moved the figure with each library's defaults to 6.7x. - Replace the legacy performance claims left in the website tagline and the repository description. The Docusaurus tagline, "more than 10x faster than Immer", went with its config in docs: migrate the website from Docusaurus to Fumadocs #193, and the new site describes Mutative as about 3.6x faster than Immer with matched settings and 6.7x faster with each library's defaults, across 97 workloads, after the rerun of perf: rerun the full benchmark on main and refresh the published results #199 and perf: find a moved array element's original index from the offset of its move #200; the repository description says about 3-6x. The README labels its figures with their 2026-10-03/04 date and source (fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184); the 1.0 announcement of 2023 keeps its figures as a dated post.
- Add a v1-to-v2 migration guide covering the ES2018 output, the changes to patch order and patch values (perf: draft fast paths for copies, traps, finalization, and patch generation #174), and the package entry changes: browsers that load the ESM file without a bundler need
mutative.esm.production.min.mjs, Node.js ESM resolves the CommonJS entry, and errors 13–20 are minified in production (build: strip development code from production ESM bundles and minify the remaining errors #182). The README and the website have it, with arrays, returned values, development warnings and the fixes that change results, each checked against npm 1.3.0 (perf: frozen returns, Set reads, frozen array copies and moved-element edits, plus a v1 to v2 migration guide #183). fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184 adds its own changes: patches of moved drafts and of Set items, frozen Map and Set instances, iterator behavior and lifetimes, failed producers, nestedunsafe(), and the retired error code 12. fix: reject Map keys that are not strings and symbol keys in string patch paths in development builds #189 adds the development check of string patch path keys, fix: keep inserted base elements as they are and patch regrown indices of array drafts #190 the patches of an array whose length shrinks and grows again, fix: harden apply() patch paths and fix mark copies, current() snapshots and async recipe types #192 a TypeScript section and its fixes ofapply(), mark copies andcurrent(), perf: change the copy of a Set draft directly, and keep the changes of a Set draft that left its key #195 moved Set drafts and how drafts of Set subclasses behave, and fix: harden apply() for Set positions and mutable root replacement, and fix development checks and recipe types #201 the development checks of cycles and Map keys, theapply()changes for Set paths and mutable root replacements, and two result types; both guides now list the same changes. fix: bound the search for repeated moved array elements and type maybe-async recipes of primitive states #202 drops the exception for primitive state types from the TypeScript section, docs: name the state types that keep async results typed as the state, fix the JSON Patch note and state the TypeScript versions #203 names the explicit state types that still type async results as the state, and fix: type curried recipes whose draft is annotated with a type parameter #204 adds curried recipes whose draft is annotated with a type parameter. - Document patch behavior and important differences from Immer. The native array method contract and the failures of Immer's array-method plugin are documented (test: reproduce Immer array methods patch failures on 11.1.18 #172, perf: run array methods natively on the draft copy #75, Replace the legacy benchmarks with an extended perf-testing suite #177), and the migration guide covers the patch order and patch values that changed in v2 (perf: frozen returns, Set reads, frozen array copies and moved-element edits, plus a v1 to v2 migration guide #183). The patches guide describes Sets of objects, and the FAQ describes a Map iteration that starts before the first change (fix: patches of moved drafts, Map and Set freezing and iterators, and other review findings #184). The
pathAsArraydocs say that string paths cannot name Map keys that are not strings or symbol keys (fix: reject Map keys that are not strings and symbol keys in string patch paths in development builds #189). The FAQ says that searches find base elements that the recipe assigned or inserted (fix: keep inserted base elements as they are and patch regrown indices of array drafts #190). The Comparison with Immer page lists the differences in one place, and the Patches guide describes how patches are generated and applied (docs: list the differences from Immer and describe patch behavior #197). The README and the website describe which JSON Patch operations and options patches follow instead of claiming full compliance with JSON Patch (fix: harden apply() for Set positions and mutable root replacement, and fix development checks and recipe types #201), and the notes onarrayLengthAssignmentsay that JSON Patch compatibility also needspathAsArray: false(docs: name the state types that keep async results typed as the state, fix the JSON Patch note and state the TypeScript versions #203). - Regenerate the API reference in
docs/, last generated in November 2024; it shows the signatures ofcreate()andmakeCreator()(fix: harden apply() for Set positions and mutable root replacement, and fix development checks and recipe types #201, fix: bound the search for repeated moved array elements and type maybe-async recipes of primitive states #202, fix: type curried recipes whose draft is annotated with a type parameter #204). - Recommend
current()for read-only scans of large draft arrays and explain why an index found inoriginal()stops matching once the recipe changed the list; the README, the website and the strict mode guide are updated (perf: make current() of changed arrays cheap and recommend it for read-only scans #180). - Compare bundle sizes with Immer out of the box and with all of its plugins, measured from the entries bundlers resolve (build: strip development code from production ESM bundles and minify the remaining errors #182). perf: rerun the full benchmark on main and refresh the published results #199 refreshed Mutative's figures to 7.8 kB and 8.4 kB, against 3.6 kB and 6.4 kB for Immer.
- Show the projects that replaced Immer with Mutative, Appsmith, InstantDB, Notesnook and Plate, with the quotes, context and dates of their pull requests and release notes, on the home page and on a docs page that also lists Mol*, OpenRewrite and MSW Data; each of them depended on Mutative directly on its default branch on October 9, 2026 (docs: migrate the website from Docusaurus to Fumadocs #193).
- Keep the website refactor independent from the core runtime release. docs: migrate the website from Docusaurus to Fumadocs #193 changes no file of the library, only the website, its CI workflow and the two docs scripts of the root
package.json.
Real-world validation
- Test v2 in representative applications before the stable release. On October 9, 2026 (Asia/Shanghai), the packed artifact from
a8655eawas tested against pinned revisions of InstantDB, Plate, MSW Data, OpenRewrite, Notesnook, Mol*, and Appsmith; no new v2 regressions were found in the tested local suites. See the results below. - Verify ESM, CommonJS, TypeScript, bundler, and supported Node.js compatibility. Packed-package checks cover CommonJS, ESM, browser UMD, and NodeNext and Bundler type resolution (build: migrate to tsdown with package and size regression checks #170), and since build: strip development code from production ESM bundles and minify the remaining errors #182 also Node.js ESM in development and production and an esbuild bundle of the ESM entry; webpack and Vite builds were checked by hand in build: strip development code from production ESM bundles and minify the remaining errors #182. CI runs Node.js 22 and 24, and checks the packed package on Node.js 14.0.0, the oldest version that
enginesallows:importfailed there since build: strip development code from production ESM bundles and minify the remaining errors #182, because the Node.js ESM entry re-exported the CommonJS entry by name, which Node.js supports only since 14.13; the entry now takes the CommonJS exports as its default import, and the package checks also bundle it with esbuild for Node.js (fix: make the Node.js ESM entry work on Node.js 14.0 #191). Vite 8 client builds, its dev server and SSR, and the browser ESM and UMD builds in Chrome were checked by hand for fix: make the Node.js ESM entry work on Node.js 14.0 #191. The packed consumer also compiles with TypeScript 5.0, and typical calls resolve the same on TypeScript 4.8 to 7.0 (fix: harden apply() patch paths and fix mark copies, current() snapshots and async recipe types #192); the README and the TypeScript guide say that the types support TypeScript 5.0 and later (docs: name the state types that keep async results typed as the state, fix the JSON Patch note and state the TypeScript versions #203). On October 9, 2026, Bun 1.4.2 and Deno 2.9.6 passed development and production packed-package semantic checks, including ESM/CommonJS shared-instance behavior; Deno consumer types also passed. React Native 0.87.1 Metro resolved the shipped ESM entry and built development and production bundles, both executed as JavaScript and optimized bytecode in the matching native Hermes 250829098.0.17 VM on macOS ARM64; the untranspiled production UMD artifact passed there too. This verifies the Metro/Hermes JavaScript boundary; device UI and native application lifecycle testing remain outside this validation. - Collect 2.0 feedback and resolve correctness or compatibility blockers. Partial: existing public maintainer feedback has been reviewed, including OpenRewrite's acceptance of the nested-create reference trade-off and development warning and MSW Data's earlier getter/relationship feedback. The local application and runtime tests found no new correctness or compatibility blockers, but no new external user feedback on Mutative 2.0 has been collected, so this item remains open.
- Define a maintenance window for Mutative v1.
Local packed-artifact validation — October 9, 2026
Source: a8655eac5bbe311036d4cd8422b2fb98a9acb51d. The local identification version was 2.0.0-beta.validation.a8655ea; only package.json's version changed after building and packing this commit. This package was not published to npm. Candidate SHA-256: 4b43be4d6fb91deafb377236990a75f6b77390a223383adc92ac1e3eb3e903e7.
Each application's installed Mutative was backed up and replaced with the complete packed contents; all 76 files matched the candidate at all eight application installation locations. Baseline and candidate results were compared on the same application revisions.
| Application / pinned revision | Tested scope | Candidate result |
|---|---|---|
InstantDB b97a116 |
Core Node suite and www tests | 208 + 9 passed, 1 existing skipped; core type tests passed |
Plate 6f3815d |
Official fast and slow runners with pinned Bun 1.3.9 | 3,739 fast and 410 slow passed; core/table type checks passed |
MSW Data 43b2cf1 |
Node, type checking and Chromium persistence/sync tests | 187 Node and 16 browser tests passed |
OpenRewrite 2627cb4 |
Current JavaScript rewrite package's full Vitest suite | 2,647 passed, 56 existing pending; type check passed |
Notesnook 1a40555 |
Web test build; notes/notebooks/tags/tabs Chromium suites | 96 passed, 2 existing TODO skips, 4 existing archive locator timeouts; 4 supplemental archive/unarchive cases passed on baseline and candidate |
Mol* 4fcb35e |
Full Jest suite plus actual StateBuilder function updates | 1,504 passed, 14 existing pending; 10 supplemental consumer tests passed on baseline and candidate |
Appsmith 4f8e991 |
Main client's full Jest suite | 528 suites, 4,108 tests and 19 snapshots passed |
Notesnook's original archive helper targets #notes [data-test-id="archive-list"], while both builds render it under #archive. The supplemental cases kept the original archive-state assertions and selected the visible archive note card; all four passed on both versions. The 102 original browser cases had identical names and statuses across baseline and candidate. Two stream unit tests also passed on both versions; the toolbar suite could not load in the original happy-dom environment because of its mac-scrollbar SSR entry and missing Worker, and is not counted as passed. The candidate Web bundle's source map contained the exact shipped Mutative ESM source.
Mol*'s original suite made no create() calls, so the supplemental tests changed two existing state updates to the application's supported function-update API, retained dependency/relink assertions, and checked input immutability. Native V8 coverage confirmed two calls each to the packed candidate's create() and finalizeDraft().
The initial OpenRewrite baseline heap-threshold failure passed when rerun with the unchanged baseline. An initial MSW browser persistence failure passed in isolation and in the complete serial baseline/candidate runs. Appsmith's optional unbuilt canvas installation was moved to a local backup so its existing Jest canvas mock and Jsdom fallback could run; worker interruptions were independently rerun, and the final full candidate run passed without coverage, with two workers and no Jest cache. No Mutative implementation changes were needed.
Bun, Deno and native Hermes each passed seven semantic check groups covering moved array drafts and forward/inverse patches, frozen Map/Set values and mutator guards, iterator continuation/exhaustion/revocation, multiple changed Set children and patch replay, returned drafts and rawReturn, stable current() snapshots, and symbol values/object Map keys. Hermes executed the raw production UMD artifact and both Metro JavaScript and optimized bytecode variants in its real VM.
The source repository's build, 66 test files (4,917 passed, 234 expected failures, 8 skipped), type check, publint, attw and packed-consumer checks also passed. Validation reports, commands, pinned revisions, logs, package hashes and supplemental tests remain in the local evidence archive; they are not hosted in this issue.
Activity