Summary
A store wraps any non-frozen object — including collections — but two of the store get trap's this-binding paths hand the proxy to code that brand-checks internal slots, so ordinary reads and draft writes crash:
const [store, setStore] = createStore({ map: new Map([["k", 1]]), set: new Set([1, 2]) });
store.map.get("k"); // 1 ✓ (inherited methods are bound to the raw value on the read path)
store.map.size;
// TypeError: Method get Map.prototype.size called on incompatible receiver #<Object>
setStore(s => {
s.map.set("k2", 2);
});
// TypeError: Method Map.prototype.set called on incompatible receiver #<Object>
Found on 2.0.0-beta.26 / current next. This is a long-standing report (pre-beta.25 hunts) re-verified against HEAD today.
Mechanism
The trap currently has three different this conventions:
- Read path, inherited methods →
value.bind(storeValue) (raw). Works for Map.prototype.get / Set.prototype.has.
- Read path, inherited accessors →
Reflect.get(storeValue, property, receiver) (proxy). This is deliberate and load-bearing for user classes — prototype getters read this.field through the proxy so they track and resolve write overlays (there are dedicated tests) — but a native accessor like Map.prototype.size brand-checks its internal slots and throws on a proxy receiver.
- Write path (draft), inherited methods → returned bare (
isWrappable(fn) is false, so the function exits early unbound). Invoked as s.map.set(...), this is the draft proxy → throws.
markRaw is the documented escape hatch for Maps, but an unmarked Map shouldn't crash the store — especially when half the operations (method reads) already work.
Suggested fix
Discriminate native from user code instead of picking one convention:
- Inherited accessors: resolve the getter and invoke it with the raw object when the getter is a native function, the receiver otherwise. User-class getters keep full reactivity; native accessors stop brand-check-throwing. (
.bind()-ed user getters stringify as native and get called raw — harmless, since bound functions ignore this. The nativeness check is cached per function in a WeakMap.)
- Draft (write-path) inherited methods: bind custom-proto methods to the raw value, mirroring what the read path has always done.
Mutations through such methods land on the raw collection and are not per-property reactive — the same contract as markRaw children — but nothing crashes and subsequent reads see the data.
I have this implemented with regression tests (including the discriminator case: a user-class getter staying tracked on the same store where map.size works); the full suite passes. Happy to open a PR.
Related, larger question
This leaves one deliberate inconsistency in the trap: read-path methods on plain user classes are still raw-bound, so they read pre-write state forever (writes live in the override layer, and the raw base never mutates) while a getter on the same class is fresh:
class Counter { n = 1; double() { return this.n * 2; } get doubled() { return this.n * 2; } }
const [store, setStore] = createStore({ c: new Counter() });
setStore(s => { s.c.n = 5; });
store.c.doubled; // 10 ✓
store.c.double(); // 2 ✗ — stale forever
Receiver-binding user methods would fix that (and match the getter convention), at the cost of breaking #private-field methods — the same trade-off the getter path already accepted. Flagging it here since any fix should pick one convention deliberately; I can split it into its own issue if preferred.
🤖 Found and analyzed with Claude Code
Summary
A store wraps any non-frozen object — including collections — but two of the store get trap's
this-binding paths hand the proxy to code that brand-checks internal slots, so ordinary reads and draft writes crash:Found on
2.0.0-beta.26/ currentnext. This is a long-standing report (pre-beta.25 hunts) re-verified against HEAD today.Mechanism
The trap currently has three different
thisconventions:value.bind(storeValue)(raw). Works forMap.prototype.get/Set.prototype.has.Reflect.get(storeValue, property, receiver)(proxy). This is deliberate and load-bearing for user classes — prototype getters readthis.fieldthrough the proxy so they track and resolve write overlays (there are dedicated tests) — but a native accessor likeMap.prototype.sizebrand-checks its internal slots and throws on a proxy receiver.isWrappable(fn)is false, so the function exits early unbound). Invoked ass.map.set(...),thisis the draft proxy → throws.markRawis the documented escape hatch for Maps, but an unmarked Map shouldn't crash the store — especially when half the operations (method reads) already work.Suggested fix
Discriminate native from user code instead of picking one convention:
.bind()-ed user getters stringify as native and get called raw — harmless, since bound functions ignorethis. The nativeness check is cached per function in a WeakMap.)Mutations through such methods land on the raw collection and are not per-property reactive — the same contract as
markRawchildren — but nothing crashes and subsequent reads see the data.I have this implemented with regression tests (including the discriminator case: a user-class getter staying tracked on the same store where
map.sizeworks); the full suite passes. Happy to open a PR.Related, larger question
This leaves one deliberate inconsistency in the trap: read-path methods on plain user classes are still raw-bound, so they read pre-write state forever (writes live in the override layer, and the raw base never mutates) while a getter on the same class is fresh:
Receiver-binding user methods would fix that (and match the getter convention), at the cost of breaking
#private-field methods — the same trade-off the getter path already accepted. Flagging it here since any fix should pick one convention deliberately; I can split it into its own issue if preferred.🤖 Found and analyzed with Claude Code