From fd13fbf866c2aa8c59502efe8b9978d108ff2e18 Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Sat, 5 Sep 2026 14:05:02 +0700 Subject: [PATCH] fix: do not record a deletion when a base value is written back The set trap has a fast path for assigning a property its own base value back after Immer drafted it. That path recorded the property in `assigned_` as `false`, which patch generation reads as a deletion, so a producer that writes a base value back and then changes anything else on the same object emits a spurious "remove" patch. Replaying the patches then drops a property that is still present in the produced result. The property still holds its base value at that point, so nothing was assigned and nothing was deleted. Remove the entry instead. --- __tests__/patch.js | 32 ++++++++++++++++++++++++++++++++ src/core/proxy.ts | 4 +++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/__tests__/patch.js b/__tests__/patch.js index 7e26cb418..d6afcbeb2 100644 --- a/__tests__/patch.js +++ b/__tests__/patch.js @@ -1605,3 +1605,35 @@ test("applyPatches throws proper immer error when intermediate path value is nul applyPatches({a: null}, [{op: "add", path: ["a", "b"], value: 1}]) }).toThrow(isProd ? "18" : "Cannot apply patch, path doesn't resolve: a/b") }) + +describe("assigning a base value back over its own draft", () => { + // Reading a property drafts it; writing the base value back leaves the + // property untouched, so it must not show up in the patches at all. + const rootChild = {id: 1} + runPatchTests( + "root level", + {child: rootChild, other: 0}, + d => { + d.child + d.child = rootChild + d.other = 1 + }, + [{op: "replace", path: ["other"], value: 1}], + [{op: "replace", path: ["other"], value: 0}], + {child: {id: 1}, other: 1} + ) + + const nestedChild = {id: 1} + runPatchTests( + "nested", + {a: {child: nestedChild, other: 0}}, + d => { + d.a.child + d.a.child = nestedChild + d.a.other = 1 + }, + [{op: "replace", path: ["a", "other"], value: 1}], + [{op: "replace", path: ["a", "other"], value: 0}], + {a: {child: {id: 1}, other: 1}} + ) +}) diff --git a/src/core/proxy.ts b/src/core/proxy.ts index 70e95ad56..be46afc87 100644 --- a/src/core/proxy.ts +++ b/src/core/proxy.ts @@ -186,7 +186,9 @@ export const objectTraps: ProxyHandler = { const currentState: ProxyObjectState = current?.[DRAFT_STATE] if (currentState && currentState.base_ === value) { state.copy_![prop] = value - state.assigned_!.set(prop, false) + // The property still holds its base value, so nothing was assigned + // and nothing was deleted. `false` here reads as a deletion later. + state.assigned_!.delete(prop) return true } if (