js-dojo

2 stale callback registry

GOAL

find the exact line where a stored callback goes stale, then repair a registry so every watcher receives the state that is current at call time.

CONCEPT

a closure stays fresh only if it reads a variable that is still being updated. Copying state into a local const and closing over the copy freezes what the callback can ever see.

HINT

the bug is never in the redraw loop - it is in what watch() chose to close over. Store the callback bare, and hand it fresh data at the moment you call it.

MIRRORS

the HUD's skill tree store. Every panel that wants to know when a point is spent hands the store a bare callback, and the store reads its own points through the live object as it calls each one - so no panel is left painting a number that stopped moving three level-ups ago.

Run

node 02-stale-callback-registry.cjs

Source

// DOJO · Module 9 / Exercise 2 — stale callback registry
// GOAL: find the exact line where a stored callback goes stale, then
//       repair a registry so every watcher receives the state that is
//       current at call time.
// CONCEPT: a closure stays fresh only if it reads a variable that is
//       still being updated. Copying state into a local const and
//       closing over the copy freezes what the callback can ever see.
// HINT: the bug is never in the redraw loop - it is in what watch()
//       chose to close over. Store the callback bare, and hand it
//       fresh data at the moment you call it.
// MIRRORS: the HUD's skill tree store. Every panel that wants to know
//       when a point is spent hands the store a bare callback, and the
//       store reads its own points through the live object as it calls
//       each one - so no panel is left painting a number that stopped
//       moving three level-ups ago.
// Run: node 02-stale-callback-registry.cjs

'use strict';
const assert = require('node:assert');

// ------------------------------------------------------------------
// Part A - a broken registry. Do NOT fix this one; predict what it
// does. watch() copies `treeState` into a const and stores a wrapper
// closed over the copy. `treeState` is later reassigned to brand-new
// objects; the copies are not.
// ------------------------------------------------------------------
let treeState = { points: 0 };
const watchers = new Map();
const drawn = [];

function watch(id, onRedraw) {
  const snapshot = treeState; // copies the CURRENT object reference
  watchers.set(id, () => onRedraw(snapshot));
}

function redraw() {
  for (const run of [...watchers.values()]) {
    run();
  }
}

// -- TODO 1 --------------------------------------------------------
// 'branch' starts watching while treeState.points is 0. Then
// treeState is reassigned twice, with a redraw() after each
// reassignment. The wrapper stored for 'branch' links to its own
// `snapshot` const, assigned once and never again.
// Replace null with the array of two [id, points] pairs pushed into
// `drawn`, e.g. [['branch', 7], ['branch', 7]].
watch('branch', (s) => drawn.push(['branch', s.points]));
treeState = { points: 1 };
redraw();
treeState = { points: 2 };
redraw();

const answerBranch = null; // replace me

assert.deepStrictEqual(
  drawn,
  answerBranch,
  'TODO 1 - what did branch draw across both redraws?',
);

// -- TODO 2 --------------------------------------------------------
// 'leaf' starts watching NOW, while treeState.points is 2 - so its
// `snapshot` const is not the same object 'branch' froze. treeState
// then moves to 3, and one redraw() runs both watchers ('branch'
// first - a Map preserves insertion order).
// Replace null with the array of two [id, points] pairs pushed this
// round, e.g. [['branch', 9], ['leaf', 9]].
drawn.length = 0;
watch('leaf', (s) => drawn.push(['leaf', s.points]));
treeState = { points: 3 };
redraw();

const answerPairs = null; // replace me

assert.deepStrictEqual(
  drawn,
  answerPairs,
  'TODO 2 - one pair per watcher, in Map order',
);

// -- TODO 3 --------------------------------------------------------
// Now build the fixed registry. Same shape, but watchers must receive
// the state current AT CALL TIME. Two lines are broken, both marked.
// Store the callback bare, and have redraw() pass `current` into each
// call - variable and property reads at call time are always fresh.
function createSkillTreeStore() {
  let current = { points: 0 };
  const subs = new Map();
  return {
    watch(id, onRedraw) {
      const snapshot = current; // BROKEN: freezes watch-time state
      subs.set(id, () => onRedraw(snapshot)); // BROKEN: frozen wrapper
    },
    setState(next) {
      current = next;
    },
    redraw() {
      for (const run of [...subs.values()]) {
        run();
      }
    },
  };
}

const store = createSkillTreeStore();
const log = [];
store.watch('a', (s) => log.push(s.points));
store.setState({ points: 5 });
store.redraw();
store.setState({ points: 9 });
store.redraw();

assert.deepStrictEqual(
  log,
  [5, 9],
  'TODO 3 - watchers must see call-time state',
);

console.log('PASS');

Solution