js-dojo

1 dispatcher on the prototype

GOAL

Bolt a `requestUpdate` batching API onto a third-party Game class the way engine plugins do it: ONE stateless dispatcher on the prototype plus PER-INSTANCE state born in an init event. Then PROVE that two running matches never touch each other's queue.

CONCEPT

A method on the prototype is one function shared by every instance, so it cannot own any state of its own. State parked in the module closure is worse still: every match in the process shares it. The pattern: - prototype method = stateless dispatcher, reads/writes `this._xxx` - init event hook = hands each instance its own `this._xxx` The starter plugin below ships with the classic bug: closure state.

HINT

Remove the module-level array. Inside the 'init' hook, create the state on `this` (the game being constructed). In the dispatchers, touch nothing but `this._pendingPatches` — never anything from the surrounding closure.

MIRRORS

Plugins for UI toolkits, game engines and event emitters all look alike: `addEvent(Game, 'init', function () { this.xyz = ... })` paired with `Game.prototype.someMethod = function () { ...this.xyz... }`. A lobby server hosts dozens of matches at once — closure state means match B flushes match A's pending settings patches.

Run

node 01-dispatcher-on-the-prototype.cjs

Source

// ---------------------------------------------------------------------
// 04-patching-foreign-code / 01-dispatcher-on-the-prototype.cjs
//
// GOAL: Bolt a `requestUpdate` batching API onto a third-party Game
//       class the way engine plugins do it: ONE stateless dispatcher on
//       the prototype plus PER-INSTANCE state born in an init event.
//       Then PROVE that two running matches never touch each other's
//       queue.
//
// CONCEPT: A method on the prototype is one function shared by every
//          instance, so it cannot own any state of its own. State
//          parked in the module closure is worse still: every match in
//          the process shares it. The pattern:
//          - prototype method = stateless dispatcher, reads/writes
//            `this._xxx`
//          - init event hook  = hands each instance its own
//            `this._xxx` The starter plugin below ships with the
//            classic bug: closure state.
//
// HINT: Remove the module-level array. Inside the 'init' hook, create
//       the state on `this` (the game being constructed). In the
//       dispatchers, touch nothing but `this._pendingPatches` — never
//       anything from the surrounding closure.
//
// MIRRORS: Plugins for UI toolkits, game engines and event emitters all
//          look alike: `addEvent(Game, 'init', function () { this.xyz =
//          ... })` paired with `Game.prototype.someMethod = function ()
//          { ...this.xyz... }`. A lobby server hosts dozens of matches
//          at once — closure state means match B flushes match A's
//          pending settings patches.
//
// Run: node 01-dispatcher-on-the-prototype.cjs
// ---------------------------------------------------------------------
'use strict';
const assert = require('node:assert');

// -- Third-party `engine` library (pretend node_modules — do not edit)
// ---
const hooks = { init: [] };
function addEvent(Class, type, handler) {
  hooks[type].push(handler);
}
class Game {
  constructor(settings) {
    this.settings = settings;
    hooks.init.forEach((h) => h.call(this));   // fires like the engine's 'init'
  }
  render() { this.rendered = (this.rendered || 0) + 1; }
}

// -- YOUR PLUGIN MODULE (fix the TODOs) -------------------------------
// BUG: this array lives in the MODULE CLOSURE — one array for every
//      match
// that will ever run in this process.
let pendingPatches = [];

addEvent(Game, 'init', function () {
  // TODO 1: give THIS game its own state. The dispatchers below must
  // find an own `_pendingPatches` array on every constructed game.
});

Game.prototype.requestUpdate = function (patch) {
  // TODO 2: stateless dispatcher — queue onto THIS game's state, not
  // the
  // closure array.
  pendingPatches.push(patch);
};

Game.prototype.flushUpdates = function () {
  // TODO 3: merge THIS game's queued patches into this.settings, clear only
  // THIS game's queue, render, and return the merged patch object.
  const merged = Object.assign({}, ...pendingPatches);
  pendingPatches = [];
  Object.assign(this.settings, merged);
  this.render();
  return merged;
};

// -- Checks (do not edit) ---------------------------------------------
const knight = new Game({ hero: 'knight', volume: 30 });
const rogue = new Game({ hero: 'rogue', volume: 30 });

// The dispatcher itself must be SHARED — that part of the pattern is
// free:
assert.strictEqual(knight.requestUpdate, rogue.requestUpdate,
  'the dispatcher stays ONE function on Game.prototype — the plugin adds ' +
  'no per-instance functions');

// But the STATE must be per-instance, created at init:
assert.ok(Array.isArray(knight._pendingPatches),
  'TODO 1: the init hook must create this._pendingPatches on every game ' +
  '(`this` inside the hook IS the game being constructed)');
assert.notStrictEqual(knight._pendingPatches, rogue._pendingPatches,
  'TODO 1: each game needs its OWN array — a shared one is the module-' +
  'closure bug wearing a different costume');

knight.requestUpdate({ volume: 80 });
knight.requestUpdate({ difficulty: 'hard' });
rogue.requestUpdate({ volume: 10 });

assert.strictEqual(knight._pendingPatches.length, 2,
  'TODO 2: the knight match queued exactly 2 patches — if you see 3, the ' +
  'rogue patch leaked in through shared state');
assert.strictEqual(rogue._pendingPatches.length, 1,
  'TODO 2: the rogue match queued exactly 1 patch — isolation is the whole point');

const knightMerged = knight.flushUpdates();
assert.deepStrictEqual(knightMerged, { volume: 80, difficulty: 'hard' },
  'TODO 3: flushing the knight match merges ONLY its own patches');
assert.strictEqual(knight.settings.volume, 80, 'TODO 3: merged into this.settings');
assert.strictEqual(knight.settings.difficulty, 'hard', 'TODO 3: merged into this.settings');
assert.strictEqual(knight.rendered, 1, 'TODO 3: flush renders the game');

// THE isolation proof: flushing match A must not drain match B.
assert.strictEqual(rogue._pendingPatches.length, 1,
  'ISOLATION: knight flushed — rogue must still hold its own pending patch');
const rogueMerged = rogue.flushUpdates();
assert.deepStrictEqual(rogueMerged, { volume: 10 },
  'ISOLATION: rogue flushes only its own patch, untouched by knight activity');
assert.strictEqual(rogue.settings.hero, 'rogue', 'sanity: settings intact');
assert.strictEqual(knight.settings.volume, 80,
  'ISOLATION: rogue\'s volume:10 must NOT bleed into the knight match');

console.log('OK — 01-dispatcher-on-the-prototype: one dispatcher, private queues.');

Solution