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.cjsSource
// ---------------------------------------------------------------------
// 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.');