fix: draft relocated base refs after reverse/sort in array-methods plugin#1255
fix: draft relocated base refs after reverse/sort in array-methods plugin#1255spokodev wants to merge 1 commit into
Conversation
…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.
| ) { | ||
| return false | ||
| } | ||
| return (state.base_ as AnyArray).indexOf(value) !== -1 |
There was a problem hiding this comment.
the indexOf operation here is expensive, do we need it, or should we just return true in any case?
There was a problem hiding this comment.
Good question. It is needed, and it is doing more than a perf guard: indexOf is the only thing that tells a relocated base reference (which we must draft, or a later write reaches the base) apart from any other draftable value the reorder moved into this slot. An assigned value can land here too, and immer's rule is that assigned values are never drafted.
I checked it. With return true, a freshly pushed object gets drafted after a reverse(); with the indexOf membership test it does not:
const fresh = {id: 3}
produce([{id: 1}, {id: 2}], d => {
d.push(fresh) // assigned, not a base ref
d.reverse() // fresh -> index 0
isDraft(d[0]) // indexOf: false (correct) | return true: true (drafts an assigned value)
})The earlier guards (allIndicesReassigned_, !assigned_.get(prop), isDraftable, !value[DRAFT_STATE]) narrow it, but they cannot tell base refs from assigned refs, because after a native reverse/sort the assigned_ indices no longer line up with the new positions. The base-membership test is what keeps us from drafting values immer promised to leave alone. In my tests the final output was the same either way (an untouched draft finalizes back to its source), so this is about the invariant and about not creating proxies for assigned values, not a visible output bug.
On the cost you are right: indexOf is O(n) per hit, so reading a whole reordered array is O(n^2) worst case. If you prefer, I can build a Set of the base references once when allIndicesReassigned_ is set and test membership against that. That makes it O(1) per hit and O(n) overall with the same behavior. I can push that if you want it.
|
Thanks for the catch! Mostly looking great, got one follow up question |
Problem
immer's core contract is that the base state passed to
produceis never mutated (the docs state the result is produced "without modifying the originalbaseState"). WithenableArrayMethods(), callingreverse()orsort()inside a recipe and then mutating an element breaks this guarantee: the write lands on the user's base object.Repro
Without the plugin, vanilla immer handles the same recipe correctly.
Root cause
handleReorderingOperationinsrc/plugins/arrayMethods.tsruns the nativereverse/sortdirectly onstate.copy_. SinceprepareCopyshallow-copiesbase_,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.tsonly drafts a child when it sits at its original position (value === peek(state.base_, prop)). After a reordercopy_[0] !== base_[0], so the check falls through andreturn valuehands 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: assertsisDraft(d[0])is true, base and the relocated object are untouched, and the result is correct.sort()then mutate index 0 withproduceWithPatches: 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).