js-dojo

6 internal slots

GOAL

Meet THE classic Proxy failure — wrapping objects that own internal slots (a Map's [[MapData]]) or #private fields — and repair it with a get trap that BINDS every method back onto the target.

CONCEPT

A Proxy happily forwards the property LOOKUP, but the moment you CALL what came back, `this` is the PROXY. Map.prototype.get reaches for the [[MapData]] slot directly on `this`; the proxy has no such slot (internal slots are not properties, so nothing forwards them) and the engine throws "called on incompatible receiver". Class #private fields fail the same way: they sit on the target instance, and a private access with `this` = proxy throws. The well-known cure: inside the get trap read the value with the TARGET as receiver, and when it is a function hand back value.bind(target).

HINT

get(t, key) { log it; const v = t[key]; // receiver = target, so accessors such as Map's `size` keep working return typeof v === 'function' ? v.bind(t) : v; }

MIRRORS

a dev-mode spy that was perfect on plain settings objects gets pointed at a player's hit cache (a Map keyed by `at`) and every console in the studio fills with "incompatible receiver". This is exactly why reactive state libraries ship SEPARATE handlers for Map/Set collections — and why nobody casually wraps a whole running Game instance in a Proxy.

Run

node 06-internal-slots.cjs   (fails until you fix the TODO)

Source

/*
 * proto-dojo 05-proxy-reflect / 06-internal-slots
 * -----------------------------------------
 * GOAL: Meet THE classic Proxy failure — wrapping objects that own
 *       internal slots (a Map's [[MapData]]) or #private fields — and
 *       repair it with a get trap that BINDS every method back onto the
 *       target.
 *
 * CONCEPT: A Proxy happily forwards the property LOOKUP, but the moment
 *          you CALL what came back, `this` is the PROXY.
 *          Map.prototype.get reaches for the [[MapData]] slot directly
 *          on `this`; the proxy has no such slot (internal slots are
 *          not properties, so nothing forwards them) and the engine
 *          throws "called on incompatible receiver". Class #private
 *          fields fail the same way: they sit on the target instance,
 *          and a private access with `this` = proxy throws. The
 *          well-known cure: inside the get trap read the value with the
 *          TARGET as receiver, and when it is a function hand back
 *          value.bind(target).
 *
 * HINT: get(t, key) { log it; const v = t[key];  // receiver = target,
 *       so accessors such as Map's `size` keep working
 *       return typeof v === 'function' ? v.bind(t) : v; }
 *
 * MIRRORS: a dev-mode spy that was perfect on plain settings objects
 *          gets pointed at a player's hit cache (a Map keyed by `at`)
 *          and every console in the studio fills with "incompatible
 *          receiver". This is exactly why reactive state libraries ship
 *          SEPARATE handlers for Map/Set collections — and why nobody
 *          casually wraps a whole running Game instance in a Proxy.
 *
 * Run: node 06-internal-slots.cjs   (fails until you fix the TODO)
 */
'use strict';
const assert = require('node:assert');

/* ---------- the breakage, demonstrated (this part already passes) --- */
const cache = new Map([[1000, 12]]); // hit cache: at (ms) -> damage
const naive = new Proxy(cache, {}); // empty handler = "transparent", right? no.

assert.throws(() => naive.get(1000), TypeError,
    'sanity: a naive Proxy around a Map DOES throw — [[MapData]] lives on the ' +
    'target and internal slots do not forward');
assert.strictEqual(cache.get(1000), 12,
    'sanity: the raw Map itself is fine');

/* ------------------------------------------------------------------ */
/* TODO 1: implement transparentSpy(target, log) -> Proxy               */
/*   get trap only:                                                     */
/*     - push string keys onto `log` (skip symbols),                    */
/*     - read the value as t[key] (target as receiver — this makes      */
/*       accessor props like Map's `size` and class getters work),      */
/*     - if the value is a function, return it BOUND to the target.     */
/* ------------------------------------------------------------------ */
function transparentSpy(target, log) {
    // TODO 1: an empty handler is NOT transparent here — add the get
    // trap
    return new Proxy(target, {});
}

/* ------------------------- checks: Map ----------------------------- */
const mapLog = [];
const spied = transparentSpy(cache, mapLog);

let damage;
assert.doesNotThrow(() => { damage = spied.get(1000); },
    'TODO 1: spied.get(1000) exploded — Map.prototype.get ran with the PROXY ' +
    'as `this` and found no [[MapData]] internal slot. Bind the method to the ' +
    'target in your get trap.');
assert.strictEqual(damage, 12, 'reads must return the real cached damage');

spied.set(2000, 30);
assert.strictEqual(cache.size, 2,
    'writes through the spy must land in the REAL Map (bind => this === target)');
assert.strictEqual(spied.size, 2,
    "TODO 1: `size` is an ACCESSOR with its own internal-slot check — read it " +
    'with the target as receiver (t[key]), not Reflect.get(t, key, proxyReceiver)');
assert.deepStrictEqual(
    mapLog.filter((k) => ['get', 'set', 'size'].includes(k)).sort(),
    ['get', 'set', 'size'],
    'the spy must still LOG what was touched — that was its whole job');

/* ------------------- checks: #private fields ----------------------- */
class HitQueue {
    #hits = [];
    push(hit) { this.#hits.push(hit); return this.#hits.length; }
    get length() { return this.#hits.length; }
}
const queue = new HitQueue();
const queueLog = [];
const spiedQueue = transparentSpy(queue, queueLog);

assert.doesNotThrow(() => spiedQueue.push({ at: 1000, damage: 12 }),
    'TODO 1: #hits lives on the TARGET instance; a private-field access with ' +
    '`this` = proxy throws. Binding push() to the target fixes it.');
assert.strictEqual(spiedQueue.length, 1,
    'class getters touching #private fields need the target as receiver too');

/* -------------- proxy identity: the caveat that remains ------------- */
// Binding fixes method calls, but NOTHING fixes identity: proxy !== target.
// Identity-keyed structures (WeakSet/WeakMap registries, game.players
// membership checks, React deps arrays) cannot see through the wrapper.
const liveQueues = new WeakSet([queue]);
assert.notStrictEqual(spiedQueue, queue,
    'a proxy is a NEW object — it never === its target');
assert.strictEqual(liveQueues.has(spiedQueue), false,
    'identity-keyed collections do not see through proxies: hand out EITHER ' +
    'the proxy OR the target consistently, never a mix');

console.log('OK 06-internal-slots — Map + #private spied, identity caveat noted');

Solution