Skip to content

Commit b208d58

Browse files
authored
fix: Fix broken array patching and ensure all values in draft Maps/Sets are finalized (#1201)
* Remove hardcoded key_ field name check * Only build for `test:build` command * Rewrite Vitest prod build config to ensure prod build is used * Add tests for prod patch paths * Ensure plain values in draft maps/sets get finalized * Work around an error with prod tests Somehow was seeing an error with `each` "not existing" on startup when running the prod build tests * Fix test run
1 parent d626513 commit b208d58

8 files changed

Lines changed: 228 additions & 29 deletions

File tree

__tests__/map-set.js

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,3 +367,118 @@ function runBaseTest(name, autoFreeze, useListener) {
367367
})
368368
})
369369
}
370+
371+
// Bug reproduction: DraftSet leakage when placed in new object and set into Map
372+
// This bug occurs when a plain object containing a draft Set is set into a DraftMap
373+
describe("RTK-5159: DraftSet leakage in Map", () => {
374+
const {produce} = new Immer({autoFreeze: true})
375+
376+
test("DraftSet should not leak when placed in new object and set into Map", () => {
377+
const baseState = {
378+
map: new Map([["key1", {users: new Set()}]])
379+
}
380+
381+
// First produce: add a user
382+
const state1 = produce(baseState, draft => {
383+
const entry = draft.map.get("key1")
384+
entry.users.add({name: "user1"})
385+
})
386+
387+
// Second produce: create new object with existing Set and set into Map
388+
const state2 = produce(state1, draft => {
389+
const existingUsers = draft.map.get("key1")?.users ?? new Set()
390+
const newEntry = {users: existingUsers} // New plain object with existing draft Set
391+
draft.map.set("key1", newEntry)
392+
newEntry.users.add({name: "user2"})
393+
})
394+
395+
// Should not throw "Cannot use a proxy that has been revoked"
396+
const users = state2.map.get("key1").users
397+
398+
expect(users).toBeInstanceOf(Set)
399+
expect([...users].map(u => u.name)).toEqual(["user1", "user2"])
400+
})
401+
402+
test("DraftSet in new object set into Map should finalize correctly", () => {
403+
const baseState = {
404+
map: new Map([["key1", {items: new Set([1, 2, 3])}]])
405+
}
406+
407+
const result = produce(baseState, draft => {
408+
const existingItems = draft.map.get("key1").items
409+
// Create a new plain object containing the draft Set
410+
const newEntry = {items: existingItems, extra: "data"}
411+
draft.map.set("key1", newEntry)
412+
newEntry.items.add(4)
413+
})
414+
415+
// Verify the result is properly finalized (not a draft/proxy)
416+
const items = result.map.get("key1").items
417+
expect(items).toBeInstanceOf(Set)
418+
expect([...items]).toEqual([1, 2, 3, 4])
419+
expect(result.map.get("key1").extra).toBe("data")
420+
})
421+
422+
// Test for DraftSet.add() with non-draft object containing nested draft
423+
// This covers the handleCrossReference() call in DraftSet.add()
424+
test("DraftSet.add() should handle non-draft object containing nested draft", () => {
425+
const baseState = {
426+
items: [{id: 1, name: "item1"}],
427+
itemSet: new Set()
428+
}
429+
430+
const result = produce(baseState, draft => {
431+
// Get a draft of an existing item
432+
const draftItem = draft.items[0]
433+
// Create a new plain object that contains the draft
434+
const wrapper = {item: draftItem, extra: "wrapper data"}
435+
// Add the non-draft wrapper (containing a draft) to the Set
436+
draft.itemSet.add(wrapper)
437+
// Modify the draft item after adding
438+
draftItem.name = "modified"
439+
})
440+
441+
// The wrapper should be finalized correctly
442+
const addedItems = [...result.itemSet]
443+
expect(addedItems).toHaveLength(1)
444+
expect(addedItems[0].item.id).toBe(1)
445+
expect(addedItems[0].item.name).toBe("modified")
446+
expect(addedItems[0].extra).toBe("wrapper data")
447+
// Verify it's not a proxy
448+
expect(() => JSON.stringify(result)).not.toThrow()
449+
})
450+
451+
test("DraftSet.add() should handle deeply nested drafts in plain objects", () => {
452+
const baseState = {
453+
nested: {
454+
deep: {
455+
value: "original"
456+
}
457+
},
458+
mySet: new Set()
459+
}
460+
461+
const result = produce(baseState, draft => {
462+
// Get a draft of a deeply nested object
463+
const deepDraft = draft.nested.deep
464+
// Create a plain object hierarchy containing the draft
465+
const wrapper = {
466+
level1: {
467+
level2: {
468+
draftRef: deepDraft
469+
}
470+
}
471+
}
472+
// Add to Set
473+
draft.mySet.add(wrapper)
474+
// Modify the draft
475+
deepDraft.value = "modified"
476+
})
477+
478+
const items = [...result.mySet]
479+
expect(items).toHaveLength(1)
480+
expect(items[0].level1.level2.draftRef.value).toBe("modified")
481+
// Should not throw when accessing
482+
expect(() => JSON.stringify(result)).not.toThrow()
483+
})
484+
})

__tests__/patch.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1494,3 +1494,42 @@ test("#897 appendPatch", () => {
14941494
a: [1, 2, 3]
14951495
})
14961496
})
1497+
1498+
// Bug reproduction: Patch path truncation in production build
1499+
// This bug is caused by the "key_" in state check failing after minification
1500+
// The patch path should include the full path for nested array modifications
1501+
describe("RTK-5159: Patch path truncation bug", () => {
1502+
test("patch paths should include full path for nested array modifications", () => {
1503+
const baseState = {
1504+
lists: [{items: [{id: 1}]}]
1505+
}
1506+
1507+
const [result, patches] = produceWithPatches(baseState, draft => {
1508+
draft.lists[0].items.push({id: 2})
1509+
})
1510+
1511+
// The patch path should be ["lists", 0, "items", 1], not just [1]
1512+
expect(patches[0].path).toEqual(["lists", 0, "items", 1])
1513+
expect(patches).toEqual([
1514+
{op: "add", path: ["lists", 0, "items", 1], value: {id: 2}}
1515+
])
1516+
})
1517+
1518+
test("patch paths correct for deeply nested object in array", () => {
1519+
const baseState = {
1520+
queries: {
1521+
queryKey: {
1522+
data: {
1523+
items: [{name: "item1"}]
1524+
}
1525+
}
1526+
}
1527+
}
1528+
1529+
const [result, patches] = produceWithPatches(baseState, draft => {
1530+
draft.queries.queryKey.data.items.push({name: "item2"})
1531+
})
1532+
1533+
expect(patches[0].path).toEqual(["queries", "queryKey", "data", "items", 1])
1534+
})
1535+
})

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,10 @@
2626
"types": "./dist/immer.d.ts",
2727
"sideEffects": false,
2828
"scripts": {
29-
"pretest": "yarn build",
3029
"test": "vitest run && yarn test:build && yarn test:flow",
3130
"test:perf": "cd __performance_tests__ && node add-data.mjs && node todo.mjs && node incremental.mjs && node large-obj.mjs",
3231
"test:flow": "yarn flow check __tests__/flow",
33-
"test:build": "vitest run --config vitest.config.build.ts",
32+
"test:build": "yarn build && vitest run --config vitest.config.build.ts",
3433
"test:src": "vitest run",
3534
"watch": "vitest",
3635
"coverage": "vitest run --coverage",

src/core/finalize.ts

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -240,23 +240,29 @@ export function handleCrossReference(
240240
target.callbacks_.push(function nestedDraftCleanup() {
241241
const targetCopy = latest(target)
242242

243-
if (get(targetCopy, key, target.type_) === value) {
244-
// Process the value to replace any nested drafts
245-
// finalizeAssigned(target, key, target.scope_)
246-
247-
if (
248-
scope_.drafts_.length > 1 &&
249-
((target as Exclude<ImmerState, SetState>).assigned_!.get(key) ??
250-
false) === true &&
251-
target.copy_
252-
) {
253-
// This might be a non-draft value that has drafts
254-
// inside. We do need to recurse here to handle those.
255-
handleValue(
256-
get(target.copy_, key, target.type_),
257-
scope_.handledSet_,
258-
scope_
259-
)
243+
// For Sets, check if value is still in the set
244+
if (target.type_ === ArchType.Set) {
245+
if (targetCopy.has(value)) {
246+
// Process the value to replace any nested drafts
247+
handleValue(value, scope_.handledSet_, scope_)
248+
}
249+
} else {
250+
// Maps/objects
251+
if (get(targetCopy, key, target.type_) === value) {
252+
if (
253+
scope_.drafts_.length > 1 &&
254+
((target as Exclude<ImmerState, SetState>).assigned_!.get(key) ??
255+
false) === true &&
256+
target.copy_
257+
) {
258+
// This might be a non-draft value that has drafts
259+
// inside. We do need to recurse here to handle those.
260+
handleValue(
261+
get(target.copy_, key, target.type_),
262+
scope_.handledSet_,
263+
scope_
264+
)
265+
}
260266
}
261267
}
262268
})

src/core/proxy.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import {
2-
each,
32
has,
43
is,
54
isDraftable,
@@ -17,7 +16,6 @@ import {
1716
die,
1817
createProxy,
1918
ArchType,
20-
ImmerScope,
2119
handleCrossReference,
2220
WRITABLE,
2321
CONFIGURABLE,
@@ -256,14 +254,17 @@ export const objectTraps: ProxyHandler<ProxyState> = {
256254
*/
257255

258256
const arrayTraps: ProxyHandler<[ProxyArrayState]> = {}
259-
each(objectTraps, (key, fn) => {
257+
// Use `for..in` instead of `each` to work around a weird
258+
// prod test suite issue
259+
for (let key in objectTraps) {
260+
let fn = objectTraps[key as keyof typeof objectTraps] as Function
260261
// @ts-ignore
261262
arrayTraps[key] = function() {
262263
const args = arguments
263264
args[0] = args[0][0]
264265
return fn.apply(this, args)
265266
}
266-
})
267+
}
267268
arrayTraps.deleteProperty = function(state, prop) {
268269
if (process.env.NODE_ENV !== "production" && isNaN(parseInt(prop as any)))
269270
die(13)

src/plugins/mapset.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import {
1616
ArchType,
1717
each,
1818
getValue,
19-
PluginMapSet
19+
PluginMapSet,
20+
handleCrossReference
2021
} from "../internal"
2122

2223
export function enableMapSet() {
@@ -58,6 +59,7 @@ export function enableMapSet() {
5859
state.assigned_!.set(key, true)
5960
state.copy_!.set(key, value)
6061
state.assigned_!.set(key, true)
62+
handleCrossReference(state, key, value)
6163
}
6264
return this
6365
}
@@ -222,6 +224,7 @@ export function enableMapSet() {
222224
prepareSetCopy(state)
223225
markChanged(state)
224226
state.copy_!.add(value)
227+
handleCrossReference(state, value, value)
225228
}
226229
return this
227230
}

src/plugins/patches.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export function enablePatches() {
4949

5050
function getPath(state: ImmerState, path: PatchPath = []): PatchPath | null {
5151
// Step 1: Check if state has a stored key
52-
if ("key_" in state && state.key_ !== undefined) {
52+
if (state.key_ !== undefined) {
5353
// Step 2: Validate the key is still valid in parent
5454

5555
const parentCopy = state.parent_!.copy_ ?? state.parent_!.base_

vitest.config.build.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,38 @@
11
import {defineConfig} from "vitest/config"
22
import path from "path"
33

4+
const prodCJSPath = path.resolve(__dirname, "dist/cjs/immer.cjs.production.js")
5+
46
export default defineConfig({
57
resolve: {
6-
alias: {
7-
"^src/(.*)": path.resolve(__dirname, "dist/cjs/immer.cjs.production.js")
8-
}
8+
// // Make all `immer` imports use the production build
9+
alias: [
10+
{
11+
find: /^src\/(.*)/,
12+
replacement: prodCJSPath
13+
},
14+
{
15+
find: "../src/immer",
16+
replacement: prodCJSPath
17+
},
18+
{
19+
find: "immer",
20+
replacement: prodCJSPath
21+
}
22+
],
23+
// Ensure only one copy of immer is used throughout the dependency tree
24+
dedupe: ["immer"]
25+
},
26+
// Force Vite to process immer so our alias applies to dependencies too
27+
optimizeDeps: {
28+
include: ["immer"],
29+
// Force re-bundling to pick up our alias
30+
force: true
31+
},
32+
// SSR settings are needed because Vitest runs in Node environment
33+
ssr: {
34+
// Don't externalize immer - this makes our alias apply to it
35+
noExternal: ["immer"]
936
},
1037
define: {
1138
"global.USES_BUILD": true,
@@ -18,6 +45,15 @@ export default defineConfig({
1845
resolveSnapshotPath: (testPath: string, snapExtension: string) =>
1946
testPath.replace("__tests__", "__tests__/__prod_snapshots__") +
2047
snapExtension,
21-
reporters: ["default", "./vitest-custom-reporter.ts"]
48+
reporters: ["default", "./vitest-custom-reporter.ts"],
49+
// Ensure deps are processed through Vite's transform pipeline
50+
deps: {
51+
optimizer: {
52+
// For SSR (Node) environment, include immer so it's transformed
53+
ssr: {
54+
include: ["immer"]
55+
}
56+
}
57+
}
2258
}
2359
})

0 commit comments

Comments
 (0)