From 8986e26ed548616e7130e8eb39c44bf86c66133b Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Sun, 6 Sep 2026 14:00:35 +0700 Subject: [PATCH] fix: throw on reads of a revoked Map or Set draft Once produce or finishDraft returns, the draft is revoked and any further use is meant to fail loudly. Object and array drafts get that from Proxy.revocable, but Map and Set drafts are subclasses guarded by a manual revoked_ flag, and several read paths never checked it: const map = new Map([["a", 1]]) let draft produce({map}, d => { draft = d.map }) draft.has("a") // true, should throw draft.size // 1, should throw [...draft.keys()] // ["a"], should throw draft.get("a") // throws, as expected DraftSet.has already calls assertUnrevoked, so the same read on a Set draft throws while the Map one silently returns stale data. This is the exact async-leak case error 3 is meant to catch, so it should not depend on which accessor the caller happens to use. Add assertUnrevoked to DraftMap.has, DraftMap.size, DraftMap.keys, DraftMap.forEach and DraftSet.size. DraftMap.values and DraftMap.entries build on keys, so they now fail when they are called rather than on the first next(). Nothing outside the plugin reads these accessors after revocation, and the full suite is unchanged. --- __tests__/base.js | 20 ++++++++++++++++++++ src/plugins/mapset.ts | 17 +++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/__tests__/base.js b/__tests__/base.js index c76e359c..473a5853 100644 --- a/__tests__/base.js +++ b/__tests__/base.js @@ -2334,6 +2334,18 @@ function runBaseTest( expect(() => m.set("x", 3)).toThrowErrorMatchingSnapshot() }) + it("revokes map proxies for reads that do not go through get", () => { + let m + produce(baseState, s => { + m = s.aMap + }) + const revoked = /has been revoked|minified error nr: 3/ + expect(() => m.has("jedi")).toThrow(revoked) + expect(() => m.size).toThrow(revoked) + expect(() => m.keys()).toThrow(revoked) + expect(() => m.forEach(() => {})).toThrow(revoked) + }) + it("does not draft map keys", () => { // anything else would be terribly confusing const key = {a: 1} @@ -2638,6 +2650,14 @@ function runBaseTest( expect(() => m.add("x")).toThrowErrorMatchingSnapshot() }) + it("revokes set size reads", () => { + let m + produce(baseState, s => { + m = s.aSet + }) + expect(() => m.size).toThrow(/has been revoked|minified error nr: 3/) + }) + it("does support instanceof Set", () => { const set = new Set() produce(set, d => { diff --git a/src/plugins/mapset.ts b/src/plugins/mapset.ts index 0cc2158e..c9b21cbe 100644 --- a/src/plugins/mapset.ts +++ b/src/plugins/mapset.ts @@ -54,11 +54,15 @@ export function enableMapSet() { } get size(): number { - return latest(this[DRAFT_STATE]).size + const state: MapState = this[DRAFT_STATE] + assertUnrevoked(state) + return latest(state).size } has(key: any): boolean { - return latest(this[DRAFT_STATE]).has(key) + const state: MapState = this[DRAFT_STATE] + assertUnrevoked(state) + return latest(state).has(key) } set(key: any, value: any) { @@ -109,6 +113,7 @@ export function enableMapSet() { forEach(cb: (value: any, key: any, self: any) => void, thisArg?: any) { const state: MapState = this[DRAFT_STATE] + assertUnrevoked(state) latest(state).forEach((_value: any, key: any, _map: any) => { cb.call(thisArg, this.get(key), key, this) }) @@ -132,7 +137,9 @@ export function enableMapSet() { } keys(): IterableIterator { - return latest(this[DRAFT_STATE]).keys() + const state: MapState = this[DRAFT_STATE] + assertUnrevoked(state) + return latest(state).keys() } values(): IterableIterator { @@ -225,7 +232,9 @@ export function enableMapSet() { } get size(): number { - return latest(this[DRAFT_STATE]).size + const state: SetState = this[DRAFT_STATE] + assertUnrevoked(state) + return latest(state).size } has(value: any): boolean {