js-dojo

5 build the expansion store

GOAL

assemble the whole module into one skill tree store - a sparse map of overrides plus useSyncExternalStore - so expanding one branch skill redraws that branch and nothing else.

CONCEPT

useSyncExternalStore(subscribe, getSnapshot) redraws a component only when getSnapshot() comes back unequal to last time. Ping every subscriber; only the ones whose answer changed are redrawn.

HINT

every piece is a drill you have already done: add and remove a listener, read the state at call time, ping from a frozen copy of the listener set. The Map holds overrides ONLY - an id with no entry falls back to the default the caller worked out.

MIRRORS

the HUD's skill tree, where a hundred branch skills share one store. The store remembers only the branches the player actually touched; every other branch falls back to the tree's own default, and expanding one leaves the other ninety-nine alone.

Run

This koan renders React, so it needs a one-time setup.

pnpm koan 09-closures-and-stores/05-build-the-expansion-store.tsx

Source

// DOJO · Module 9 / Exercise 5 — build the expansion store
// GOAL: assemble the whole module into one skill tree store - a sparse
//       map of overrides plus useSyncExternalStore - so expanding one
//       branch skill redraws that branch and nothing else.
// CONCEPT: useSyncExternalStore(subscribe, getSnapshot) redraws a
//       component only when getSnapshot() comes back unequal to last
//       time. Ping every subscriber; only the ones whose answer
//       changed are redrawn.
// HINT: every piece is a drill you have already done: add and remove a
//       listener, read the state at call time, ping from a frozen copy
//       of the listener set. The Map holds overrides ONLY - an id with
//       no entry falls back to the default the caller worked out.
// MIRRORS: the HUD's skill tree, where a hundred branch skills share
//       one store. The store remembers only the branches the player
//       actually touched; every other branch falls back to the tree's
//       own default, and expanding one leaves the other ninety-nine
//       alone.
// Run: pnpm koan 09-closures-and-stores/05-build-the-expansion-store.tsx

import { memo, useSyncExternalStore } from 'react';
import { afterEach, expect, it } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';

// HUD redraw tally: every branch skill bumps its own id at the top of
// its body, so a test can ask how often the HUD drew that branch.
const redraws = new Map<string, number>();

function drew(id: string): void {
  redraws.set(id, (redraws.get(id) ?? 0) + 1);
}

function redrawsOf(id: string): number {
  return redraws.get(id) ?? 0;
}

afterEach(() => {
  cleanup();
  redraws.clear();
});

type Listener = () => void;

class SkillTreeStore {
  // Sparse: holds ONLY the branches the player actually toggled.
  // Public so the tests can look inside; a shipped store keeps it
  // private.
  readonly overrides = new Map<string, boolean>();

  // Public for the same reason.
  readonly listeners = new Set<Listener>();

  // -- TODO 1 ---------------------------------------------------------
  // subscribe: put the listener into the Set, and hand back a function
  // that takes it out again. It is an arrow-function class field on
  // purpose: `this` stays pinned to the store and the function's own
  // identity never changes, so useSyncExternalStore can be handed
  // `store.subscribe` itself without resubscribing on every redraw.
  subscribe = (listener: Listener): (() => void) => {
    void listener; // BROKEN: registers nobody, returns a decoy
    return () => {};
  };

  // -- TODO 2 ---------------------------------------------------------
  // isOpen: a branch with an entry in `overrides` uses that entry; a
  // branch with no entry uses the default the caller worked out
  // (`fallback`). The read happens at call time, every time, so
  // nothing in here can go stale.
  isOpen(id: string, fallback: boolean): boolean {
    void id; // BROKEN: never looks at the overrides
    return fallback;
  }

  // -- TODO 3 ---------------------------------------------------------
  // toggle: write the OPPOSITE of the current answer into `overrides`
  // (current answer = this.isOpen(id, fallback), read at call time),
  // then ping every listener by looping over a frozen copy of the Set.
  toggle(id: string, fallback: boolean): void {
    void id; // BROKEN: writes nothing, pings nobody
    void fallback;
  }
}

interface BranchProps {
  id: string;
  store: SkillTreeStore;
}

// Given: three branch skills. Each one is memo()'d and subscribes for
// itself, and the store object's identity never changes, so a branch
// redraws only when its own snapshot - store.isOpen(id, false) - comes
// back unequal to the one before.
const BranchSkill = memo(function BranchSkill({ id, store }: BranchProps) {
  drew(id);
  const open = useSyncExternalStore(store.subscribe, () =>
    store.isOpen(id, false),
  );
  return (
    <div>
      <button onClick={() => store.toggle(id, false)}>{id}</button>
      <span data-testid={`state-${id}`}>{open ? 'open' : 'closed'}</span>
    </div>
  );
});

function SkillTree({ store }: { store: SkillTreeStore }) {
  return (
    <div>
      <BranchSkill id="blade" store={store} />
      <BranchSkill id="ward" store={store} />
      <BranchSkill id="hex" store={store} />
    </div>
  );
}

it('TODO 1 — subscribe registers, and its return value unregisters', () => {
  const store = new SkillTreeStore();
  const unsubscribe = store.subscribe(() => {});
  expect(store.listeners.size).toBe(1);
  expect(typeof unsubscribe).toBe('function');
  unsubscribe();
  expect(store.listeners.size).toBe(0);
});

it('TODO 2 — isOpen: an override wins, an untouched branch falls back', () => {
  const store = new SkillTreeStore();
  expect(store.isOpen('ward', true)).toBe(true);
  expect(store.isOpen('ward', false)).toBe(false);
  store.overrides.set('ward', true);
  expect(store.isOpen('ward', false)).toBe(true);
  store.overrides.set('ward', false);
  expect(store.isOpen('ward', true)).toBe(false);
});

it('TODO 3 — expanding one branch redraws only that branch', () => {
  const store = new SkillTreeStore();

  // The store on its own first: toggle flips through the call-time
  // answer, and every listener gets pinged.
  let pings = 0;
  const unsubscribe = store.subscribe(() => {
    pings += 1;
  });
  store.toggle('ward', false);
  expect(store.isOpen('ward', false)).toBe(true);
  expect(pings).toBe(1);
  store.toggle('ward', false);
  expect(store.isOpen('ward', false)).toBe(false);
  expect(pings).toBe(2);
  // sparse: one entry for the one touched branch, after two toggles
  expect(store.overrides.size).toBe(1);
  store.overrides.clear();
  unsubscribe();

  // Now the same store wired through the HUD.
  render(<SkillTree store={store} />);
  expect(screen.getByTestId('state-ward').textContent).toBe('closed');

  fireEvent.click(screen.getByRole('button', { name: 'ward' }));

  expect(screen.getByTestId('state-ward').textContent).toBe('open');
  expect(redrawsOf('ward')).toBe(2); // mount, then the toggle
  expect(redrawsOf('blade')).toBe(1); // pinged, same answer, not redrawn
  expect(redrawsOf('hex')).toBe(1);
  // blade and hex never got an entry of their own
  expect([...store.overrides.keys()]).toEqual(['ward']);
});

Solution