Skip to content

2.0.0-beta.26 | Map/Set in a store: internal-slot accessors and draft methods throw "called on incompatible receiver" #2952

Description

@brenelz

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:

  1. Read path, inherited methodsvalue.bind(storeValue) (raw). Works for Map.prototype.get / Set.prototype.has.
  2. Read path, inherited accessorsReflect.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.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions