Skip to content

fix: harden apply() patch paths and fix mark copies, current() snapshots and async recipe types - #192

Merged
unadlib merged 11 commits into
mainfrom
fix/v2-review-r5-r11
Oct 8, 2026
Merged

unadlib merged 11 commits into
mainfrom
fix/v2-review-r5-r11

Conversation

@unadlib

@unadlib unadlib commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Part of #168.

Summary

This PR handles eight issues that npm 1.3.0 already had. Four get a runtime fix, one a type fix, and three whose fixes cost more than their rare cases justify are documented as limits:

# Issue main and 1.3.0 Immer 11.1.18 This PR
1 apply() with mutable: true and a path that ends in __proto__ replaces the prototype of the target, also of a nested object no mutable mode; the default mode throws throws error 13, as for __proto__ earlier in a path, in every mode, also for a key that converts to __proto__
2 markSimpleObject or an immutable mark, and an own __proto__ key as JSON.parse() creates one the copy gets that value as its prototype — the copy keeps an own __proto__ property, as the default copy already did
3 apply() with a Map key that is an object without a prototype TypeError: Cannot convert object to primitive value; other object keys run their Symbol.toPrimitive the same TypeError uses the key as it is
4 union(), intersection() and the other ES2025 Set methods on a Set draft of objects, with original objects wrong results, although has() accepts the original objects wrong as well documented, with current(draft), original(draft) or ids
5 Types of create() and apply() create<State>(base, async (draft) => …) is typed State but returns a Promise; apply() rejects enableAutoFreeze: true and types a mutable option of type boolean as State — Promise<State>; Immutable<State>; State | void
6 current() of an object that the recipe assigned and that a mark makes draftable the snapshot shows later changes of the drafts in it correct the snapshot is isolated
7 A manual finalize() that throws the drafts stay writable, and a second finalize() returns a revoked draft — documented
8 An async recipe from another realm, such as a vm context rejects with a revoked Proxy the same documented, with a wrapper from this realm

Changes, one commit per item

Commits 1 to 8 handle the issues of the table in its order; 9 to 11 follow up on them.

  1. fix: reject patches that end in __proto__ on objects and arrays: apply() checked __proto__ and constructor only before the last segment of a path; the last segment is assigned, and an assignment to __proto__ sets the prototype. It now throws error 13 there too, for objects and arrays; a Map key named __proto__ stays an ordinary key. Without mutable, the patch failed before as well, with error 3.
  2. fix: keep an own __proto__ key as a property in copies for marks: strictCopy(), which copies objects for marks, assigned plain properties; an own __proto__ property now goes through defineProperty, as the other copies already do.
  3. fix: use Map keys of patch paths as they are in apply(): apply() converted every key of a path to a string before checking it, which is needed for objects and arrays only.
  4. docs: describe how the Set methods compare the objects of a Set draft: a README and website FAQ entry. These methods compare elements as iterating the draft returns them, drafts for objects of the base state; making them agree with has() while keeping drafts in their results needs a design and about 60–100 B, for a rare case that Immer gets wrong as well.
  5. fix: type async recipes with an explicit state type and the options and results of apply(): with an explicit state type, TypeScript infers no other type parameter, so the return type of the recipe took its default, void, which accepts any function. Four overloads that apply only when the call supplies the state type now precede the existing ones of each form, a synchronous and an async one for direct and for curried calls; they tell an async recipe by its return type. TypeScript cannot infer their state type from the base or from the result, so calls without a type argument resolve to the existing overloads as before. A Promise type that no actual Promise matches gives the synchronous overload the context to keep the literal values that an async recipe returns. apply() accepts enableAutoFreeze: true and returns Immutable<State> with it, as apply<State, true>() does now; a mutable or enableAutoFreeze option of type boolean, or an optional one, gives State | void or State | Immutable<State>. test/types.test.ts checks these types with expectTypeOf, and the packed consumer check compiles typical calls, also with strictNullChecks disabled.
  6. fix: snapshot assigned objects that a mark makes draftable in current(): current() passes the options of the draft above to the objects that the recipe assigned, so a mark makes them draftable there too.
  7. docs: say that a manual finalize() that throws leaves its drafts writable: README and the website create() and currying docs. You chose to document this limit when it came up for fix: reject Map keys that are not strings and symbol keys in string patch paths in development builds #189; revoking the drafts would cost 13 B in the production CJS bundle and 35 B in an ESM bundle of create.
  8. docs: say how to use an async recipe from another realm: README and the website create() docs, with a wrapper that returns the result of the recipe, so a replacement passes through. Immer has the same limit; recognizing Promises of other realms would cost 11 B.
  9. docs: add the note on regrown array lengths to the README migration guide: fix: keep inserted base elements as they are and patch regrown indices of array drafts #190 added this bullet to the website migration guide only; both guides now have the same 38 bullets.
  10. fix: normalize property keys before checking patch paths: apply() compared the keys of a path with __proto__, constructor and prototype before JavaScript converted them for the property access, so a key such as a boxed string or the array ['__proto__'] passed the check and then named the reserved key. Object and function keys are now converted once, as a property access converts them, and the check uses the result; a key whose conversion returns a symbol stays a symbol. Array add and remove patches keep their splice indices.
  11. test: compile the packed consumer with TypeScript 5.0: overload resolution and contextual typing differ between TypeScript versions, so the packed consumer check also compiles with TypeScript 5.0.4, a new development dependency typescript-5.0. CI otherwise checks the types with TypeScript 5.8 only.

The fixes add their changes to the "Fixes that change results" section of the migration guide, and commit 5 adds a TypeScript section.

Behavior changes to review

  • Items 1 and 10: a patch whose path ends in __proto__ on an object or array throws error 13 in every mode; without mutable it threw error 3 before. A path key that converts to a reserved name, such as a boxed string, is checked after the conversion.
  • Item 5: with an explicit state type, an async recipe now gives Promise<State>, and so does a curried producer. Synchronous recipes keep their result types, calls without a type argument resolve as before, and type errors stay where they were. apply<State, true>() now returns Immutable<State> instead of State, and widened or optional mutable and enableAutoFreeze options give the union results above.

Not covered

  • Set patches of objects that are replayed in successive batches stay as they are, by decision.
  • The rest of item 5, which keeps the result types of v1: with an explicit state type, a recipe that can return a value or a Promise, a state type that also accepts a Promise, such as object or unknown, and generic wrappers that pass their own type parameter, as create<T>(state, recipe) does, still give the synchronous type for async recipes, and a replacement that does not match the state type is not reported. Also not covered: curried producers with an annotated draft that return a new state, the types of Map keys in string patch paths, and create(1) without a recipe, which the types accept and the runtime rejects.

Size

size-limit measures 8,387 B for the production CJS bundle (8,367 B on main; the cap is 8,400 B), 7,341 B for an ESM bundle of create (unchanged) and 8,236 B for all ESM exports (8,225 B). The production CJS artifact shrinks by 22 B raw and grows by 17 B Brotli; in the size-limit figure, the key normalization of commit 10 saves 10 B. All artifacts stay within the tolerance of the size baseline, so the baseline is not refreshed. The declaration of create grows from 1,823 B to 4,795 B.

Performance

The fixes leave the draft read and write paths alone: items 1 and 10 add comparisons per path segment in apply(), item 3 removes string conversions from it, item 6 passes the options in current(), and item 2 adds one comparison per property when a mark copies an object. In isolated processes, main and this PR alternating over three rounds at 100 rows, apply-update-10pct, apply-array-ops and apply-reverse with and without auto-freeze have a geometric mean of 0.997 over 6 cells, each between 0.99 and 1.01. class-update and class-wide-update, which do not run apply(), measured between 0.99 and 1.02 before.

Type-checking against the published declarations takes as many instantiations as on main for a direct call without a type argument, 12 more for a curried one, and 127 instead of 83 for a call with an explicit state type; the time per call differs from main by at most 0.4 ms, also for a state of 40 nested fields.

Verification

  • The test of each fix fails without it: items 1, 2 and 6 with the old results, item 3 with the TypeError, and item 10 in 4 tests; on the types of main, test/types.test.ts has 44 type errors.
  • test/types.test.ts passes against the built declarations with TypeScript 5.0.4, 5.4.5, 5.6.3, 5.7.3, 5.9.3, 6.0.3 and 7.0.2, and typical calls resolve the same on TypeScript 4.8.4 to 7.0.2.
  • The Node CI sequence passes locally at the head, coverage of src stays at 100% of lines, branches and functions, and each commit passes the tests, type-check, lint and the format check.
  • The minimum Node.js check from fix: make the Node.js ESM entry work on Node.js 14.0 #191 passes on Node.js 14.0.0.

@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

Coverage after merging fix/v2-review-r5-r11 into main will be

100.00%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   apply.ts100%100%100%100%
   array.ts100%100%100%100%
   constant.ts100%100%100%100%
   create.ts100%100%100%100%
   current.ts100%100%100%100%
   draft.ts100%100%100%100%
   draftify.ts100%100%100%100%
   error.ts100%100%100%100%
   index.ts100%100%100%100%
   interface.ts100%100%100%100%
   internal.ts100%100%100%100%
   makeCreator.ts100%100%100%100%
   map.ts100%100%100%100%
   original.ts100%100%100%100%
   patch.ts100%100%100%100%
   rawReturn.ts100%100%100%100%
   set.ts100%100%100%100%
   unsafe.ts100%100%100%100%
src/utils
   cast.ts100%100%100%100%
   copy.ts100%100%100%100%
   deepFreeze.ts100%100%100%100%
   draft.ts100%100%100%100%
   finalize.ts100%100%100%100%
   forEach.ts100%100%100%100%
   index.ts100%100%100%100%
   mark.ts100%100%100%100%
   marker.ts100%100%100%100%
   proto.ts100%100%100%100%

@unadlib
unadlib force-pushed the fix/v2-review-r5-r11 branch from 209c5c2 to 4b9ec16 Compare October 8, 2026 11:53
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

Coverage after merging fix/v2-review-r5-r11 into main will be

100.00%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   apply.ts100%100%100%100%
   array.ts100%100%100%100%
   constant.ts100%100%100%100%
   create.ts100%100%100%100%
   current.ts100%100%100%100%
   draft.ts100%100%100%100%
   draftify.ts100%100%100%100%
   error.ts100%100%100%100%
   index.ts100%100%100%100%
   interface.ts100%100%100%100%
   internal.ts100%100%100%100%
   makeCreator.ts100%100%100%100%
   map.ts100%100%100%100%
   original.ts100%100%100%100%
   patch.ts100%100%100%100%
   rawReturn.ts100%100%100%100%
   set.ts100%100%100%100%
   unsafe.ts100%100%100%100%
src/utils
   cast.ts100%100%100%100%
   copy.ts100%100%100%100%
   deepFreeze.ts100%100%100%100%
   draft.ts100%100%100%100%
   finalize.ts100%100%100%100%
   forEach.ts100%100%100%100%
   index.ts100%100%100%100%
   mark.ts100%100%100%100%
   marker.ts100%100%100%100%
   proto.ts100%100%100%100%

@unadlib unadlib changed the title fix: handle R5 to R11 of the v2 pre-release review fix: harden apply() patch paths and fix mark copies, current() snapshots and async recipe types Oct 8, 2026
@unadlib
unadlib force-pushed the fix/v2-review-r5-r11 branch from 4b9ec16 to fa66997 Compare October 8, 2026 12:54
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

Coverage after merging fix/v2-review-r5-r11 into main will be

100.00%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   apply.ts100%100%100%100%
   array.ts100%100%100%100%
   constant.ts100%100%100%100%
   create.ts100%100%100%100%
   current.ts100%100%100%100%
   draft.ts100%100%100%100%
   draftify.ts100%100%100%100%
   error.ts100%100%100%100%
   index.ts100%100%100%100%
   interface.ts100%100%100%100%
   internal.ts100%100%100%100%
   makeCreator.ts100%100%100%100%
   map.ts100%100%100%100%
   original.ts100%100%100%100%
   patch.ts100%100%100%100%
   rawReturn.ts100%100%100%100%
   set.ts100%100%100%100%
   unsafe.ts100%100%100%100%
src/utils
   cast.ts100%100%100%100%
   copy.ts100%100%100%100%
   deepFreeze.ts100%100%100%100%
   draft.ts100%100%100%100%
   finalize.ts100%100%100%100%
   forEach.ts100%100%100%100%
   index.ts100%100%100%100%
   mark.ts100%100%100%100%
   marker.ts100%100%100%100%
   proto.ts100%100%100%100%

@unadlib
unadlib merged commit ac5a82d into main Oct 8, 2026
5 checks passed
@unadlib
unadlib deleted the fix/v2-review-r5-r11 branch October 8, 2026 13:07
@unadlib unadlib mentioned this pull request Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant