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');