js-dojo

2 the extracted method loses its this

GOAL

Trigger and then cure the most frequent `this` bug in UI and game code: handing `obj.method` over as a callback. The `obj.` half never gets packed into the function reference — it is gone the instant the reference is stored, and the eventual plain call arrives receiverless.

CONCEPT

`screen.handleResize` is only a lookup; what comes back is the naked function. The emitter later runs `handler()` — form 1, this === undefined. Cures: bind where you subscribe, wrap the call in an arrow, or (class component style) bind once in the constructor / use a class field.

HINT

Both broken spots need the receiver to TRAVEL WITH the function: fn.bind(receiver) manufactures a fresh function with this welded on.

MIRRORS

Component frameworks: `<Canvas onResize={this.handleResize} />` dying with "cannot read gameId of undefined"; the same tale with a third-party engine's `engine.on('resize', this.onResize)` in a wrapper.

Run

node 02-extracted-method-loses-this.cjs

Source

'use strict';
// ---------------------------------------------------------------------
// PROTO-DOJO · Module 2 / Exercise 2 — the extracted method loses its
// `this`
//
// GOAL:    Trigger and then cure the most frequent `this` bug in UI and
//          game code: handing `obj.method` over as a callback. The
//          `obj.` half never gets packed into the function reference —
//          it is gone the instant the reference is stored, and the
//          eventual plain call arrives receiverless.
// CONCEPT: `screen.handleResize` is only a lookup; what comes back is
//          the naked function. The emitter later runs `handler()` —
//          form 1, this === undefined. Cures: bind where you subscribe,
//          wrap the call in an arrow, or (class component style) bind
//          once in the constructor / use a class field.
// HINT:    Both broken spots need the receiver to TRAVEL WITH the
//          function: fn.bind(receiver) manufactures a fresh function
//          with this welded on.
// MIRRORS: Component frameworks: `<Canvas onResize={this.handleResize}
//          />` dying with "cannot read gameId of undefined"; the same
//          tale with a third-party engine's `engine.on('resize',
//          this.onResize)` in a wrapper.
//
// Run: node 02-extracted-method-loses-this.cjs
// ---------------------------------------------------------------------
const assert = require('node:assert');

class GameScreen {
  constructor(gameId) {
    this.gameId = gameId;
    this.resizeCount = 0;
  }
  handleResize() {
    this.resizeCount += 1;
    return 'resized:' + this.gameId;
  }
}

// A viewport-resize emitter, the kind every engine ships: it keeps BARE
// function references and later fires them as PLAIN calls — no
// receiver,
// no dot, no this.
const resizeHandlers = [];
function onResize(handler) {
  resizeHandlers.push(handler);
}
function fireResize() {
  return resizeHandlers.map((handler) => handler()); // plain call!
}

const screen = new GameScreen('match-1');

// -- TODO 1 -----------------------------------------------------------
// The classic component-callback bug: this gives the emitter a NAKED function —
// the `screen.` prefix is dropped the moment the reference is stored.
// Fix THIS LINE so the handler keeps its screen (bind it, or wrap in an arrow).
onResize(screen.handleResize);

let results;
let boom;
try {
  results = fireResize();
} catch (err) {
  boom = err;
}
assert.strictEqual(
  boom,
  undefined,
  'TODO 1: firing resize blew up with "' + (boom && boom.message) + '" — the extracted method ran as a ' +
    'PLAIN call, so this === undefined. The call site picks `this`; the subscription must carry the receiver along.'
);
assert.deepStrictEqual(results, ['resized:match-1'], 'the handler must reach the real screen');
assert.strictEqual(screen.resizeCount, 1, 'the real screen state must have been updated');

// -- TODO 2 -----------------------------------------------------------
// Same disease, no emitter: build a standalone, pass-anywhere version of
// handleResize that is PERMANENTLY welded to `screen`, starting from the
// bare `detached` reference below.
const detached = screen.handleResize; // bare — `detached()` would explode
const weldedToScreen = detached;

assert.strictEqual(typeof weldedToScreen, 'function',
  'TODO 2: Function.prototype.bind returns a NEW function — it does not call anything');
assert.strictEqual(weldedToScreen(), 'resized:match-1',
  'TODO 2: the bound this must survive a plain call');
assert.strictEqual(screen.resizeCount, 2, 'the bound call must hit the same screen state');

console.log('PASS — 02-extracted-method-loses-this: receivers do not travel with function references');

Solution