js-dojo

5 the closure parked on a shared object

GOAL

Chase down, explain, and repair the bug this whole dojo is built around: an arrow function dropped onto a SHARED prototype slot during construction pins `this` to whichever instance booted LAST — so the knight's match starts handing back the rogue's hero. Then meet the mirror image: a spot where the arrow IS the fix.

CONCEPT

arrow = `this` captured LEXICALLY, once, when the arrow is written. function = `this` looked up at CALL time from the call site. A prototype method is a DISPATCHER shared by many instances — it has to find its instance on every call, so it must be a `function`. A callback the engine later fires as a plain call gets no receiver at all — it has to CAPTURE, so it wants an arrow.

HINT

TODO 1: hoist the accessor out of the constructor and hand the prototype ONE plain `function` under `Game.prototype.getHero`. TODO 2: the callback pushed onto the queue should become `() => {...}`.

MIRRORS

The classic split-screen / minimap bug in game engines and UI libraries: a closure that captures one instance gets stashed on a shared prototype (or a module singleton) during init — from then on every running match reports the last one that booted. Engine prototype methods are plain `function`s for exactly this reason: `this` re-resolves per game, per call.

Run

node 05-arrow-on-shared-proto-wrong-game.cjs

Source

'use strict';
// ---------------------------------------------------------------------
// PROTO-DOJO · Module 2 / Exercise 5 — the closure parked on a shared
// object
//                                       serves the WRONG game
//
// GOAL:    Chase down, explain, and repair the bug this whole dojo is
//          built around: an arrow function dropped onto a SHARED
//          prototype slot during construction pins `this` to whichever
//          instance booted LAST — so the knight's match starts handing
//          back the rogue's hero. Then meet the mirror image: a spot
//          where the arrow IS the fix.
// CONCEPT: arrow  = `this` captured LEXICALLY, once, when the arrow is
//          written. function = `this` looked up at CALL time from the
//          call site. A prototype method is a DISPATCHER shared by many
//          instances — it has to find its instance on every call, so it
//          must be a `function`. A callback the engine later fires as a
//          plain call gets no receiver at all — it has to CAPTURE, so
//          it wants an arrow.
// HINT:    TODO 1: hoist the accessor out of the constructor and hand
//          the prototype ONE plain `function` under
//          `Game.prototype.getHero`. TODO 2: the callback pushed onto
//          the queue should become `() => {...}`.
// MIRRORS: The classic split-screen / minimap bug in game engines and
//          UI libraries: a closure that captures one instance gets
//          stashed on a shared prototype (or a module singleton) during
//          init — from then on every running match reports the last one
//          that booted. Engine prototype methods are plain `function`s
//          for exactly this reason: `this` re-resolves per game, per
//          call.
//
// Run: node 05-arrow-on-shared-proto-wrong-game.cjs
// ---------------------------------------------------------------------
const assert = require('node:assert');

function Game(id) {
  this.id = id;
  this.players = [{ id: id + '-hero', hits: [] }];
  this.renderCount = 0;

  // -- TODO 1 -- a well-meaning "speed-up" wires the accessor up during boot.
  // Two things go wrong on this one line:
  //   a) every `new Game(...)` OVERWRITES the same shared prototype slot, and
  // b) the arrow pins `this` to the game under construction — so once
  // the
  //      screen is up, the slot answers for whichever game booted LAST.
  // Lift the method out of the constructor and make it an ordinary `function`
  // on the prototype: a dispatcher that finds its game through `this` at CALL
  // time. All instances must share ONE function.
  Game.prototype.getHero = () => this.players[0];
}

Game.prototype.scheduleRender = function (queue) {
  // -- TODO 2 -- the other side of the coin. The render queue fires this
  // callback later as a PLAIN call (this === undefined) — a `function`
  // here
  // looks `this` up at call time and comes back empty-handed. THIS is where
  // the arrow earns its keep: it would capture scheduleRender's `this`, which
  // IS the game. Convert it.
  queue.push(function () {
    this.renderCount += 1;
  });
};

// -- the game screen boots two matches --------------------------------
const knightGame = new Game('knight');
const rogueGame = new Game('rogue');

assert.strictEqual(
  knightGame.getHero().id,
  'knight-hero',
  'TODO 1: the knight\'s match answered with "' + knightGame.getHero().id + '" — the arrow parked on ' +
    'the shared prototype was pinned to the LAST game constructed. `this` must resolve when the method is CALLED.'
);
assert.strictEqual(rogueGame.getHero().id, 'rogue-hero');
assert.strictEqual(
  knightGame.getHero,
  rogueGame.getHero,
  'TODO 1: keep ONE shared function on the prototype — per-instance copies dodge the lesson (and cost memory per game)'
);

// booting a third match must never re-aim the first two again:
const mageGame = new Game('mage');
assert.strictEqual(knightGame.getHero().id, 'knight-hero',
  'TODO 1: constructing the mage\'s match re-aimed EVERY game on the screen');
assert.strictEqual(mageGame.getHero().id, 'mage-hero');

// -- the render queue flushes callbacks as PLAIN calls ----------------
const renderQueue = [];
knightGame.scheduleRender(renderQueue);
rogueGame.scheduleRender(renderQueue);

let boom;
try {
  for (const callback of renderQueue.splice(0)) callback(); // no receiver!
} catch (err) {
  boom = err;
}
assert.strictEqual(
  boom,
  undefined,
  'TODO 2: flushing the queue threw "' + (boom && boom.message) + '" — the `function` callback re-resolved ' +
    'this at call time and the queue offered nothing. An arrow inside scheduleRender captures the game lexically.'
);
assert.strictEqual(knightGame.renderCount, 1, 'each queued render must hit ITS OWN game');
assert.strictEqual(rogueGame.renderCount, 1, 'each queued render must hit ITS OWN game');

console.log('PASS — 05-arrow-on-shared-proto-wrong-game: dispatchers resolve, callbacks capture');

Solution