Skip to content

fix: key inserted array indices by name in the array-methods plugin - #1283

Merged
markerikson merged 1 commit into
immerjs:mainfrom
Jaybhade:fix/array-methods-assigned-index-key
Aug 5, 2026
Merged

fix: key inserted array indices by name in the array-methods plugin#1283
markerikson merged 1 commit into
immerjs:mainfrom
Jaybhade:fix/array-methods-assigned-index-key

Conversation

@Jaybhade

@Jaybhade Jaybhade commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

The bug

With enableArrayMethods(), an array update that removes an element and inserts a new one produces the correct next state but no patches at all, so anything built on the patch stream (undo/redo, state sync) silently loses the change.

Reproduced against the published immer@11.1.15:

import {produce, applyPatches, enablePatches, enableArrayMethods} from "immer"

enablePatches()
enableArrayMethods()

const base = ["a", "b", "c"]
let patches
const next = produce(
  base,
  draft => {
    draft.pop()      // drop the oldest
    draft.push("z")  // append the newest
  },
  p => { patches = p }
)

next                          // ["a", "b", "z"]   ✔
patches                       // []                ✘ expected [{op: "replace", path: [2], value: "z"}]
applyPatches(base, patches)   // ["a", "b", "c"]   ✘ the update is gone

The same happens for draft.length = n; draft.push(x) and for a splice that inserts over an index the base already had. Without the plugin the patch is generated correctly, so this is specific to the array-methods path.

Root cause

assigned_ is keyed by property name everywhere else in immer — the proxy traps only ever receive strings, so an array index is stored as "2". handleInsertedValues stored it as the number 2:

const index = startIndex + i
state.assigned_!.set(index, true)

Patch generation reads it back by name (src/plugins/patches.ts):

const isAssigned = allReassigned || assigned_?.get(i.toString())

get("2") never finds the key 2, so the index looks untouched and no replace patch is emitted. The mismatch stayed hidden for a plain push because the added indices loop in generateArrayPatches covers everything past base_.length unconditionally; it only shows up once an insert lands on an index that already existed in the base.

isRelocatedBaseRef in src/core/proxy.ts reads the same map by name and was equally blind to these keys.

The fix

Stringify the index so the plugin follows the same key convention as the traps. The key is also what handleCrossReference carries into the finalization lookup, so both readers now agree on it.

Verification

  • Added a test next to the existing push() cases in __tests__/base.js. That file runs every case both with and without the plugin, and the new test fails only in the plugin variant before this change.
  • yarn test (vitest + test:build): 3689 passing, no changes to existing expectations. test:flow could not run locally — flow-bin has no darwin-arm64 binary — but nothing here touches the flow types.
  • yarn test:perf shows no movement.
  • I also ran a randomized round-trip check over 30k generated base states and mutation scripts, asserting applyPatches(base, patches) ≍ next and applyPatches(next, inverse) ≍ base. Scenarios where the forward patches failed to reproduce the result dropped from 57 to 21, with no scenario newly broken.

The 21 that remain are a separate defect with a different cause — the plugin's in-place removals (pop, shift, splice) never record the removed indices in assigned_, so an index that is vacated and then re-covered by a later length write or out-of-range index assignment still goes unpatched. I left it out to keep this change to one root cause; happy to follow up on it if you'd like.

`handleInsertedValues` recorded inserted indices in `assigned_` as numbers,
but `assigned_` is keyed by property name everywhere else, because the proxy
traps only ever see strings. Patch generation looks indices up with
`assigned_?.get(i.toString())`, so a numeric key was never found.

The added indices loop in `generateArrayPatches` covers everything past
`base_.length` regardless of `assigned_`, which hid the mismatch for a plain
push. It surfaced when an insert reuses an index that already existed in the
base, e.g. dropping the oldest entry and appending a new one:

    const [next, patches] = produceWithPatches(["a", "b", "c"], draft => {
        draft.pop()
        draft.push("z")
    })
    // next    -> ["a", "b", "z"]
    // patches -> []   (expected: replace /2 with "z")

The result was correct but no patch described it, so undo/redo and state
sync built on the patch stream silently dropped the change.

Stringifying the index also keeps the key consistent for the finalization
lookup in `handleCrossReference`, which reads `assigned_` with the same key
this function passes it.
@markerikson
markerikson merged commit d2c158f into immerjs:main Aug 5, 2026
1 check passed
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 11.1.16 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants