js-dojo

5 partial delegation and wrappers

GOAL

A subclass override that PARTIALLY hands off to the base method comes in two flavors — live lookup (`super.updatePosition()`) and a reference pinned at definition time. Predict which flavor a monkey-patch on the BASE prototype can observe, then close the blind spot in a perf probe.

CONCEPT

`super.updatePosition()` walks the prototype chain AT CALL TIME — wrap Player.prototype.updatePosition afterwards and every super-call passes through your wrapper. But `const base = Player.prototype.updatePosition` grabbed at module load freezes the ORIGINAL function; a wrapper installed on the prototype later is invisible to it. Whoever instruments a base method has to know which hand-off style each subclass uses — or wrap at the subclass level instead.

HINT

The probe never sees WarriorSnapshot because its override calls the pinned original. There is no way to un-pin it from outside — so wrap the prototype that actually owns the snapshot override. And do NOT wrap WarriorLive as well: its super-call already funnels through the wrapped base (double count!).

MIRRORS

Game engines and UI toolkits let every entity type override a shared lifecycle method (update, render, layout), and most overrides open with `super.update()` — live, so the engine's own profiler wrapper on the base prototype counts subclass calls too. Plugins that snapshot (`const baseX = proto.x` at import time) quietly slip past every wrapper installed after them — the classic "why does the profiler report 0ms for my warriors?" bug.

Run

node 05-partial-delegation-and-wrappers.cjs

Source

// ---------------------------------------------------------------------
// 03-classes-desugared / 05-partial-delegation-and-wrappers.cjs
//
// GOAL: A subclass override that PARTIALLY hands off to the base method
//       comes in two flavors — live lookup (`super.updatePosition()`)
//       and a reference pinned at definition time. Predict which flavor
//       a monkey-patch on the BASE prototype can observe, then close
//       the blind spot in a perf probe.
//
// CONCEPT: `super.updatePosition()` walks the prototype chain AT CALL
//          TIME — wrap Player.prototype.updatePosition afterwards and
//          every super-call passes through your wrapper. But `const
//          base = Player.prototype.updatePosition` grabbed at module
//          load freezes the ORIGINAL function; a wrapper installed on
//          the prototype later is invisible to it. Whoever instruments
//          a base method has to know which hand-off style each subclass
//          uses — or wrap at the subclass level instead.
//
// HINT: The probe never sees WarriorSnapshot because its override calls
//       the pinned original. There is no way to un-pin it from outside
//       — so wrap the prototype that actually owns the snapshot
//       override. And do NOT wrap WarriorLive as well: its super-call
//       already funnels through the wrapped base (double count!).
//
// MIRRORS: Game engines and UI toolkits let every entity type override
//          a shared lifecycle method (update, render, layout), and most
//          overrides open with `super.update()` — live, so the engine's
//          own profiler wrapper on the base prototype counts subclass
//          calls too. Plugins that snapshot (`const baseX = proto.x` at
//          import time) quietly slip past every wrapper installed after
//          them — the classic "why does the profiler report 0ms for my
//          warriors?" bug.
// Run: node 05-partial-delegation-and-wrappers.cjs
// ---------------------------------------------------------------------
'use strict';
const assert = require('node:assert');

// -- Setup (do not edit) ----------------------------------------------
class Player {
  constructor(name, hits) {
    this.name = name;
    this.hits = hits;          // [{ at (ms), damage }, ...]
    this.positions = null;
  }
  updatePosition() {         // base layout: hits → knockback positions on screen
    this.positions = this.hits.map((h, i) => ({ x: i, y: 100 - h.damage }));
  }
}

// Flavor 1 — LIVE hand-off: super resolves through the chain at call
// time.
class WarriorLive extends Player {
  updatePosition() {
    super.updatePosition();
    this.positions.forEach((p) => { p.hitbox = { x: p.x * 10, height: 100 - p.y }; });
  }
}

// Flavor 2 — SNAPSHOT hand-off: base method pinned when this module
// loaded.
const capturedBaseUpdatePosition = Player.prototype.updatePosition;
class WarriorSnapshot extends Player {
  updatePosition() {
    capturedBaseUpdatePosition.call(this);
    this.positions.forEach((p) => { p.hitbox = { x: p.x * 10, height: 100 - p.y }; });
  }
}

// A minimal perf probe (do not edit) — counts and logs every call it
// can see.
// ("probe hits" below = wrapper invocations, not damage taken.)
let probeHits = 0;
const probeLog = [];
function installProbe(proto, methodName) {
  const original = proto[methodName];
  proto[methodName] = function (...args) {
    probeHits += 1;
    probeLog.push(`${this.name}:${methodName}`);
    return original.apply(this, args);
  };
}

// The engine's profiler wraps the BASE — "one wrap covers every player
// type", right?
installProbe(Player.prototype, 'updatePosition');

// Three players update their position once each:
const plain = new Player('knight', [{ at: 0, damage: 40 }, { at: 16, damage: 60 }]);
const before1 = probeHits; plain.updatePosition();
const hitsForBase = probeHits - before1;

const live = new WarriorLive('rogue', [{ at: 0, damage: 30 }, { at: 16, damage: 70 }]);
const before2 = probeHits; live.updatePosition();
const hitsForLiveWarrior = probeHits - before2;

const snap = new WarriorSnapshot('mage', [{ at: 0, damage: 20 }, { at: 16, damage: 80 }]);
const before3 = probeHits; snap.updatePosition();
const hitsForSnapshotWarrior = probeHits - before3;

// -- TODO 1: predictions ----------------------------------------------
// How many times did the probe fire for EACH single updatePosition() call
// above? Replace each null with a number (0, 1, 2, ...).
const predictions = {
  hitsForBase: null,
  hitsForLiveWarrior: null,
  hitsForSnapshotWarrior: null,
};

// -- TODO 2: close the blind spot -------------------------------------
// Make the probe see WarriorSnapshot position updates as well — WITHOUT
// touching the snapshot class and WITHOUT making WarriorLive double-count.
// One line.
function fixProbeCoverage() {
  // your one line here
}

// -- Checks (do not edit) ---------------------------------------------
assert.strictEqual(predictions.hitsForBase, hitsForBase,
  'TODO 1 hitsForBase: plain Player.updatePosition() looks the method up on ' +
  'Player.prototype — which now holds the wrapper');
assert.strictEqual(predictions.hitsForLiveWarrior, hitsForLiveWarrior,
  'TODO 1 hitsForLiveWarrior: super.updatePosition() resolves through the ' +
  'prototype chain AT CALL TIME — does it find the original or the wrapper?');
assert.strictEqual(predictions.hitsForSnapshotWarrior, hitsForSnapshotWarrior,
  'TODO 1 hitsForSnapshotWarrior: capturedBaseUpdatePosition was pinned BEFORE ' +
  'the probe was installed — can a later prototype swap reach a const?');

assert.deepStrictEqual(snap.positions.map((p) => p.hitbox.height), [20, 80],
  'sanity: the snapshot warrior still positioned itself CORRECTLY — that is ' +
  'the trap: behavior is fine, observability is not');

fixProbeCoverage();

const before4 = probeHits; snap.updatePosition();
assert.strictEqual(probeHits - before4, 1,
  'TODO 2: after your fix, one WarriorSnapshot.updatePosition() must register ' +
  'exactly 1 probe hit — wrap the prototype that OWNS the snapshot override');
assert.ok(probeLog.includes('mage:updatePosition'),
  'TODO 2: the probe log should now show the snapshot warrior by name');

const before5 = probeHits; live.updatePosition();
assert.strictEqual(probeHits - before5, 1,
  'TODO 2: WarriorLive must still count exactly ONCE — if you see 2, you ' +
  'wrapped WarriorLive.prototype too, and its super-call already flows ' +
  'through the wrapped base');

console.log('OK — 05-partial-delegation-and-wrappers: you now know where wraps can and cannot see.');

Solution