Skip to content

fix: draft relocated base refs after reverse/sort in array-methods plugin#1255

Open
spokodev wants to merge 1 commit into
immerjs:mainfrom
spokodev:fix/array-reorder-base-mutation
Open

fix: draft relocated base refs after reverse/sort in array-methods plugin#1255
spokodev wants to merge 1 commit into
immerjs:mainfrom
spokodev:fix/array-reorder-base-mutation

Conversation

@spokodev

Copy link
Copy Markdown

Problem

immer's core contract is that the base state passed to produce is never mutated (the docs state the result is produced "without modifying the original baseState"). With enableArrayMethods(), calling reverse() or sort() inside a recipe and then mutating an element breaks this guarantee: the write lands on the user's base object.

Repro

import {produce, isDraft, enableArrayMethods, setAutoFreeze} from "immer"

enableArrayMethods()
setAutoFreeze(false)

const obj3 = {id: 3}
const base = [{id: 1}, {id: 2}, obj3]

const next = produce(base, d => {
  d.reverse()
  // isDraft(d[0]) === false  (bug: should be true)
  d[0].id = 99
})

// base is now [{id:1},{id:2},{id:99}]  and obj3 is {id:99}  -- base mutated

Without the plugin, vanilla immer handles the same recipe correctly.

Root cause

handleReorderingOperation in src/plugins/arrayMethods.ts runs the native reverse/sort directly on state.copy_. Since prepareCopy shallow-copies base_, copy_ still holds the raw base references, and the reorder relocates an un-drafted base object to a new index.

The get trap in src/core/proxy.ts only drafts a child when it sits at its original position (value === peek(state.base_, prop)). After a reorder copy_[0] !== base_[0], so the check falls through and return value hands back the raw base object. Mutating it then mutates the base, and the generated patches/inverse are wrong too.

Fix

Recognize a relocated base reference in the get trap and draft it before exposing it. The extra check only runs after a reorder (allIndicesReassigned_), skips indices the user explicitly assigned, and skips values that are already drafts or non-draftable, so the plugin's lazy-proxy optimization is preserved for the common path.

Tests

Added two cases under "mutating array methods" in __tests__/base.js:

  • reverse() then mutate index 0: asserts isDraft(d[0]) is true, base and the relocated object are untouched, and the result is correct.
  • sort() then mutate index 0 with produceWithPatches: same base-untouched assertions plus a patch / inverse-patch round-trip.

Both fail on main (in the array-plugin suite) and pass with the fix. Full suite is green (3669 passed, 8 skipped).

…ugin

With enableArrayMethods(), reverse() and sort() run natively on copy_,
which still holds raw base references. Reordering relocates an un-drafted
base object to an index where it no longer equals base_[prop], so the get
trap's positional check misses it and returns the raw base object. Writing
to that object mutates the user's base state, violating immer's guarantee
that the base state is never modified. Patches and inverse patches are
wrong too.

Detect a relocated base reference in the get trap (only after a reorder,
for indices the user did not explicitly assign) and draft it before
exposing it.
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