js-dojo

3 symbol vs weakmap stash

GOAL

Hang YOUR per-hit metadata (render timings) onto a Hit class that belongs to the third-party `engine` — without dirtying it. Do it once under a Symbol key and once through a WeakMap, then nail down what each approach hides, what it still exposes, and what it keeps alive.

CONCEPT

Three ways to stash data on an object you don't own: string key → can collide with the engine's own fields, and leaks into every JSON body, for-in loop and Object.keys call. Never use one on an object you don't own. Symbol key → cannot clash with anything, and JSON / Object.keys / for-in all walk straight past it… yet it still lives ON the object (Object.getOwnPropertySymbols finds it) and it is destroyed together with the object. WeakMap → never touches the object. Keys are held WEAKLY: once the last outside reference to a hit is gone, its entry becomes garbage too — a WeakMap CANNOT leak dead hits. The cost: no .size, no iteration (an iterable map would have to pin its keys — that is what a Map does).

HINT

Every Symbol('...') call mints a brand-new key, so nothing can ever clash with it. A WeakMap is just .set(obj, value) and .get(obj). In the leak demo, watch which registry still reports 3 hits after the game has thrown them away.

MIRRORS

Your game ships hit records to a replay/leaderboard service — `hit._perfMeta = {...}` marches straight into that JSON body (and into the engine's own settings-diffing for-in loops). Engines and UI libraries routinely keep object→wrapper lookups (DOM node → component, entity → physics body); only the WeakMap flavour survives 10 000 hit updates an hour without growing forever.

Run

node 03-symbol-vs-weakmap-stash.cjs

Source

// ---------------------------------------------------------------------
// 04-patching-foreign-code / 03-symbol-vs-weakmap-stash.cjs
//
// GOAL: Hang YOUR per-hit metadata (render timings) onto a Hit class
//       that belongs to the third-party `engine` — without dirtying it.
//       Do it once under a Symbol key and once through a WeakMap, then
//       nail down what each approach hides, what it still exposes, and
//       what it keeps alive.
//
// CONCEPT: Three ways to stash data on an object you don't own: string
//          key  → can collide with the engine's own fields, and leaks
//          into every JSON body, for-in loop and Object.keys call.
//          Never use one on an object you don't own. Symbol key  →
//          cannot clash with anything, and JSON / Object.keys / for-in
//          all walk straight past it… yet it still lives ON the object
//          (Object.getOwnPropertySymbols finds it) and it is destroyed
//          together with the object. WeakMap     → never touches the
//          object. Keys are held WEAKLY: once the last outside
//          reference to a hit is gone, its entry becomes garbage too —
//          a WeakMap CANNOT leak dead hits. The cost: no .size, no
//          iteration (an iterable map would have to pin its keys — that
//          is what a Map does).
//
// HINT: Every Symbol('...') call mints a brand-new key, so nothing can
//       ever clash with it. A WeakMap is just .set(obj, value) and
//       .get(obj). In the leak demo, watch which registry still reports
//       3 hits after the game has thrown them away.
//
// MIRRORS: Your game ships hit records to a replay/leaderboard service
//          — `hit._perfMeta = {...}` marches straight into that JSON
//          body (and into the engine's own settings-diffing for-in
//          loops). Engines and UI libraries routinely keep
//          object→wrapper lookups (DOM node → component, entity →
//          physics body); only the WeakMap flavour survives 10 000 hit
//          updates an hour without growing forever.
// Run: node 03-symbol-vs-weakmap-stash.cjs
// ---------------------------------------------------------------------
'use strict';
const assert = require('node:assert');

// -- Third-party class (pretend node_modules/engine — do not edit) ----
class Hit {
  constructor(at, damage) {
    this.at = at;
    this.damage = damage;
  }
}

// The naive stash, for contrast (this is the anti-pattern — do not
// edit):
function tagWithStringKey(hit, meta) {
  hit._perfMeta = meta;
}

// -- TODO 1: Symbol stash ---------------------------------------------
// 1a: create a real Symbol key.
const PERF_META = null;

// 1b/1c: store & read meta under that Symbol on the hit itself.
function tagWithSymbol(hit, meta) {
  // TODO 1b
}
function readSymbolMeta(hit) {
  // TODO 1c
}

// -- TODO 2: WeakMap stash --------------------------------------------
// 2a: create the store.
const metaStore = null;

// 2b/2c: store & read meta WITHOUT touching the hit at all.
function tagWithWeakMap(hit, meta) {
  // TODO 2b
}
function readWeakMapMeta(hit) {
  // TODO 2c
}

// -- TODO 3: predictions — replace each null with true or false -------
const predictions = {
  stringKeyVisibleInJson: null,    // does _perfMeta show up in JSON.stringify?
  symbolVisibleInJson: null,       // does the Symbol-keyed meta show up in JSON?
  symbolVisibleInObjectKeys: null, // ...in Object.keys?
  symbolDiscoverable: null,        // can getOwnPropertySymbols still find it?
  weakMapLeavesTraceOnHit: null,   // does the WeakMap add ANY own key/symbol?
  canReadWeakMapSize: null,        // does WeakMap expose a .size to audit?
};

// -- Checks (do not edit) ---------------------------------------------
// Symbol stash:
assert.strictEqual(typeof PERF_META, 'symbol',
  'TODO 1a: PERF_META must be an actual Symbol — unique by construction, so ' +
  'it can never collide with a future engine property');
const h1 = new Hit(1200, 9);
tagWithSymbol(h1, { renderMs: 0.4 });
assert.deepStrictEqual(readSymbolMeta(h1), { renderMs: 0.4 },
  'TODO 1b/1c: round-trip the meta through hit[PERF_META]');
assert.deepStrictEqual(Object.keys(h1), ['at', 'damage'],
  'Symbol keys are invisible to Object.keys — the engine\'s for-in loops ' +
  'never meet your stash');

// The anti-pattern, demonstrated — for contrast with the Symbol
// version:
const naive = new Hit(1200, 9);
tagWithStringKey(naive, { renderMs: 0.4 });
assert.strictEqual(predictions.stringKeyVisibleInJson,
  JSON.stringify(naive).includes('_perfMeta'),
  'TODO 3 stringKeyVisibleInJson: this JSON goes to your replay service — ' +
  'does your probe data ride along?');

assert.strictEqual(predictions.symbolVisibleInJson,
  JSON.stringify(h1).includes('renderMs'),
  'TODO 3 symbolVisibleInJson: JSON.stringify only serializes string-keyed ' +
  'enumerable props');
assert.strictEqual(predictions.symbolVisibleInObjectKeys, false,
  'TODO 3 symbolVisibleInObjectKeys: see the Object.keys assertion above');
assert.strictEqual(predictions.symbolDiscoverable,
  Object.getOwnPropertySymbols(h1).length > 0,
  'TODO 3 symbolDiscoverable: Symbols are hidden from ENUMERATION, not ' +
  'private — anyone with getOwnPropertySymbols can find your stash');

// WeakMap stash:
assert.ok(metaStore instanceof WeakMap,
  'TODO 2a: metaStore must be a WeakMap — a plain Map would pin every hit ' +
  'in memory forever');
const h2 = new Hit(1350, 14);
tagWithWeakMap(h2, { renderMs: 0.7 });
assert.deepStrictEqual(readWeakMapMeta(h2), { renderMs: 0.7 },
  'TODO 2b/2c: round-trip the meta through metaStore');
assert.deepStrictEqual(
  [Object.keys(h2), Object.getOwnPropertySymbols(h2)], [['at', 'damage'], []],
  'the WeakMap version leaves the hit COMPLETELY untouched — no key, no ' +
  'symbol, nothing for JSON or the engine to trip on');
assert.strictEqual(predictions.weakMapLeavesTraceOnHit, false,
  'TODO 3 weakMapLeavesTraceOnHit: see the previous assertion');
assert.strictEqual(predictions.canReadWeakMapSize,
  metaStore.size !== undefined,
  'TODO 3 canReadWeakMapSize: WeakMap has no .size and no iteration — ' +
  'observability is the price of being un-leakable (an iterable map must ' +
  'keep its keys alive)');

// The leak, made visible with a STRONG Map registry:
const strongRegistry = new Map();
let gameHits = [new Hit(100, 1), new Hit(200, 2), new Hit(300, 3)];
gameHits.forEach((h) => strongRegistry.set(h, { renderMs: 1 }));
gameHits = [];   // the game threw away every hit — no outside refs remain
assert.strictEqual(strongRegistry.size, 3,
  'THE LEAK: the Map still holds all 3 dead hits strongly — with a ' +
  'WeakMap those entries are unreachable garbage the GC may reclaim, and ' +
  'nothing can observe them anymore');

console.log('OK — 03-symbol-vs-weakmap-stash: hidden, collision-proof, and leak-free — pick two, then pick WeakMap.');

Solution